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 ( +