Initial frontend import
This commit is contained in:
17
src/components/ArkLogoMark.tsx
Normal file
17
src/components/ArkLogoMark.tsx
Normal 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
|
||||
/>
|
||||
);
|
||||
}
|
||||
63
src/components/CategoryIcon.tsx
Normal file
63
src/components/CategoryIcon.tsx
Normal 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} />;
|
||||
}
|
||||
37
src/components/FigmaBanner.tsx
Normal file
37
src/components/FigmaBanner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
56
src/components/HeroVisual.tsx
Normal file
56
src/components/HeroVisual.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
72
src/components/LatestUpdateRow.tsx
Normal file
72
src/components/LatestUpdateRow.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
138
src/components/RecommendedCard.tsx
Normal file
138
src/components/RecommendedCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
101
src/components/ResourceCard.tsx
Normal file
101
src/components/ResourceCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
54
src/components/ResourceListFooter.tsx
Normal file
54
src/components/ResourceListFooter.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
30
src/components/SectionHeader.tsx
Normal file
30
src/components/SectionHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
127
src/components/WalletLoginControls.tsx
Normal file
127
src/components/WalletLoginControls.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user