feat: link nav to home sections

This commit is contained in:
TerryM
2026-05-28 15:55:37 +08:00
parent 28b0ef3f9a
commit 9d977be2d2
4 changed files with 139 additions and 46 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 253 KiB

View File

@@ -9,11 +9,7 @@ import {
const FIGMA_ASSET_BASE = "/assets/ark-library/figma"; const FIGMA_ASSET_BASE = "/assets/ark-library/figma";
export const officialRecommendationCoverFallbacks = [ export const officialRecommendationCoverFallbacks = [
`${FIGMA_ASSET_BASE}/official-recommendation-1.png`, `${FIGMA_ASSET_BASE}/official-recommendation-cover.png`,
`${FIGMA_ASSET_BASE}/official-recommendation-2.png`,
`${FIGMA_ASSET_BASE}/official-recommendation-3.png`,
`${FIGMA_ASSET_BASE}/official-recommendation-4.png`,
`${FIGMA_ASSET_BASE}/official-recommendation-5.png`,
] as const; ] as const;
type BannerSlide = { type BannerSlide = {

View File

@@ -4,8 +4,8 @@ import type { Resource } from "../api";
import { assetUrl } from "../api"; import { assetUrl } from "../api";
import { useI18n } from "../i18n"; import { useI18n } from "../i18n";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { cleanCategoryDisplayName } from "../utils/categoryDisplay";
import { formatDateYmd } from "../utils/format"; import { formatDateYmd } from "../utils/format";
import { DownloadCloudIcon } from "./icons/DownloadCloudIcon";
import { officialRecommendationCoverFallbacks } from "./FigmaBanner"; import { officialRecommendationCoverFallbacks } from "./FigmaBanner";
import { import {
downloadAttachment, downloadAttachment,
@@ -17,7 +17,7 @@ function isPlaceholderAsset(path: string | undefined | null) {
} }
const CARD_CLASS = const CARD_CLASS =
"group flex w-[208px] shrink-0 flex-col overflow-hidden rounded-xl border border-transparent bg-[#1D1E23] transition hover:border-ark-gold/55 md:w-[240px] md:border-ark-line md:bg-ark-panel lg:w-[246.4px] min-[1100px]:max-xl:w-[273px] xl:w-[246.4px]"; "group flex w-[208px] shrink-0 flex-col overflow-hidden rounded-xl border bg-[#1D1E23] transition hover:border-ark-gold/55 md:w-[240px] lg:w-[246.4px] min-[1100px]:max-xl:w-[273px] xl:w-[246.4px]";
type RecommendedResource = Resource & { type RecommendedResource = Resource & {
downloadPostId?: string; downloadPostId?: string;
@@ -48,7 +48,7 @@ export function RecommendedCard({
return assetUrl(original); return assetUrl(original);
}, [figmaCover, r.coverImage, r.previewUrl, useFigmaDesign]); }, [figmaCover, r.coverImage, r.previewUrl, useFigmaDesign]);
const displayTitle = r.title; const displayTitle = r.title;
const displayCategoryName = cleanCategoryDisplayName(r.categoryName); const displayCategoryName = r.categoryName;
const dateStr = formatDateYmd(r.updatedAt); const dateStr = formatDateYmd(r.updatedAt);
const dateTime = r.updatedAt; const dateTime = r.updatedAt;
@@ -57,11 +57,37 @@ export function RecommendedCard({
? assetUrl(r.fileUrl || r.previewUrl) ? assetUrl(r.fileUrl || r.previewUrl)
: ""; : "";
const handleDownload = async () => {
if (isDownloading) return;
setIsDownloading(true);
try {
if (r.downloadPostId && r.downloadAttachmentId) {
await downloadAttachment(
r.downloadPostId,
r.downloadAttachmentId,
displayTitle,
);
return;
}
await downloadFile(dl, displayTitle);
} catch {
/* ignore */
} finally {
setIsDownloading(false);
}
};
return ( return (
<article className={CARD_CLASS}> <article
className={`${CARD_CLASS} ${
useFigmaDesign
? "border-[#27292E]"
: "border-transparent md:border-ark-line md:bg-ark-panel"
}`}
>
<Link <Link
to={`/resource/${r.id}`} to={`/resource/${r.id}`}
className="relative block h-[108px] overflow-hidden bg-black md:aspect-[246.4/138.6] md:h-auto" className="relative block h-[108px] overflow-hidden bg-[#111116] md:aspect-[246.4/138.6] md:h-auto"
> >
{cover ? ( {cover ? (
<img <img
@@ -69,7 +95,7 @@ export function RecommendedCard({
alt="" alt=""
className={`h-full w-full object-cover transition duration-300 ${ className={`h-full w-full object-cover transition duration-300 ${
useFigmaDesign useFigmaDesign
? "origin-top scale-[1.08] group-hover:scale-[1.1]" ? "group-hover:scale-[1.02]"
: "group-hover:scale-[1.02]" : "group-hover:scale-[1.02]"
}`} }`}
loading="lazy" loading="lazy"
@@ -77,60 +103,83 @@ export function RecommendedCard({
) : ( ) : (
<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" />
)} )}
{r.badgeLabel ? ( {!useFigmaDesign && 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"> <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} {r.badgeLabel}
</span> </span>
) : null} ) : null}
</Link> </Link>
<div className="flex min-h-[131px] flex-1 flex-col p-4 pt-3 md:min-h-[121px]"> <div
<Link className={
to={`/resource/${r.id}`} useFigmaDesign
className="text-[15px] font-semibold leading-[21.72px] text-white line-clamp-2 hover:text-ark-gold2 md:text-base md:font-bold md:leading-snug" ? "flex h-[131px] flex-col px-4 py-3"
: "flex min-h-[131px] flex-1 flex-col p-4 pt-3 md:min-h-[121px]"
}
>
<div className="flex flex-col gap-1.5">
<Link
to={`/resource/${r.id}`}
className="text-[15px] font-semibold leading-[21.72px] text-white line-clamp-2 hover:text-ark-gold2 md:text-base md:font-bold md:leading-snug"
>
{displayTitle}
</Link>
{useFigmaDesign ? (
<div className="truncate text-[12px] font-normal leading-[17.376px] text-[#A8A9AE]">
{displayCategoryName}
</div>
) : null}
</div>
<div
className={
useFigmaDesign
? "mt-auto flex h-10 items-end justify-between gap-2"
: "mt-auto flex items-center justify-between gap-2 pt-4 text-[12px] leading-[17.38px] text-ark-muted"
}
> >
{displayTitle} <div
</Link> className={
<div className="mt-auto flex items-center justify-between gap-2 pt-4 text-[12px] leading-[17.38px] text-ark-muted"> useFigmaDesign
<div className="min-w-0 truncate"> ? "min-w-0 truncate pb-[1px] text-[12px] font-normal leading-[17.376px] text-[#A8A9AE]"
<span className="text-neutral-400">{displayCategoryName}</span> : "min-w-0 truncate"
<span className="mx-1.5 text-ark-line">·</span> }
>
{useFigmaDesign ? null : (
<>
<span className="text-neutral-400">{displayCategoryName}</span>
<span className="mx-1.5 text-ark-line">·</span>
</>
)}
<time dateTime={dateTime}>{dateStr}</time> <time dateTime={dateTime}>{dateStr}</time>
</div> </div>
{dl ? ( {dl ? (
<button <button
type="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 disabled:cursor-wait" 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"
}
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")}
aria-busy={isDownloading} aria-busy={isDownloading}
disabled={isDownloading} disabled={isDownloading}
onClick={async (e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
if (isDownloading) return; void handleDownload();
setIsDownloading(true);
try {
if (r.downloadPostId && r.downloadAttachmentId) {
await downloadAttachment(
r.downloadPostId,
r.downloadAttachmentId,
displayTitle,
);
return;
}
await downloadFile(dl, displayTitle);
} catch {
/* ignore */
} finally {
setIsDownloading(false);
}
}} }}
> >
{isDownloading ? ( {isDownloading ? (
<LoaderCircle <LoaderCircle
className="h-5 w-5 animate-spin" className={
useFigmaDesign
? "h-5 w-5 animate-spin"
: "h-5 w-5 animate-spin"
}
strokeWidth={2.2} strokeWidth={2.2}
/> />
) : useFigmaDesign ? (
<DownloadCloudIcon className="h-6 w-6" />
) : ( ) : (
<Download className="h-5 w-5" strokeWidth={2.2} /> <Download className="h-5 w-5" strokeWidth={2.2} />
)} )}
@@ -154,7 +203,7 @@ export function ComingSoonRecommendedCard({
return ( return (
<article <article
className={`${CARD_CLASS} cursor-default opacity-95`} className={`${CARD_CLASS} cursor-default border-transparent opacity-95 md:border-ark-line md:bg-ark-panel`}
aria-label="即将到来" aria-label="即将到来"
> >
<div className="relative block h-[108px] overflow-hidden bg-black md:aspect-[246.4/138.6] md:h-auto"> <div className="relative block h-[108px] overflow-hidden bg-black md:aspect-[246.4/138.6] md:h-auto">

View File

@@ -1,5 +1,5 @@
import { ChevronRight } from "lucide-react"; import { ChevronRight } from "lucide-react";
import { Link } from "react-router-dom"; import { Link, useLocation } from "react-router-dom";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { getJSON, itemsOrEmpty, type Category } from "../../api"; import { getJSON, itemsOrEmpty, type Category } from "../../api";
import { CategoryIcon } from "../../components/CategoryIcon"; import { CategoryIcon } from "../../components/CategoryIcon";
@@ -44,10 +44,13 @@ function figmaCategoryRank(category: Category): number {
export function Home() { export function Home() {
const { t, lang } = useI18n(); const { t, lang } = useI18n();
const { hash } = useLocation();
const [cats, setCats] = useState<Category[]>([]); const [cats, setCats] = useState<Category[]>([]);
const [rec, setRec] = useState<PostBackedResource[]>([]); const [rec, setRec] = useState<PostBackedResource[]>([]);
const [latest, setLatest] = useState<PostBackedResource[]>([]); const [latest, setLatest] = useState<PostBackedResource[]>([]);
const [latestPosts, setLatestPosts] = useState<Post[]>([]); const [latestPosts, setLatestPosts] = useState<Post[]>([]);
const [popular, setPopular] = useState<PostBackedResource[]>([]);
const [popularPosts, setPopularPosts] = useState<Post[]>([]);
const [err, setErr] = useState<string | null>(null); const [err, setErr] = useState<string | null>(null);
const recRowRef = useRef<HTMLDivElement>(null); const recRowRef = useRef<HTMLDivElement>(null);
const categoryRowRef = useRef<HTMLDivElement>(null); const categoryRowRef = useRef<HTMLDivElement>(null);
@@ -64,8 +67,11 @@ export function Home() {
getJSON<Category[]>(`/api/categories${catQ}`), getJSON<Category[]>(`/api/categories${catQ}`),
getJSON<{ items: Post[] }>(`/api/posts/recommended${postQ}&limit=12`), getJSON<{ items: Post[] }>(`/api/posts/recommended${postQ}&limit=12`),
getJSON<{ items: Post[] }>(`/api/posts/latest${postQ}&limit=8`), getJSON<{ items: Post[] }>(`/api/posts/latest${postQ}&limit=8`),
getJSON<{ items: Post[] }>(
`/api/posts${postQ}&sort=popular&limit=8`,
).catch((): { items: Post[] } => ({ items: [] })),
]) ])
.then(([c, r, l]) => { .then(([c, r, l, p]) => {
setCats(itemsOrEmpty(c)); setCats(itemsOrEmpty(c));
setRec( setRec(
itemsOrEmpty(r.items).map((post) => itemsOrEmpty(r.items).map((post) =>
@@ -79,6 +85,13 @@ export function Home() {
postToResource(post, lang, itemsOrEmpty(c)), postToResource(post, lang, itemsOrEmpty(c)),
), ),
); );
const popularItems = itemsOrEmpty<Post>(p.items);
setPopularPosts(popularItems);
setPopular(
popularItems.map((post) =>
postToResource(post, lang, itemsOrEmpty(c)),
),
);
}) })
.catch((e) => setErr(String(e))); .catch((e) => setErr(String(e)));
}, [lang]); }, [lang]);
@@ -152,7 +165,18 @@ export function Home() {
recRowRef.current?.scrollBy({ left: dir * 280, behavior: "smooth" }); recRowRef.current?.scrollBy({ left: dir * 280, behavior: "smooth" });
}; };
useEffect(() => {
if (!hash) return;
const id = hash.slice(1);
if (!id) return;
const frame = window.requestAnimationFrame(() => {
document.getElementById(id)?.scrollIntoView({ block: "start" });
});
return () => window.cancelAnimationFrame(frame);
}, [hash, cats.length, rec.length, latest.length, popular.length]);
const latestPlaceholderCount = Math.max(0, 5 - latest.length); const latestPlaceholderCount = Math.max(0, 5 - latest.length);
const popularPlaceholderCount = Math.max(0, 5 - popular.length);
const recommendedDotCount = Math.max(1, Math.min(4, rec.length || 4)); const recommendedDotCount = Math.max(1, Math.min(4, rec.length || 4));
const activeRecommendedDot = Math.min( const activeRecommendedDot = Math.min(
recommendedDotCount - 1, recommendedDotCount - 1,
@@ -356,7 +380,31 @@ export function Home() {
</div> </div>
</section> </section>
<span id="popular" className="block scroll-mt-24" aria-hidden="true" /> <section id="popular" className="scroll-mt-24">
<div className="px-4 md:px-0">
<SectionHeader
title={t("popularSection")}
viewAllTo="/browse?sort=popular"
viewAllLabel={t("viewAll")}
/>
</div>
<div className="flex flex-col gap-3 md:hidden">
{popularPosts.slice(0, 4).map((post) => (
<MessageBubble key={post.id} post={post} />
))}
</div>
<div className="mt-7 hidden gap-3 min-[576px]:grid-cols-2 md:grid md:gap-4 lg:grid-cols-3 xl:grid-cols-5">
{popular.map((r) => (
<LatestUpdateRow key={r.id} r={r} iconKey={iconKeyForResource(r)} />
))}
{Array.from({ length: popularPlaceholderCount }).map((_, index) => (
<ComingSoonLatestUpdateRow
key={`popular-coming-soon-${index}`}
index={popular.length + index}
/>
))}
</div>
</section>
</div> </div>
); );
} }