feat: scroll to post bubble from recommended card + back-to-top button
Some checks failed
Deploy to Frontend Servers / deploy (push) Failing after 14s
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:
56
package-lock.json
generated
56
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
45
src/components/BackToTop.tsx
Normal file
45
src/components/BackToTop.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
24
src/components/Skeleton.tsx
Normal file
24
src/components/Skeleton.tsx
Normal 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
98
src/components/Toast.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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 ${
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
21
src/motion/MotionProvider.tsx
Normal file
21
src/motion/MotionProvider.tsx
Normal 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
40
src/motion/Reveal.tsx
Normal 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
12
src/motion/index.ts
Normal 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";
|
||||||
50
src/motion/useRevealOnScroll.ts
Normal file
50
src/motion/useRevealOnScroll.ts
Normal 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
58
src/motion/variants.ts
Normal 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 },
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user