diff --git a/package-lock.json b/package-lock.json
index 963e66b..1fd805d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,7 +8,7 @@
"name": "ark-database-web",
"version": "1.0.0",
"dependencies": {
- "animate.css": "^4.1.1",
+ "framer-motion": "^11.18.2",
"lucide-react": "^0.460.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
@@ -1722,12 +1722,6 @@
"url": "https://opencollective.com/vitest"
}
},
- "node_modules/animate.css": {
- "version": "4.1.1",
- "resolved": "https://registry.npmjs.org/animate.css/-/animate.css-4.1.1.tgz",
- "integrity": "sha512-+mRmCTv6SbCmtYJCN4faJMNFVNN5EuCTTprDTAo7YzIGji2KADmakjVA3+8mVDkZ2Bf09vayB35lSQIex2+QaQ==",
- "license": "MIT"
- },
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
@@ -2360,6 +2354,33 @@
"url": "https://github.com/sponsors/rawify"
}
},
+ "node_modules/framer-motion": {
+ "version": "11.18.2",
+ "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-11.18.2.tgz",
+ "integrity": "sha512-5F5Och7wrvtLVElIpclDT0CBzMVg3dL22B64aZwHtsIY8RB4mXICLrkajK4G9R+ieSAGcgrLeae2SeUTg2pr6w==",
+ "license": "MIT",
+ "dependencies": {
+ "motion-dom": "^11.18.1",
+ "motion-utils": "^11.18.1",
+ "tslib": "^2.4.0"
+ },
+ "peerDependencies": {
+ "@emotion/is-prop-valid": "*",
+ "react": "^18.0.0 || ^19.0.0",
+ "react-dom": "^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@emotion/is-prop-valid": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ },
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -2774,6 +2795,21 @@
"node": ">=4"
}
},
+ "node_modules/motion-dom": {
+ "version": "11.18.1",
+ "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-11.18.1.tgz",
+ "integrity": "sha512-g76KvA001z+atjfxczdRtw/RXOM3OMSdd1f4DL77qCTF/+avrRJiawSG4yDibEQ215sr9kpinSlX2pCTJ9zbhw==",
+ "license": "MIT",
+ "dependencies": {
+ "motion-utils": "^11.18.1"
+ }
+ },
+ "node_modules/motion-utils": {
+ "version": "11.18.1",
+ "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-11.18.1.tgz",
+ "integrity": "sha512-49Kt+HKjtbJKLtgO/LKj9Ld+6vw9BjH5d9sc40R/kVyH8GLAXgT42M2NnuPcJNuA3s9ZfZBUcwIgpmZWGEE+hA==",
+ "license": "MIT"
+ },
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -3728,6 +3764,12 @@
"dev": true,
"license": "Apache-2.0"
},
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "license": "0BSD"
+ },
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
diff --git a/package.json b/package.json
index c40e5ff..ec43e26 100644
--- a/package.json
+++ b/package.json
@@ -13,7 +13,7 @@
"test:watch": "vitest"
},
"dependencies": {
- "animate.css": "^4.1.1",
+ "framer-motion": "^11.18.2",
"lucide-react": "^0.460.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
diff --git a/src/App.tsx b/src/App.tsx
index b366566..c661b21 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,5 +1,7 @@
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
import { I18nProvider } from "./i18n";
+import { MotionProvider } from "./motion";
+import { ToastProvider } from "./components/Toast";
import { PublicLayout } from "./layouts/PublicLayout";
import { Home } from "./pages/Home";
import { Browse } from "./pages/Browse";
@@ -21,7 +23,9 @@ const adminEnabled = import.meta.env.VITE_DISABLE_ADMIN !== "true";
export default function App() {
return (
-
+
+
+
@@ -55,7 +59,9 @@ export default function App() {
-
+
+
+
);
}
diff --git a/src/components/BackToTop.tsx b/src/components/BackToTop.tsx
new file mode 100644
index 0000000..99afa8f
--- /dev/null
+++ b/src/components/BackToTop.tsx
@@ -0,0 +1,45 @@
+import { ArrowUp } from "lucide-react";
+import { AnimatePresence, m } from "framer-motion";
+import { useEffect, useState } from "react";
+import { useI18n } from "../i18n";
+
+const SHOW_AFTER = 400;
+
+/**
+ * Floating "back to top" button. Appears once the page is scrolled past
+ * SHOW_AFTER px and smoothly returns the window to the top on click. Sits
+ * above the mobile bottom nav and clears it on larger screens.
+ */
+export function BackToTop() {
+ const { t } = useI18n();
+ const [visible, setVisible] = useState(false);
+
+ useEffect(() => {
+ const update = () => setVisible(window.scrollY > SHOW_AFTER);
+ update();
+ window.addEventListener("scroll", update, { passive: true });
+ return () => window.removeEventListener("scroll", update);
+ }, []);
+
+ return (
+
+ {visible ? (
+
+ window.scrollTo({ top: 0, behavior: "smooth" })
+ }
+ className="fixed bottom-[94px] right-4 z-30 flex h-11 w-11 items-center justify-center rounded-full bg-ark-gold text-black shadow-lg shadow-black/40 outline-none transition hover:bg-ark-gold2 active:scale-95 focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg md:bottom-8 md:right-8"
+ aria-label={t("backToTop")}
+ title={t("backToTop")}
+ >
+
+
+ ) : null}
+
+ );
+}
diff --git a/src/components/RecommendedCard.tsx b/src/components/RecommendedCard.tsx
index 03c4d89..cc08720 100644
--- a/src/components/RecommendedCard.tsx
+++ b/src/components/RecommendedCard.tsx
@@ -1,4 +1,5 @@
import { Download, LoaderCircle } from "lucide-react";
+import { m } from "framer-motion";
import { Link } from "react-router-dom";
import type { Resource } from "../api";
import { assetUrl } from "../api";
@@ -11,13 +12,20 @@ import {
downloadAttachment,
downloadFile,
} from "./messageStream/utils/downloadFile";
+import { useToast } from "./Toast";
function isPlaceholderAsset(path: string | undefined | null) {
return !path || path.includes("placeholder-cover");
}
const CARD_BASE_CLASS =
- "group flex shrink-0 flex-col overflow-hidden rounded-xl border bg-[#1D1E23] transition hover:border-ark-gold/55";
+ "group flex shrink-0 flex-col overflow-hidden rounded-xl border bg-[#1D1E23] transition hover:border-ark-gold/55 hover:shadow-lg hover:shadow-black/30";
+
+const CARD_HOVER_SPRING = {
+ type: "spring",
+ stiffness: 380,
+ damping: 26,
+} as const;
const CARD_CAROUSEL_SIZE_CLASS =
"w-[208px] md:w-[240px] lg:w-[246.4px] min-[1100px]:max-xl:w-[273px] xl:w-[246.4px]";
@@ -41,6 +49,7 @@ export function RecommendedCard({
layout?: "carousel" | "grid";
}) {
const { t } = useI18n();
+ const { showToast } = useToast();
const [isDownloading, setIsDownloading] = useState(false);
const figmaCover =
officialRecommendationCoverFallbacks[
@@ -73,18 +82,21 @@ export function RecommendedCard({
r.downloadAttachmentId,
displayTitle,
);
- return;
+ } else {
+ await downloadFile(dl, displayTitle);
}
- await downloadFile(dl, displayTitle);
+ showToast(t("downloadOk"));
} catch {
- /* ignore */
+ showToast(t("downloadFail"), "error");
} finally {
setIsDownloading(false);
}
};
return (
- e.currentTarget.classList.add("is-loaded")}
/>
) : (
@@ -164,8 +173,8 @@ export function RecommendedCard({
type="button"
className={
useFigmaDesign
- ? "flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[#191921] text-ark-gold outline-none transition hover:bg-[#22232D] focus-visible:ring-2 focus-visible:ring-ark-gold/80 disabled:cursor-wait"
- : "shrink-0 rounded-lg p-1 text-ark-gold outline-none hover:bg-ark-gold/10 focus-visible:ring-2 focus-visible:ring-ark-gold/80 disabled:cursor-wait"
+ ? "flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[#191921] text-ark-gold outline-none transition hover:bg-[#22232D] active:scale-95 focus-visible:ring-2 focus-visible:ring-ark-gold/80 disabled:cursor-wait"
+ : "shrink-0 rounded-lg p-1 text-ark-gold outline-none transition hover:bg-ark-gold/10 active:scale-95 focus-visible:ring-2 focus-visible:ring-ark-gold/80 disabled:cursor-wait"
}
title={isDownloading ? t("downloading") : t("download")}
aria-label={isDownloading ? t("downloading") : t("download")}
@@ -179,11 +188,7 @@ export function RecommendedCard({
>
{isDownloading ? (
) : useFigmaDesign ? (
@@ -195,7 +200,7 @@ export function RecommendedCard({
) : null}
-
+
);
}
diff --git a/src/components/Skeleton.tsx b/src/components/Skeleton.tsx
new file mode 100644
index 0000000..e68ed1d
--- /dev/null
+++ b/src/components/Skeleton.tsx
@@ -0,0 +1,24 @@
+import type { CSSProperties } from "react";
+
+type SkeletonProps = {
+ /** Sizing / rounding utilities, e.g. "h-[88px] w-full rounded-xl". */
+ className?: string;
+ style?: CSSProperties;
+};
+
+/**
+ * Reusable shimmer placeholder. Renders the reduced-motion-guarded
+ * `.ark-skeleton` utility (defined in index.css), so the shimmer animation
+ * automatically stops when the user prefers reduced motion. Pass the same
+ * sizing/rounding classes as the real content it stands in for to avoid any
+ * layout shift when the data loads.
+ */
+export function Skeleton({ className = "", style }: SkeletonProps) {
+ return (
+
+ );
+}
diff --git a/src/components/Toast.tsx b/src/components/Toast.tsx
new file mode 100644
index 0000000..d03bfe3
--- /dev/null
+++ b/src/components/Toast.tsx
@@ -0,0 +1,98 @@
+import { AnimatePresence, m } from "framer-motion";
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useMemo,
+ useRef,
+ useState,
+ type ReactNode,
+} from "react";
+
+type ToastVariant = "success" | "error";
+
+type ToastItem = {
+ id: number;
+ message: string;
+ variant: ToastVariant;
+};
+
+type ToastContextValue = {
+ showToast: (message: string, variant?: ToastVariant) => void;
+};
+
+const ToastContext = createContext(null);
+
+export function useToast(): ToastContextValue {
+ const ctx = useContext(ToastContext);
+ if (!ctx) {
+ throw new Error("useToast must be used within a ToastProvider");
+ }
+ return ctx;
+}
+
+const AUTO_DISMISS_MS = 3000;
+
+/**
+ * App-level toast host. Renders an aria-live region so screen readers announce
+ * messages. Enter/exit animations go through framer's `m` (under MotionProvider),
+ * so they automatically respect the user's reduced-motion preference.
+ */
+export function ToastProvider({ children }: { children: ReactNode }) {
+ const [toasts, setToasts] = useState([]);
+ const idRef = useRef(0);
+
+ const dismiss = useCallback((id: number) => {
+ setToasts((prev) => prev.filter((toast) => toast.id !== id));
+ }, []);
+
+ const showToast = useCallback(
+ (message: string, variant: ToastVariant = "success") => {
+ const id = (idRef.current += 1);
+ setToasts((prev) => [...prev, { id, message, variant }]);
+ window.setTimeout(() => dismiss(id), AUTO_DISMISS_MS);
+ },
+ [dismiss],
+ );
+
+ const value = useMemo(() => ({ showToast }), [showToast]);
+
+ return (
+
+ {children}
+
+
+ {toasts.map((toast) => (
+
+
+ {toast.message}
+
+ ))}
+
+
+
+ );
+}
diff --git a/src/components/messageStream/AttachmentDownloadPill.tsx b/src/components/messageStream/AttachmentDownloadPill.tsx
index 017d1c7..6b8ea14 100644
--- a/src/components/messageStream/AttachmentDownloadPill.tsx
+++ b/src/components/messageStream/AttachmentDownloadPill.tsx
@@ -5,6 +5,7 @@ import { useI18n } from "../../i18n";
import type { Attachment } from "../../types/post";
import { downloadAttachment } from "./utils/downloadFile";
import { formatBytes } from "./utils/formatBytes";
+import { useToast } from "../Toast";
type AttachmentDownloadPillProps = {
postId: string;
@@ -20,15 +21,21 @@ export function AttachmentDownloadPill({
className = "absolute left-2 top-2",
}: AttachmentDownloadPillProps) {
const { t } = useI18n();
+ const { showToast } = useToast();
const [isDownloading, setIsDownloading] = useState(false);
- const handleDownload = (e: MouseEvent) => {
+ const handleDownload = async (e: MouseEvent) => {
e.stopPropagation();
if (isDownloading) return;
setIsDownloading(true);
- void downloadAttachment(postId, attachment.id, attachment.filename)
- .finally(() => setIsDownloading(false))
- .catch(() => {});
+ try {
+ await downloadAttachment(postId, attachment.id, attachment.filename);
+ showToast(t("downloadOk"));
+ } catch {
+ showToast(t("downloadFail"), "error");
+ } finally {
+ setIsDownloading(false);
+ }
};
return (
diff --git a/src/components/messageStream/MessageBubble.tsx b/src/components/messageStream/MessageBubble.tsx
index 077ad1f..6dda60f 100644
--- a/src/components/messageStream/MessageBubble.tsx
+++ b/src/components/messageStream/MessageBubble.tsx
@@ -35,7 +35,7 @@ export function MessageBubble({ post }: { post: Post }) {
return (
io.disconnect();
}, [loadMore]);
+ // When arriving with a `#post-` hash (e.g. from a recommended card),
+ // scroll to that bubble — loading more pages until it shows up — then give
+ // it a brief highlight so the user can see where they landed.
+ const targetPostId =
+ hash.startsWith("#post-") ? hash.slice("#post-".length) : "";
+ const handledTargetRef = useRef("");
+
+ useEffect(() => {
+ handledTargetRef.current = "";
+ }, [targetPostId]);
+
+ useEffect(() => {
+ if (!targetPostId || handledTargetRef.current === targetPostId) return;
+
+ const el = document.getElementById(`post-${targetPostId}`);
+ if (el) {
+ handledTargetRef.current = targetPostId;
+ const frame = window.requestAnimationFrame(() => {
+ el.scrollIntoView({ block: "start", behavior: "smooth" });
+ el.classList.add("ark-bubble-highlight");
+ window.setTimeout(
+ () => el.classList.remove("ark-bubble-highlight"),
+ 2000,
+ );
+ });
+ return () => window.cancelAnimationFrame(frame);
+ }
+
+ // Not loaded yet — keep paging until it appears or the stream is exhausted.
+ if (hasMore && !isLoading) loadMore();
+ }, [targetPostId, items, hasMore, isLoading, loadMore]);
+
const updateParam = (key: string, value: string) => {
const n = new URLSearchParams(sp);
if (!value || value === "all") n.delete(key);
@@ -72,41 +107,64 @@ export function MessageStream({ scope }: MessageStreamProps) {
setSp(n, { replace: true });
};
+ const isInitialLoad = isLoading && items.length === 0;
+
return (
updateParam("type", v)} />
- {groups.map((group) => (
-
- {group.items.map((post) => (
-
+ {isInitialLoad ? (
+ <>
+ {Array.from({ length: 10 }).map((_, i) => (
+
+
+
+ ))}
+ >
+ ) : (
+ <>
+ {groups.map((group) => (
+
+ {group.items.map((post, index) => (
+
+
+
+ ))}
+
))}
-
- ))}
- {!isLoading && !error && items.length === 0 ? (
-
- {t("noResults")}
-
- ) : null}
+ {!isLoading && !error && items.length === 0 ? (
+
+ {t("noResults")}
+
+ ) : null}
- {error ? (
-
- {error}
-
-
- ) : null}
+ {error ? (
+
+ {error}
+
+
+ ) : null}
- {isLoading ? (
-
…
- ) : null}
+ {isLoading ? (
+
…
+ ) : null}
+ >
+ )}
diff --git a/src/components/messageStream/bubbles/AlbumBubble.tsx b/src/components/messageStream/bubbles/AlbumBubble.tsx
index ee04115..c186571 100644
--- a/src/components/messageStream/bubbles/AlbumBubble.tsx
+++ b/src/components/messageStream/bubbles/AlbumBubble.tsx
@@ -10,6 +10,7 @@ import { autolink } from "../utils/autolink";
import { downloadAttachment } from "../utils/downloadFile";
import { formatBytes } from "../utils/formatBytes";
import { postDisplayText } from "../utils/postText";
+import { useToast } from "../../Toast";
const MAX_VISIBLE = 4;
@@ -32,14 +33,20 @@ function ImageListDownloadButton({
attachment: Attachment;
}) {
const { t } = useI18n();
+ const { showToast } = useToast();
const [isDownloading, setIsDownloading] = useState(false);
- const handleDownload = () => {
+ const handleDownload = async () => {
if (isDownloading) return;
setIsDownloading(true);
- void downloadAttachment(postId, attachment.id, attachment.filename)
- .finally(() => setIsDownloading(false))
- .catch(() => {});
+ try {
+ await downloadAttachment(postId, attachment.id, attachment.filename);
+ showToast(t("downloadOk"));
+ } catch {
+ showToast(t("downloadFail"), "error");
+ } finally {
+ setIsDownloading(false);
+ }
};
return (
diff --git a/src/components/messageStream/bubbles/FileDocBubble.tsx b/src/components/messageStream/bubbles/FileDocBubble.tsx
index 6cbd7dd..5642c57 100644
--- a/src/components/messageStream/bubbles/FileDocBubble.tsx
+++ b/src/components/messageStream/bubbles/FileDocBubble.tsx
@@ -11,19 +11,26 @@ import {
} from "../utils/filenameDisplay";
import { formatBytes } from "../utils/formatBytes";
import { postDisplayText } from "../utils/postText";
+import { useToast } from "../../Toast";
function AttachmentRow({ postId, att }: { postId: string; att: Attachment }) {
const { t } = useI18n();
+ const { showToast } = useToast();
const { Icon, color } = fileIcon({ mime: att.mime, filename: att.filename });
const displayFilename = filenameWithExtension(att.filename, att.mime);
const [isDownloading, setIsDownloading] = useState(false);
- const handleDownload = () => {
+ const handleDownload = async () => {
if (isDownloading) return;
setIsDownloading(true);
- void downloadAttachment(postId, att.id, displayFilename)
- .finally(() => setIsDownloading(false))
- .catch(() => {});
+ try {
+ await downloadAttachment(postId, att.id, displayFilename);
+ showToast(t("downloadOk"));
+ } catch {
+ showToast(t("downloadFail"), "error");
+ } finally {
+ setIsDownloading(false);
+ }
};
return (
diff --git a/src/components/messageStream/bubbles/VideoBubble.tsx b/src/components/messageStream/bubbles/VideoBubble.tsx
index 6ec6fb3..9832d4b 100644
--- a/src/components/messageStream/bubbles/VideoBubble.tsx
+++ b/src/components/messageStream/bubbles/VideoBubble.tsx
@@ -10,6 +10,7 @@ import { autolink } from "../utils/autolink";
import { downloadAttachment } from "../utils/downloadFile";
import { formatBytes } from "../utils/formatBytes";
import { postDisplayText } from "../utils/postText";
+import { useToast } from "../../Toast";
const MAX_VISIBLE = 4;
@@ -173,14 +174,20 @@ function AttachmentListDownloadButton({
attachment: Attachment;
}) {
const { t } = useI18n();
+ const { showToast } = useToast();
const [isDownloading, setIsDownloading] = useState(false);
- const handleDownload = () => {
+ const handleDownload = async () => {
if (isDownloading) return;
setIsDownloading(true);
- void downloadAttachment(postId, attachment.id, attachment.filename)
- .finally(() => setIsDownloading(false))
- .catch(() => {});
+ try {
+ await downloadAttachment(postId, attachment.id, attachment.filename);
+ showToast(t("downloadOk"));
+ } catch {
+ showToast(t("downloadFail"), "error");
+ } finally {
+ setIsDownloading(false);
+ }
};
return (
diff --git a/src/i18n.tsx b/src/i18n.tsx
index 300e840..caddf20 100644
--- a/src/i18n.tsx
+++ b/src/i18n.tsx
@@ -32,6 +32,7 @@ const zhDict: Dict = {
tagPostsTitle: "#{{tag}} 相关资料",
noTagPosts: "暂时找不到带有此标签的资料。",
viewAll: "查看全部",
+ backToTop: "回到顶部",
heroTitle: "ARK 官方数据库",
heroSub:
"集中、分类、管理 ARK 数据库,让你快速找到所需资源,推动社群共识与成长。",
@@ -42,6 +43,8 @@ const zhDict: Dict = {
preview: "预览",
download: "下载",
downloading: "下载中…",
+ downloadOk: "下载完成",
+ downloadFail: "下载失败,请重试",
share: "分享",
langLabel: "语言",
admin: "后台",
@@ -159,6 +162,7 @@ const enDict: Dict = {
tagPostsTitle: "#{{tag}} related posts",
noTagPosts: "No posts with this tag yet.",
viewAll: "View all",
+ backToTop: "Back to top",
heroTitle: "ARK Official Library",
heroSub:
"Centralize, organize, and manage the ARK library so you can find what you need fast and help the community grow together.",
@@ -169,6 +173,8 @@ const enDict: Dict = {
preview: "Preview",
download: "Download",
downloading: "Downloading…",
+ downloadOk: "Download complete",
+ downloadFail: "Download failed, please retry",
share: "Share",
langLabel: "Language",
admin: "Admin",
diff --git a/src/index.css b/src/index.css
index 9e64db7..c1ffb61 100644
--- a/src/index.css
+++ b/src/index.css
@@ -20,9 +20,6 @@ body {
/* Match theme: avoid default blue accent on controls */
:root {
accent-color: #eeb726;
- --animate-duration: 720ms;
- --animate-delay: 0s;
- --animate-repeat: 1;
}
header a,
@@ -49,26 +46,6 @@ header button {
}
}
-.ark-page-fade-in {
- animation: ark-page-fade-in 240ms ease-out both;
-}
-
-@keyframes ark-page-fade-in {
- from {
- opacity: 0;
- }
-
- to {
- opacity: 1;
- }
-}
-
-@media (prefers-reduced-motion: reduce) {
- .ark-page-fade-in {
- animation: none;
- }
-}
-
/* Desktop header nav: thin scrollbar when links overflow (still 單列) */
.header-nav-scroll {
scrollbar-width: thin;
@@ -89,6 +66,73 @@ header button {
box-shadow: inset 0 -2px 0 #eeb726;
}
+/* Reusable skeleton placeholder with a soft shimmer sweep. */
+.ark-skeleton {
+ position: relative;
+ overflow: hidden;
+ background-color: #1d1e23;
+}
+.ark-skeleton::after {
+ content: "";
+ position: absolute;
+ inset: 0;
+ transform: translateX(-100%);
+ background: linear-gradient(
+ 90deg,
+ transparent,
+ rgba(255, 255, 255, 0.05),
+ transparent
+ );
+ animation: ark-shimmer 1.5s infinite;
+}
+
+@keyframes ark-shimmer {
+ 100% {
+ transform: translateX(100%);
+ }
+}
+
+/* Image fade-in: add .is-loaded on the img's onLoad handler. */
+.ark-img-fade {
+ opacity: 0;
+ transition: opacity 0.4s ease-out;
+}
+.ark-img-fade.is-loaded {
+ opacity: 1;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .ark-skeleton::after {
+ animation: none;
+ }
+ .ark-img-fade {
+ opacity: 1;
+ transition: none;
+ }
+}
+
+/* Brief gold ring pulse when a bubble is targeted via a #post- deep link. */
+.ark-bubble-highlight {
+ border-radius: 1rem;
+ animation: ark-bubble-highlight 2s ease-out both;
+}
+
+@keyframes ark-bubble-highlight {
+ 0%,
+ 35% {
+ box-shadow: 0 0 0 2px rgba(238, 183, 38, 0.9);
+ }
+ 100% {
+ box-shadow: 0 0 0 2px rgba(238, 183, 38, 0);
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .ark-bubble-highlight {
+ animation: none;
+ }
+}
+
.message-stream-copyable-text,
.message-stream-copyable-text * {
-webkit-user-select: text;
diff --git a/src/layouts/PublicLayout.tsx b/src/layouts/PublicLayout.tsx
index b156e00..bbda386 100644
--- a/src/layouts/PublicLayout.tsx
+++ b/src/layouts/PublicLayout.tsx
@@ -1,7 +1,15 @@
import { ChevronDown, Menu, Search as SearchIcon, X } from "lucide-react";
+import { AnimatePresence, m } from "framer-motion";
import { useEffect, useRef, useState } from "react";
-import { Link, Outlet, useLocation, useNavigate } from "react-router-dom";
+import {
+ Link,
+ useLocation,
+ useNavigate,
+ useOutlet,
+} from "react-router-dom";
+import { pageTransition } from "../motion";
import { ArkLogoMark } from "../components/ArkLogoMark";
+import { BackToTop } from "../components/BackToTop";
import { DocumentMeta } from "../components/DocumentMeta";
import { SearchPanel } from "../components/SearchPanel";
import { useI18n, type Lang } from "../i18n";
@@ -53,7 +61,9 @@ function navIsActive(
function navClassName(active: boolean) {
return [
- "shrink-0 rounded-sm px-2 py-2 text-[13px] font-medium leading-none whitespace-nowrap no-underline outline-none transition-colors",
+ "relative shrink-0 rounded-sm px-2 py-2 text-[13px] font-medium leading-none whitespace-nowrap no-underline outline-none transition-colors",
+ // Hover-only gold underline that slides in (resting/active look unchanged).
+ "after:pointer-events-none after:absolute after:inset-x-2 after:bottom-1 after:h-[2px] after:origin-left after:scale-x-0 after:rounded-full after:bg-ark-gold after:transition-transform after:duration-300 hover:after:scale-x-100 motion-reduce:after:transition-none",
"focus-visible:ring-2 focus-visible:ring-ark-gold/90 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg",
active
? "text-ark-gold visited:text-ark-gold"
@@ -274,6 +284,7 @@ function MobileLanguageButton({
export function PublicLayout() {
const { t, lang, setLang } = useI18n();
const { pathname, search, hash } = useLocation();
+ const outlet = useOutlet();
const [open, setOpen] = useState(false);
const [mobileSearchOpen, setMobileSearchOpen] = useState(false);
const [q, setQ] = useState("");
@@ -606,9 +617,17 @@ export function PublicLayout() {
: "flex-1 px-4 pb-6 pt-6 min-[440px]:px-5 sm:px-6 md:px-9 md:pb-10 md:pt-10 xl:px-0"
}`}
>
-
-
-
+
+
+ {outlet}
+
+
+
+
);
}
diff --git a/src/main.tsx b/src/main.tsx
index f1abb8b..743eaa6 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -1,6 +1,5 @@
import React from "react";
import ReactDOM from "react-dom/client";
-import "animate.css";
import "./index.css";
const adminOnly = import.meta.env.VITE_ADMIN_ONLY === "true";
diff --git a/src/motion/MotionProvider.tsx b/src/motion/MotionProvider.tsx
new file mode 100644
index 0000000..6648a28
--- /dev/null
+++ b/src/motion/MotionProvider.tsx
@@ -0,0 +1,21 @@
+import { LazyMotion, MotionConfig, domAnimation } from "framer-motion";
+import type { ReactNode } from "react";
+
+/**
+ * Wraps the app once at the root.
+ *
+ * - `LazyMotion` + `domAnimation` keeps the framer-motion bundle small
+ * (~feature subset, lazily loaded). Components MUST use the `m` namespace
+ * (e.g. `m.div`) rather than `motion.*`; `strict` enforces this so we never
+ * accidentally pull in the full bundle.
+ * - `MotionConfig reducedMotion="user"` makes every framer animation respect
+ * the OS "reduce motion" setting automatically (transforms are dropped,
+ * opacity is kept), so individual components don't each need to handle it.
+ */
+export function MotionProvider({ children }: { children: ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/src/motion/Reveal.tsx b/src/motion/Reveal.tsx
new file mode 100644
index 0000000..8c367c0
--- /dev/null
+++ b/src/motion/Reveal.tsx
@@ -0,0 +1,40 @@
+import { m } from "framer-motion";
+import type { ReactNode } from "react";
+import { fadeInUp } from "./variants";
+
+type RevealProps = {
+ children: ReactNode;
+ /** Per-item delay in seconds (e.g. index * 0.06 for a staggered list). */
+ delay?: number;
+ className?: string;
+ /** Only animate the first time it enters the viewport. */
+ once?: boolean;
+ /** Fraction of the element that must be visible to trigger (0-1). */
+ amount?: number;
+};
+
+/**
+ * Fade + lift a block in as it scrolls into view. Uses framer's `whileInView`,
+ * so it respects the global `reducedMotion="user"` config from MotionProvider
+ * (no manual guard needed). Pass an incremental `delay` for simple stagger.
+ */
+export function Reveal({
+ children,
+ delay = 0,
+ className,
+ once = true,
+ amount = 0.15,
+}: RevealProps) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/src/motion/index.ts b/src/motion/index.ts
new file mode 100644
index 0000000..7e1dda3
--- /dev/null
+++ b/src/motion/index.ts
@@ -0,0 +1,12 @@
+export { MotionProvider } from "./MotionProvider";
+export { Reveal } from "./Reveal";
+export { useRevealOnScroll } from "./useRevealOnScroll";
+export {
+ EASE_OUT,
+ baseTransition,
+ fadeInUp,
+ scaleIn,
+ staggerContainer,
+ pageTransition,
+ cardHover,
+} from "./variants";
diff --git a/src/motion/useRevealOnScroll.ts b/src/motion/useRevealOnScroll.ts
new file mode 100644
index 0000000..30f212d
--- /dev/null
+++ b/src/motion/useRevealOnScroll.ts
@@ -0,0 +1,50 @@
+import { useEffect, useRef, useState } from "react";
+
+type RevealOptions = {
+ rootMargin?: string;
+ threshold?: number;
+};
+
+/**
+ * Lightweight IntersectionObserver hook. Sets `inView` to true the first time
+ * the element enters the viewport, then stops observing (fires once). Falls
+ * back to immediately visible when IntersectionObserver is unavailable (SSR /
+ * very old browsers) so content is never hidden.
+ */
+export function useRevealOnScroll(
+ options?: RevealOptions,
+) {
+ const ref = useRef(null);
+ const [inView, setInView] = useState(false);
+
+ const rootMargin = options?.rootMargin ?? "0px 0px -10% 0px";
+ const threshold = options?.threshold ?? 0.1;
+
+ useEffect(() => {
+ const el = ref.current;
+ if (!el) return;
+
+ if (typeof IntersectionObserver === "undefined") {
+ setInView(true);
+ return;
+ }
+
+ const observer = new IntersectionObserver(
+ (entries) => {
+ for (const entry of entries) {
+ if (entry.isIntersecting) {
+ setInView(true);
+ observer.disconnect();
+ break;
+ }
+ }
+ },
+ { rootMargin, threshold },
+ );
+
+ observer.observe(el);
+ return () => observer.disconnect();
+ }, [rootMargin, threshold]);
+
+ return { ref, inView };
+}
diff --git a/src/motion/variants.ts b/src/motion/variants.ts
new file mode 100644
index 0000000..002636a
--- /dev/null
+++ b/src/motion/variants.ts
@@ -0,0 +1,58 @@
+import type { Transition, Variants } from "framer-motion";
+
+/** Premium ease-out curve shared by all motion. */
+export const EASE_OUT = [0.22, 1, 0.36, 1] as const;
+
+/** Base transition for reveal-style animations. */
+export const baseTransition: Transition = {
+ duration: 0.4,
+ ease: EASE_OUT,
+};
+
+/**
+ * Fade + lift in. `visible` is a function variant so callers can pass a
+ * per-item delay via `custom` (defaults to 0, e.g. when driven by a
+ * stagger container).
+ */
+export const fadeInUp: Variants = {
+ hidden: { opacity: 0, y: 16 },
+ visible: (delay: number = 0) => ({
+ opacity: 1,
+ y: 0,
+ transition: { ...baseTransition, delay },
+ }),
+};
+
+/** Fade + subtle scale in (cards, popovers). */
+export const scaleIn: Variants = {
+ hidden: { opacity: 0, scale: 0.96 },
+ visible: (delay: number = 0) => ({
+ opacity: 1,
+ scale: 1,
+ transition: { ...baseTransition, delay },
+ }),
+};
+
+/** Parent container that staggers its children's `visible` state. */
+export const staggerContainer: Variants = {
+ hidden: {},
+ visible: {
+ transition: { staggerChildren: 0.06, delayChildren: 0.02 },
+ },
+};
+
+/** Route enter/exit transition used with AnimatePresence. */
+export const pageTransition: Variants = {
+ initial: { opacity: 0, y: 8 },
+ enter: { opacity: 1, y: 0, transition: { duration: 0.24, ease: EASE_OUT } },
+ exit: { opacity: 0, y: -6, transition: { duration: 0.16, ease: EASE_OUT } },
+};
+
+/** Springy hover lift for cards. Use rest/hover states on an `m` element. */
+export const cardHover: Variants = {
+ rest: { y: 0 },
+ hover: {
+ y: -4,
+ transition: { type: "spring", stiffness: 380, damping: 26 },
+ },
+};
diff --git a/src/pages/About/index.tsx b/src/pages/About/index.tsx
index f2a9cc3..4d18e46 100644
--- a/src/pages/About/index.tsx
+++ b/src/pages/About/index.tsx
@@ -1,13 +1,18 @@
import { useI18n } from "../../i18n";
+import { Reveal } from "../../motion";
export function AboutPage() {
const { t } = useI18n();
return (
-
{t("aboutTitle")}
-
- {t("aboutIntro")}
-
+
+ {t("aboutTitle")}
+
+
+
+ {t("aboutIntro")}
+
+
);
}
diff --git a/src/pages/Categories/index.tsx b/src/pages/Categories/index.tsx
index 58c0071..4327e24 100644
--- a/src/pages/Categories/index.tsx
+++ b/src/pages/Categories/index.tsx
@@ -3,8 +3,10 @@ import { Link } from "react-router-dom";
import { getJSON, itemsOrEmpty, readJSONCache, type Category } from "../../api";
import { CategoryIcon } from "../../components/CategoryIcon";
import { SectionHeader } from "../../components/SectionHeader";
+import { Skeleton } from "../../components/Skeleton";
import { langQuery, useI18n } from "../../i18n";
import { cleanCategoryDisplayName } from "../../utils/categoryDisplay";
+import { Reveal } from "../../motion";
const FIGMA_CATEGORY_ORDER = [
"project-ppt",
@@ -70,26 +72,40 @@ export function CategoriesPage() {
);
}
+ const isLoading = cats.length === 0;
+
return (
- {cats.map((category) => (
-
-
-
- {cleanCategoryDisplayName(category.name)}
-
-
- ))}
+ {isLoading
+ ? Array.from({ length: 14 }).map((_, i) => (
+
+ ))
+ : cats.map((category, index) => (
+
+
+
+
+ {cleanCategoryDisplayName(category.name)}
+
+
+
+ ))}
);
diff --git a/src/pages/Favorites/index.tsx b/src/pages/Favorites/index.tsx
index 00aedc9..0638a8e 100644
--- a/src/pages/Favorites/index.tsx
+++ b/src/pages/Favorites/index.tsx
@@ -1,12 +1,13 @@
import { Heart } from "lucide-react";
import { Link } from "react-router-dom";
import { useI18n } from "../../i18n";
+import { Reveal } from "../../motion";
export default function Favorites() {
const { t } = useI18n();
return (
-
+
{t("backToHome")}
-
+
);
}
diff --git a/src/pages/Home/index.tsx b/src/pages/Home/index.tsx
index 4946464..5a70ee9 100644
--- a/src/pages/Home/index.tsx
+++ b/src/pages/Home/index.tsx
@@ -19,6 +19,7 @@ import {
type PostBackedResource,
} from "../../utils/postResourceAdapter";
import type { Post } from "../../types/post";
+import { Reveal } from "../../motion";
const FIGMA_CATEGORY_ORDER = [
"project-ppt",
@@ -269,6 +270,7 @@ export function Home() {
+
- {figmaOrderedCategories.map((c) => (
-
-
-
- {cleanCategoryDisplayName(c.name)}
-
-
+ {figmaOrderedCategories.map((c, index) => (
+
+
+
+
+ {cleanCategoryDisplayName(c.name)}
+
+
+
))}
+
+
{rec.map((r, index) => (
-
+
+
+
))}
@@ -435,7 +442,9 @@ export function Home() {
) : null}
+
+
+
{hasPopular ? (
+
+
) : null}
);
diff --git a/tailwind.config.js b/tailwind.config.js
index 10ebebe..af32365 100644
--- a/tailwind.config.js
+++ b/tailwind.config.js
@@ -14,6 +14,24 @@ export default {
muted: "#8f9099",
},
},
+ keyframes: {
+ shimmer: {
+ "100%": { transform: "translateX(100%)" },
+ },
+ "fade-in-up": {
+ from: { opacity: "0", transform: "translateY(16px)" },
+ to: { opacity: "1", transform: "translateY(0)" },
+ },
+ "scale-in": {
+ from: { opacity: "0", transform: "scale(0.96)" },
+ to: { opacity: "1", transform: "scale(1)" },
+ },
+ },
+ animation: {
+ shimmer: "shimmer 1.5s infinite",
+ "fade-in-up": "fade-in-up 0.5s cubic-bezier(0.22, 1, 0.36, 1) both",
+ "scale-in": "scale-in 0.4s cubic-bezier(0.22, 1, 0.36, 1) both",
+ },
fontFamily: {
sans: [
"Noto Sans SC",