Merge pull request 'terry-staging' (#12) from terry-staging into main
All checks were successful
Deploy to Frontend Servers / deploy (push) Successful in 36s

Reviewed-on: #12
This commit was merged in pull request #12.
This commit is contained in:
2026-05-30 10:45:30 +00:00
18 changed files with 403 additions and 76 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 264 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

View File

@@ -15,7 +15,7 @@ import {
Play,
type LucideIcon,
} from "lucide-react";
import { categorySvgUrlForSlug } from "../lib/categorySvgSlug";
import { categoryAssetUrlForSlug } from "../lib/categorySvgSlug";
const map: Record<string, LucideIcon> = {
folder: Folder,
@@ -40,15 +40,15 @@ export function CategoryIcon({
className,
}: {
iconKey: string;
/** When set, prefer branded SVG from `public/assets/ark-library/media/svg/`. */
/** When set, prefer branded asset from `public/assets/ark-library/media/`. */
categorySlug?: string;
className?: string;
}) {
const svgUrl = categorySlug ? categorySvgUrlForSlug(categorySlug) : null;
if (svgUrl) {
const assetUrl = categorySlug ? categoryAssetUrlForSlug(categorySlug) : null;
if (assetUrl) {
return (
<img
src={svgUrl}
src={assetUrl}
alt=""
className={[className, "object-contain pointer-events-none select-none"]
.filter(Boolean)

View File

@@ -3,8 +3,10 @@ import {
useEffect,
useRef,
useState,
type MouseEvent as ReactMouseEvent,
type PointerEvent as ReactPointerEvent,
} from "react";
import { useNavigate } from "react-router-dom";
import { assetUrl, getJSON, itemsOrEmpty, readJSONCache } from "../api";
import { langQuery, useI18n, type Lang } from "../i18n";
@@ -41,6 +43,24 @@ function bannerLangParam(lang: Lang): string {
return langQuery(lang).toLowerCase();
}
/**
* If `linkUrl` points inside this app (a relative path, or an absolute URL on
* the same origin), return the router path so we can navigate client-side —
* e.g. `/browse?post=<id>` triggers the stream's scroll-to-post without a full
* reload. External URLs return null and fall back to a plain `<a href>`.
*/
function internalPath(linkUrl: string): string | null {
try {
if (linkUrl.startsWith("//")) return null;
if (linkUrl.startsWith("/")) return linkUrl;
const url = new URL(linkUrl, window.location.origin);
if (url.origin !== window.location.origin) return null;
return `${url.pathname}${url.search}${url.hash}`;
} catch {
return null;
}
}
function toSlides(items: BannerApiItem[]): BannerSlide[] {
return [...items]
.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0))
@@ -59,8 +79,12 @@ function toSlides(items: BannerApiItem[]): BannerSlide[] {
export function FigmaBanner() {
const { lang } = useI18n();
const navigate = useNavigate();
const [slides, setSlides] = useState<BannerSlide[]>([]);
const scrollerRef = useRef<HTMLDivElement>(null);
// Set when a mouse drag just moved the carousel, so the synthetic `click`
// that follows pointerup doesn't navigate the slide's link.
const suppressClickRef = useRef(false);
const [activeIndex, setActiveIndex] = useState(0);
const [autoplayPaused, setAutoplayPaused] = useState(false);
const [publicMenuOpen, setPublicMenuOpen] = useState(false);
@@ -177,7 +201,9 @@ export function FigmaBanner() {
startScrollLeft: scroller.scrollLeft,
moved: false,
};
scroller.setPointerCapture(event.pointerId);
// Don't capture the pointer yet: capturing on press makes the browser
// dispatch the following `click` to the scroller instead of the slide's
// <a>, swallowing the link. Capture only once a real drag begins (below).
pauseAutoplay();
};
@@ -189,6 +215,7 @@ export function FigmaBanner() {
const dx = event.clientX - drag.startX;
if (!drag.moved && Math.abs(dx) > 4) {
drag.moved = true;
scroller.setPointerCapture(event.pointerId);
scroller.style.scrollSnapType = "none";
}
if (drag.moved) {
@@ -206,6 +233,11 @@ export function FigmaBanner() {
scroller.releasePointerCapture(event.pointerId);
}
if (drag.moved) {
// Swallow the click that the browser fires right after a drag-release.
suppressClickRef.current = true;
window.setTimeout(() => {
suppressClickRef.current = false;
}, 0);
const width = scroller.clientWidth || 1;
const nearest = Math.round(scroller.scrollLeft / width);
const clamped = Math.max(0, Math.min(slides.length - 1, nearest));
@@ -214,6 +246,27 @@ export function FigmaBanner() {
}
};
const handleSlideClick = (
event: ReactMouseEvent<HTMLAnchorElement>,
linkUrl: string,
) => {
if (suppressClickRef.current) {
suppressClickRef.current = false;
event.preventDefault();
return;
}
// Let modified clicks (new tab / window) and external links use default
// browser behavior; route same-app links through the SPA so the stream's
// `?post=<id>` scroll-to-post runs without a full page reload.
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
return;
}
const path = internalPath(linkUrl);
if (!path) return;
event.preventDefault();
navigate(path);
};
if (slides.length === 0) return null;
const pagination = hasMultiple ? (
@@ -287,7 +340,12 @@ export function FigmaBanner() {
aria-label={`${index + 1} / ${slides.length}`}
>
{slide.linkUrl ? (
<a href={slide.linkUrl} className="block" rel="noreferrer">
<a
href={slide.linkUrl}
className="block"
rel="noreferrer"
onClick={(event) => handleSlideClick(event, slide.linkUrl!)}
>
{image}
</a>
) : (

View File

@@ -119,7 +119,9 @@ function PopularRankRow({
<article className="relative flex items-center gap-3 rounded-2xl border border-ark-line bg-ark-panel p-3 transition hover:border-ark-gold/45 md:gap-4 md:p-4">
<button
type="button"
onClick={() => navigate(`/resource/${post.id}`)}
onClick={() =>
navigate(`/browse?sort=popular&post=${encodeURIComponent(post.id)}`)
}
aria-label={r.title}
className="absolute inset-0 z-0 rounded-2xl outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/70"
/>

View File

@@ -7,14 +7,14 @@ import { useLocation } from "react-router-dom";
* clamp to wherever the previous (taller) page was scrolled — e.g. landing at
* the bottom of a category page after clicking a card far down the home grid.
*
* Skips navigations that carry a hash (`#post-<id>`, `#categories`, …) so
* anchor / deep-link targets keep their own scroll handling.
* Skips navigations that carry a hash (`#post-<id>`, `#categories`, …) or a
* `?post=<id>` deep link so the target page can handle its own alignment.
*/
export function ScrollToTop() {
const { pathname, search, hash } = useLocation();
useEffect(() => {
if (hash) return;
if (hash || new URLSearchParams(search).has("post")) return;
window.scrollTo({ top: 0, left: 0 });
}, [pathname, search, hash]);

View File

@@ -48,7 +48,7 @@ export function FilterChips({ type, onTypeChange }: FilterChipsProps) {
].join(" ");
return (
<div className="bg-ark-bg/95 backdrop-blur md:rounded-t-xl">
<div className="select-none bg-ark-bg/95 backdrop-blur md:rounded-t-xl">
<div
ref={scrollRef}
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"

View File

@@ -1,5 +1,27 @@
import type { LinkPreview } from "../../types/post";
const LINK_ACCENT = "#eeb726";
const DOMAIN_ACCENT_OVERRIDES: Array<[string, string]> = [
// Tencent Meeting returns themeColor="#000000", which disappears on the
// dark bubble surface. Keep it consistent with inline link color instead.
["meeting.tencent.com", LINK_ACCENT],
];
function linkPreviewAccent(preview: LinkPreview): string {
const url = preview.canonicalUrl || preview.url;
try {
const host = new URL(url).hostname.replace(/^www\./, "");
const match = DOMAIN_ACCENT_OVERRIDES.find(
([domain]) => host === domain || host.endsWith(`.${domain}`),
);
if (match) return match[1];
} catch {
// Ignore malformed preview URLs and fall back below.
}
return preview.themeColor || LINK_ACCENT;
}
/**
* Telegram-style rich preview card for a single URL embedded in a post.
*
@@ -8,7 +30,7 @@ import type { LinkPreview } from "../../types/post";
* that opens `canonicalUrl` in a new tab.
*/
export function LinkPreviewCard({ preview }: { preview: LinkPreview }) {
const accent = preview.themeColor || "#EEB726";
const accent = linkPreviewAccent(preview);
const hasUsefulText =
preview.title.length > 0 || preview.description.length > 0;
if (!hasUsefulText && !preview.imageUrl) return null;

View File

@@ -120,13 +120,22 @@ export function MessageInlineVideo({
const [currentTime, setCurrentTime] = useState(initialTime);
const [duration, setDuration] = useState(attachment.durationSec ?? 0);
const [isScrubbing, setIsScrubbing] = useState(false);
// When we programmatically seek (e.g. syncing the playhead back from the
// fullscreen overlay) the progress fill should jump straight to the watched
// position instead of sweeping up from its old width via the CSS transition.
// Cleared as soon as real playback resumes so live progress stays smooth.
const [snapProgress, setSnapProgress] = useState(false);
const t = TOKENS[size];
useEffect(() => {
const v = videoRef.current;
if (!v) return;
const onPlay = () => setIsPlaying(true);
const onPlay = () => {
setIsPlaying(true);
// Real playback advances the fill smoothly again; re-enable transitions.
setSnapProgress(false);
};
const onPause = () => setIsPlaying(false);
const onTime = () => {
setCurrentTime(v.currentTime);
@@ -232,7 +241,9 @@ export function MessageInlineVideo({
// Update React state synchronously so the progress bar paints the
// new playhead in the next frame, before the <video> seek round-
// trip emits its own events (paused videos don't fire timeupdate
// and `seeked` can lag ~hundreds of ms).
// and `seeked` can lag ~hundreds of ms). Snap the fill to the new
// position so it doesn't sweep up from its pre-fullscreen width.
setSnapProgress(true);
setCurrentTime(finalTime);
onTimeUpdate?.(finalTime);
const apply = () => {
@@ -334,7 +345,9 @@ export function MessageInlineVideo({
>
<div
className={`h-full bg-white ${
isScrubbing ? "" : "transition-[width] duration-150 ease-out"
isScrubbing || snapProgress
? ""
: "transition-[width] duration-150 ease-out"
}`}
style={{ width: `${progressPct}%` }}
/>

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useLocation, useSearchParams } from "react-router-dom";
import { postJSON } from "../../api";
import { useI18n } from "../../i18n";
@@ -79,23 +79,72 @@ export function MessageStream({ scope }: MessageStreamProps) {
? hash.slice("#post-".length)
: "";
const targetPostId = queryTargetPostId || hashTargetPostId;
const [isAligningQueryTarget, setIsAligningQueryTarget] = useState(
Boolean(queryTargetPostId),
);
const handledTargetRef = useRef<string>("");
const targetScrollTimersRef = useRef<number[]>([]);
const targetScrollFrameRef = useRef<number | null>(null);
const clearTargetScrollTimers = () => {
for (const timer of targetScrollTimersRef.current) {
window.clearTimeout(timer);
}
targetScrollTimersRef.current = [];
if (targetScrollFrameRef.current !== null) {
window.cancelAnimationFrame(targetScrollFrameRef.current);
targetScrollFrameRef.current = null;
}
};
useEffect(() => {
handledTargetRef.current = "";
clearTargetScrollTimers();
}, [targetPostId]);
setIsAligningQueryTarget(Boolean(queryTargetPostId));
}, [queryTargetPostId, targetPostId]);
useEffect(() => clearTargetScrollTimers, []);
useEffect(() => {
if (!isAligningQueryTarget) return;
const preventScroll = (event: Event) => event.preventDefault();
const preventScrollKeys = (event: KeyboardEvent) => {
if (
[
"ArrowDown",
"ArrowUp",
"PageDown",
"PageUp",
"Home",
"End",
" ",
].includes(event.key)
) {
event.preventDefault();
}
};
const html = document.documentElement;
const previousOverscroll = html.style.overscrollBehavior;
html.style.overscrollBehavior = "none";
window.addEventListener("wheel", preventScroll, {
capture: true,
passive: false,
});
window.addEventListener("touchmove", preventScroll, {
capture: true,
passive: false,
});
window.addEventListener("keydown", preventScrollKeys, { capture: true });
return () => {
html.style.overscrollBehavior = previousOverscroll;
window.removeEventListener("wheel", preventScroll, { capture: true });
window.removeEventListener("touchmove", preventScroll, { capture: true });
window.removeEventListener("keydown", preventScrollKeys, {
capture: true,
});
};
}, [isAligningQueryTarget]);
useEffect(() => {
if (!targetPostId || handledTargetRef.current === targetPostId) return;
@@ -104,27 +153,68 @@ export function MessageStream({ scope }: MessageStreamProps) {
handledTargetRef.current = targetPostId;
clearTargetScrollTimers();
const scrollToTarget = (behavior: ScrollBehavior = "auto") => {
const targetScrollTop = () => {
const target = document.getElementById(`post-${targetPostId}`);
if (!target) return;
if (!target) return null;
const filterBottom =
filterBarRef.current?.getBoundingClientRect().bottom ?? 0;
const targetTop = target.getBoundingClientRect().top + window.scrollY;
window.scrollTo({
top: Math.max(0, targetTop - filterBottom - 12),
left: 0,
behavior,
});
return Math.max(0, targetTop - filterBottom - 12);
};
const scrollToTarget = (behavior: ScrollBehavior = "auto") => {
const top = targetScrollTop();
if (top === null) return;
window.scrollTo({ top, left: 0, behavior });
};
const boundedSmoothScrollToTarget = () => {
const firstTarget = targetScrollTop();
if (firstTarget === null) return;
const start = window.scrollY;
const startedAt = performance.now();
const duration = 520;
const direction = firstTarget >= start ? 1 : -1;
const easeOutCubic = (x: number) => 1 - Math.pow(1 - x, 3);
const tick = (now: number) => {
const latestTarget = targetScrollTop();
if (latestTarget === null) return;
const progress = Math.min(1, (now - startedAt) / duration);
const ideal = start + (latestTarget - start) * easeOutCubic(progress);
const next =
direction >= 0
? Math.min(ideal, latestTarget)
: Math.max(ideal, latestTarget);
window.scrollTo({ top: next, left: 0, behavior: "auto" });
if (progress < 1) {
targetScrollFrameRef.current = window.requestAnimationFrame(tick);
return;
}
scrollToTarget("auto");
setIsAligningQueryTarget(false);
targetScrollFrameRef.current = null;
};
targetScrollFrameRef.current = window.requestAnimationFrame(tick);
};
const prefersReducedMotion = window.matchMedia(
"(prefers-reduced-motion: reduce)",
).matches;
// Show a deliberate "from top to target" transition when opening a card
// from Home. The later auto re-alignments are intentionally delayed so
// they don't interrupt the visible smooth scroll animation.
window.scrollTo({ top: 0, left: 0, behavior: "auto" });
if (queryTargetPostId) {
// Query deep-links (`?post=<id>`) usually come from Home cards/list
// rows. Keep the premium motion, but drive it ourselves so scrolling is
// bounded to the current target position and cannot visibly pass the
// target before snapping back. User-driven scroll is temporarily locked
// by the effect above; programmatic scroll remains allowed.
targetScrollTimersRef.current = [80].map((ms) =>
window.setTimeout(boundedSmoothScrollToTarget, ms),
);
} else {
window.requestAnimationFrame(() =>
scrollToTarget(prefersReducedMotion ? "auto" : "smooth"),
);
@@ -135,6 +225,7 @@ export function MessageStream({ scope }: MessageStreamProps) {
targetScrollTimersRef.current = [900, 1400, 2000].map((ms) =>
window.setTimeout(() => scrollToTarget("auto"), ms),
);
}
el.classList.add("ark-bubble-highlight");
window.setTimeout(
@@ -146,6 +237,7 @@ export function MessageStream({ scope }: MessageStreamProps) {
// Not loaded yet — keep paging until it appears or the stream is exhausted.
if (hasMore && !isLoading) loadMore();
else if (!hasMore && !isLoading) setIsAligningQueryTarget(false);
}, [targetPostId, items, hasMore, isLoading, loadMore]);
const updateParam = (key: string, value: string) => {

View File

@@ -5,10 +5,7 @@ import { useI18n } from "../../../i18n";
import type { Attachment, Post } from "../../../types/post";
import { downloadAttachment } from "../utils/downloadFile";
import { fileIcon } from "../utils/fileIcon";
import {
filenameWithExtension,
middleEllipsisFilename,
} from "../utils/filenameDisplay";
import { filenameWithExtension, splitFilename } from "../utils/filenameDisplay";
import { formatBytes } from "../utils/formatBytes";
import { postDisplayText } from "../utils/postText";
import { CollapsibleText } from "../CollapsibleText";
@@ -60,10 +57,21 @@ function AttachmentRow({ postId, att }: { postId: string; att: Attachment }) {
)}
<div className="min-w-0 flex-1">
<div
className="truncate text-[15px] font-medium leading-6 text-ark-gold group-hover:text-ark-gold2"
className="flex min-w-0 items-baseline text-[15px] font-medium leading-6 text-ark-gold group-hover:text-ark-gold2"
title={displayFilename}
>
{middleEllipsisFilename(displayFilename)}
{(() => {
const { base, ext } = splitFilename(displayFilename);
const tailChars = Math.min(4, base.length);
const head = base.slice(0, base.length - tailChars);
const tail = base.slice(base.length - tailChars) + ext;
return (
<>
<span className="min-w-0 truncate">{head}</span>
<span className="shrink-0 whitespace-pre">{tail}</span>
</>
);
})()}
</div>
<div className="text-[12px] font-medium leading-[19.2px] text-[#A8A9AE]">
{isDownloading ? t("downloading") : formatBytes(att.sizeBytes)}

View File

@@ -10,6 +10,7 @@ import {
import { createPortal } from "react-dom";
import { ChevronLeft, ChevronRight, X } from "lucide-react";
import type { Attachment } from "../../../types/post";
import { useI18n } from "../../../i18n";
import { AttachmentDownloadPill } from "../AttachmentDownloadPill";
import { BubbleImage } from "../BubbleImage";
import { autolink } from "../utils/autolink";
@@ -147,8 +148,10 @@ function LightboxView({
postId?: string;
onClose: () => void;
}) {
const { t } = useI18n();
const [index, setIndex] = useState(startIndex);
const [isCaptionVisible, setIsCaptionVisible] = useState(true);
const [showSaveHint, setShowSaveHint] = useState(true);
const touchStartX = useRef<number | null>(null);
// Clamp at the ends instead of wrapping; the nav arrows / swipe / arrow
@@ -187,6 +190,12 @@ function LightboxView({
}, [captionText, index]);
const current = images[index];
useEffect(() => {
const timer = window.setTimeout(() => setShowSaveHint(false), 2000);
return () => window.clearTimeout(timer);
}, []);
const caption = captionText?.trim();
const showCaption = !!caption && isCaptionVisible;
const hasMany = images.length > 1;
@@ -260,6 +269,30 @@ function LightboxView({
</button>
) : null}
{showSaveHint ? (
<div className="pointer-events-none fixed inset-x-0 top-[58%] z-30 flex -translate-y-1/2 justify-center px-4">
<div
className="pointer-events-auto relative flex w-44 max-w-[calc(100vw-2rem)] flex-col items-center gap-1.5 rounded-xl bg-black/70 px-4 py-3 text-center text-white shadow-2xl ring-1 ring-white/20 backdrop-blur-md animate-scale-in"
onClick={(e) => e.stopPropagation()}
>
<button
type="button"
onClick={() => setShowSaveHint(false)}
className="absolute right-1.5 top-1.5 flex h-5 w-5 items-center justify-center rounded-full bg-white/10 text-white/80 transition hover:bg-white/20 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/70"
aria-label="Close hint"
>
<X className="h-3.5 w-3.5" />
</button>
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-white/15 text-2xl animate-bounce">
</div>
<div className="max-w-full break-words text-xs font-medium leading-4">
{t("longPressImageSave")}
</div>
</div>
</div>
) : null}
<div
className="flex h-full w-full items-center justify-center px-4 py-2"
onClick={(e) => {
@@ -279,11 +312,12 @@ function LightboxView({
touchStartX.current = null;
}}
>
{/* No select-none / touch-callout:none here so iOS Safari's native
long-press menu ("Save in Photos") works on the full-size image. */}
<img
src={current.url}
alt={current.filename}
className="max-h-full max-w-full select-none object-contain"
draggable={false}
className="max-h-full max-w-full object-contain [-webkit-touch-callout:default]"
/>
</div>

View File

@@ -51,7 +51,7 @@ export function middleEllipsisFilename(
return `${base.slice(0, headLength)}${ellipsis}${base.slice(-tailLength)}${ext}`;
}
function splitFilename(filename: string): { base: string; ext: string } {
export function splitFilename(filename: string): { base: string; ext: string } {
const dotIndex = filename.lastIndexOf(".");
if (!hasFileExtension(filename)) return { base: filename, ext: "" };
return {

View File

@@ -21,7 +21,7 @@ const zhDict: Dict = {
popular: "热门资料",
search: "搜索",
searchPlaceholder: "搜索资料...",
searchPanelPlaceholder: "搜索 PPT、影片、海报、公告、教程、文...",
searchPanelPlaceholder: "搜索资料...",
searchNow: "立即搜索资料",
searchSubmit: "搜索",
cancel: "取消",
@@ -45,6 +45,7 @@ const zhDict: Dict = {
downloading: "下载中…",
downloadOk: "下载完成",
downloadFail: "下载失败,请重试",
longPressImageSave: "长按图片保存到相册",
showMore: "展开全部",
showLess: "收起全部",
share: "分享",
@@ -148,7 +149,7 @@ const enDict: Dict = {
popular: "Popular",
search: "Search",
searchPlaceholder: "Search resources...",
searchPanelPlaceholder: "Search PPT, videos, posters, news, guides...",
searchPanelPlaceholder: "Search assets...",
searchNow: "Search now",
searchSubmit: "Search",
cancel: "Cancel",
@@ -173,6 +174,7 @@ const enDict: Dict = {
downloading: "Downloading…",
downloadOk: "Download complete",
downloadFail: "Download failed, please retry",
longPressImageSave: "Long-press image to save",
showMore: "Show all",
showLess: "Show less",
share: "Share",

View File

@@ -413,7 +413,7 @@ export function PublicLayout() {
return (
<div className="min-h-full flex flex-col">
<DocumentMeta />
<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 select-none 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-4 py-3 md:hidden">
<div className="flex h-8 min-w-0 shrink items-center gap-2 text-[20px] font-black leading-5 tracking-tight text-ark-gold">
{/* Logo → home; page-name text → scroll to top of the current page. */}
@@ -715,7 +715,7 @@ export function PublicLayout() {
</AnimatePresence>
</main>
<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 select-none bg-[#0C0D0F]/90 backdrop-blur md:hidden">
<div className="grid h-[78px] grid-cols-4 gap-3 px-5 py-4 text-center text-[11px] leading-[17.6px]">
<BottomNavIcon
to="/"

View File

@@ -1,23 +1,24 @@
/** Basename under `/assets/ark-library/media/svg/` for each category slug (matches ark-library-media/svg). */
const slugToSvg: Record<string, string> = {
"project-ppt": "project-details.svg",
"daily-class": "everyday-class.svg",
"official-announcement": "official-announcement.svg",
"academy-materials": "educational-clips.svg",
"global-evangelism": "global-news.svg",
"daily-poster": "poster.svg",
"community-tweets": "community.svg",
"video-hub": "videos.svg",
"subsidy-policy": "gift.svg",
"how-to": "guidelines.svg",
"official-assets": "directory.svg",
"media-coverage": "news-record.svg",
"academy-video": "educational-clips.svg",
general: "general.svg",
/** Asset path under `/assets/ark-library/media/` for each category slug. */
const slugToAsset: Record<string, string> = {
"project-ppt": "svg/project-details.svg",
"daily-class": "svg/everyday-class.svg",
"official-announcement": "svg/official-announcement.svg",
"academy-materials": "png/academy-materials.png",
"global-evangelism": "svg/global-news.svg",
"daily-poster": "svg/poster.svg",
"community-tweets": "svg/community.svg",
"video-hub": "svg/videos.svg",
"subsidy-policy": "svg/gift.svg",
"how-to": "svg/guidelines.svg",
"official-assets": "svg/directory.svg",
"media-coverage": "svg/news-record.svg",
"academy-video": "png/academy-video.png",
"acedemy-video": "png/academy-video.png",
general: "svg/general.svg",
};
export function categorySvgUrlForSlug(slug: string): string | null {
const file = slugToSvg[slug];
export function categoryAssetUrlForSlug(slug: string): string | null {
const file = slugToAsset[slug];
if (!file) return null;
return `/assets/ark-library/media/svg/${file}`;
return `/assets/ark-library/media/${file}`;
}

View File

@@ -0,0 +1,93 @@
import { describe, expect, it } from "vitest";
import type { Post } from "../types/post";
import { postToResource } from "./postResourceAdapter";
const basePost: Post = {
id: "post-1",
postType: "link",
categoryId: 1,
categorySlug: "official-announcement",
language: "zh",
title: "Link post",
text: "https://example.com/article",
attachments: [],
isRecommended: true,
publishedAt: "2026-05-30T00:00:00Z",
};
describe("postToResource", () => {
it("uses link preview images as the resource cover when there is no attachment", () => {
const resource = postToResource(
{
...basePost,
linkPreview: {
url: "https://example.com/article",
canonicalUrl: "https://example.com/article",
siteName: "Example",
title: "Example article",
description: "Example description",
imageUrl: "https://example.com/preview.jpg",
},
},
"zh-TW",
);
expect(resource.coverImage).toBe("https://example.com/preview.jpg");
expect(resource.previewUrl).toBe("https://example.com/preview.jpg");
});
it("uses document thumbnails before poster URLs", () => {
const resource = postToResource(
{
...basePost,
postType: "pdf",
attachments: [
{
id: "att-1",
kind: "document",
url: "/uploads/file.pdf",
mime: "application/pdf",
filename: "file.pdf",
sizeBytes: 123,
posterUrl: "/uploads/file.pdf",
thumbnailUrl: "/uploads/thumb.jpg",
},
],
},
"zh-TW",
);
expect(resource.coverImage).toBe("/uploads/thumb.jpg");
expect(resource.previewUrl).toBe("/uploads/thumb.jpg");
});
it("keeps an attachment thumbnail ahead of a link preview image", () => {
const resource = postToResource(
{
...basePost,
attachments: [
{
id: "att-1",
kind: "image",
url: "/uploads/full.jpg",
mime: "image/jpeg",
filename: "full.jpg",
sizeBytes: 123,
thumbnailUrl: "/uploads/thumb.jpg",
},
],
linkPreview: {
url: "https://example.com/article",
canonicalUrl: "https://example.com/article",
siteName: "Example",
title: "Example article",
description: "Example description",
imageUrl: "https://example.com/preview.jpg",
},
},
"zh-TW",
);
expect(resource.coverImage).toBe("/uploads/thumb.jpg");
});
});

View File

@@ -24,12 +24,13 @@ function inferType(post: Post, att: Attachment | undefined): string {
return "text";
}
function coverFor(att: Attachment | undefined) {
if (!att) return "";
function coverFor(post: Post, att: Attachment | undefined) {
const linkPreviewImage = post.linkPreview?.imageUrl || "";
if (!att) return linkPreviewImage;
if (att.kind === "image" || att.mime.startsWith("image/")) {
return att.thumbnailUrl || att.url;
return att.thumbnailUrl || att.url || linkPreviewImage;
}
return att.posterUrl || att.thumbUrl || att.thumbnailUrl || "";
return att.thumbnailUrl || att.posterUrl || att.thumbUrl || linkPreviewImage;
}
export function postToResource(
@@ -49,9 +50,10 @@ export function postToResource(
categoryId: post.categoryId,
categorySlug: post.categorySlug,
categoryName: category?.name || post.categorySlug,
coverImage: coverFor(first),
coverImage: coverFor(post, first),
fileUrl: first?.url,
previewUrl: first?.posterUrl || first?.thumbnailUrl,
previewUrl:
first?.thumbnailUrl || first?.posterUrl || post.linkPreview?.imageUrl,
externalUrl: undefined,
bodyText: postDisplayText(post, lang),
badgeLabel: post.isRecommended ? "Recommended" : undefined,