From 88a25b6ad41137fd5813f0427f6d84ab7d1db35e Mon Sep 17 00:00:00 2001 From: TerryM Date: Fri, 29 May 2026 11:50:27 +0800 Subject: [PATCH] feat: scroll to post bubble from recommended card + back-to-top button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recommended cards already routed to /browse#post-, 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- 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) --- package-lock.json | 56 +++++++-- package.json | 2 +- src/App.tsx | 10 +- src/components/BackToTop.tsx | 45 +++++++ src/components/RecommendedCard.tsx | 41 ++++--- src/components/Skeleton.tsx | 24 ++++ src/components/Toast.tsx | 98 +++++++++++++++ .../messageStream/AttachmentDownloadPill.tsx | 15 ++- .../messageStream/MessageBubble.tsx | 2 +- .../messageStream/MessageStream.tsx | 112 +++++++++++++----- .../messageStream/bubbles/AlbumBubble.tsx | 15 ++- .../messageStream/bubbles/FileDocBubble.tsx | 15 ++- .../messageStream/bubbles/VideoBubble.tsx | 15 ++- src/i18n.tsx | 6 + src/index.css | 90 ++++++++++---- src/layouts/PublicLayout.tsx | 31 ++++- src/main.tsx | 1 - src/motion/MotionProvider.tsx | 21 ++++ src/motion/Reveal.tsx | 40 +++++++ src/motion/index.ts | 12 ++ src/motion/useRevealOnScroll.ts | 50 ++++++++ src/motion/variants.ts | 58 +++++++++ src/pages/About/index.tsx | 13 +- src/pages/Categories/index.tsx | 48 +++++--- src/pages/Favorites/index.tsx | 5 +- src/pages/Home/index.tsx | 44 ++++--- tailwind.config.js | 18 +++ 27 files changed, 748 insertions(+), 139 deletions(-) create mode 100644 src/components/BackToTop.tsx create mode 100644 src/components/Skeleton.tsx create mode 100644 src/components/Toast.tsx create mode 100644 src/motion/MotionProvider.tsx create mode 100644 src/motion/Reveal.tsx create mode 100644 src/motion/index.ts create mode 100644 src/motion/useRevealOnScroll.ts create mode 100644 src/motion/variants.ts diff --git a/package-lock.json b/package-lock.json index 963e66b..1fd805d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,7 @@ "name": "ark-database-web", "version": "1.0.0", "dependencies": { - "animate.css": "^4.1.1", + "framer-motion": "^11.18.2", "lucide-react": "^0.460.0", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -1722,12 +1722,6 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/animate.css": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/animate.css/-/animate.css-4.1.1.tgz", - "integrity": "sha512-+mRmCTv6SbCmtYJCN4faJMNFVNN5EuCTTprDTAo7YzIGji2KADmakjVA3+8mVDkZ2Bf09vayB35lSQIex2+QaQ==", - "license": "MIT" - }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -2360,6 +2354,33 @@ "url": "https://github.com/sponsors/rawify" } }, + "node_modules/framer-motion": { + "version": "11.18.2", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-11.18.2.tgz", + "integrity": "sha512-5F5Och7wrvtLVElIpclDT0CBzMVg3dL22B64aZwHtsIY8RB4mXICLrkajK4G9R+ieSAGcgrLeae2SeUTg2pr6w==", + "license": "MIT", + "dependencies": { + "motion-dom": "^11.18.1", + "motion-utils": "^11.18.1", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2774,6 +2795,21 @@ "node": ">=4" } }, + "node_modules/motion-dom": { + "version": "11.18.1", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-11.18.1.tgz", + "integrity": "sha512-g76KvA001z+atjfxczdRtw/RXOM3OMSdd1f4DL77qCTF/+avrRJiawSG4yDibEQ215sr9kpinSlX2pCTJ9zbhw==", + "license": "MIT", + "dependencies": { + "motion-utils": "^11.18.1" + } + }, + "node_modules/motion-utils": { + "version": "11.18.1", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-11.18.1.tgz", + "integrity": "sha512-49Kt+HKjtbJKLtgO/LKj9Ld+6vw9BjH5d9sc40R/kVyH8GLAXgT42M2NnuPcJNuA3s9ZfZBUcwIgpmZWGEE+hA==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -3728,6 +3764,12 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", diff --git a/package.json b/package.json index c40e5ff..ec43e26 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "test:watch": "vitest" }, "dependencies": { - "animate.css": "^4.1.1", + "framer-motion": "^11.18.2", "lucide-react": "^0.460.0", "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/src/App.tsx b/src/App.tsx index b366566..c661b21 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,7 @@ import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom"; import { I18nProvider } from "./i18n"; +import { MotionProvider } from "./motion"; +import { ToastProvider } from "./components/Toast"; import { PublicLayout } from "./layouts/PublicLayout"; import { Home } from "./pages/Home"; import { Browse } from "./pages/Browse"; @@ -21,7 +23,9 @@ const adminEnabled = import.meta.env.VITE_DISABLE_ADMIN !== "true"; export default function App() { return ( - + + + @@ -55,7 +59,9 @@ export default function App() { - + + + ); } diff --git a/src/components/BackToTop.tsx b/src/components/BackToTop.tsx new file mode 100644 index 0000000..99afa8f --- /dev/null +++ b/src/components/BackToTop.tsx @@ -0,0 +1,45 @@ +import { ArrowUp } from "lucide-react"; +import { AnimatePresence, m } from "framer-motion"; +import { useEffect, useState } from "react"; +import { useI18n } from "../i18n"; + +const SHOW_AFTER = 400; + +/** + * Floating "back to top" button. Appears once the page is scrolled past + * SHOW_AFTER px and smoothly returns the window to the top on click. Sits + * above the mobile bottom nav and clears it on larger screens. + */ +export function BackToTop() { + const { t } = useI18n(); + const [visible, setVisible] = useState(false); + + useEffect(() => { + const update = () => setVisible(window.scrollY > SHOW_AFTER); + update(); + window.addEventListener("scroll", update, { passive: true }); + return () => window.removeEventListener("scroll", update); + }, []); + + return ( + + {visible ? ( + + window.scrollTo({ top: 0, behavior: "smooth" }) + } + className="fixed bottom-[94px] right-4 z-30 flex h-11 w-11 items-center justify-center rounded-full bg-ark-gold text-black shadow-lg shadow-black/40 outline-none transition hover:bg-ark-gold2 active:scale-95 focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg md:bottom-8 md:right-8" + aria-label={t("backToTop")} + title={t("backToTop")} + > + + + ) : null} + + ); +} diff --git a/src/components/RecommendedCard.tsx b/src/components/RecommendedCard.tsx index 03c4d89..cc08720 100644 --- a/src/components/RecommendedCard.tsx +++ b/src/components/RecommendedCard.tsx @@ -1,4 +1,5 @@ import { Download, LoaderCircle } from "lucide-react"; +import { m } from "framer-motion"; import { Link } from "react-router-dom"; import type { Resource } from "../api"; import { assetUrl } from "../api"; @@ -11,13 +12,20 @@ import { downloadAttachment, downloadFile, } from "./messageStream/utils/downloadFile"; +import { useToast } from "./Toast"; function isPlaceholderAsset(path: string | undefined | null) { return !path || path.includes("placeholder-cover"); } const CARD_BASE_CLASS = - "group flex shrink-0 flex-col overflow-hidden rounded-xl border bg-[#1D1E23] transition hover:border-ark-gold/55"; + "group flex shrink-0 flex-col overflow-hidden rounded-xl border bg-[#1D1E23] transition hover:border-ark-gold/55 hover:shadow-lg hover:shadow-black/30"; + +const CARD_HOVER_SPRING = { + type: "spring", + stiffness: 380, + damping: 26, +} as const; const CARD_CAROUSEL_SIZE_CLASS = "w-[208px] md:w-[240px] lg:w-[246.4px] min-[1100px]:max-xl:w-[273px] xl:w-[246.4px]"; @@ -41,6 +49,7 @@ export function RecommendedCard({ layout?: "carousel" | "grid"; }) { const { t } = useI18n(); + const { showToast } = useToast(); const [isDownloading, setIsDownloading] = useState(false); const figmaCover = officialRecommendationCoverFallbacks[ @@ -73,18 +82,21 @@ export function RecommendedCard({ r.downloadAttachmentId, displayTitle, ); - return; + } else { + await downloadFile(dl, displayTitle); } - await downloadFile(dl, displayTitle); + showToast(t("downloadOk")); } catch { - /* ignore */ + showToast(t("downloadFail"), "error"); } finally { setIsDownloading(false); } }; return ( -
e.currentTarget.classList.add("is-loaded")} /> ) : (
@@ -164,8 +173,8 @@ export function RecommendedCard({ type="button" className={ useFigmaDesign - ? "flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[#191921] text-ark-gold outline-none transition hover:bg-[#22232D] focus-visible:ring-2 focus-visible:ring-ark-gold/80 disabled:cursor-wait" - : "shrink-0 rounded-lg p-1 text-ark-gold outline-none hover:bg-ark-gold/10 focus-visible:ring-2 focus-visible:ring-ark-gold/80 disabled:cursor-wait" + ? "flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[#191921] text-ark-gold outline-none transition hover:bg-[#22232D] active:scale-95 focus-visible:ring-2 focus-visible:ring-ark-gold/80 disabled:cursor-wait" + : "shrink-0 rounded-lg p-1 text-ark-gold outline-none transition hover:bg-ark-gold/10 active:scale-95 focus-visible:ring-2 focus-visible:ring-ark-gold/80 disabled:cursor-wait" } title={isDownloading ? t("downloading") : t("download")} aria-label={isDownloading ? t("downloading") : t("download")} @@ -179,11 +188,7 @@ export function RecommendedCard({ > {isDownloading ? ( ) : useFigmaDesign ? ( @@ -195,7 +200,7 @@ export function RecommendedCard({ ) : null}
-
+ ); } diff --git a/src/components/Skeleton.tsx b/src/components/Skeleton.tsx new file mode 100644 index 0000000..e68ed1d --- /dev/null +++ b/src/components/Skeleton.tsx @@ -0,0 +1,24 @@ +import type { CSSProperties } from "react"; + +type SkeletonProps = { + /** Sizing / rounding utilities, e.g. "h-[88px] w-full rounded-xl". */ + className?: string; + style?: CSSProperties; +}; + +/** + * Reusable shimmer placeholder. Renders the reduced-motion-guarded + * `.ark-skeleton` utility (defined in index.css), so the shimmer animation + * automatically stops when the user prefers reduced motion. Pass the same + * sizing/rounding classes as the real content it stands in for to avoid any + * layout shift when the data loads. + */ +export function Skeleton({ className = "", style }: SkeletonProps) { + return ( +