Initial frontend import

This commit is contained in:
TerryM
2026-05-16 00:18:22 +08:00
commit 9c54ffec76
99 changed files with 14992 additions and 0 deletions

View File

@@ -0,0 +1,17 @@
import logoSrc from "../assets/logo-primary.webp?url";
/** Primary ARK mark — imported so Vite emits a content-hashed URL (avoids stale inline-SVG JS + stable /assets/*.webp CDN cache). */
export function ArkLogoMark({ className = "" }: { className?: string }) {
return (
<img
src={logoSrc}
alt=""
className={["object-contain select-none", className]
.filter(Boolean)
.join(" ")}
decoding="async"
draggable={false}
aria-hidden
/>
);
}

View File

@@ -0,0 +1,63 @@
import {
BookOpen,
Calendar,
Film,
Folder,
Gift,
Globe2,
GraduationCap,
Hash,
Image as ImageIcon,
Megaphone,
MessageCircle,
Newspaper,
Palette,
Play,
type LucideIcon,
} from "lucide-react";
import { categorySvgUrlForSlug } from "../lib/categorySvgSlug";
const map: Record<string, LucideIcon> = {
folder: Folder,
calendar: Calendar,
megaphone: Megaphone,
graduation: GraduationCap,
globe: Globe2,
image: ImageIcon,
chat: MessageCircle,
film: Film,
gift: Gift,
book: BookOpen,
palette: Palette,
newspaper: Newspaper,
play: Play,
hash: Hash,
};
export function CategoryIcon({
iconKey,
categorySlug,
className,
}: {
iconKey: string;
/** When set, prefer branded SVG from `public/assets/ark-library/media/svg/`. */
categorySlug?: string;
className?: string;
}) {
const svgUrl = categorySlug ? categorySvgUrlForSlug(categorySlug) : null;
if (svgUrl) {
return (
<img
src={svgUrl}
alt=""
className={[className, "object-contain pointer-events-none select-none"]
.filter(Boolean)
.join(" ")}
loading="lazy"
decoding="async"
/>
);
}
const Icon = map[iconKey] || Folder;
return <Icon className={className} />;
}

View File

@@ -0,0 +1,37 @@
const FIGMA_ASSET_BASE = "/assets/ark-library/figma";
export const recommendationCoverFallbacks = [
`${FIGMA_ASSET_BASE}/recommendation-1.png`,
`${FIGMA_ASSET_BASE}/recommendation-2.png`,
`${FIGMA_ASSET_BASE}/recommendation-3.png`,
`${FIGMA_ASSET_BASE}/recommendation-4.png`,
`${FIGMA_ASSET_BASE}/recommendation-5.png`,
] as const;
export function FigmaBanner() {
return (
<picture className="block overflow-hidden border border-[#2a2a32] bg-black shadow-[0_24px_70px_rgba(0,0,0,0.18)] max-md:-mx-4 max-md:rounded-none max-md:border-x-0 md:rounded-xl">
<source
media="(max-width: 439px)"
srcSet={`${FIGMA_ASSET_BASE}/banner-375.png`}
/>
<source
media="(max-width: 575px)"
srcSet={`${FIGMA_ASSET_BASE}/banner-440.png`}
/>
<source
media="(max-width: 767px)"
srcSet={`${FIGMA_ASSET_BASE}/banner-576.png`}
/>
<img
src={`${FIGMA_ASSET_BASE}/banner-desktop.png`}
alt=""
className="h-auto w-full object-cover"
width={1280}
height={290}
loading="eager"
decoding="async"
/>
</picture>
);
}

View File

