terry-staging #5
|
Before Width: | Height: | Size: 113 KiB After Width: | Height: | Size: 148 KiB |
|
Before Width: | Height: | Size: 146 KiB After Width: | Height: | Size: 148 KiB |
|
Before Width: | Height: | Size: 171 KiB After Width: | Height: | Size: 148 KiB |
|
Before Width: | Height: | Size: 153 KiB After Width: | Height: | Size: 148 KiB |
|
Before Width: | Height: | Size: 167 KiB After Width: | Height: | Size: 148 KiB |
|
After Width: | Height: | Size: 253 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 357 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 343 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 646 KiB |
|
Before Width: | Height: | Size: 785 B After Width: | Height: | Size: 646 KiB |
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 445 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 291 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 381 KiB |
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 489 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 406 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 407 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 414 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 379 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 504 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 405 KiB |
13
src/App.tsx
@@ -3,7 +3,8 @@ import { I18nProvider } from "./i18n";
|
|||||||
import { PublicLayout } from "./layouts/PublicLayout";
|
import { PublicLayout } from "./layouts/PublicLayout";
|
||||||
import { Home } from "./pages/Home";
|
import { Home } from "./pages/Home";
|
||||||
import { Browse } from "./pages/Browse";
|
import { Browse } from "./pages/Browse";
|
||||||
import { CategoryPage } from "./pages/Category";
|
import { CategoriesPage } from "./pages/Categories";
|
||||||
|
import { OfficialRecommendationsPage } from "./pages/OfficialRecommendations";
|
||||||
import { SearchPage } from "./pages/Search";
|
import { SearchPage } from "./pages/Search";
|
||||||
import { PostRedirect } from "./pages/PostRedirect";
|
import { PostRedirect } from "./pages/PostRedirect";
|
||||||
import { AboutPage } from "./pages/About";
|
import { AboutPage } from "./pages/About";
|
||||||
@@ -27,7 +28,15 @@ export default function App() {
|
|||||||
<Route element={<PublicLayout />}>
|
<Route element={<PublicLayout />}>
|
||||||
<Route path="/" element={<Home />} />
|
<Route path="/" element={<Home />} />
|
||||||
<Route path="/browse" element={<Browse />} />
|
<Route path="/browse" element={<Browse />} />
|
||||||
<Route path="/category/:slug" element={<CategoryPage />} />
|
<Route path="/categories" element={<CategoriesPage />} />
|
||||||
|
<Route
|
||||||
|
path="/official-recommendations"
|
||||||
|
element={<OfficialRecommendationsPage />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/category/:slug"
|
||||||
|
element={<Navigate to="/browse" replace />}
|
||||||
|
/>
|
||||||
<Route path="/search" element={<SearchPage />} />
|
<Route path="/search" element={<SearchPage />} />
|
||||||
<Route path="/resource/:id" element={<PostRedirect />} />
|
<Route path="/resource/:id" element={<PostRedirect />} />
|
||||||
<Route path="/about" element={<AboutPage />} />
|
<Route path="/about" element={<AboutPage />} />
|
||||||
|
|||||||
@@ -5,15 +5,13 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
type PointerEvent as ReactPointerEvent,
|
type PointerEvent as ReactPointerEvent,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
import { assetUrl, getJSON, itemsOrEmpty } from "../api";
|
||||||
|
import { langQuery, useI18n, type Lang } from "../i18n";
|
||||||
|
|
||||||
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 = {
|
||||||
@@ -21,42 +19,46 @@ type BannerSlide = {
|
|||||||
mobile: string;
|
mobile: string;
|
||||||
desktop: string;
|
desktop: string;
|
||||||
alt: string;
|
alt: string;
|
||||||
|
linkUrl?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const BANNERS_BASE = "/assets/ark-library/banners";
|
type BannerApiItem = {
|
||||||
|
id: number | string;
|
||||||
|
imageUrl: string;
|
||||||
|
linkUrl?: string;
|
||||||
|
sortOrder?: number;
|
||||||
|
};
|
||||||
|
|
||||||
const BANNER_SLIDES: BannerSlide[] = [
|
type BannerApiResponse = {
|
||||||
{
|
items?: BannerApiItem[] | null;
|
||||||
id: "ark-banner-1",
|
};
|
||||||
mobile: `${BANNERS_BASE}/banner-1.png`,
|
|
||||||
desktop: `${BANNERS_BASE}/banner-1.png`,
|
|
||||||
alt: "",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "ark-banner-2",
|
|
||||||
mobile: `${BANNERS_BASE}/banner-2.png`,
|
|
||||||
desktop: `${BANNERS_BASE}/banner-2.png`,
|
|
||||||
alt: "",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "ark-banner-3",
|
|
||||||
mobile: `${BANNERS_BASE}/banner-3.png`,
|
|
||||||
desktop: `${BANNERS_BASE}/banner-3.png`,
|
|
||||||
alt: "",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "ark-banner-4",
|
|
||||||
mobile: `${BANNERS_BASE}/banner-4.png`,
|
|
||||||
desktop: `${BANNERS_BASE}/banner-4.png`,
|
|
||||||
alt: "",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const AUTOPLAY_MS = 3000;
|
const AUTOPLAY_MS = 3000;
|
||||||
const RESUME_AFTER_INTERACTION_MS = 8000;
|
const RESUME_AFTER_INTERACTION_MS = 8000;
|
||||||
|
|
||||||
|
function bannerLangParam(lang: Lang): string {
|
||||||
|
return langQuery(lang).toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toSlides(items: BannerApiItem[]): BannerSlide[] {
|
||||||
|
return [...items]
|
||||||
|
.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0))
|
||||||
|
.filter((item) => item.imageUrl)
|
||||||
|
.map((item) => {
|
||||||
|
const imageUrl = assetUrl(item.imageUrl);
|
||||||
|
return {
|
||||||
|
id: String(item.id),
|
||||||
|
mobile: imageUrl,
|
||||||
|
desktop: imageUrl,
|
||||||
|
alt: "",
|
||||||
|
linkUrl: item.linkUrl || undefined,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function FigmaBanner() {
|
export function FigmaBanner() {
|
||||||
const slides = BANNER_SLIDES;
|
const { lang } = useI18n();
|
||||||
|
const [slides, setSlides] = useState<BannerSlide[]>([]);
|
||||||
const scrollerRef = useRef<HTMLDivElement>(null);
|
const scrollerRef = useRef<HTMLDivElement>(null);
|
||||||
const [activeIndex, setActiveIndex] = useState(0);
|
const [activeIndex, setActiveIndex] = useState(0);
|
||||||
const [autoplayPaused, setAutoplayPaused] = useState(false);
|
const [autoplayPaused, setAutoplayPaused] = useState(false);
|
||||||
@@ -69,6 +71,22 @@ export function FigmaBanner() {
|
|||||||
} | null>(null);
|
} | null>(null);
|
||||||
const hasMultiple = slides.length > 1;
|
const hasMultiple = slides.length > 1;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setActiveIndex(0);
|
||||||
|
getJSON<BannerApiResponse>(`/api/banners?lang=${bannerLangParam(lang)}`)
|
||||||
|
.then((res) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setSlides(toSlides(itemsOrEmpty(res.items)));
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setSlides([]);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [lang]);
|
||||||
|
|
||||||
const goTo = useCallback((index: number, behavior: ScrollBehavior) => {
|
const goTo = useCallback((index: number, behavior: ScrollBehavior) => {
|
||||||
const scroller = scrollerRef.current;
|
const scroller = scrollerRef.current;
|
||||||
if (!scroller) return;
|
if (!scroller) return;
|
||||||
@@ -172,48 +190,11 @@ export function FigmaBanner() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
if (slides.length === 0) return null;
|
||||||
<div>
|
|
||||||
<div
|
|
||||||
ref={scrollerRef}
|
|
||||||
onPointerDown={handlePointerDown}
|
|
||||||
onPointerMove={handlePointerMove}
|
|
||||||
onPointerUp={endDrag}
|
|
||||||
onPointerCancel={endDrag}
|
|
||||||
onTouchStart={pauseAutoplay}
|
|
||||||
className="flex cursor-grab snap-x snap-mandatory overflow-x-auto overflow-y-hidden scroll-smooth select-none touch-pan-x [-ms-overflow-style:none] [scrollbar-width:none] active:cursor-grabbing [&::-webkit-scrollbar]:hidden"
|
|
||||||
role="region"
|
|
||||||
aria-roledescription="carousel"
|
|
||||||
aria-label="ARK Library banner"
|
|
||||||
>
|
|
||||||
{slides.map((slide, index) => (
|
|
||||||
<div
|
|
||||||
key={slide.id}
|
|
||||||
className="relative w-full shrink-0 snap-start"
|
|
||||||
role="group"
|
|
||||||
aria-roledescription="slide"
|
|
||||||
aria-label={`${index + 1} / ${slides.length}`}
|
|
||||||
>
|
|
||||||
<picture className="block w-full overflow-hidden rounded-xl bg-black">
|
|
||||||
<source media="(max-width: 767px)" srcSet={slide.mobile} />
|
|
||||||
<img
|
|
||||||
src={slide.desktop}
|
|
||||||
alt={slide.alt}
|
|
||||||
className="pointer-events-none h-[200px] w-full object-cover md:h-auto"
|
|
||||||
width={1280}
|
|
||||||
height={290}
|
|
||||||
loading={index === 0 ? "eager" : "lazy"}
|
|
||||||
decoding="async"
|
|
||||||
draggable={false}
|
|
||||||
/>
|
|
||||||
</picture>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{hasMultiple ? (
|
const pagination = hasMultiple ? (
|
||||||
<div
|
<div
|
||||||
className="mt-3 flex items-center justify-center gap-2"
|
className="flex items-center justify-center gap-1.5 md:gap-2"
|
||||||
role="tablist"
|
role="tablist"
|
||||||
aria-label="Banner pagination"
|
aria-label="Banner pagination"
|
||||||
>
|
>
|
||||||
@@ -234,12 +215,72 @@ export function FigmaBanner() {
|
|||||||
className={`h-1.5 rounded-full transition-all ${
|
className={`h-1.5 rounded-full transition-all ${
|
||||||
active
|
active
|
||||||
? "w-6 bg-ark-gold"
|
? "w-6 bg-ark-gold"
|
||||||
: "w-1.5 bg-white/30 hover:bg-white/50"
|
: "w-1.5 bg-[#7C7C7C] hover:bg-white/50"
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<div
|
||||||
|
ref={scrollerRef}
|
||||||
|
onPointerDown={handlePointerDown}
|
||||||
|
onPointerMove={handlePointerMove}
|
||||||
|
onPointerUp={endDrag}
|
||||||
|
onPointerCancel={endDrag}
|
||||||
|
onTouchStart={pauseAutoplay}
|
||||||
|
className="flex cursor-grab snap-x snap-mandatory overflow-x-auto overflow-y-hidden scroll-smooth select-none touch-pan-x [-ms-overflow-style:none] [scrollbar-width:none] active:cursor-grabbing [&::-webkit-scrollbar]:hidden"
|
||||||
|
role="region"
|
||||||
|
aria-roledescription="carousel"
|
||||||
|
aria-label="ARK Library banner"
|
||||||
|
>
|
||||||
|
{slides.map((slide, index) => {
|
||||||
|
const image = (
|
||||||
|
<picture className="block w-full overflow-hidden bg-black md:rounded-xl">
|
||||||
|
<source media="(max-width: 767px)" srcSet={slide.mobile} />
|
||||||
|
<img
|
||||||
|
src={slide.desktop}
|
||||||
|
alt={slide.alt}
|
||||||
|
className="pointer-events-none h-[219px] w-full object-cover md:h-auto"
|
||||||
|
width={1280}
|
||||||
|
height={290}
|
||||||
|
loading={index === 0 ? "eager" : "lazy"}
|
||||||
|
decoding="async"
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
|
</picture>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={slide.id}
|
||||||
|
className="relative w-full shrink-0 snap-start"
|
||||||
|
role="group"
|
||||||
|
aria-roledescription="slide"
|
||||||
|
aria-label={`${index + 1} / ${slides.length}`}
|
||||||
|
>
|
||||||
|
{slide.linkUrl ? (
|
||||||
|
<a href={slide.linkUrl} className="block" rel="noreferrer">
|
||||||
|
{image}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
image
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasMultiple ? (
|
||||||
|
<>
|
||||||
|
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex h-[30px] items-center justify-center bg-gradient-to-b from-[#14131900] to-[#141319] md:hidden">
|
||||||
|
<div className="pointer-events-auto">{pagination}</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 hidden md:block">{pagination}</div>
|
||||||
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { assetUrl } from "../api";
|
|||||||
import { useI18n } from "../i18n";
|
import { useI18n } from "../i18n";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
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,
|
||||||
@@ -16,7 +17,7 @@ function isPlaceholderAsset(path: string | undefined | null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const CARD_CLASS =
|
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] 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;
|
||||||
@@ -26,74 +27,36 @@ type RecommendedResource = Resource & {
|
|||||||
export function RecommendedCard({
|
export function RecommendedCard({
|
||||||
r,
|
r,
|
||||||
visualIndex = 0,
|
visualIndex = 0,
|
||||||
|
useFigmaDesign = false,
|
||||||
}: {
|
}: {
|
||||||
r: RecommendedResource;
|
r: RecommendedResource;
|
||||||
visualIndex?: number;
|
visualIndex?: number;
|
||||||
|
useFigmaDesign?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [isDownloading, setIsDownloading] = useState(false);
|
const [isDownloading, setIsDownloading] = useState(false);
|
||||||
|
const figmaCover =
|
||||||
|
officialRecommendationCoverFallbacks[
|
||||||
|
visualIndex % officialRecommendationCoverFallbacks.length
|
||||||
|
];
|
||||||
const cover = useMemo(() => {
|
const cover = useMemo(() => {
|
||||||
const original = r.coverImage || r.previewUrl;
|
const original = r.coverImage || r.previewUrl;
|
||||||
if (isPlaceholderAsset(original)) {
|
if (isPlaceholderAsset(original)) {
|
||||||
return officialRecommendationCoverFallbacks[
|
return useFigmaDesign ? "" : figmaCover;
|
||||||
visualIndex % officialRecommendationCoverFallbacks.length
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
return assetUrl(original);
|
return assetUrl(original);
|
||||||
}, [r.coverImage, r.previewUrl, visualIndex]);
|
}, [figmaCover, r.coverImage, r.previewUrl, useFigmaDesign]);
|
||||||
|
const displayTitle = r.title;
|
||||||
|
const displayCategoryName = r.categoryName;
|
||||||
const dateStr = formatDateYmd(r.updatedAt);
|
const dateStr = formatDateYmd(r.updatedAt);
|
||||||
|
const dateTime = r.updatedAt;
|
||||||
|
|
||||||
const dl =
|
const dl =
|
||||||
r.isDownloadable && (r.fileUrl || r.previewUrl)
|
r.isDownloadable && (r.fileUrl || r.previewUrl)
|
||||||
? assetUrl(r.fileUrl || r.previewUrl)
|
? assetUrl(r.fileUrl || r.previewUrl)
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
return (
|
const handleDownload = async () => {
|
||||||
<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 disabled:cursor-wait"
|
|
||||||
title={isDownloading ? t("downloading") : t("download")}
|
|
||||||
aria-label={isDownloading ? t("downloading") : t("download")}
|
|
||||||
aria-busy={isDownloading}
|
|
||||||
disabled={isDownloading}
|
|
||||||
onClick={async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
if (isDownloading) return;
|
if (isDownloading) return;
|
||||||
setIsDownloading(true);
|
setIsDownloading(true);
|
||||||
try {
|
try {
|
||||||
@@ -101,23 +64,121 @@ export function RecommendedCard({
|
|||||||
await downloadAttachment(
|
await downloadAttachment(
|
||||||
r.downloadPostId,
|
r.downloadPostId,
|
||||||
r.downloadAttachmentId,
|
r.downloadAttachmentId,
|
||||||
r.title,
|
displayTitle,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await downloadFile(dl, r.title);
|
await downloadFile(dl, displayTitle);
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
} finally {
|
} finally {
|
||||||
setIsDownloading(false);
|
setIsDownloading(false);
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article
|
||||||
|
className={`${CARD_CLASS} ${
|
||||||
|
useFigmaDesign
|
||||||
|
? "border-[#27292E]"
|
||||||
|
: "border-transparent md:border-ark-line md:bg-ark-panel"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
to={`/resource/${r.id}`}
|
||||||
|
className="relative block h-[108px] overflow-hidden bg-[#111116] md:aspect-[246.4/138.6] md:h-auto"
|
||||||
|
>
|
||||||
|
{cover ? (
|
||||||
|
<img
|
||||||
|
src={cover}
|
||||||
|
alt=""
|
||||||
|
className={`h-full w-full object-cover transition duration-300 ${
|
||||||
|
useFigmaDesign
|
||||||
|
? "group-hover:scale-[1.02]"
|
||||||
|
: "group-hover:scale-[1.02]"
|
||||||
|
}`}
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="h-full w-full bg-gradient-to-br from-neutral-900 to-neutral-950" />
|
||||||
|
)}
|
||||||
|
{!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">
|
||||||
|
{r.badgeLabel}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</Link>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
useFigmaDesign
|
||||||
|
? "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"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
useFigmaDesign
|
||||||
|
? "min-w-0 truncate pb-[1px] text-[12px] font-normal leading-[17.376px] text-[#A8A9AE]"
|
||||||
|
: "min-w-0 truncate"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{useFigmaDesign ? null : (
|
||||||
|
<>
|
||||||
|
<span className="text-neutral-400">{displayCategoryName}</span>
|
||||||
|
<span className="mx-1.5 text-ark-line">·</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<time dateTime={dateTime}>{dateStr}</time>
|
||||||
|
</div>
|
||||||
|
{dl ? (
|
||||||
|
<button
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
title={isDownloading ? t("downloading") : t("download")}
|
||||||
|
aria-label={isDownloading ? t("downloading") : t("download")}
|
||||||
|
aria-busy={isDownloading}
|
||||||
|
disabled={isDownloading}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
void handleDownload();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{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} />
|
||||||
)}
|
)}
|
||||||
@@ -141,10 +202,10 @@ 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 aspect-[246.4/138.6] overflow-hidden bg-black">
|
<div className="relative block h-[108px] overflow-hidden bg-black md:aspect-[246.4/138.6] md:h-auto">
|
||||||
<img
|
<img
|
||||||
src={cover}
|
src={cover}
|
||||||
alt=""
|
alt=""
|
||||||
@@ -155,8 +216,8 @@ export function ComingSoonRecommendedCard({
|
|||||||
即将到来
|
即将到来
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex min-h-[121px] flex-1 flex-col p-4 pt-3">
|
<div className="flex min-h-[131px] flex-1 flex-col p-4 pt-3 md:min-h-[121px]">
|
||||||
<div className="text-base font-bold leading-snug text-white line-clamp-2">
|
<div className="text-[15px] font-semibold leading-[21.72px] text-white line-clamp-2 md:text-base md:font-bold md:leading-snug">
|
||||||
即将到来
|
即将到来
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-auto pt-4 text-xs text-ark-muted">
|
<div className="mt-auto pt-4 text-xs text-ark-muted">
|
||||||
|
|||||||
@@ -7,24 +7,29 @@ export function SectionHeader({
|
|||||||
viewAllLabel,
|
viewAllLabel,
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
viewAllTo: string;
|
viewAllTo?: string;
|
||||||
viewAllLabel: string;
|
viewAllLabel?: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-6 items-center justify-between gap-4">
|
<div className="flex h-[49px] items-center justify-between gap-4 md:h-6">
|
||||||
<div className="flex min-w-0 items-center gap-4">
|
<div className="flex min-w-0 items-center gap-3 md:gap-4">
|
||||||
<span className="h-6 w-1 shrink-0 bg-ark-gold" aria-hidden />
|
<span
|
||||||
<h2 className="truncate text-2xl font-bold leading-6 tracking-tight text-white md:text-2xl">
|
className="h-5 w-[3px] shrink-0 bg-ark-gold md:h-6 md:w-1"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<h2 className="truncate text-lg font-semibold leading-[24.5px] tracking-tight text-white md:text-2xl md:font-bold md:leading-6">
|
||||||
{title}
|
{title}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
{viewAllTo && viewAllLabel ? (
|
||||||
<Link
|
<Link
|
||||||
to={viewAllTo}
|
to={viewAllTo}
|
||||||
className="inline-flex shrink-0 items-center gap-1 text-[15px] font-medium leading-none text-ark-gold hover:text-ark-gold2"
|
className="inline-flex shrink-0 items-center gap-1 text-[13px] font-normal leading-none text-ark-gold hover:text-ark-gold2 md:text-[15px] md:font-medium"
|
||||||
>
|
>
|
||||||
{viewAllLabel}
|
{viewAllLabel}
|
||||||
<ChevronRight className="h-4 w-4" strokeWidth={2.7} />
|
<ChevronRight className="h-4 w-4" strokeWidth={2.7} />
|
||||||
</Link>
|
</Link>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,17 +23,17 @@ export function FilterChips({ type, onTypeChange }: FilterChipsProps) {
|
|||||||
|
|
||||||
const tabClass = (active: boolean) =>
|
const tabClass = (active: boolean) =>
|
||||||
[
|
[
|
||||||
"relative shrink-0 whitespace-nowrap px-1 py-3 text-[15px] leading-none outline-none transition-colors",
|
"relative flex h-[52px] shrink-0 items-center whitespace-nowrap px-3 pb-4 pt-3 text-[15px] leading-6 outline-none transition-colors md:h-auto md:px-1 md:py-3 md:leading-none",
|
||||||
"border-b-2",
|
"border-b-0 md:border-b-2",
|
||||||
active
|
active
|
||||||
? "border-ark-gold text-ark-gold font-medium"
|
? "border-ark-gold font-medium text-white md:text-ark-gold"
|
||||||
: "border-transparent text-neutral-400 hover:text-ark-gold/80",
|
: "border-transparent text-[#97989A] hover:text-ark-gold/80 md:text-neutral-400",
|
||||||
].join(" ");
|
].join(" ");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="sticky top-0 z-10 border-b border-ark-line bg-ark-bg/95 backdrop-blur md:rounded-t-xl">
|
<div className="sticky top-0 z-10 bg-ark-bg/95 backdrop-blur md:rounded-t-xl md:border-b md:border-ark-line">
|
||||||
<div
|
<div
|
||||||
className="flex items-end gap-5 overflow-x-auto overflow-y-hidden px-1 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
className="flex items-end gap-2 overflow-x-auto overflow-y-hidden px-4 pr-10 [-ms-overflow-style:none] [scrollbar-width:none] md:gap-5 md:px-1 md:pr-1 [&::-webkit-scrollbar]:hidden"
|
||||||
role="tablist"
|
role="tablist"
|
||||||
>
|
>
|
||||||
{TYPE_FILTERS.map((tp) => {
|
{TYPE_FILTERS.map((tp) => {
|
||||||
@@ -52,6 +52,10 @@ export function FilterChips({ type, onTypeChange }: FilterChipsProps) {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute right-0 top-0 hidden h-[52px] w-10 bg-gradient-to-l from-ark-bg via-ark-bg/80 to-transparent max-md:block"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,10 +18,11 @@ export function MessageStream({ scope }: MessageStreamProps) {
|
|||||||
|
|
||||||
const type = sp.get("type") || "all";
|
const type = sp.get("type") || "all";
|
||||||
const q = (sp.get("q") || "").trim();
|
const q = (sp.get("q") || "").trim();
|
||||||
|
const sort = sp.get("sort") || "";
|
||||||
|
|
||||||
const params = useMemo(
|
const params = useMemo(
|
||||||
() => ({ scope, type, q, lang }),
|
() => ({ scope, type, q, sort, lang }),
|
||||||
[scope, type, q, lang],
|
[scope, type, q, sort, lang],
|
||||||
);
|
);
|
||||||
|
|
||||||
const { items, isLoading, error, hasMore, loadMore, reset } =
|
const { items, isLoading, error, hasMore, loadMore, reset } =
|
||||||
@@ -72,10 +73,10 @@ export function MessageStream({ scope }: MessageStreamProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-full px-4 md:max-w-[820px] lg:max-w-[1080px] xl:max-w-[1180px]">
|
<div className="mx-auto max-w-full md:max-w-[820px] lg:max-w-[1080px] xl:max-w-[1180px]">
|
||||||
<FilterChips type={type} onTypeChange={(v) => updateParam("type", v)} />
|
<FilterChips type={type} onTypeChange={(v) => updateParam("type", v)} />
|
||||||
|
|
||||||
<div className="flex flex-col gap-3 pt-2">
|
<div className="flex flex-col gap-3 px-4 pt-4 md:px-0 md:pt-2">
|
||||||
{groups.map((group) => (
|
{groups.map((group) => (
|
||||||
<div key={group.dayKey} className="flex flex-col gap-3">
|
<div key={group.dayKey} className="flex flex-col gap-3">
|
||||||
{group.items.map((post) => (
|
{group.items.map((post) => (
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import { postDisplayText } from "../utils/postText";
|
|||||||
|
|
||||||
function AttachmentRow({ postId, att }: { postId: string; att: Attachment }) {
|
function AttachmentRow({ postId, att }: { postId: string; att: Attachment }) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const isImageAsDoc = att.mime.startsWith("image/");
|
|
||||||
const { Icon, color } = fileIcon({ mime: att.mime, filename: att.filename });
|
const { Icon, color } = fileIcon({ mime: att.mime, filename: att.filename });
|
||||||
const displayFilename = filenameWithExtension(att.filename, att.mime);
|
const displayFilename = filenameWithExtension(att.filename, att.mime);
|
||||||
const [isDownloading, setIsDownloading] = useState(false);
|
const [isDownloading, setIsDownloading] = useState(false);
|
||||||
@@ -28,50 +27,41 @@ function AttachmentRow({ postId, att }: { postId: string; att: Attachment }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="group flex items-center gap-2 rounded-xl px-1 py-0.5 transition hover:bg-white/5">
|
<div className="group flex h-[52px] items-center gap-3">
|
||||||
|
<div
|
||||||
|
className="flex h-[52px] w-[52px] shrink-0 items-center justify-center rounded-full"
|
||||||
|
style={{ backgroundColor: color }}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<Icon className="h-8 w-8 text-white" strokeWidth={2.1} />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div
|
||||||
|
className="truncate text-[15px] font-medium leading-6 text-ark-gold group-hover:text-ark-gold2"
|
||||||
|
title={displayFilename}
|
||||||
|
>
|
||||||
|
{middleEllipsisFilename(displayFilename)}
|
||||||
|
</div>
|
||||||
|
<div className="text-[12px] font-medium leading-[19.2px] text-[#A8A9AE]">
|
||||||
|
{isDownloading ? t("downloading") : formatBytes(att.sizeBytes)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleDownload}
|
onClick={handleDownload}
|
||||||
disabled={isDownloading}
|
disabled={isDownloading}
|
||||||
className="relative h-11 w-11 shrink-0 overflow-hidden rounded-full disabled:cursor-wait md:h-12 md:w-12"
|
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[#191921] text-white transition hover:bg-[#22232D] disabled:cursor-wait"
|
||||||
aria-label={
|
aria-label={
|
||||||
isDownloading ? t("downloading") : `Download ${att.filename}`
|
isDownloading ? t("downloading") : `Download ${att.filename}`
|
||||||
}
|
}
|
||||||
aria-busy={isDownloading}
|
aria-busy={isDownloading}
|
||||||
>
|
>
|
||||||
{isImageAsDoc && att.thumbnailUrl ? (
|
|
||||||
<img
|
|
||||||
src={att.thumbnailUrl}
|
|
||||||
alt=""
|
|
||||||
className="absolute inset-0 h-full w-full object-cover"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
className="flex h-full w-full items-center justify-center"
|
|
||||||
style={{ backgroundColor: color }}
|
|
||||||
>
|
|
||||||
<Icon className="h-5 w-5 text-white" strokeWidth={2.2} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center bg-black/35 text-white opacity-100 transition group-hover:bg-black/45 group-focus-visible:bg-black/45">
|
|
||||||
{isDownloading ? (
|
{isDownloading ? (
|
||||||
<LoaderCircle className="h-4 w-4 animate-spin" strokeWidth={2.3} />
|
<LoaderCircle className="h-5 w-5 animate-spin" strokeWidth={2.3} />
|
||||||
) : (
|
) : (
|
||||||
<DownloadCloudIcon className="h-4 w-4" />
|
<DownloadCloudIcon className="h-6 w-6" />
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
</button>
|
</button>
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div
|
|
||||||
className="truncate text-[14px] font-medium text-ark-gold2 group-hover:text-ark-gold"
|
|
||||||
title={displayFilename}
|
|
||||||
>
|
|
||||||
{middleEllipsisFilename(displayFilename)}
|
|
||||||
</div>
|
|
||||||
<div className="text-[11px] text-neutral-400">
|
|
||||||
{isDownloading ? t("downloading") : formatBytes(att.sizeBytes)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -80,12 +70,12 @@ export function FileDocBubble({ post }: { post: Post }) {
|
|||||||
const { lang } = useI18n();
|
const { lang } = useI18n();
|
||||||
const text = postDisplayText(post, lang);
|
const text = postDisplayText(post, lang);
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-3">
|
||||||
{post.attachments.map((att) => (
|
{post.attachments.map((att) => (
|
||||||
<AttachmentRow key={att.id} postId={post.id} att={att} />
|
<AttachmentRow key={att.id} postId={post.id} att={att} />
|
||||||
))}
|
))}
|
||||||
{text ? (
|
{text ? (
|
||||||
<div className="message-stream-copyable-text mt-1 select-text whitespace-pre-wrap break-words text-[14px] leading-snug text-neutral-100">
|
<div className="message-stream-copyable-text select-text whitespace-pre-wrap break-words text-[15px] font-medium leading-6 text-neutral-100">
|
||||||
{text}
|
{text}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ function VideoAttachmentCard({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{playing && !compact ? (
|
{playing && !compact ? (
|
||||||
|
<>
|
||||||
<video
|
<video
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
src={attachment.url}
|
src={attachment.url}
|
||||||
@@ -86,6 +87,13 @@ function VideoAttachmentCard({
|
|||||||
autoPlay
|
autoPlay
|
||||||
className="absolute inset-0 h-full w-full"
|
className="absolute inset-0 h-full w-full"
|
||||||
/>
|
/>
|
||||||
|
<AttachmentDownloadPill
|
||||||
|
postId={postId}
|
||||||
|
attachment={attachment}
|
||||||
|
leadingLabel={duration}
|
||||||
|
className="absolute left-2 top-2 z-20"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{posterUrl ? (
|
{posterUrl ? (
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export type PostStreamParams = {
|
|||||||
type?: string;
|
type?: string;
|
||||||
language?: string;
|
language?: string;
|
||||||
q?: string;
|
q?: string;
|
||||||
|
sort?: string;
|
||||||
lang: Lang;
|
lang: Lang;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -87,6 +88,7 @@ function buildRealUrl(params: PostStreamParams, cursor?: string): string {
|
|||||||
if (q) sp.set("q", q);
|
if (q) sp.set("q", q);
|
||||||
if (params.scope.kind === "category") sp.set("category", params.scope.slug);
|
if (params.scope.kind === "category") sp.set("category", params.scope.slug);
|
||||||
if (params.type && params.type !== "all") sp.set("type", params.type);
|
if (params.type && params.type !== "all") sp.set("type", params.type);
|
||||||
|
if (params.sort) sp.set("sort", params.sort);
|
||||||
if (params.language) sp.set("language", sourceLanguageQuery(params.language));
|
if (params.language) sp.set("language", sourceLanguageQuery(params.language));
|
||||||
if (cursor) sp.set("cursor", cursor);
|
if (cursor) sp.set("cursor", cursor);
|
||||||
return `${q ? "/api/posts/search" : "/api/posts"}?${sp.toString()}`;
|
return `${q ? "/api/posts/search" : "/api/posts"}?${sp.toString()}`;
|
||||||
@@ -163,6 +165,7 @@ export function usePostStream(params: PostStreamParams): PostStreamResult {
|
|||||||
params.type,
|
params.type,
|
||||||
params.language,
|
params.language,
|
||||||
params.q,
|
params.q,
|
||||||
|
params.sort,
|
||||||
params.lang,
|
params.lang,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ const zhDict: Dict = {
|
|||||||
mainNav: "网站导航",
|
mainNav: "网站导航",
|
||||||
home: "首页",
|
home: "首页",
|
||||||
all: "全部资料",
|
all: "全部资料",
|
||||||
categories: "分类浏览",
|
categories: "资料分类",
|
||||||
latest: "最新更新",
|
latest: "最新更新",
|
||||||
official: "官方推荐",
|
official: "官方推荐",
|
||||||
popular: "热门资料",
|
popular: "热门资料",
|
||||||
@@ -121,6 +121,9 @@ const zhDict: Dict = {
|
|||||||
favorites: "我的收藏",
|
favorites: "我的收藏",
|
||||||
favoritesComingSoon: "功能即将推出",
|
favoritesComingSoon: "功能即将推出",
|
||||||
favoritesComingSoonDesc: "登入与收藏功能开发中,敬请期待。",
|
favoritesComingSoonDesc: "登入与收藏功能开发中,敬请期待。",
|
||||||
|
featureUnavailable: "未开放",
|
||||||
|
featureUnavailableDesc: "该功能暂未开放。",
|
||||||
|
confirm: "知道了",
|
||||||
backToHome: "返回首页",
|
backToHome: "返回首页",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -236,6 +239,9 @@ const enDict: Dict = {
|
|||||||
favoritesComingSoon: "Coming Soon",
|
favoritesComingSoon: "Coming Soon",
|
||||||
favoritesComingSoonDesc:
|
favoritesComingSoonDesc:
|
||||||
"Sign-in and favorites are in development. Stay tuned.",
|
"Sign-in and favorites are in development. Stay tuned.",
|
||||||
|
featureUnavailable: "Not available yet",
|
||||||
|
featureUnavailableDesc: "This feature is not available yet.",
|
||||||
|
confirm: "Got it",
|
||||||
backToHome: "Back to Home",
|
backToHome: "Back to Home",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ type PublicNavWhich =
|
|||||||
| "browseLatest"
|
| "browseLatest"
|
||||||
| "browseRecommended"
|
| "browseRecommended"
|
||||||
| "browsePopular"
|
| "browsePopular"
|
||||||
|
| "favorites"
|
||||||
| "about";
|
| "about";
|
||||||
|
|
||||||
function navIsActive(
|
function navIsActive(
|
||||||
@@ -27,13 +28,20 @@ function navIsActive(
|
|||||||
case "browseAll":
|
case "browseAll":
|
||||||
return pathname === "/browse" && !sp.has("sort");
|
return pathname === "/browse" && !sp.has("sort");
|
||||||
case "categories":
|
case "categories":
|
||||||
return pathname === "/" && hash === "#categories";
|
return (
|
||||||
|
pathname === "/categories" ||
|
||||||
|
(pathname === "/" && hash === "#categories")
|
||||||
|
);
|
||||||
case "browseLatest":
|
case "browseLatest":
|
||||||
return pathname === "/browse" && sp.get("sort") === "latest";
|
return pathname === "/browse" && sp.get("sort") === "latest";
|
||||||
case "browseRecommended":
|
case "browseRecommended":
|
||||||
return pathname === "/browse" && sp.get("sort") === "recommended";
|
return pathname === "/official-recommendations";
|
||||||
case "browsePopular":
|
case "browsePopular":
|
||||||
return pathname === "/browse" && sp.get("sort") === "popular";
|
return pathname === "/browse" && sp.get("sort") === "popular";
|
||||||
|
case "favorites":
|
||||||
|
return (
|
||||||
|
pathname === "/favorites" || (pathname === "/" && hash === "#favorites")
|
||||||
|
);
|
||||||
case "about":
|
case "about":
|
||||||
return pathname === "/about";
|
return pathname === "/about";
|
||||||
default:
|
default:
|
||||||
@@ -266,8 +274,9 @@ export function PublicLayout() {
|
|||||||
|
|
||||||
const na = (which: PublicNavWhich) =>
|
const na = (which: PublicNavWhich) =>
|
||||||
navIsActive(pathname, search, hash, which);
|
navIsActive(pathname, search, hash, which);
|
||||||
const footerInContentFlow =
|
const isHome = pathname === "/";
|
||||||
pathname === "/browse" || pathname.startsWith("/category/");
|
const footerInContentFlow = pathname === "/browse";
|
||||||
|
const popularHref = "/browse?sort=popular";
|
||||||
|
|
||||||
const goSearch = () => {
|
const goSearch = () => {
|
||||||
const s = q.trim();
|
const s = q.trim();
|
||||||
@@ -280,13 +289,13 @@ export function PublicLayout() {
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-full flex flex-col">
|
<div className="min-h-full flex flex-col">
|
||||||
<header className="sticky top-0 z-40 bg-[#08070c] backdrop-blur-md md:border-b md:border-ark-line md:bg-ark-nav/98">
|
<header className="sticky top-0 z-40 bg-[#08070c] backdrop-blur-md md:border-b md:border-ark-line md:bg-ark-nav/98">
|
||||||
<div className="flex h-[64px] items-center justify-between bg-[#08070c] px-[20px] py-[12px] md:hidden">
|
<div className="flex h-[64px] items-center justify-between bg-[#08070c] px-4 py-3 md:hidden">
|
||||||
<Link
|
<Link
|
||||||
to="/"
|
to="/"
|
||||||
className="flex h-[28px] shrink-0 items-center gap-[8px] rounded-sm text-[18px] font-bold tracking-wide text-ark-gold outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-[#08070c]"
|
className="flex h-8 shrink-0 items-center gap-2 rounded-sm text-[20px] font-black leading-5 tracking-tight text-ark-gold outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-[#08070c]"
|
||||||
aria-label={t("brand")}
|
aria-label={t("brand")}
|
||||||
>
|
>
|
||||||
<ArkLogoMark className="h-[28px] w-[28px] shrink-0" />
|
<ArkLogoMark className="h-8 w-8 shrink-0" />
|
||||||
<span className="truncate text-ark-gold">{t("brand")}</span>
|
<span className="truncate text-ark-gold">{t("brand")}</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
@@ -379,13 +388,6 @@ export function PublicLayout() {
|
|||||||
className="header-nav-scroll hidden min-w-0 flex-1 items-center justify-center gap-4 overflow-x-auto overflow-y-hidden py-1 min-[1200px]:flex lg:gap-5"
|
className="header-nav-scroll hidden min-w-0 flex-1 items-center justify-center gap-4 overflow-x-auto overflow-y-hidden py-1 min-[1200px]:flex lg:gap-5"
|
||||||
aria-label={t("mainNav")}
|
aria-label={t("mainNav")}
|
||||||
>
|
>
|
||||||
<Link
|
|
||||||
to="/"
|
|
||||||
className={navClassName(na("home"))}
|
|
||||||
aria-current={na("home") ? "page" : undefined}
|
|
||||||
>
|
|
||||||
{t("home")}
|
|
||||||
</Link>
|
|
||||||
<Link
|
<Link
|
||||||
to="/browse"
|
to="/browse"
|
||||||
className={navClassName(na("browseAll"))}
|
className={navClassName(na("browseAll"))}
|
||||||
@@ -394,12 +396,19 @@ export function PublicLayout() {
|
|||||||
{t("all")}
|
{t("all")}
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
to="/#categories"
|
to="/categories"
|
||||||
className={navClassName(na("categories"))}
|
className={navClassName(na("categories"))}
|
||||||
aria-current={na("categories") ? "page" : undefined}
|
aria-current={na("categories") ? "page" : undefined}
|
||||||
>
|
>
|
||||||
{t("categories")}
|
{t("categories")}
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/official-recommendations"
|
||||||
|
className={navClassName(na("browseRecommended"))}
|
||||||
|
aria-current={na("browseRecommended") ? "page" : undefined}
|
||||||
|
>
|
||||||
|
{t("official")}
|
||||||
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
to="/browse?sort=latest"
|
to="/browse?sort=latest"
|
||||||
className={navClassName(na("browseLatest"))}
|
className={navClassName(na("browseLatest"))}
|
||||||
@@ -408,19 +417,26 @@ export function PublicLayout() {
|
|||||||
{t("latest")}
|
{t("latest")}
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
to="/browse?sort=recommended"
|
to={popularHref}
|
||||||
className={navClassName(na("browseRecommended"))}
|
|
||||||
aria-current={na("browseRecommended") ? "page" : undefined}
|
|
||||||
>
|
|
||||||
{t("official")}
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
to="/browse?sort=popular"
|
|
||||||
className={navClassName(na("browsePopular"))}
|
className={navClassName(na("browsePopular"))}
|
||||||
aria-current={na("browsePopular") ? "page" : undefined}
|
aria-current={na("browsePopular") ? "page" : undefined}
|
||||||
>
|
>
|
||||||
{t("popular")}
|
{t("popular")}
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/favorites"
|
||||||
|
className={navClassName(na("favorites"))}
|
||||||
|
aria-current={na("favorites") ? "page" : undefined}
|
||||||
|
>
|
||||||
|
{t("favorites")}
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/about"
|
||||||
|
className={navClassName(na("about"))}
|
||||||
|
aria-current={na("about") ? "page" : undefined}
|
||||||
|
>
|
||||||
|
{t("footerAbout")}
|
||||||
|
</Link>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="flex min-w-0 flex-1 items-center justify-end gap-2 min-[1200px]:flex-none">
|
<div className="flex min-w-0 flex-1 items-center justify-end gap-2 min-[1200px]:flex-none">
|
||||||
@@ -470,14 +486,6 @@ export function PublicLayout() {
|
|||||||
ariaLabel={t("langLabel")}
|
ariaLabel={t("langLabel")}
|
||||||
className="mb-1 hidden md:block"
|
className="mb-1 hidden md:block"
|
||||||
/>
|
/>
|
||||||
<Link
|
|
||||||
to="/"
|
|
||||||
className={navClassName(na("home"))}
|
|
||||||
aria-current={na("home") ? "page" : undefined}
|
|
||||||
onClick={() => setOpen(false)}
|
|
||||||
>
|
|
||||||
{t("home")}
|
|
||||||
</Link>
|
|
||||||
<Link
|
<Link
|
||||||
to="/browse"
|
to="/browse"
|
||||||
className={navClassName(na("browseAll"))}
|
className={navClassName(na("browseAll"))}
|
||||||
@@ -487,13 +495,21 @@ export function PublicLayout() {
|
|||||||
{t("all")}
|
{t("all")}
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
to="/#categories"
|
to="/categories"
|
||||||
className={navClassName(na("categories"))}
|
className={navClassName(na("categories"))}
|
||||||
aria-current={na("categories") ? "page" : undefined}
|
aria-current={na("categories") ? "page" : undefined}
|
||||||
onClick={() => setOpen(false)}
|
onClick={() => setOpen(false)}
|
||||||
>
|
>
|
||||||
{t("categories")}
|
{t("categories")}
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/official-recommendations"
|
||||||
|
className={navClassName(na("browseRecommended"))}
|
||||||
|
aria-current={na("browseRecommended") ? "page" : undefined}
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
>
|
||||||
|
{t("official")}
|
||||||
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
to="/browse?sort=latest"
|
to="/browse?sort=latest"
|
||||||
className={navClassName(na("browseLatest"))}
|
className={navClassName(na("browseLatest"))}
|
||||||
@@ -503,21 +519,21 @@ export function PublicLayout() {
|
|||||||
{t("latest")}
|
{t("latest")}
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
to="/browse?sort=recommended"
|
to={popularHref}
|
||||||
className={navClassName(na("browseRecommended"))}
|
|
||||||
aria-current={na("browseRecommended") ? "page" : undefined}
|
|
||||||
onClick={() => setOpen(false)}
|
|
||||||
>
|
|
||||||
{t("official")}
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
to="/browse?sort=popular"
|
|
||||||
className={navClassName(na("browsePopular"))}
|
className={navClassName(na("browsePopular"))}
|
||||||
aria-current={na("browsePopular") ? "page" : undefined}
|
aria-current={na("browsePopular") ? "page" : undefined}
|
||||||
onClick={() => setOpen(false)}
|
onClick={() => setOpen(false)}
|
||||||
>
|
>
|
||||||
{t("popular")}
|
{t("popular")}
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/favorites"
|
||||||
|
className={navClassName(na("favorites"))}
|
||||||
|
aria-current={na("favorites") ? "page" : undefined}
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
>
|
||||||
|
{t("favorites")}
|
||||||
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
to="/about"
|
to="/about"
|
||||||
className={navClassName(na("about"))}
|
className={navClassName(na("about"))}
|
||||||
@@ -531,18 +547,18 @@ export function PublicLayout() {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main
|
<main
|
||||||
className={`mx-auto w-full max-w-[1280px] px-4 pt-6 min-[440px]:px-5 sm:px-6 md:px-9 md:pt-10 xl:px-0 ${
|
className={`mx-auto w-full max-w-[1280px] ${
|
||||||
footerInContentFlow ? "pb-0" : "flex-1 pb-6 md:pb-10"
|
isHome
|
||||||
|
? "flex-1 px-0 pb-6 pt-0 md:px-9 md:pb-10 md:pt-10 xl:px-0"
|
||||||
|
: footerInContentFlow
|
||||||
|
? "px-0 pb-0 pt-0 md:px-9 md:pt-10 xl:px-0"
|
||||||
|
: "flex-1 px-4 pb-6 pt-6 min-[440px]:px-5 sm:px-6 md:px-9 md:pb-10 md:pt-10 xl:px-0"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer
|
<footer className="mt-auto bg-transparent md:border-t md:border-ark-line md:bg-ark-nav/90">
|
||||||
className={`bg-transparent md:border-t md:border-ark-line md:bg-ark-nav/90 ${
|
|
||||||
footerInContentFlow ? "mt-3" : "mt-auto"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="mx-auto flex h-[52px] max-w-[358px] items-center justify-center px-4 py-4 text-[13px] leading-5 md:h-auto md:max-w-[1280px] md:justify-start md:px-9 md:py-6 md:text-sm xl:px-0">
|
<div className="mx-auto flex h-[52px] max-w-[358px] items-center justify-center px-4 py-4 text-[13px] leading-5 md:h-auto md:max-w-[1280px] md:justify-start md:px-9 md:py-6 md:text-sm xl:px-0">
|
||||||
<Link
|
<Link
|
||||||
to="/about"
|
to="/about"
|
||||||
@@ -556,7 +572,7 @@ export function PublicLayout() {
|
|||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<nav className="sticky inset-x-0 bottom-0 z-40 bg-[#0C0D0F]/90 backdrop-blur md:hidden">
|
<nav className="sticky inset-x-0 bottom-0 z-40 bg-[#0C0D0F]/90 backdrop-blur md:hidden">
|
||||||
<div className="grid h-[78px] grid-cols-4 gap-3 px-5 py-4 text-center text-[13px] leading-[18px]">
|
<div className="grid h-[78px] grid-cols-4 gap-3 px-5 py-4 text-center text-[11px] leading-[17.6px]">
|
||||||
<BottomNavIcon
|
<BottomNavIcon
|
||||||
to="/"
|
to="/"
|
||||||
label={t("home")}
|
label={t("home")}
|
||||||
@@ -575,15 +591,15 @@ export function PublicLayout() {
|
|||||||
to="/favorites"
|
to="/favorites"
|
||||||
label={t("favorites")}
|
label={t("favorites")}
|
||||||
icon="bookmark"
|
icon="bookmark"
|
||||||
active={pathname === "/favorites"}
|
active={na("favorites")}
|
||||||
/>
|
/>
|
||||||
<BottomNavIcon
|
<BottomNavIcon
|
||||||
to="/browse?sort=latest"
|
to={popularHref}
|
||||||
label={t("latest")}
|
label={t("popular")}
|
||||||
icon="update"
|
icon="update"
|
||||||
active={
|
active={
|
||||||
pathname === "/browse" &&
|
pathname === "/browse" &&
|
||||||
new URLSearchParams(search).get("sort") === "latest"
|
new URLSearchParams(search).get("tag") === "popular"
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,16 +1,27 @@
|
|||||||
import { useSearchParams } from "react-router-dom";
|
import { useSearchParams } from "react-router-dom";
|
||||||
import { MessageStream } from "../../components/messageStream/MessageStream";
|
import { MessageStream } from "../../components/messageStream/MessageStream";
|
||||||
|
import { SectionHeader } from "../../components/SectionHeader";
|
||||||
import { useI18n } from "../../i18n";
|
import { useI18n } from "../../i18n";
|
||||||
|
|
||||||
export function Browse() {
|
export function Browse() {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [sp] = useSearchParams();
|
const [sp] = useSearchParams();
|
||||||
const q = sp.get("q") || "";
|
const q = sp.get("q") || "";
|
||||||
|
const sort = sp.get("sort") || "";
|
||||||
|
const title = q
|
||||||
|
? `${t("search")}: ${q}`
|
||||||
|
: sort === "latest"
|
||||||
|
? t("latest")
|
||||||
|
: sort === "recommended"
|
||||||
|
? t("official")
|
||||||
|
: sort === "popular"
|
||||||
|
? t("popular")
|
||||||
|
: t("all");
|
||||||
return (
|
return (
|
||||||
<section className="space-y-3">
|
<section>
|
||||||
<h1 className="mx-auto max-w-full px-4 text-2xl font-bold md:max-w-[820px] lg:max-w-[1080px] xl:max-w-[1180px]">
|
<div className="mx-auto max-w-full px-4 md:max-w-[820px] lg:max-w-[1080px] xl:max-w-[1180px]">
|
||||||
{q ? `${t("search")}: ${q}` : t("all")}
|
<SectionHeader title={title} />
|
||||||
</h1>
|
</div>
|
||||||
<MessageStream scope={{ kind: "all" }} />
|
<MessageStream scope={{ kind: "all" }} />
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
114
src/pages/Categories/index.tsx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { getJSON, itemsOrEmpty, type Category } from "../../api";
|
||||||
|
import { CategoryIcon } from "../../components/CategoryIcon";
|
||||||
|
import { SectionHeader } from "../../components/SectionHeader";
|
||||||
|
import { langQuery, useI18n } from "../../i18n";
|
||||||
|
import { cleanCategoryDisplayName } from "../../utils/categoryDisplay";
|
||||||
|
|
||||||
|
const FIGMA_CATEGORY_ORDER = [
|
||||||
|
"project-ppt",
|
||||||
|
"daily-class",
|
||||||
|
"official-announcement",
|
||||||
|
"academy-materials",
|
||||||
|
"global-evangelism",
|
||||||
|
"daily-poster",
|
||||||
|
"community-tweets",
|
||||||
|
"video-hub",
|
||||||
|
"subsidy-policy",
|
||||||
|
"how-to",
|
||||||
|
"official-assets",
|
||||||
|
"media-coverage",
|
||||||
|
"academy-video",
|
||||||
|
"general",
|
||||||
|
];
|
||||||
|
|
||||||
|
function figmaCategoryRank(category: Category): number {
|
||||||
|
const index = FIGMA_CATEGORY_ORDER.indexOf(category.slug);
|
||||||
|
return index === -1 ? FIGMA_CATEGORY_ORDER.length : index;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CategoriesPage() {
|
||||||
|
const { t, lang } = useI18n();
|
||||||
|
const [cats, setCats] = useState<Category[]>([]);
|
||||||
|
const [unavailableOpen, setUnavailableOpen] = useState(false);
|
||||||
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getJSON<Category[]>(
|
||||||
|
`/api/categories?lang=${encodeURIComponent(langQuery(lang))}`,
|
||||||
|
)
|
||||||
|
.then((items) =>
|
||||||
|
setCats(
|
||||||
|
itemsOrEmpty(items).sort(
|
||||||
|
(a, b) => figmaCategoryRank(a) - figmaCategoryRank(b),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.catch((e) => setErr(String(e)));
|
||||||
|
}, [lang]);
|
||||||
|
|
||||||
|
if (err) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-red-900 bg-red-950/40 p-4 text-red-200">
|
||||||
|
{err}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<SectionHeader title={t("categorySection")} />
|
||||||
|
<div className="mt-7 grid grid-cols-3 gap-2 md:grid-cols-5 md:gap-3 lg:grid-cols-6 xl:grid-cols-7 xl:gap-4">
|
||||||
|
{cats.map((category) => (
|
||||||
|
<button
|
||||||
|
key={category.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setUnavailableOpen(true)}
|
||||||
|
className="flex h-[88px] min-w-0 flex-col items-center justify-center gap-2 rounded-xl border border-[#27292E] bg-[#1D1E23] px-4 py-3 text-center outline-none transition hover:border-ark-gold/55 hover:bg-[#252630] focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg"
|
||||||
|
>
|
||||||
|
<CategoryIcon
|
||||||
|
iconKey={category.iconKey}
|
||||||
|
categorySlug={category.slug}
|
||||||
|
className="h-9 w-9 shrink-0 text-ark-gold"
|
||||||
|
/>
|
||||||
|
<div className="w-full text-center text-[13px] font-medium leading-[19.5px] text-white line-clamp-2">
|
||||||
|
{cleanCategoryDisplayName(category.name)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{unavailableOpen ? (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/70 px-6 backdrop-blur-sm"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="category-unavailable-title"
|
||||||
|
onClick={() => setUnavailableOpen(false)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-full max-w-[320px] rounded-2xl border border-[#27292E] bg-[#1D1E23] p-5 text-center shadow-2xl"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
id="category-unavailable-title"
|
||||||
|
className="text-xl font-bold text-white"
|
||||||
|
>
|
||||||
|
{t("featureUnavailable")}
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-sm leading-6 text-[#A8A9AE]">
|
||||||
|
{t("featureUnavailableDesc")}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setUnavailableOpen(false)}
|
||||||
|
className="mt-5 h-10 w-full rounded-full bg-ark-gold text-sm font-semibold text-black transition hover:bg-ark-gold2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-[#1D1E23]"
|
||||||
|
>
|
||||||
|
{t("confirm")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ChevronRight } from "lucide-react";
|
import { ChevronRight } from "lucide-react";
|
||||||
import { Link } from "react-router-dom";
|
import { 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";
|
||||||
@@ -10,22 +10,52 @@ import {
|
|||||||
} from "../../components/LatestUpdateRow";
|
} from "../../components/LatestUpdateRow";
|
||||||
import { RecommendedCard } from "../../components/RecommendedCard";
|
import { RecommendedCard } from "../../components/RecommendedCard";
|
||||||
import { SectionHeader } from "../../components/SectionHeader";
|
import { SectionHeader } from "../../components/SectionHeader";
|
||||||
|
import { MessageBubble } from "../../components/messageStream/MessageBubble";
|
||||||
import { langQuery, useI18n } from "../../i18n";
|
import { langQuery, useI18n } from "../../i18n";
|
||||||
import { sourceLanguageQuery } from "../../i18nLanguages";
|
import { sourceLanguageQuery } from "../../i18nLanguages";
|
||||||
import { categoryCardLines } from "../../utils/categoryDisplay";
|
import { cleanCategoryDisplayName } from "../../utils/categoryDisplay";
|
||||||
import {
|
import {
|
||||||
postToResource,
|
postToResource,
|
||||||
type PostBackedResource,
|
type PostBackedResource,
|
||||||
} from "../../utils/postResourceAdapter";
|
} from "../../utils/postResourceAdapter";
|
||||||
import type { Post } from "../../types/post";
|
import type { Post } from "../../types/post";
|
||||||
|
|
||||||
|
const FIGMA_CATEGORY_ORDER = [
|
||||||
|
"project-ppt",
|
||||||
|
"daily-class",
|
||||||
|
"official-announcement",
|
||||||
|
"academy-materials",
|
||||||
|
"global-evangelism",
|
||||||
|
"daily-poster",
|
||||||
|
"community-tweets",
|
||||||
|
"video-hub",
|
||||||
|
"subsidy-policy",
|
||||||
|
"how-to",
|
||||||
|
"official-assets",
|
||||||
|
"media-coverage",
|
||||||
|
"academy-video",
|
||||||
|
"general",
|
||||||
|
];
|
||||||
|
|
||||||
|
function figmaCategoryRank(category: Category): number {
|
||||||
|
const index = FIGMA_CATEGORY_ORDER.indexOf(category.slug);
|
||||||
|
return index === -1 ? FIGMA_CATEGORY_ORDER.length : index;
|
||||||
|
}
|
||||||
|
|
||||||
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 [popular, setPopular] = useState<PostBackedResource[]>([]);
|
||||||
|
const [popularPosts, setPopularPosts] = useState<Post[]>([]);
|
||||||
|
const [categoryUnavailableOpen, setCategoryUnavailableOpen] = useState(false);
|
||||||
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 [activeCategoryPage, setActiveCategoryPage] = useState(0);
|
||||||
const [canScrollRec, setCanScrollRec] = useState(false);
|
const [canScrollRec, setCanScrollRec] = useState(false);
|
||||||
const [recScroll, setRecScroll] = useState({ ratio: 1, progress: 0 });
|
const [recScroll, setRecScroll] = useState({ ratio: 1, progress: 0 });
|
||||||
|
|
||||||
@@ -37,17 +67,29 @@ export function Home() {
|
|||||||
Promise.all([
|
Promise.all([
|
||||||
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${postQ}&sort=latest&limit=5`),
|
||||||
|
getJSON<{ items: Post[] }>(
|
||||||
|
`/api/posts${postQ}&sort=popular&limit=5`,
|
||||||
|
).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) =>
|
||||||
postToResource(post, lang, itemsOrEmpty(c)),
|
postToResource(post, lang, itemsOrEmpty(c)),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
const latestItems = itemsOrEmpty(l.items);
|
||||||
|
setLatestPosts(latestItems);
|
||||||
setLatest(
|
setLatest(
|
||||||
itemsOrEmpty(l.items).map((post) =>
|
latestItems.map((post) =>
|
||||||
|
postToResource(post, lang, itemsOrEmpty(c)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const popularItems = itemsOrEmpty<Post>(p.items);
|
||||||
|
setPopularPosts(popularItems);
|
||||||
|
setPopular(
|
||||||
|
popularItems.map((post) =>
|
||||||
postToResource(post, lang, itemsOrEmpty(c)),
|
postToResource(post, lang, itemsOrEmpty(c)),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -58,6 +100,39 @@ export function Home() {
|
|||||||
const iconKeyForResource = (r: PostBackedResource) =>
|
const iconKeyForResource = (r: PostBackedResource) =>
|
||||||
cats.find((c) => c.id === r.categoryId)?.iconKey ?? "folder";
|
cats.find((c) => c.id === r.categoryId)?.iconKey ?? "folder";
|
||||||
|
|
||||||
|
const figmaOrderedCategories = [...cats].sort(
|
||||||
|
(a, b) => figmaCategoryRank(a) - figmaCategoryRank(b),
|
||||||
|
);
|
||||||
|
|
||||||
|
const categoryPages: Category[][] = [];
|
||||||
|
for (let index = 0; index < figmaOrderedCategories.length; index += 9) {
|
||||||
|
categoryPages.push(figmaOrderedCategories.slice(index, index + 9));
|
||||||
|
}
|
||||||
|
const activeCategoryCount = categoryPages[activeCategoryPage]?.length ?? 0;
|
||||||
|
const activeCategoryRows = Math.ceil(activeCategoryCount / 3);
|
||||||
|
const mobileCategoryHeight =
|
||||||
|
activeCategoryRows * 88 + Math.max(0, activeCategoryRows - 1) * 8;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const row = categoryRowRef.current;
|
||||||
|
if (!row) return;
|
||||||
|
|
||||||
|
const update = () => {
|
||||||
|
const width = row.clientWidth || 1;
|
||||||
|
const next = Math.round(row.scrollLeft / width);
|
||||||
|
setActiveCategoryPage((prev) => (prev === next ? prev : next));
|
||||||
|
};
|
||||||
|
|
||||||
|
update();
|
||||||
|
row.addEventListener("scroll", update, { passive: true });
|
||||||
|
return () => row.removeEventListener("scroll", update);
|
||||||
|
}, [cats.length]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setActiveCategoryPage(0);
|
||||||
|
categoryRowRef.current?.scrollTo({ left: 0 });
|
||||||
|
}, [lang]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const row = recRowRef.current;
|
const row = recRowRef.current;
|
||||||
if (!row) {
|
if (!row) {
|
||||||
@@ -91,7 +166,28 @@ 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: id === "latest" ? "center" : "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 = rec.length;
|
||||||
|
const activeRecommendedDot =
|
||||||
|
recommendedDotCount > 0
|
||||||
|
? Math.min(
|
||||||
|
recommendedDotCount - 1,
|
||||||
|
Math.round(recScroll.progress * (recommendedDotCount - 1)),
|
||||||
|
)
|
||||||
|
: 0;
|
||||||
|
|
||||||
if (err) {
|
if (err) {
|
||||||
return (
|
return (
|
||||||
@@ -102,72 +198,156 @@ export function Home() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-[30px] pb-10 md:space-y-10 md:pb-16 xl:space-y-[34px]">
|
<div className="pb-4 md:space-y-10 md:pb-16 xl:space-y-[34px]">
|
||||||
<section className="-mt-6 md:mt-0">
|
<section className="md:mt-0">
|
||||||
<FigmaBanner />
|
<FigmaBanner />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="categories" className="scroll-mt-24">
|
<section id="categories" className="scroll-mt-16 md:scroll-mt-24">
|
||||||
|
<div className="px-4 md:px-0">
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title={t("categorySection")}
|
title={t("categorySection")}
|
||||||
viewAllTo="/browse"
|
viewAllTo="/categories"
|
||||||
viewAllLabel={t("viewAll")}
|
viewAllLabel={t("viewAll")}
|
||||||
/>
|
/>
|
||||||
<div className="mt-7 grid grid-cols-3 gap-3 min-[440px]:gap-3.5 md:grid-cols-5 md:gap-3 lg:grid-cols-6 xl:grid-cols-7 xl:gap-4">
|
</div>
|
||||||
{cats.map((c) => {
|
|
||||||
const { line1, line2 } = categoryCardLines(c.name);
|
<div className="md:hidden">
|
||||||
return (
|
<div
|
||||||
<Link
|
ref={categoryRowRef}
|
||||||
|
className="flex snap-x snap-mandatory items-start overflow-x-auto overflow-y-hidden scroll-smooth transition-[height] duration-300 ease-out motion-reduce:transition-none [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||||
|
style={{ height: `${mobileCategoryHeight}px` }}
|
||||||
|
aria-label={t("categorySection")}
|
||||||
|
>
|
||||||
|
{categoryPages.map((page, pageIndex) => (
|
||||||
|
<div
|
||||||
|
key={`category-page-${pageIndex}`}
|
||||||
|
className="grid w-full shrink-0 snap-start grid-cols-3 gap-2 px-4"
|
||||||
|
>
|
||||||
|
{page.map((c) => (
|
||||||
|
<button
|
||||||
key={c.id}
|
key={c.id}
|
||||||
to={`/category/${c.slug}`}
|
type="button"
|
||||||
className="group flex min-h-[111px] min-w-0 flex-col items-center justify-center gap-3 rounded-xl border border-ark-line bg-ark-panel px-2.5 py-4 text-center transition hover:border-ark-gold/55 hover:shadow-[0_0_0_1px_rgba(238,183,38,0.12)] md:min-h-24 md:flex-row md:justify-start md:gap-4 md:px-5 md:text-left"
|
onClick={() => setCategoryUnavailableOpen(true)}
|
||||||
|
className="flex h-[88px] min-w-0 flex-col items-center justify-center gap-2 rounded-xl border border-[#27292E] bg-[#1D1E23] px-4 py-3 text-center outline-none transition hover:border-ark-gold/55 hover:bg-[#252630] focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg"
|
||||||
>
|
>
|
||||||
<CategoryIcon
|
<CategoryIcon
|
||||||
iconKey={c.iconKey}
|
iconKey={c.iconKey}
|
||||||
categorySlug={c.slug}
|
categorySlug={c.slug}
|
||||||
className="h-9 w-9 shrink-0 text-ark-gold md:h-9 md:w-9"
|
className="h-9 w-9 shrink-0 text-ark-gold"
|
||||||
/>
|
/>
|
||||||
<div className="min-w-0">
|
<div className="w-full truncate text-[13px] font-medium leading-[19.5px] text-white">
|
||||||
<div className="text-[15px] font-bold leading-snug text-white line-clamp-2 md:text-sm">
|
{cleanCategoryDisplayName(c.name)}
|
||||||
{line1}
|
|
||||||
</div>
|
</div>
|
||||||
{line2 ? (
|
</button>
|
||||||
<div className="mt-0.5 text-[15px] font-bold leading-snug text-white line-clamp-2 md:text-sm">
|
))}
|
||||||
{line2}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<SectionHeader
|
|
||||||
title={t("officialSection")}
|
|
||||||
viewAllTo="/browse?sort=recommended"
|
|
||||||
viewAllLabel={t("viewAll")}
|
|
||||||
/>
|
|
||||||
<div className="relative mt-7">
|
|
||||||
<div
|
|
||||||
ref={recRowRef}
|
|
||||||
className="flex gap-3 overflow-x-auto pb-5 pr-0 scroll-smooth snap-x snap-mandatory [-ms-overflow-style:none] [scrollbar-width:none] md:gap-4 [&::-webkit-scrollbar]:hidden"
|
|
||||||
>
|
|
||||||
{rec.map((r, index) => (
|
|
||||||
<div key={r.id} className="snap-start">
|
|
||||||
<RecommendedCard r={r} visualIndex={index} />
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="h-1 overflow-hidden rounded-full bg-black/80 md:hidden">
|
|
||||||
|
{categoryPages.length > 1 ? (
|
||||||
<div
|
<div
|
||||||
className="h-full rounded-full bg-ark-gold transition-transform duration-300 ease-out"
|
className="flex h-[30px] items-center justify-center gap-1.5"
|
||||||
style={{
|
aria-label="Category pagination"
|
||||||
width: `${Math.round(recScroll.ratio * 100)}%`,
|
>
|
||||||
transform: `translateX(${recScroll.progress * (100 / recScroll.ratio - 100)}%)`,
|
{categoryPages.map((_, index) => (
|
||||||
|
<button
|
||||||
|
key={`category-dot-${index}`}
|
||||||
|
type="button"
|
||||||
|
aria-label={`Go to category page ${index + 1}`}
|
||||||
|
aria-current={activeCategoryPage === index}
|
||||||
|
onClick={() => {
|
||||||
|
const row = categoryRowRef.current;
|
||||||
|
if (!row) return;
|
||||||
|
row.scrollTo({
|
||||||
|
left: row.clientWidth * index,
|
||||||
|
behavior: "smooth",
|
||||||
|
});
|
||||||
|
setActiveCategoryPage(index);
|
||||||
}}
|
}}
|
||||||
|
className={`h-1.5 rounded-full transition-all ${
|
||||||
|
activeCategoryPage === index
|
||||||
|
? "w-6 bg-ark-gold"
|
||||||
|
: "w-1.5 bg-[#7C7C7C]"
|
||||||
|
}`}
|
||||||
/>
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-7 hidden grid-cols-3 gap-3 min-[440px]:gap-3.5 md:grid md:grid-cols-5 md:gap-3 lg:grid-cols-6 xl:grid-cols-7 xl:gap-4">
|
||||||
|
{figmaOrderedCategories.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCategoryUnavailableOpen(true)}
|
||||||
|
className="flex h-[88px] min-w-0 flex-col items-center justify-center gap-2 rounded-xl border border-[#27292E] bg-[#1D1E23] px-4 py-3 text-center outline-none transition hover:border-ark-gold/55 hover:bg-[#252630] focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg"
|
||||||
|
>
|
||||||
|
<CategoryIcon
|
||||||
|
iconKey={c.iconKey}
|
||||||
|
categorySlug={c.slug}
|
||||||
|
className="h-9 w-9 shrink-0 text-ark-gold"
|
||||||
|
/>
|
||||||
|
<div className="w-full text-center text-[13px] font-medium leading-[19.5px] text-white line-clamp-2">
|
||||||
|
{cleanCategoryDisplayName(c.name)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="official" className="scroll-mt-16 md:scroll-mt-24">
|
||||||
|
<div className="px-4 md:px-0">
|
||||||
|
<SectionHeader
|
||||||
|
title={t("officialSection")}
|
||||||
|
viewAllTo="/official-recommendations"
|
||||||
|
viewAllLabel={t("viewAll")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<div
|
||||||
|
ref={recRowRef}
|
||||||
|
className="flex gap-3 overflow-x-auto overflow-y-hidden px-4 pb-0 pr-4 scroll-smooth [-ms-overflow-style:none] [scrollbar-width:none] md:mt-7 md:gap-4 md:px-0 md:pr-0 md:pb-5 [&::-webkit-scrollbar]:hidden"
|
||||||
|
>
|
||||||
|
{rec.map((r, index) => (
|
||||||
|
<div key={r.id}>
|
||||||
|
<RecommendedCard r={r} visualIndex={index} useFigmaDesign />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="flex h-[30px] items-center justify-center gap-1.5 md:hidden"
|
||||||
|
aria-label="Recommended pagination"
|
||||||
|
>
|
||||||
|
{Array.from({ length: recommendedDotCount }).map((_, index) => (
|
||||||
|
<button
|
||||||
|
key={`recommended-dot-${index}`}
|
||||||
|
type="button"
|
||||||
|
aria-label={`Go to recommendation page ${index + 1}`}
|
||||||
|
aria-current={activeRecommendedDot === index}
|
||||||
|
onClick={() => {
|
||||||
|
const row = recRowRef.current;
|
||||||
|
if (!row) return;
|
||||||
|
const maxScroll = Math.max(
|
||||||
|
0,
|
||||||
|
row.scrollWidth - row.clientWidth,
|
||||||
|
);
|
||||||
|
row.scrollTo({
|
||||||
|
left:
|
||||||
|
recommendedDotCount === 1
|
||||||
|
? 0
|
||||||
|
: (maxScroll * index) / (recommendedDotCount - 1),
|
||||||
|
behavior: "smooth",
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className={`h-1.5 rounded-full transition-all ${
|
||||||
|
activeRecommendedDot === index
|
||||||
|
? "w-6 bg-ark-gold"
|
||||||
|
: "w-1.5 bg-[#7C7C7C]"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
{canScrollRec ? (
|
{canScrollRec ? (
|
||||||
<button
|
<button
|
||||||
@@ -182,13 +362,20 @@ export function Home() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section id="latest" className="scroll-mt-16 md:scroll-mt-24">
|
||||||
|
<div className="px-4 md:px-0">
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title={t("latestSection")}
|
title={t("latestSection")}
|
||||||
viewAllTo="/browse?sort=latest"
|
viewAllTo="/browse"
|
||||||
viewAllLabel={t("viewAll")}
|
viewAllLabel={t("viewAll")}
|
||||||
/>
|
/>
|
||||||
<div className="mt-7 grid gap-3 min-[576px]:grid-cols-2 md:gap-4 lg:grid-cols-3 xl:grid-cols-5">
|
</div>
|
||||||
|
<div className="flex flex-col gap-3 md:hidden">
|
||||||
|
{latestPosts.slice(0, 5).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">
|
||||||
{latest.map((r) => (
|
{latest.map((r) => (
|
||||||
<LatestUpdateRow key={r.id} r={r} iconKey={iconKeyForResource(r)} />
|
<LatestUpdateRow key={r.id} r={r} iconKey={iconKeyForResource(r)} />
|
||||||
))}
|
))}
|
||||||
@@ -200,6 +387,64 @@ export function Home() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section id="popular" className="scroll-mt-16 md: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, 5).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>
|
||||||
|
|
||||||
|
{categoryUnavailableOpen ? (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/70 px-6 backdrop-blur-sm"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="category-unavailable-title"
|
||||||
|
onClick={() => setCategoryUnavailableOpen(false)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-full max-w-[320px] rounded-2xl border border-[#27292E] bg-[#1D1E23] p-5 text-center shadow-2xl"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
id="category-unavailable-title"
|
||||||
|
className="text-xl font-bold text-white"
|
||||||
|
>
|
||||||
|
{t("featureUnavailable")}
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-sm leading-6 text-[#A8A9AE]">
|
||||||
|
{t("featureUnavailableDesc")}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setCategoryUnavailableOpen(false)}
|
||||||
|
className="mt-5 h-10 w-full rounded-full bg-ark-gold text-sm font-semibold text-black transition hover:bg-ark-gold2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-[#1D1E23]"
|
||||||
|
>
|
||||||
|
{t("confirm")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
61
src/pages/OfficialRecommendations/index.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { getJSON, itemsOrEmpty, type Category } from "../../api";
|
||||||
|
import { RecommendedCard } from "../../components/RecommendedCard";
|
||||||
|
import { SectionHeader } from "../../components/SectionHeader";
|
||||||
|
import { langQuery, useI18n } from "../../i18n";
|
||||||
|
import { sourceLanguageQuery } from "../../i18nLanguages";
|
||||||
|
import type { Post } from "../../types/post";
|
||||||
|
import {
|
||||||
|
postToResource,
|
||||||
|
type PostBackedResource,
|
||||||
|
} from "../../utils/postResourceAdapter";
|
||||||
|
|
||||||
|
export function OfficialRecommendationsPage() {
|
||||||
|
const { t, lang } = useI18n();
|
||||||
|
const [items, setItems] = useState<PostBackedResource[]>([]);
|
||||||
|
const [err, setErr] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const langParam = encodeURIComponent(langQuery(lang));
|
||||||
|
const languageParam = encodeURIComponent(sourceLanguageQuery(lang));
|
||||||
|
Promise.all([
|
||||||
|
getJSON<Category[]>(`/api/categories?lang=${langParam}`),
|
||||||
|
getJSON<{ items: Post[] }>(
|
||||||
|
`/api/posts/recommended?lang=${langParam}&language=${languageParam}&limit=100`,
|
||||||
|
),
|
||||||
|
])
|
||||||
|
.then(([categories, recommended]) => {
|
||||||
|
const cats = itemsOrEmpty(categories);
|
||||||
|
setItems(
|
||||||
|
itemsOrEmpty(recommended.items).map((post) =>
|
||||||
|
postToResource(post, lang, cats),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch((e) => setErr(String(e)));
|
||||||
|
}, [lang]);
|
||||||
|
|
||||||
|
if (err) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-red-900 bg-red-950/40 p-4 text-red-200">
|
||||||
|
{err}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section>
|
||||||
|
<SectionHeader title={t("officialSection")} />
|
||||||
|
<div className="mt-7 grid grid-cols-[repeat(auto-fill,208px)] justify-center gap-3 md:grid-cols-[repeat(auto-fill,240px)] md:justify-start md:gap-4 lg:grid-cols-[repeat(auto-fill,246.4px)]">
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<RecommendedCard
|
||||||
|
key={item.id}
|
||||||
|
r={item}
|
||||||
|
visualIndex={index}
|
||||||
|
useFigmaDesign
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -19,10 +19,9 @@ export function PostRedirect() {
|
|||||||
|
|
||||||
if (POST_STREAM_USES_MOCK) {
|
if (POST_STREAM_USES_MOCK) {
|
||||||
const post = MOCK_POSTS.find((p) => p.id === id);
|
const post = MOCK_POSTS.find((p) => p.id === id);
|
||||||
navigate(
|
navigate(post ? `/browse#post-${post.id}` : "/browse", {
|
||||||
post ? `/category/${post.categorySlug}#post-${post.id}` : "/browse",
|
replace: true,
|
||||||
{ replace: true },
|
});
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,7 +29,7 @@ export function PostRedirect() {
|
|||||||
`/api/posts/${id}?lang=${encodeURIComponent(langQuery(lang))}`,
|
`/api/posts/${id}?lang=${encodeURIComponent(langQuery(lang))}`,
|
||||||
)
|
)
|
||||||
.then((post) => {
|
.then((post) => {
|
||||||
navigate(`/category/${post.categorySlug}#post-${post.id}`, {
|
navigate(`/browse#post-${post.id}`, {
|
||||||
replace: true,
|
replace: true,
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { categoryCardLines } from "./categoryDisplay";
|
import { categoryCardLines, cleanCategoryDisplayName } from "./categoryDisplay";
|
||||||
|
|
||||||
|
describe("cleanCategoryDisplayName", () => {
|
||||||
|
it("removes parenthetical suffixes used as backend qualifiers", () => {
|
||||||
|
expect(cleanCategoryDisplayName("官方公告(繁中)")).toBe("官方公告");
|
||||||
|
expect(cleanCategoryDisplayName("Tutorials (EN)")).toBe("Tutorials");
|
||||||
|
expect(cleanCategoryDisplayName("补贴政策\r")).toBe("补贴政策");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("categoryCardLines", () => {
|
describe("categoryCardLines", () => {
|
||||||
it("splits Chinese and ASCII parenthetical subtitles", () => {
|
it("splits Chinese and ASCII parenthetical subtitles", () => {
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
|
export function cleanCategoryDisplayName(name: string): string {
|
||||||
|
return name
|
||||||
|
.replace(/\s*[((][^()()]*[))]\s*/g, " ")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
/** Split display name into title + parenthetical subtitle (matches design cards). */
|
/** Split display name into title + parenthetical subtitle (matches design cards). */
|
||||||
export function categoryCardLines(
|
export function categoryCardLines(
|
||||||
name: string,
|
name: string,
|
||||||
|
|||||||
@@ -23,10 +23,10 @@ function inferType(post: Post, att: Attachment | undefined): string {
|
|||||||
|
|
||||||
function coverFor(att: Attachment | undefined) {
|
function coverFor(att: Attachment | undefined) {
|
||||||
if (!att) return "";
|
if (!att) return "";
|
||||||
if (att.kind === "image") return att.thumbnailUrl || att.url;
|
if (att.kind === "image" || att.mime.startsWith("image/")) {
|
||||||
if (att.kind === "video") return att.posterUrl || att.thumbnailUrl || "";
|
return att.thumbnailUrl || att.url;
|
||||||
if (att.mime.startsWith("image/")) return att.thumbnailUrl || att.url;
|
}
|
||||||
return "";
|
return att.posterUrl || att.thumbnailUrl || "";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function postToResource(
|
export function postToResource(
|
||||||
|
|||||||