feat: scroll to post bubble from recommended card + back-to-top button
Some checks failed
Deploy to Frontend Servers / deploy (push) Failing after 14s

Recommended cards already routed to /browse#post-<id>, but the stream had
no logic to scroll to the target bubble — and the post might not be paged
in yet. MessageStream now resolves the #post-<id> hash, auto-loads more
pages until the bubble renders, scrolls to it, and gives it a brief gold
highlight. Bubbles get scroll-mt so they clear the sticky header.

Also adds a global floating back-to-top button (BackToTop) mounted in
PublicLayout, shown after scrolling past 400px.

Bundles related staging UI work already present in the working tree.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
TerryM
2026-05-29 11:50:27 +08:00
parent 8e36894851
commit 88a25b6ad4
27 changed files with 748 additions and 139 deletions

56
package-lock.json generated
View File

@@ -8,7 +8,7 @@
"name": "ark-database-web", "name": "ark-database-web",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"animate.css": "^4.1.1", "framer-motion": "^11.18.2",
"lucide-react": "^0.460.0", "lucide-react": "^0.460.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
@@ -1722,12 +1722,6 @@
"url": "https://opencollective.com/vitest" "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": { "node_modules/ansi-regex": {
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
@@ -2360,6 +2354,33 @@
"url": "https://github.com/sponsors/rawify" "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": { "node_modules/fsevents": {
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -2774,6 +2795,21 @@
"node": ">=4" "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": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -3728,6 +3764,12 @@
"dev": true, "dev": true,
"license": "Apache-2.0" "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": { "node_modules/typescript": {
"version": "5.9.3", "version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",

View File

@@ -13,7 +13,7 @@
"test:watch": "vitest" "test:watch": "vitest"
}, },
"dependencies": { "dependencies": {
"animate.css": "^4.1.1", "framer-motion": "^11.18.2",
"lucide-react": "^0.460.0", "lucide-react": "^0.460.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",

View File

@@ -1,5 +1,7 @@
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom"; import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
import { I18nProvider } from "./i18n"; import { I18nProvider } from "./i18n";
import { MotionProvider } from "./motion";
import { ToastProvider } from "./components/Toast";
import { PublicLayout } from "./layouts/PublicLayout"; import { PublicLayout } from "./layouts/PublicLayout";
import { Home } from "./pages/Home"; import { Home } from "./pages/Home";
import { Browse } from "./pages/Browse"; import { Browse } from "./pages/Browse";
@@ -21,6 +23,8 @@ const adminEnabled = import.meta.env.VITE_DISABLE_ADMIN !== "true";
export default function App() { export default function App() {
return ( return (
<I18nProvider> <I18nProvider>
<MotionProvider>
<ToastProvider>
<AdminRouterModeProvider value="absolute"> <AdminRouterModeProvider value="absolute">
<ImageLightboxProvider> <ImageLightboxProvider>
<VideoPlayerProvider> <VideoPlayerProvider>
@@ -56,6 +60,8 @@ export default function App() {
</VideoPlayerProvider> </VideoPlayerProvider>
</ImageLightboxProvider> </ImageLightboxProvider>
</AdminRouterModeProvider> </AdminRouterModeProvider>
</ToastProvider>
</MotionProvider>
</I18nProvider> </I18nProvider>
); );
} }

View File

@@ -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 (
<AnimatePresence>
{visible ? (
<m.button
type="button"
initial={{ opacity: 0, scale: 0.8, y: 8 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.8, y: 8 }}
transition={{ type: "spring", stiffness: 380, damping: 26 }}
onClick={() =>
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")}
>
<ArrowUp className="h-5 w-5" strokeWidth={2.4} />
</m.button>
) : null}
</AnimatePresence>
);
}

View File

@@ -1,4 +1,5 @@
import { Download, LoaderCircle } from "lucide-react"; import { Download, LoaderCircle } from "lucide-react";
import { m } from "framer-motion";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import type { Resource } from "../api"; import type { Resource } from "../api";
import { assetUrl } from "../api"; import { assetUrl } from "../api";
@@ -11,13 +12,20 @@ import {
downloadAttachment, downloadAttachment,
downloadFile, downloadFile,
} from "./messageStream/utils/downloadFile"; } from "./messageStream/utils/downloadFile";
import { useToast } from "./Toast";
function isPlaceholderAsset(path: string | undefined | null) { function isPlaceholderAsset(path: string | undefined | null) {
return !path || path.includes("placeholder-cover"); return !path || path.includes("placeholder-cover");
} }
const CARD_BASE_CLASS = 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 = const CARD_CAROUSEL_SIZE_CLASS =
"w-[208px] md:w-[240px] lg:w-[246.4px] min-[1100px]:max-xl:w-[273px] xl:w-[246.4px]"; "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"; layout?: "carousel" | "grid";
}) { }) {
const { t } = useI18n(); const { t } = useI18n();
const { showToast } = useToast();
const [isDownloading, setIsDownloading] = useState(false); const [isDownloading, setIsDownloading] = useState(false);
const figmaCover = const figmaCover =
officialRecommendationCoverFallbacks[ officialRecommendationCoverFallbacks[
@@ -73,18 +82,21 @@ export function RecommendedCard({
r.downloadAttachmentId, r.downloadAttachmentId,
displayTitle, displayTitle,
); );
return; } else {
}
await downloadFile(dl, displayTitle); await downloadFile(dl, displayTitle);
}
showToast(t("downloadOk"));
} catch { } catch {
/* ignore */ showToast(t("downloadFail"), "error");
} finally { } finally {
setIsDownloading(false); setIsDownloading(false);
} }
}; };
return ( return (
<article <m.article
whileHover={{ y: -4 }}
transition={CARD_HOVER_SPRING}
className={`${CARD_BASE_CLASS} ${ className={`${CARD_BASE_CLASS} ${
layout === "grid" ? CARD_GRID_SIZE_CLASS : CARD_CAROUSEL_SIZE_CLASS layout === "grid" ? CARD_GRID_SIZE_CLASS : CARD_CAROUSEL_SIZE_CLASS
} ${ } ${
@@ -101,12 +113,9 @@ export function RecommendedCard({
<img <img
src={cover} src={cover}
alt="" alt=""
className={`h-full w-full object-cover transition duration-300 ${ className="ark-img-fade h-full w-full object-cover transition duration-300 group-hover:scale-[1.02]"
useFigmaDesign
? "group-hover:scale-[1.02]"
: "group-hover:scale-[1.02]"
}`}
loading="lazy" loading="lazy"
onLoad={(e) => e.currentTarget.classList.add("is-loaded")}
/> />
) : ( ) : (
<div className="h-full w-full bg-gradient-to-br from-neutral-900 to-neutral-950" /> <div className="h-full w-full bg-gradient-to-br from-neutral-900 to-neutral-950" />
@@ -164,8 +173,8 @@ export function RecommendedCard({
type="button" type="button"
className={ className={
useFigmaDesign 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" ? "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 hover:bg-ark-gold/10 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")} title={isDownloading ? t("downloading") : t("download")}
aria-label={isDownloading ? t("downloading") : t("download")} aria-label={isDownloading ? t("downloading") : t("download")}
@@ -179,11 +188,7 @@ export function RecommendedCard({
> >
{isDownloading ? ( {isDownloading ? (
<LoaderCircle <LoaderCircle
className={ className="h-5 w-5 animate-spin"
useFigmaDesign
? "h-5 w-5 animate-spin"
: "h-5 w-5 animate-spin"
}
strokeWidth={2.2} strokeWidth={2.2}
/> />
) : useFigmaDesign ? ( ) : useFigmaDesign ? (
@@ -195,7 +200,7 @@ export function RecommendedCard({
) : null} ) : null}
</div> </div>
</div> </div>
</article> </m.article>
); );
} }

View File

@@ -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 (
<div
className={`ark-skeleton ${className}`.trim()}
style={style}
aria-hidden="true"
/>
);
}

98
src/components/Toast.tsx Normal file
View File

@@ -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<ToastContextValue | null>(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<ToastItem[]>([]);
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 (
<ToastContext.Provider value={value}>
{children}
<div
className="pointer-events-none fixed inset-x-0 bottom-[92px] z-[100] flex flex-col items-center gap-2 px-4 md:bottom-6"
aria-live="polite"
aria-atomic="true"
>
<AnimatePresence initial={false}>
{toasts.map((toast) => (
<m.div
key={toast.id}
initial={{ opacity: 0, y: 12, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 8, scale: 0.96 }}
transition={{ duration: 0.22, ease: [0.22, 1, 0.36, 1] }}
role="status"
className={[
"pointer-events-auto flex max-w-[90vw] items-center gap-2 rounded-full border px-4 py-2.5 text-sm font-medium shadow-2xl shadow-black/50 backdrop-blur-md",
toast.variant === "success"
? "border-ark-gold/40 bg-[#1c1c21]/95 text-ark-gold2"
: "border-red-500/40 bg-[#1c1c21]/95 text-red-300",
].join(" ")}
>
<span
className={[
"h-2 w-2 shrink-0 rounded-full",
toast.variant === "success" ? "bg-ark-gold" : "bg-red-400",
].join(" ")}
aria-hidden
/>
{toast.message}
</m.div>
))}
</AnimatePresence>
</div>
</ToastContext.Provider>
);
}

View File

@@ -5,6 +5,7 @@ import { useI18n } from "../../i18n";
import type { Attachment } from "../../types/post"; import type { Attachment } from "../../types/post";
import { downloadAttachment } from "./utils/downloadFile"; import { downloadAttachment } from "./utils/downloadFile";
import { formatBytes } from "./utils/formatBytes"; import { formatBytes } from "./utils/formatBytes";
import { useToast } from "../Toast";
type AttachmentDownloadPillProps = { type AttachmentDownloadPillProps = {
postId: string; postId: string;
@@ -20,15 +21,21 @@ export function AttachmentDownloadPill({
className = "absolute left-2 top-2", className = "absolute left-2 top-2",
}: AttachmentDownloadPillProps) { }: AttachmentDownloadPillProps) {
const { t } = useI18n(); const { t } = useI18n();
const { showToast } = useToast();
const [isDownloading, setIsDownloading] = useState(false); const [isDownloading, setIsDownloading] = useState(false);
const handleDownload = (e: MouseEvent<HTMLButtonElement>) => { const handleDownload = async (e: MouseEvent<HTMLButtonElement>) => {
e.stopPropagation(); e.stopPropagation();
if (isDownloading) return; if (isDownloading) return;
setIsDownloading(true); setIsDownloading(true);
void downloadAttachment(postId, attachment.id, attachment.filename) try {
.finally(() => setIsDownloading(false)) await downloadAttachment(postId, attachment.id, attachment.filename);
.catch(() => {}); showToast(t("downloadOk"));
} catch {
showToast(t("downloadFail"), "error");
} finally {
setIsDownloading(false);
}
}; };
return ( return (

View File

@@ -35,7 +35,7 @@ export function MessageBubble({ post }: { post: Post }) {
return ( return (
<div <div
id={`post-${post.id}`} id={`post-${post.id}`}
className="mx-auto w-full max-w-[358px] md:max-w-[680px] lg:max-w-[900px] xl:max-w-[1120px]" className="mx-auto w-full max-w-[358px] scroll-mt-20 md:max-w-[680px] md:scroll-mt-24 lg:max-w-[900px] xl:max-w-[1120px]"
> >
<article <article
className={`relative w-full overflow-hidden rounded-2xl bg-[#272632] text-left shadow-sm ${ className={`relative w-full overflow-hidden rounded-2xl bg-[#272632] text-left shadow-sm ${

View File

@@ -1,8 +1,10 @@
import { useEffect, useMemo, useRef } from "react"; import { useEffect, useMemo, useRef } from "react";
import { useSearchParams } from "react-router-dom"; import { useLocation, useSearchParams } from "react-router-dom";
import { postJSON } from "../../api"; import { postJSON } from "../../api";
import { useI18n } from "../../i18n"; import { useI18n } from "../../i18n";
import type { PostScope } from "../../types/post"; import type { PostScope } from "../../types/post";
import { Reveal } from "../../motion";
import { Skeleton } from "../Skeleton";
import { FilterChips } from "./FilterChips"; import { FilterChips } from "./FilterChips";
import { MessageBubble } from "./MessageBubble"; import { MessageBubble } from "./MessageBubble";
import { useGroupedByDay } from "./hooks/useGroupedByDay"; import { useGroupedByDay } from "./hooks/useGroupedByDay";
@@ -15,6 +17,7 @@ export type MessageStreamProps = {
export function MessageStream({ scope }: MessageStreamProps) { export function MessageStream({ scope }: MessageStreamProps) {
const { t, lang } = useI18n(); const { t, lang } = useI18n();
const [sp, setSp] = useSearchParams(); const [sp, setSp] = useSearchParams();
const { hash } = useLocation();
const type = sp.get("type") || "all"; const type = sp.get("type") || "all";
const q = (sp.get("q") || "").trim(); const q = (sp.get("q") || "").trim();
@@ -65,6 +68,38 @@ export function MessageStream({ scope }: MessageStreamProps) {
return () => io.disconnect(); return () => io.disconnect();
}, [loadMore]); }, [loadMore]);
// When arriving with a `#post-<id>` 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<string>("");
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 updateParam = (key: string, value: string) => {
const n = new URLSearchParams(sp); const n = new URLSearchParams(sp);
if (!value || value === "all") n.delete(key); if (!value || value === "all") n.delete(key);
@@ -72,15 +107,36 @@ export function MessageStream({ scope }: MessageStreamProps) {
setSp(n, { replace: true }); setSp(n, { replace: true });
}; };
const isInitialLoad = isLoading && items.length === 0;
return ( return (
<div className="mx-auto max-w-full md:max-w-[820px] lg:max-w-[1080px] xl:max-w-[1180px]"> <div className="mx-auto max-w-full md:max-w-[820px] lg:max-w-[1080px] xl:max-w-[1180px]">
<FilterChips type={type} onTypeChange={(v) => updateParam("type", v)} /> <FilterChips type={type} onTypeChange={(v) => updateParam("type", v)} />
<div className="flex flex-col gap-3 px-4 pt-4 md:px-0 md:pt-2"> <div className="flex flex-col gap-3 px-4 pt-4 md:px-0 md:pt-2">
{isInitialLoad ? (
<>
{Array.from({ length: 10 }).map((_, i) => (
<div
key={i}
className="mx-auto w-full max-w-[358px] md:max-w-[680px] lg:max-w-[900px] xl:max-w-[1120px]"
>
<Skeleton
className={`rounded-2xl ${
i % 3 === 0 ? "h-[220px]" : "h-[80px]"
}`}
/>
</div>
))}
</>
) : (
<>
{groups.map((group) => ( {groups.map((group) => (
<div key={group.dayKey} className="flex flex-col gap-3"> <div key={group.dayKey} className="flex flex-col gap-3">
{group.items.map((post) => ( {group.items.map((post, index) => (
<MessageBubble key={post.id} post={post} /> <Reveal key={post.id} delay={Math.min(index, 8) * 0.05}>
<MessageBubble post={post} />
</Reveal>
))} ))}
</div> </div>
))} ))}
@@ -107,6 +163,8 @@ export function MessageStream({ scope }: MessageStreamProps) {
{isLoading ? ( {isLoading ? (
<div className="py-4 text-center text-xs text-neutral-500"></div> <div className="py-4 text-center text-xs text-neutral-500"></div>
) : null} ) : null}
</>
)}
<div ref={sentinelRef} aria-hidden className="h-1" /> <div ref={sentinelRef} aria-hidden className="h-1" />
</div> </div>

View File

@@ -10,6 +10,7 @@ import { autolink } from "../utils/autolink";
import { downloadAttachment } from "../utils/downloadFile"; import { downloadAttachment } from "../utils/downloadFile";
import { formatBytes } from "../utils/formatBytes"; import { formatBytes } from "../utils/formatBytes";
import { postDisplayText } from "../utils/postText"; import { postDisplayText } from "../utils/postText";
import { useToast } from "../../Toast";
const MAX_VISIBLE = 4; const MAX_VISIBLE = 4;
@@ -32,14 +33,20 @@ function ImageListDownloadButton({
attachment: Attachment; attachment: Attachment;
}) { }) {
const { t } = useI18n(); const { t } = useI18n();
const { showToast } = useToast();
const [isDownloading, setIsDownloading] = useState(false); const [isDownloading, setIsDownloading] = useState(false);
const handleDownload = () => { const handleDownload = async () => {
if (isDownloading) return; if (isDownloading) return;
setIsDownloading(true); setIsDownloading(true);
void downloadAttachment(postId, attachment.id, attachment.filename) try {
.finally(() => setIsDownloading(false)) await downloadAttachment(postId, attachment.id, attachment.filename);
.catch(() => {}); showToast(t("downloadOk"));
} catch {
showToast(t("downloadFail"), "error");
} finally {
setIsDownloading(false);
}
}; };
return ( return (

View File

@@ -11,19 +11,26 @@ import {
} from "../utils/filenameDisplay"; } from "../utils/filenameDisplay";
import { formatBytes } from "../utils/formatBytes"; import { formatBytes } from "../utils/formatBytes";
import { postDisplayText } from "../utils/postText"; import { postDisplayText } from "../utils/postText";
import { useToast } from "../../Toast";
function AttachmentRow({ postId, att }: { postId: string; att: Attachment }) { function AttachmentRow({ postId, att }: { postId: string; att: Attachment }) {
const { t } = useI18n(); const { t } = useI18n();
const { showToast } = useToast();
const { Icon, color } = fileIcon({ mime: att.mime, filename: att.filename }); const { Icon, color } = fileIcon({ mime: att.mime, filename: att.filename });
const displayFilename = filenameWithExtension(att.filename, att.mime); const displayFilename = filenameWithExtension(att.filename, att.mime);
const [isDownloading, setIsDownloading] = useState(false); const [isDownloading, setIsDownloading] = useState(false);
const handleDownload = () => { const handleDownload = async () => {
if (isDownloading) return; if (isDownloading) return;
setIsDownloading(true); setIsDownloading(true);
void downloadAttachment(postId, att.id, displayFilename) try {
.finally(() => setIsDownloading(false)) await downloadAttachment(postId, att.id, displayFilename);
.catch(() => {}); showToast(t("downloadOk"));
} catch {
showToast(t("downloadFail"), "error");
} finally {
setIsDownloading(false);
}
}; };
return ( return (

View File

@@ -10,6 +10,7 @@ import { autolink } from "../utils/autolink";
import { downloadAttachment } from "../utils/downloadFile"; import { downloadAttachment } from "../utils/downloadFile";
import { formatBytes } from "../utils/formatBytes"; import { formatBytes } from "../utils/formatBytes";
import { postDisplayText } from "../utils/postText"; import { postDisplayText } from "../utils/postText";
import { useToast } from "../../Toast";
const MAX_VISIBLE = 4; const MAX_VISIBLE = 4;
@@ -173,14 +174,20 @@ function AttachmentListDownloadButton({
attachment: Attachment; attachment: Attachment;
}) { }) {
const { t } = useI18n(); const { t } = useI18n();
const { showToast } = useToast();
const [isDownloading, setIsDownloading] = useState(false); const [isDownloading, setIsDownloading] = useState(false);
const handleDownload = () => { const handleDownload = async () => {
if (isDownloading) return; if (isDownloading) return;
setIsDownloading(true); setIsDownloading(true);
void downloadAttachment(postId, attachment.id, attachment.filename) try {
.finally(() => setIsDownloading(false)) await downloadAttachment(postId, attachment.id, attachment.filename);
.catch(() => {}); showToast(t("downloadOk"));
} catch {
showToast(t("downloadFail"), "error");
} finally {
setIsDownloading(false);
}
}; };
return ( return (

View File

@@ -32,6 +32,7 @@ const zhDict: Dict = {
tagPostsTitle: "#{{tag}} 相关资料", tagPostsTitle: "#{{tag}} 相关资料",
noTagPosts: "暂时找不到带有此标签的资料。", noTagPosts: "暂时找不到带有此标签的资料。",
viewAll: "查看全部", viewAll: "查看全部",
backToTop: "回到顶部",
heroTitle: "ARK 官方数据库", heroTitle: "ARK 官方数据库",
heroSub: heroSub:
"集中、分类、管理 ARK 数据库,让你快速找到所需资源,推动社群共识与成长。", "集中、分类、管理 ARK 数据库,让你快速找到所需资源,推动社群共识与成长。",
@@ -42,6 +43,8 @@ const zhDict: Dict = {
preview: "预览", preview: "预览",
download: "下载", download: "下载",
downloading: "下载中…", downloading: "下载中…",
downloadOk: "下载完成",
downloadFail: "下载失败,请重试",
share: "分享", share: "分享",
langLabel: "语言", langLabel: "语言",
admin: "后台", admin: "后台",
@@ -159,6 +162,7 @@ const enDict: Dict = {
tagPostsTitle: "#{{tag}} related posts", tagPostsTitle: "#{{tag}} related posts",
noTagPosts: "No posts with this tag yet.", noTagPosts: "No posts with this tag yet.",
viewAll: "View all", viewAll: "View all",
backToTop: "Back to top",
heroTitle: "ARK Official Library", heroTitle: "ARK Official Library",
heroSub: heroSub:
"Centralize, organize, and manage the ARK library so you can find what you need fast and help the community grow together.", "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", preview: "Preview",
download: "Download", download: "Download",
downloading: "Downloading…", downloading: "Downloading…",
downloadOk: "Download complete",
downloadFail: "Download failed, please retry",
share: "Share", share: "Share",
langLabel: "Language", langLabel: "Language",
admin: "Admin", admin: "Admin",

View File

@@ -20,9 +20,6 @@ body {
/* Match theme: avoid default blue accent on controls */ /* Match theme: avoid default blue accent on controls */
:root { :root {
accent-color: #eeb726; accent-color: #eeb726;
--animate-duration: 720ms;
--animate-delay: 0s;
--animate-repeat: 1;
} }
header a, 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 單列) */ /* Desktop header nav: thin scrollbar when links overflow (still 單列) */
.header-nav-scroll { .header-nav-scroll {
scrollbar-width: thin; scrollbar-width: thin;
@@ -89,6 +66,73 @@ header button {
box-shadow: inset 0 -2px 0 #eeb726; 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-<id> 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,
.message-stream-copyable-text * { .message-stream-copyable-text * {
-webkit-user-select: text; -webkit-user-select: text;

View File

@@ -1,7 +1,15 @@
import { ChevronDown, Menu, Search as SearchIcon, X } from "lucide-react"; import { ChevronDown, Menu, Search as SearchIcon, X } from "lucide-react";
import { AnimatePresence, m } from "framer-motion";
import { useEffect, useRef, useState } from "react"; 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 { ArkLogoMark } from "../components/ArkLogoMark";
import { BackToTop } from "../components/BackToTop";
import { DocumentMeta } from "../components/DocumentMeta"; import { DocumentMeta } from "../components/DocumentMeta";
import { SearchPanel } from "../components/SearchPanel"; import { SearchPanel } from "../components/SearchPanel";
import { useI18n, type Lang } from "../i18n"; import { useI18n, type Lang } from "../i18n";
@@ -53,7 +61,9 @@ function navIsActive(
function navClassName(active: boolean) { function navClassName(active: boolean) {
return [ 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", "focus-visible:ring-2 focus-visible:ring-ark-gold/90 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg",
active active
? "text-ark-gold visited:text-ark-gold" ? "text-ark-gold visited:text-ark-gold"
@@ -274,6 +284,7 @@ function MobileLanguageButton({
export function PublicLayout() { export function PublicLayout() {
const { t, lang, setLang } = useI18n(); const { t, lang, setLang } = useI18n();
const { pathname, search, hash } = useLocation(); const { pathname, search, hash } = useLocation();
const outlet = useOutlet();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [mobileSearchOpen, setMobileSearchOpen] = useState(false); const [mobileSearchOpen, setMobileSearchOpen] = useState(false);
const [q, setQ] = useState(""); 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" : "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"
}`} }`}
> >
<div key={`${pathname}${search}`} className="ark-page-fade-in"> <AnimatePresence mode="wait" initial={false}>
<Outlet /> <m.div
</div> key={`${pathname}${search}`}
variants={pageTransition}
initial="initial"
animate="enter"
exit="exit"
>
{outlet}
</m.div>
</AnimatePresence>
</main> </main>
<footer className="mt-auto bg-transparent md:border-t md:border-ark-line md:bg-ark-nav/90"> <footer className="mt-auto bg-transparent md:border-t md:border-ark-line md:bg-ark-nav/90">
@@ -654,6 +673,8 @@ export function PublicLayout() {
/> />
</div> </div>
</nav> </nav>
<BackToTop />
</div> </div>
); );
} }

View File

@@ -1,6 +1,5 @@
import React from "react"; import React from "react";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import "animate.css";
import "./index.css"; import "./index.css";
const adminOnly = import.meta.env.VITE_ADMIN_ONLY === "true"; const adminOnly = import.meta.env.VITE_ADMIN_ONLY === "true";

View File

@@ -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 (
<LazyMotion features={domAnimation} strict>
<MotionConfig reducedMotion="user">{children}</MotionConfig>
</LazyMotion>
);
}

40
src/motion/Reveal.tsx Normal file
View File

@@ -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 (
<m.div
className={className}
variants={fadeInUp}
custom={delay}
initial="hidden"
whileInView="visible"
viewport={{ once, amount }}
>
{children}
</m.div>
);
}

12
src/motion/index.ts Normal file
View File

@@ -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";

View File

@@ -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<T extends HTMLElement = HTMLDivElement>(
options?: RevealOptions,
) {
const ref = useRef<T>(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 };
}

58
src/motion/variants.ts Normal file
View File

@@ -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 },
},
};

View File

@@ -1,13 +1,18 @@
import { useI18n } from "../../i18n"; import { useI18n } from "../../i18n";
import { Reveal } from "../../motion";
export function AboutPage() { export function AboutPage() {
const { t } = useI18n(); const { t } = useI18n();
return ( return (
<div className="mx-auto max-w-2xl space-y-6"> <div className="mx-auto max-w-2xl space-y-6">
<Reveal>
<h1 className="text-2xl font-bold">{t("aboutTitle")}</h1> <h1 className="text-2xl font-bold">{t("aboutTitle")}</h1>
</Reveal>
<Reveal delay={0.08}>
<p className="text-neutral-300 leading-relaxed whitespace-pre-line"> <p className="text-neutral-300 leading-relaxed whitespace-pre-line">
{t("aboutIntro")} {t("aboutIntro")}
</p> </p>
</Reveal>
</div> </div>
); );
} }

View File

@@ -3,8 +3,10 @@ import { Link } from "react-router-dom";
import { getJSON, itemsOrEmpty, readJSONCache, type Category } from "../../api"; import { getJSON, itemsOrEmpty, readJSONCache, type Category } from "../../api";
import { CategoryIcon } from "../../components/CategoryIcon"; import { CategoryIcon } from "../../components/CategoryIcon";
import { SectionHeader } from "../../components/SectionHeader"; import { SectionHeader } from "../../components/SectionHeader";
import { Skeleton } from "../../components/Skeleton";
import { langQuery, useI18n } from "../../i18n"; import { langQuery, useI18n } from "../../i18n";
import { cleanCategoryDisplayName } from "../../utils/categoryDisplay"; import { cleanCategoryDisplayName } from "../../utils/categoryDisplay";
import { Reveal } from "../../motion";
const FIGMA_CATEGORY_ORDER = [ const FIGMA_CATEGORY_ORDER = [
"project-ppt", "project-ppt",
@@ -70,13 +72,26 @@ export function CategoriesPage() {
); );
} }
const isLoading = cats.length === 0;
return ( return (
<section> <section>
<SectionHeader title={t("categorySection")} /> <SectionHeader title={t("categorySection")} />
<div className="mt-7 grid grid-cols-3 gap-2 md:grid-cols-5 md:gap-3 lg:grid-cols-6 xl:grid-cols-7 xl:gap-4"> <div className="mt-7 grid grid-cols-3 gap-2 md:grid-cols-5 md:gap-3 lg:grid-cols-6 xl:grid-cols-7 xl:gap-4">
{cats.map((category) => ( {isLoading
<Link ? Array.from({ length: 14 }).map((_, i) => (
<Skeleton
key={i}
className="h-[88px] rounded-xl"
/>
))
: cats.map((category, index) => (
<Reveal
key={category.id} key={category.id}
delay={Math.min(index, 8) * 0.05}
className="h-[88px]"
>
<Link
to={`/category/${encodeURIComponent(category.slug)}`} to={`/category/${encodeURIComponent(category.slug)}`}
className="flex h-[88px] min-w-0 flex-col items-center justify-center gap-2 rounded-xl border border-[#27292E] bg-[#1D1E23] px-4 py-3 text-center outline-none transition hover:border-ark-gold/55 hover:bg-[#252630] focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg" className="flex h-[88px] min-w-0 flex-col items-center justify-center gap-2 rounded-xl border border-[#27292E] bg-[#1D1E23] px-4 py-3 text-center outline-none transition hover:border-ark-gold/55 hover:bg-[#252630] focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg"
> >
@@ -89,6 +104,7 @@ export function CategoriesPage() {
{cleanCategoryDisplayName(category.name)} {cleanCategoryDisplayName(category.name)}
</div> </div>
</Link> </Link>
</Reveal>
))} ))}
</div> </div>
</section> </section>

View File

@@ -1,12 +1,13 @@
import { Heart } from "lucide-react"; import { Heart } from "lucide-react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { useI18n } from "../../i18n"; import { useI18n } from "../../i18n";
import { Reveal } from "../../motion";
export default function Favorites() { export default function Favorites() {
const { t } = useI18n(); const { t } = useI18n();
return ( return (
<div className="flex min-h-[60vh] flex-col items-center justify-center gap-5 px-4 py-12 text-center"> <Reveal className="flex min-h-[60vh] flex-col items-center justify-center gap-5 px-4 py-12 text-center">
<div className="flex h-20 w-20 items-center justify-center rounded-full border border-ark-gold/30 bg-ark-gold/5"> <div className="flex h-20 w-20 items-center justify-center rounded-full border border-ark-gold/30 bg-ark-gold/5">
<Heart <Heart
className="h-10 w-10 text-ark-gold/70" className="h-10 w-10 text-ark-gold/70"
@@ -33,6 +34,6 @@ export default function Favorites() {
> >
{t("backToHome")} {t("backToHome")}
</Link> </Link>
</div> </Reveal>
); );
} }

View File

@@ -19,6 +19,7 @@ import {
type PostBackedResource, type PostBackedResource,
} from "../../utils/postResourceAdapter"; } from "../../utils/postResourceAdapter";
import type { Post } from "../../types/post"; import type { Post } from "../../types/post";
import { Reveal } from "../../motion";
const FIGMA_CATEGORY_ORDER = [ const FIGMA_CATEGORY_ORDER = [
"project-ppt", "project-ppt",
@@ -269,6 +270,7 @@ export function Home() {
<FigmaBanner /> <FigmaBanner />
</section> </section>
<Reveal delay={0}>
<section id="categories" className="scroll-mt-16 md:scroll-mt-24"> <section id="categories" className="scroll-mt-16 md:scroll-mt-24">
<div className="px-4 md:px-0"> <div className="px-4 md:px-0">
<SectionHeader <SectionHeader
@@ -342,9 +344,9 @@ export function Home() {
</div> </div>
<div className="mt-7 hidden grid-cols-3 gap-3 min-[440px]:gap-3.5 md:grid md:grid-cols-5 md:gap-3 lg:grid-cols-6 xl:grid-cols-7 xl:gap-4"> <div className="mt-7 hidden grid-cols-3 gap-3 min-[440px]:gap-3.5 md:grid md:grid-cols-5 md:gap-3 lg:grid-cols-6 xl:grid-cols-7 xl:gap-4">
{figmaOrderedCategories.map((c) => ( {figmaOrderedCategories.map((c, index) => (
<Reveal key={c.id} delay={Math.min(index, 8) * 0.05}>
<Link <Link
key={c.id}
to={`/category/${encodeURIComponent(c.slug)}`} to={`/category/${encodeURIComponent(c.slug)}`}
className="flex h-[88px] min-w-0 flex-col items-center justify-center gap-2 rounded-xl border border-[#27292E] bg-[#1D1E23] px-4 py-3 text-center outline-none transition hover:border-ark-gold/55 hover:bg-[#252630] focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg" className="flex h-[88px] min-w-0 flex-col items-center justify-center gap-2 rounded-xl border border-[#27292E] bg-[#1D1E23] px-4 py-3 text-center outline-none transition hover:border-ark-gold/55 hover:bg-[#252630] focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg"
> >
@@ -357,10 +359,13 @@ export function Home() {
{cleanCategoryDisplayName(c.name)} {cleanCategoryDisplayName(c.name)}
</div> </div>
</Link> </Link>
</Reveal>
))} ))}
</div> </div>
</section> </section>
</Reveal>
<Reveal>
<section id="official" className="scroll-mt-16 md:scroll-mt-24"> <section id="official" className="scroll-mt-16 md:scroll-mt-24">
<div className="px-4 md:px-0"> <div className="px-4 md:px-0">
<SectionHeader <SectionHeader
@@ -376,7 +381,9 @@ export function Home() {
> >
{rec.map((r, index) => ( {rec.map((r, index) => (
<div key={r.id}> <div key={r.id}>
<Reveal delay={Math.min(index, 8) * 0.05}>
<RecommendedCard r={r} visualIndex={index} useFigmaDesign /> <RecommendedCard r={r} visualIndex={index} useFigmaDesign />
</Reveal>
</div> </div>
))} ))}
</div> </div>
@@ -435,7 +442,9 @@ export function Home() {
) : null} ) : null}
</div> </div>
</section> </section>
</Reveal>
<Reveal>
<section id="latest" className="scroll-mt-16 md:scroll-mt-24"> <section id="latest" className="scroll-mt-16 md:scroll-mt-24">
<div className="px-4 md:px-0"> <div className="px-4 md:px-0">
<SectionHeader <SectionHeader
@@ -490,8 +499,10 @@ export function Home() {
) : null} ) : null}
</div> </div>
</section> </section>
</Reveal>
{hasPopular ? ( {hasPopular ? (
<Reveal>
<section id="popular" className="scroll-mt-16 md:scroll-mt-24"> <section id="popular" className="scroll-mt-16 md:scroll-mt-24">
<div className="px-4 md:px-0"> <div className="px-4 md:px-0">
<SectionHeader <SectionHeader
@@ -521,6 +532,7 @@ export function Home() {
))} ))}
</div> </div>
</section> </section>
</Reveal>
) : null} ) : null}
</div> </div>
); );

View File

@@ -14,6 +14,24 @@ export default {
muted: "#8f9099", 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: { fontFamily: {
sans: [ sans: [
"Noto Sans SC", "Noto Sans SC",