@@ -0,0 +1,56 @@
import { useEffect, useState } from "react";
import { ArkLogoMark } from "./ArkLogoMark";
const HERO_JPEG = "/assets/ark-library/media/jpeg/hero.jpg";
/** Hero: branded JPEG when present; otherwise gold mark + glow (previous behaviour). */
export function HeroVisual() {
const [usePhoto, setUsePhoto] = useState(false);
useEffect(() => {
const im = new Image();
im.onload = () => setUsePhoto(true);
im.onerror = () => setUsePhoto(false);
im.src = HERO_JPEG;
}, []);
if (usePhoto) {
return (
<div className="relative flex min-h-[220px] md:min-h-[280px] items-center justify-center md:justify-end">
<div className="pointer-events-none absolute inset-0 overflow-hidden rounded-3xl">
<div className="absolute -right-8 top-1/2 h-[140%] w-[90%] -translate-y-1/2 rounded-full bg-[radial-gradient(ellipse_at_center,rgba(212,175,55,0.18)_0%,transparent_68%)]" />
</div>
<img
src={HERO_JPEG}
alt=""
className="relative z-10 max-h-[min(340px,52vh)] w-full max-w-lg rounded-2xl object-contain shadow-[0_0_48px_rgba(212,175,55,0.22)]"
loading="eager"
decoding="async"
onError={() => setUsePhoto(false)}
/>
</div>
);
}
return (
<div className="relative flex min-h-[220px] md:min-h-[280px] items-center justify-center md:justify-end">
<div className="pointer-events-none absolute inset-0 overflow-hidden rounded-3xl">
<div className="absolute -right-8 top-1/2 h-[140%] w-[90%] -translate-y-1/2 rounded-full bg-[radial-gradient(ellipse_at_center,rgba(212,175,55,0.22)_0%,transparent_68%)]" />
<div className="absolute right-[12%] top-[8%] h-3 w-3 rotate-12 rounded-sm border border-ark-gold/40 bg-ark-gold/10" />
<div className="absolute right-[22%] top-[18%] h-2 w-2 -rotate-6 rounded-sm border border-ark-gold/30" />
<div className="absolute right-[8%] bottom-[28%] h-2.5 w-2.5 rotate-45 rounded-sm bg-ark-gold/15" />
</div>
<div className="relative flex flex-col items-center">
<div className="relative drop-shadow-[0_0_40px_rgba(212,175,55,0.35)]">
<ArkLogoMark className="h-36 w-36 md:h-44 md:w-44" />
<div className="absolute -bottom-2 left-1/2 h-16 w-28 -translate-x-1/2 rounded-[100%] bg-gradient-to-t from-ark-gold/25 via-ark-gold/08 to-transparent blur-md" />
</div>
<div
className="-mt-1 h-3 w-40 rounded-[100%] bg-gradient-to-r from-transparent via-ark-gold/35 to-transparent shadow-[0_0_24px_rgba(212,175,55,0.25)]"
aria-hidden
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,72 @@
import { Link } from "react-router-dom";
import type { Resource } from "../api";
import { CategoryIcon } from "./CategoryIcon";
import { useI18n } from "../i18n";
import { resourceTypeLabel } from "../resourceTypeLabels";
import { formatDateYmd } from "../utils/format";
const LATEST_CARD_CLASS =
"flex min-h-[106px] items-start gap-4 overflow-hidden rounded-xl border border-ark-line bg-ark-panel p-4 outline-none transition hover:border-ark-gold/45 focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg md:min-h-[138px] md:p-5";
export function LatestUpdateRow({
r,
iconKey,
}: {
r: Resource;
iconKey: string;
}) {
const { t } = useI18n();
const dateStr = formatDateYmd(r.updatedAt);
return (
<Link to={`/resource/${r.id}`} className={LATEST_CARD_CLASS}>
<div className="flex shrink-0 items-center justify-center pt-0.5">
<CategoryIcon
iconKey={iconKey}
categorySlug={r.categorySlug}
className="h-10 w-10 text-ark-gold"
/>
</div>
<div className="min-w-0 flex-1 py-0.5">
<div className="text-base font-bold leading-snug text-white line-clamp-2 md:text-lg">
{r.title}
</div>
<div className="mt-4 grid gap-1 text-sm text-[#9b9ca6] md:mt-6">
<span>{r.categoryName}</span>
<span>
{resourceTypeLabel(t, r.type)}
<time className="mx-2 text-ark-muted" dateTime={r.updatedAt}>
{dateStr}
</time>
</span>
</div>
</div>
</Link>
);
}
const comingSoonIconKeys = ["image", "film", "book", "gift", "folder"];
export function ComingSoonLatestUpdateRow({ index = 0 }: { index?: number }) {
const iconKey = comingSoonIconKeys[index % comingSoonIconKeys.length];
return (
<article
className={`${LATEST_CARD_CLASS} cursor-default opacity-95 hover:border-ark-line`}
aria-label="即将到来"
>
<div className="flex shrink-0 items-center justify-center pt-0.5">
<CategoryIcon iconKey={iconKey} className="h-10 w-10 text-ark-gold" />
</div>
<div className="min-w-0 flex-1 py-0.5">
<div className="text-base font-bold leading-snug text-white line-clamp-2 md:text-lg">
</div>
<div className="mt-4 grid gap-1 text-sm text-[#9b9ca6] md:mt-6">
<span></span>
<span>Coming soon</span>
</div>
</div>
</article>
);
}

View File

@@ -0,0 +1,138 @@
import { Download } from "lucide-react";
import { Link } from "react-router-dom";
import type { Resource } from "../api";
import { assetUrl, postJSON } from "../api";
import { useI18n } from "../i18n";
import { useMemo } from "react";
import { formatDateYmd } from "../utils/format";
import { recommendationCoverFallbacks } from "./FigmaBanner";
function isPlaceholderAsset(path: string | undefined | null) {
return !path || path.includes("placeholder-cover");
}
const CARD_CLASS =
"group flex w-[232px] shrink-0 flex-col overflow-hidden rounded-xl border border-ark-line bg-ark-panel transition hover:border-ark-gold/55 max-[439px]:w-[232px] min-[440px]:w-[230px] sm:w-[240px] lg:w-[246.4px]";
export function RecommendedCard({
r,
visualIndex = 0,
}: {
r: Resource;
visualIndex?: number;
}) {
const { t } = useI18n();
const cover = useMemo(() => {
const original = r.coverImage || r.previewUrl;
if (isPlaceholderAsset(original)) {
return recommendationCoverFallbacks[
visualIndex % recommendationCoverFallbacks.length
];
}
return assetUrl(original);
}, [r.coverImage, r.previewUrl, visualIndex]);
const dateStr = formatDateYmd(r.updatedAt);
const dl =
r.isDownloadable && (r.fileUrl || r.previewUrl)
? assetUrl(r.fileUrl || r.previewUrl)
: "";
return (
<article className={CARD_CLASS}>
<Link
to={`/resource/${r.id}`}
className="relative block aspect-[246.4/138.6] overflow-hidden bg-black"
>
{cover ? (
<img
src={cover}
alt=""
className="h-full w-full object-cover transition duration-300 group-hover:scale-[1.02]"
loading="lazy"
/>
) : (
<div className="h-full w-full bg-gradient-to-br from-neutral-900 to-neutral-950" />
)}
{r.badgeLabel ? (
<span className="absolute left-3 top-3 rounded-md bg-ark-gold px-2.5 py-1 text-xs font-semibold text-black">
{r.badgeLabel}
</span>
) : null}
</Link>
<div className="flex min-h-[121px] flex-1 flex-col p-4 pt-3">
<Link
to={`/resource/${r.id}`}
className="text-base font-bold leading-snug text-white line-clamp-2 hover:text-ark-gold2"
>
{r.title}
</Link>
<div className="mt-auto flex items-center justify-between gap-2 pt-4 text-xs text-ark-muted">
<div className="min-w-0 truncate">
<span className="text-neutral-400">{r.categoryName}</span>
<span className="mx-1.5 text-ark-line">·</span>
<time dateTime={r.updatedAt}>{dateStr}</time>
</div>
{dl ? (
<button
type="button"
className="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"
title={t("download")}
aria-label={t("download")}
onClick={async (e) => {
e.preventDefault();
e.stopPropagation();
try {
await postJSON(`/api/resources/${r.id}/download`, {});
} catch {
/* ignore */
}
window.open(dl, "_blank", "noopener,noreferrer");
}}
>
<Download className="h-5 w-5" strokeWidth={2.2} />
</button>
) : null}
</div>
</div>
</article>
);
}
export function ComingSoonRecommendedCard({
visualIndex = 0,
}: {
visualIndex?: number;
}) {
const cover =
recommendationCoverFallbacks[
visualIndex % recommendationCoverFallbacks.length
];
return (
<article
className={`${CARD_CLASS} cursor-default opacity-95`}
aria-label="即将到来"
>
<div className="relative block aspect-[246.4/138.6] overflow-hidden bg-black">
<img
src={cover}
alt=""
className="h-full w-full object-cover opacity-75 grayscale-[15%]"
loading="lazy"
/>
<span className="absolute left-3 top-3 rounded-md bg-ark-gold px-2.5 py-1 text-xs font-semibold text-black">
</span>
</div>
<div className="flex min-h-[121px] flex-1 flex-col p-4 pt-3">
<div className="text-base font-bold leading-snug text-white line-clamp-2">
</div>
<div className="mt-auto pt-4 text-xs text-ark-muted">
</div>
</div>
</article>
);
}

View File

@@ -0,0 +1,101 @@
import { Download, Eye, Heart } from "lucide-react";
import { Link } from "react-router-dom";
import type { Resource } from "../api";
import { assetUrl, postJSON, postFavoriteDelta } from "../api";
import { isFavorite, toggleFavorite } from "../favorites";
import { useI18n } from "../i18n";
import { resourceTypeLabel } from "../resourceTypeLabels";
import { formatDateYmd } from "../utils/format";
import { useMemo, useState } from "react";
export function ResourceCard({
r,
onFavoriteToggle,
}: {
r: Resource;
onFavoriteToggle?: () => void;
}) {
const { t } = useI18n();
const [fav, setFav] = useState(() => isFavorite(r.id));
const cover = useMemo(
() => assetUrl(r.coverImage || r.previewUrl),
[r.coverImage, r.previewUrl],
);
return (
<div className="rounded-2xl border border-ark-line bg-ark-panel overflow-hidden flex flex-col">
<div className="relative aspect-video bg-black">
{cover ? (
<img
src={cover}
alt=""
className="h-full w-full object-cover"
loading="lazy"
/>
) : (
<div className="h-full w-full bg-gradient-to-br from-neutral-900 to-neutral-800" />
)}
{r.badgeLabel ? (
<span className="absolute left-3 top-3 rounded-full bg-ark-gold/90 px-3 py-1 text-xs font-semibold text-black">
{r.badgeLabel}
</span>
) : null}
</div>
<div className="p-4 flex flex-col gap-2 flex-1">
<div className="text-sm text-ark-muted">{r.categoryName}</div>
<div className="text-lg font-semibold leading-snug line-clamp-2">
{r.title}
</div>
<div className="text-xs text-ark-muted">
{r.type.toUpperCase()} · {new Date(r.updatedAt).toLocaleDateString()}
</div>
{r.description ? (
<p className="text-sm text-neutral-300 line-clamp-2">
{r.description}
</p>
) : null}
<div className="mt-auto flex flex-wrap gap-2 pt-2">
<Link
to={`/resource/${r.id}`}
className="inline-flex items-center gap-1 rounded-lg border border-ark-line px-3 py-2 text-sm outline-none hover:border-ark-gold hover:text-ark-gold2 focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg"
>
<Eye size={16} /> {t("preview")}
</Link>
<button
type="button"
className={`inline-flex items-center gap-1 rounded-lg border px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg ${
fav
? "border-ark-gold text-ark-gold2"
: "border-ark-line hover:border-ark-gold"
}`}
onClick={() => {
const on = toggleFavorite(r.id);
setFav(on);
void postFavoriteDelta(r.id, on);
onFavoriteToggle?.();
}}
>
<Heart size={16} /> {t("favorite")}
</button>
{r.isDownloadable && (r.fileUrl || r.previewUrl) ? (
<button
type="button"
className="inline-flex items-center gap-1 rounded-lg bg-ark-gold px-3 py-2 text-sm font-semibold text-black outline-none hover:bg-ark-gold2 focus-visible:ring-2 focus-visible:ring-ark-gold2 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg"
onClick={async () => {
const u = assetUrl(r.fileUrl || r.previewUrl);
try {
await postJSON(`/api/resources/${r.id}/download`, {});
} catch {
/* ignore */
}
window.open(u, "_blank", "noopener,noreferrer");
}}
>
<Download size={16} /> {t("download")}
</button>
) : null}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,54 @@
type T = (k: string) => string;
export function ResourceListFooter({
page,
limit,
total,
t,
onPrev,
onNext,
}: {
page: number;
limit: number;
total: number;
t: T;
onPrev: () => void;
onNext: () => void;
}) {
const pages = Math.max(1, Math.ceil(total / limit));
const from = total === 0 ? 0 : (page - 1) * limit + 1;
const to = Math.min(page * limit, total);
return (
<div className="flex flex-col items-center justify-between gap-3 border-t border-ark-line pt-6 sm:flex-row">
<p className="text-sm text-neutral-400">
{t("listRange")
.replace("{{from}}", String(from))
.replace("{{to}}", String(to))
.replace("{{total}}", String(total))}
</p>
<div className="flex items-center gap-2">
<button
type="button"
disabled={page <= 1}
onClick={onPrev}
className="rounded-full border border-ark-line px-4 py-2 text-sm text-neutral-200 outline-none transition hover:border-ark-gold disabled:cursor-not-allowed disabled:opacity-40 focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg"
>
{t("paginationPrev")}
</button>
<span className="text-sm text-ark-muted tabular-nums">
{t("pageIndicator")
.replace("{{c}}", String(page))
.replace("{{p}}", String(pages))}
</span>
<button
type="button"
disabled={page >= pages}
onClick={onNext}
className="rounded-full border border-ark-line px-4 py-2 text-sm text-neutral-200 outline-none transition hover:border-ark-gold disabled:cursor-not-allowed disabled:opacity-40 focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg"
>
{t("paginationNext")}
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,30 @@
import { ChevronRight } from "lucide-react";
import { Link } from "react-router-dom";
export function SectionHeader({
title,
viewAllTo,
viewAllLabel,
}: {
title: string;
viewAllTo: string;
viewAllLabel: string;
}) {
return (
<div className="flex h-6 items-center justify-between gap-4">
<div className="flex min-w-0 items-center gap-4">
<span className="h-6 w-1 shrink-0 bg-ark-gold" aria-hidden />
<h2 className="truncate text-2xl font-bold leading-6 tracking-tight text-white md:text-2xl">
{title}
</h2>
</div>
<Link
to={viewAllTo}
className="inline-flex shrink-0 items-center gap-1 text-[15px] font-medium leading-none text-ark-gold hover:text-ark-gold2"
>
{viewAllLabel}
<ChevronRight className="h-4 w-4" strokeWidth={2.7} />
</Link>
</div>
);
}

View File

@@ -0,0 +1,127 @@
import { ConnectButton } from "@rainbow-me/rainbowkit";
import { useAccount, useDisconnect, useSignMessage } from "wagmi";
import { useCallback, useEffect, useState } from "react";
import { postJSON, apiBase } from "../api";
import {
clearWalletToken,
getWalletToken,
setWalletToken,
} from "../walletToken";
import { useI18n } from "../i18n";
export function WalletLoginControls() {
const { t } = useI18n();
const { address, isConnected } = useAccount();
const { disconnectAsync } = useDisconnect();
const { signMessageAsync, isPending: signing } = useSignMessage();
const [busy, setBusy] = useState(false);
const [err, setErr] = useState<string | null>(null);
const [sessionOk, setSessionOk] = useState(false);
const checkSession = useCallback(async () => {
const tok = getWalletToken();
if (!tok || !address) {
setSessionOk(false);
return;
}
try {
const r = await fetch(`${apiBase}/api/auth/wallet/me`, {
headers: { Authorization: `Bearer ${tok}` },
});
if (!r.ok) {
clearWalletToken();
setSessionOk(false);
return;
}
const j = (await r.json()) as { wallet: string };
setSessionOk(j.wallet?.toLowerCase() === address.toLowerCase());
} catch {
setSessionOk(false);
}
}, [address]);
useEffect(() => {
void checkSession();
}, [checkSession]);
useEffect(() => {
if (!address) clearWalletToken();
}, [address]);
const signIn = async () => {
if (!address) return;
setErr(null);
setBusy(true);
try {
const nonceRes = await postJSON<{ message: string }>(
"/api/auth/wallet/nonce",
{ address },
);
const sig = await signMessageAsync({ message: nonceRes.message });
const out = await postJSON<{ token: string }>("/api/auth/wallet/verify", {
address,
message: nonceRes.message,
signature: sig,
});
setWalletToken(out.token);
setSessionOk(true);
} catch (e) {
setErr(String(e));
} finally {
setBusy(false);
}
};
const signOut = async () => {
clearWalletToken();
setSessionOk(false);
await disconnectAsync();
};
if (!import.meta.env.VITE_WALLETCONNECT_PROJECT_ID) {
return (
<span
className="text-xs text-amber-500/90 max-w-[220px] text-right leading-tight"
title={t("walletMissingProjectId")}
>
{t("walletSetupNeeded")}
</span>
);
}
return (
<div className="flex flex-col items-end gap-1 min-w-[200px]">
<div className="flex flex-wrap items-center justify-end gap-2">
<ConnectButton chainStatus="icon" showBalance={false} />
{isConnected && address ? (
sessionOk ? (
<span className="text-xs text-ark-gold2 whitespace-nowrap">
{t("walletSignedIn")}
</span>
) : (
<button
type="button"
disabled={busy || signing}
onClick={() => void signIn()}
className="rounded-lg border border-ark-gold bg-ark-gold/10 px-3 py-1.5 text-xs font-medium text-ark-gold2 hover:bg-ark-gold/20 disabled:opacity-50"
>
{busy || signing ? "…" : t("signInWallet")}
</button>
)
) : null}
{isConnected && sessionOk ? (
<button
type="button"
onClick={() => void signOut()}
className="text-xs text-neutral-500 hover:text-white"
>
{t("walletLogout")}
</button>
) : null}
</div>
{err ? (
<p className="text-xs text-red-400 max-w-xs text-right">{err}</p>
) : null}
</div>
);
}