terry-staging #12
BIN
public/assets/ark-library/media/png/academy-materials.png
Normal file
BIN
public/assets/ark-library/media/png/academy-materials.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 264 KiB |
BIN
public/assets/ark-library/media/png/academy-video.png
Normal file
BIN
public/assets/ark-library/media/png/academy-video.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 60 KiB |
@@ -15,7 +15,7 @@ import {
|
|||||||
Play,
|
Play,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { categorySvgUrlForSlug } from "../lib/categorySvgSlug";
|
import { categoryAssetUrlForSlug } from "../lib/categorySvgSlug";
|
||||||
|
|
||||||
const map: Record<string, LucideIcon> = {
|
const map: Record<string, LucideIcon> = {
|
||||||
folder: Folder,
|
folder: Folder,
|
||||||
@@ -40,15 +40,15 @@ export function CategoryIcon({
|
|||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
iconKey: string;
|
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;
|
categorySlug?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
const svgUrl = categorySlug ? categorySvgUrlForSlug(categorySlug) : null;
|
const assetUrl = categorySlug ? categoryAssetUrlForSlug(categorySlug) : null;
|
||||||
if (svgUrl) {
|
if (assetUrl) {
|
||||||
return (
|
return (
|
||||||
<img
|
<img
|
||||||
src={svgUrl}
|
src={assetUrl}
|
||||||
alt=""
|
alt=""
|
||||||
className={[className, "object-contain pointer-events-none select-none"]
|
className={[className, "object-contain pointer-events-none select-none"]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ import {
|
|||||||
useEffect,
|
useEffect,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
|
type MouseEvent as ReactMouseEvent,
|
||||||
type PointerEvent as ReactPointerEvent,
|
type PointerEvent as ReactPointerEvent,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
import { assetUrl, getJSON, itemsOrEmpty, readJSONCache } from "../api";
|
import { assetUrl, getJSON, itemsOrEmpty, readJSONCache } from "../api";
|
||||||
import { langQuery, useI18n, type Lang } from "../i18n";
|
import { langQuery, useI18n, type Lang } from "../i18n";
|
||||||
|
|
||||||
@@ -41,6 +43,24 @@ function bannerLangParam(lang: Lang): string {
|
|||||||
return langQuery(lang).toLowerCase();
|
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[] {
|
function toSlides(items: BannerApiItem[]): BannerSlide[] {
|
||||||
return [...items]
|
return [...items]
|
||||||
.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0))
|
.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0))
|
||||||
@@ -59,8 +79,12 @@ function toSlides(items: BannerApiItem[]): BannerSlide[] {
|
|||||||
|
|
||||||
export function FigmaBanner() {
|
export function FigmaBanner() {
|
||||||
const { lang } = useI18n();
|
const { lang } = useI18n();
|
||||||
|
const navigate = useNavigate();
|
||||||
const [slides, setSlides] = useState<BannerSlide[]>([]);
|
const [slides, setSlides] = useState<BannerSlide[]>([]);
|
||||||
const scrollerRef = useRef<HTMLDivElement>(null);
|
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 [activeIndex, setActiveIndex] = useState(0);
|
||||||
const [autoplayPaused, setAutoplayPaused] = useState(false);
|
const [autoplayPaused, setAutoplayPaused] = useState(false);
|
||||||
const [publicMenuOpen, setPublicMenuOpen] = useState(false);
|
const [publicMenuOpen, setPublicMenuOpen] = useState(false);
|
||||||
@@ -177,7 +201,9 @@ export function FigmaBanner() {
|
|||||||
startScrollLeft: scroller.scrollLeft,
|
startScrollLeft: scroller.scrollLeft,
|
||||||
moved: false,
|
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();
|
pauseAutoplay();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -189,6 +215,7 @@ export function FigmaBanner() {
|
|||||||
const dx = event.clientX - drag.startX;
|
const dx = event.clientX - drag.startX;
|
||||||
if (!drag.moved && Math.abs(dx) > 4) {
|
if (!drag.moved && Math.abs(dx) > 4) {
|
||||||
drag.moved = true;
|
drag.moved = true;
|
||||||
|
scroller.setPointerCapture(event.pointerId);
|
||||||
scroller.style.scrollSnapType = "none";
|
scroller.style.scrollSnapType = "none";
|
||||||
}
|
}
|
||||||
if (drag.moved) {
|
if (drag.moved) {
|
||||||
@@ -206,6 +233,11 @@ export function FigmaBanner() {
|
|||||||
scroller.releasePointerCapture(event.pointerId);
|
scroller.releasePointerCapture(event.pointerId);
|
||||||
}
|
}
|
||||||
if (drag.moved) {
|
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 width = scroller.clientWidth || 1;
|
||||||
const nearest = Math.round(scroller.scrollLeft / width);
|
const nearest = Math.round(scroller.scrollLeft / width);
|
||||||
const clamped = Math.max(0, Math.min(slides.length - 1, nearest));
|
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;
|
if (slides.length === 0) return null;
|
||||||
|
|
||||||
const pagination = hasMultiple ? (
|
const pagination = hasMultiple ? (
|
||||||
@@ -287,7 +340,12 @@ export function FigmaBanner() {
|
|||||||
aria-label={`${index + 1} / ${slides.length}`}
|
aria-label={`${index + 1} / ${slides.length}`}
|
||||||
>
|
>
|
||||||
{slide.linkUrl ? (
|
{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}
|
{image}
|
||||||
</a>
|
</a>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -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">
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => navigate(`/resource/${post.id}`)}
|
onClick={() =>
|
||||||
|
navigate(`/browse?sort=popular&post=${encodeURIComponent(post.id)}`)
|
||||||
|
}
|
||||||
aria-label={r.title}
|
aria-label={r.title}
|
||||||
className="absolute inset-0 z-0 rounded-2xl outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/70"
|
className="absolute inset-0 z-0 rounded-2xl outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/70"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -7,14 +7,14 @@ import { useLocation } from "react-router-dom";
|
|||||||
* clamp to wherever the previous (taller) page was scrolled — e.g. landing at
|
* 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.
|
* 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
|
* Skips navigations that carry a hash (`#post-<id>`, `#categories`, …) or a
|
||||||
* anchor / deep-link targets keep their own scroll handling.
|
* `?post=<id>` deep link so the target page can handle its own alignment.
|
||||||
*/
|
*/
|
||||||
export function ScrollToTop() {
|
export function ScrollToTop() {
|
||||||
const { pathname, search, hash } = useLocation();
|
const { pathname, search, hash } = useLocation();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hash) return;
|
if (hash || new URLSearchParams(search).has("post")) return;
|
||||||
window.scrollTo({ top: 0, left: 0 });
|
window.scrollTo({ top: 0, left: 0 });
|
||||||
}, [pathname, search, hash]);
|
}, [pathname, search, hash]);
|
||||||
|
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export function FilterChips({ type, onTypeChange }: FilterChipsProps) {
|
|||||||
].join(" ");
|
].join(" ");
|
||||||
|
|
||||||
return (
|
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
|
<div
|
||||||
ref={scrollRef}
|
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"
|
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"
|
||||||
|
|||||||
@@ -1,5 +1,27 @@
|
|||||||
import type { LinkPreview } from "../../types/post";
|
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.
|
* 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.
|
* that opens `canonicalUrl` in a new tab.
|
||||||
*/
|
*/
|
||||||
export function LinkPreviewCard({ preview }: { preview: LinkPreview }) {
|
export function LinkPreviewCard({ preview }: { preview: LinkPreview }) {
|
||||||
const accent = preview.themeColor || "#EEB726";
|
const accent = linkPreviewAccent(preview);
|
||||||
const hasUsefulText =
|
const hasUsefulText =
|
||||||
preview.title.length > 0 || preview.description.length > 0;
|
preview.title.length > 0 || preview.description.length > 0;
|
||||||
if (!hasUsefulText && !preview.imageUrl) return null;
|
if (!hasUsefulText && !preview.imageUrl) return null;
|
||||||
|
|||||||
@@ -120,13 +120,22 @@ export function MessageInlineVideo({
|
|||||||
const [currentTime, setCurrentTime] = useState(initialTime);
|
const [currentTime, setCurrentTime] = useState(initialTime);
|
||||||
const [duration, setDuration] = useState(attachment.durationSec ?? 0);
|
const [duration, setDuration] = useState(attachment.durationSec ?? 0);
|
||||||
const [isScrubbing, setIsScrubbing] = useState(false);
|
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];
|
const t = TOKENS[size];
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const v = videoRef.current;
|
const v = videoRef.current;
|
||||||
if (!v) return;
|
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 onPause = () => setIsPlaying(false);
|
||||||
const onTime = () => {
|
const onTime = () => {
|
||||||
setCurrentTime(v.currentTime);
|
setCurrentTime(v.currentTime);
|
||||||
@@ -232,7 +241,9 @@ export function MessageInlineVideo({
|
|||||||
// Update React state synchronously so the progress bar paints the
|
// Update React state synchronously so the progress bar paints the
|
||||||
// new playhead in the next frame, before the <video> seek round-
|
// new playhead in the next frame, before the <video> seek round-
|
||||||
// trip emits its own events (paused videos don't fire timeupdate
|
// 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);
|
setCurrentTime(finalTime);
|
||||||
onTimeUpdate?.(finalTime);
|
onTimeUpdate?.(finalTime);
|
||||||
const apply = () => {
|
const apply = () => {
|
||||||
@@ -334,7 +345,9 @@ export function MessageInlineVideo({
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={`h-full bg-white ${
|
className={`h-full bg-white ${
|
||||||
isScrubbing ? "" : "transition-[width] duration-150 ease-out"
|
isScrubbing || snapProgress
|
||||||
|
? ""
|
||||||
|
: "transition-[width] duration-150 ease-out"
|
||||||
}`}
|
}`}
|
||||||
style={{ width: `${progressPct}%` }}
|
style={{ width: `${progressPct}%` }}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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 { useLocation, useSearchParams } from "react-router-dom";
|
||||||
import { postJSON } from "../../api";
|
import { postJSON } from "../../api";
|
||||||
import { useI18n } from "../../i18n";
|
import { useI18n } from "../../i18n";
|
||||||
@@ -79,23 +79,72 @@ export function MessageStream({ scope }: MessageStreamProps) {
|
|||||||
? hash.slice("#post-".length)
|
? hash.slice("#post-".length)
|
||||||
: "";
|
: "";
|
||||||
const targetPostId = queryTargetPostId || hashTargetPostId;
|
const targetPostId = queryTargetPostId || hashTargetPostId;
|
||||||
|
const [isAligningQueryTarget, setIsAligningQueryTarget] = useState(
|
||||||
|
Boolean(queryTargetPostId),
|
||||||
|
);
|
||||||
const handledTargetRef = useRef<string>("");
|
const handledTargetRef = useRef<string>("");
|
||||||
const targetScrollTimersRef = useRef<number[]>([]);
|
const targetScrollTimersRef = useRef<number[]>([]);
|
||||||
|
const targetScrollFrameRef = useRef<number | null>(null);
|
||||||
|
|
||||||
const clearTargetScrollTimers = () => {
|
const clearTargetScrollTimers = () => {
|
||||||
for (const timer of targetScrollTimersRef.current) {
|
for (const timer of targetScrollTimersRef.current) {
|
||||||
window.clearTimeout(timer);
|
window.clearTimeout(timer);
|
||||||
}
|
}
|
||||||
targetScrollTimersRef.current = [];
|
targetScrollTimersRef.current = [];
|
||||||
|
if (targetScrollFrameRef.current !== null) {
|
||||||
|
window.cancelAnimationFrame(targetScrollFrameRef.current);
|
||||||
|
targetScrollFrameRef.current = null;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
handledTargetRef.current = "";
|
handledTargetRef.current = "";
|
||||||
clearTargetScrollTimers();
|
clearTargetScrollTimers();
|
||||||
}, [targetPostId]);
|
setIsAligningQueryTarget(Boolean(queryTargetPostId));
|
||||||
|
}, [queryTargetPostId, targetPostId]);
|
||||||
|
|
||||||
useEffect(() => clearTargetScrollTimers, []);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!targetPostId || handledTargetRef.current === targetPostId) return;
|
if (!targetPostId || handledTargetRef.current === targetPostId) return;
|
||||||
|
|
||||||
@@ -104,27 +153,68 @@ export function MessageStream({ scope }: MessageStreamProps) {
|
|||||||
handledTargetRef.current = targetPostId;
|
handledTargetRef.current = targetPostId;
|
||||||
clearTargetScrollTimers();
|
clearTargetScrollTimers();
|
||||||
|
|
||||||
const scrollToTarget = (behavior: ScrollBehavior = "auto") => {
|
const targetScrollTop = () => {
|
||||||
const target = document.getElementById(`post-${targetPostId}`);
|
const target = document.getElementById(`post-${targetPostId}`);
|
||||||
if (!target) return;
|
if (!target) return null;
|
||||||
const filterBottom =
|
const filterBottom =
|
||||||
filterBarRef.current?.getBoundingClientRect().bottom ?? 0;
|
filterBarRef.current?.getBoundingClientRect().bottom ?? 0;
|
||||||
const targetTop = target.getBoundingClientRect().top + window.scrollY;
|
const targetTop = target.getBoundingClientRect().top + window.scrollY;
|
||||||
window.scrollTo({
|
return Math.max(0, targetTop - filterBottom - 12);
|
||||||
top: Math.max(0, targetTop - filterBottom - 12),
|
};
|
||||||
left: 0,
|
|
||||||
behavior,
|
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(
|
const prefersReducedMotion = window.matchMedia(
|
||||||
"(prefers-reduced-motion: reduce)",
|
"(prefers-reduced-motion: reduce)",
|
||||||
).matches;
|
).matches;
|
||||||
|
|
||||||
// Show a deliberate "from top to target" transition when opening a card
|
if (queryTargetPostId) {
|
||||||
// from Home. The later auto re-alignments are intentionally delayed so
|
// Query deep-links (`?post=<id>`) usually come from Home cards/list
|
||||||
// they don't interrupt the visible smooth scroll animation.
|
// rows. Keep the premium motion, but drive it ourselves so scrolling is
|
||||||
window.scrollTo({ top: 0, left: 0, behavior: "auto" });
|
// 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(() =>
|
window.requestAnimationFrame(() =>
|
||||||
scrollToTarget(prefersReducedMotion ? "auto" : "smooth"),
|
scrollToTarget(prefersReducedMotion ? "auto" : "smooth"),
|
||||||
);
|
);
|
||||||
@@ -135,6 +225,7 @@ export function MessageStream({ scope }: MessageStreamProps) {
|
|||||||
targetScrollTimersRef.current = [900, 1400, 2000].map((ms) =>
|
targetScrollTimersRef.current = [900, 1400, 2000].map((ms) =>
|
||||||
window.setTimeout(() => scrollToTarget("auto"), ms),
|
window.setTimeout(() => scrollToTarget("auto"), ms),
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
el.classList.add("ark-bubble-highlight");
|
el.classList.add("ark-bubble-highlight");
|
||||||
window.setTimeout(
|
window.setTimeout(
|
||||||
@@ -146,6 +237,7 @@ export function MessageStream({ scope }: MessageStreamProps) {
|
|||||||
|
|
||||||
// Not loaded yet — keep paging until it appears or the stream is exhausted.
|
// Not loaded yet — keep paging until it appears or the stream is exhausted.
|
||||||
if (hasMore && !isLoading) loadMore();
|
if (hasMore && !isLoading) loadMore();
|
||||||
|
else if (!hasMore && !isLoading) setIsAligningQueryTarget(false);
|
||||||
}, [targetPostId, items, hasMore, isLoading, loadMore]);
|
}, [targetPostId, items, hasMore, isLoading, loadMore]);
|
||||||
|
|
||||||
const updateParam = (key: string, value: string) => {
|
const updateParam = (key: string, value: string) => {
|
||||||
|
|||||||
@@ -5,10 +5,7 @@ import { useI18n } from "../../../i18n";
|
|||||||
import type { Attachment, Post } from "../../../types/post";
|
import type { Attachment, Post } from "../../../types/post";
|
||||||
import { downloadAttachment } from "../utils/downloadFile";
|
import { downloadAttachment } from "../utils/downloadFile";
|
||||||
import { fileIcon } from "../utils/fileIcon";
|
import { fileIcon } from "../utils/fileIcon";
|
||||||
import {
|
import { filenameWithExtension, splitFilename } from "../utils/filenameDisplay";
|
||||||
filenameWithExtension,
|
|
||||||
middleEllipsisFilename,
|
|
||||||
} from "../utils/filenameDisplay";
|
|
||||||
import { formatBytes } from "../utils/formatBytes";
|
import { formatBytes } from "../utils/formatBytes";
|
||||||
import { postDisplayText } from "../utils/postText";
|
import { postDisplayText } from "../utils/postText";
|
||||||
import { CollapsibleText } from "../CollapsibleText";
|
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="min-w-0 flex-1">
|
||||||
<div
|
<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}
|
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>
|
||||||
<div className="text-[12px] font-medium leading-[19.2px] text-[#A8A9AE]">
|
<div className="text-[12px] font-medium leading-[19.2px] text-[#A8A9AE]">
|
||||||
{isDownloading ? t("downloading") : formatBytes(att.sizeBytes)}
|
{isDownloading ? t("downloading") : formatBytes(att.sizeBytes)}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { ChevronLeft, ChevronRight, X } from "lucide-react";
|
import { ChevronLeft, ChevronRight, X } from "lucide-react";
|
||||||
import type { Attachment } from "../../../types/post";
|
import type { Attachment } from "../../../types/post";
|
||||||
|
import { useI18n } from "../../../i18n";
|
||||||
import { AttachmentDownloadPill } from "../AttachmentDownloadPill";
|
import { AttachmentDownloadPill } from "../AttachmentDownloadPill";
|
||||||
import { BubbleImage } from "../BubbleImage";
|
import { BubbleImage } from "../BubbleImage";
|
||||||
import { autolink } from "../utils/autolink";
|
import { autolink } from "../utils/autolink";
|
||||||
@@ -147,8 +148,10 @@ function LightboxView({
|
|||||||
postId?: string;
|
postId?: string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
const [index, setIndex] = useState(startIndex);
|
const [index, setIndex] = useState(startIndex);
|
||||||
const [isCaptionVisible, setIsCaptionVisible] = useState(true);
|
const [isCaptionVisible, setIsCaptionVisible] = useState(true);
|
||||||
|
const [showSaveHint, setShowSaveHint] = useState(true);
|
||||||
const touchStartX = useRef<number | null>(null);
|
const touchStartX = useRef<number | null>(null);
|
||||||
|
|
||||||
// Clamp at the ends instead of wrapping; the nav arrows / swipe / arrow
|
// Clamp at the ends instead of wrapping; the nav arrows / swipe / arrow
|
||||||
@@ -187,6 +190,12 @@ function LightboxView({
|
|||||||
}, [captionText, index]);
|
}, [captionText, index]);
|
||||||
|
|
||||||
const current = images[index];
|
const current = images[index];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = window.setTimeout(() => setShowSaveHint(false), 2000);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const caption = captionText?.trim();
|
const caption = captionText?.trim();
|
||||||
const showCaption = !!caption && isCaptionVisible;
|
const showCaption = !!caption && isCaptionVisible;
|
||||||
const hasMany = images.length > 1;
|
const hasMany = images.length > 1;
|
||||||
@@ -260,6 +269,30 @@ function LightboxView({
|
|||||||
</button>
|
</button>
|
||||||
) : null}
|
) : 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
|
<div
|
||||||
className="flex h-full w-full items-center justify-center px-4 py-2"
|
className="flex h-full w-full items-center justify-center px-4 py-2"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
@@ -279,11 +312,12 @@ function LightboxView({
|
|||||||
touchStartX.current = null;
|
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
|
<img
|
||||||
src={current.url}
|
src={current.url}
|
||||||
alt={current.filename}
|
alt={current.filename}
|
||||||
className="max-h-full max-w-full select-none object-contain"
|
className="max-h-full max-w-full object-contain [-webkit-touch-callout:default]"
|
||||||
draggable={false}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export function middleEllipsisFilename(
|
|||||||
return `${base.slice(0, headLength)}${ellipsis}${base.slice(-tailLength)}${ext}`;
|
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(".");
|
const dotIndex = filename.lastIndexOf(".");
|
||||||
if (!hasFileExtension(filename)) return { base: filename, ext: "" };
|
if (!hasFileExtension(filename)) return { base: filename, ext: "" };
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ const zhDict: Dict = {
|
|||||||
popular: "热门资料",
|
popular: "热门资料",
|
||||||
search: "搜索",
|
search: "搜索",
|
||||||
searchPlaceholder: "搜索资料...",
|
searchPlaceholder: "搜索资料...",
|
||||||
searchPanelPlaceholder: "搜索 PPT、影片、海报、公告、教程、文...",
|
searchPanelPlaceholder: "搜索资料...",
|
||||||
searchNow: "立即搜索资料",
|
searchNow: "立即搜索资料",
|
||||||
searchSubmit: "搜索",
|
searchSubmit: "搜索",
|
||||||
cancel: "取消",
|
cancel: "取消",
|
||||||
@@ -45,6 +45,7 @@ const zhDict: Dict = {
|
|||||||
downloading: "下载中…",
|
downloading: "下载中…",
|
||||||
downloadOk: "下载完成",
|
downloadOk: "下载完成",
|
||||||
downloadFail: "下载失败,请重试",
|
downloadFail: "下载失败,请重试",
|
||||||
|
longPressImageSave: "长按图片保存到相册",
|
||||||
showMore: "展开全部",
|
showMore: "展开全部",
|
||||||
showLess: "收起全部",
|
showLess: "收起全部",
|
||||||
share: "分享",
|
share: "分享",
|
||||||
@@ -148,7 +149,7 @@ const enDict: Dict = {
|
|||||||
popular: "Popular",
|
popular: "Popular",
|
||||||
search: "Search",
|
search: "Search",
|
||||||
searchPlaceholder: "Search resources...",
|
searchPlaceholder: "Search resources...",
|
||||||
searchPanelPlaceholder: "Search PPT, videos, posters, news, guides...",
|
searchPanelPlaceholder: "Search assets...",
|
||||||
searchNow: "Search now",
|
searchNow: "Search now",
|
||||||
searchSubmit: "Search",
|
searchSubmit: "Search",
|
||||||
cancel: "Cancel",
|
cancel: "Cancel",
|
||||||
@@ -173,6 +174,7 @@ const enDict: Dict = {
|
|||||||
downloading: "Downloading…",
|
downloading: "Downloading…",
|
||||||
downloadOk: "Download complete",
|
downloadOk: "Download complete",
|
||||||
downloadFail: "Download failed, please retry",
|
downloadFail: "Download failed, please retry",
|
||||||
|
longPressImageSave: "Long-press image to save",
|
||||||
showMore: "Show all",
|
showMore: "Show all",
|
||||||
showLess: "Show less",
|
showLess: "Show less",
|
||||||
share: "Share",
|
share: "Share",
|
||||||
|
|||||||
@@ -413,7 +413,7 @@ export function PublicLayout() {
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-full flex flex-col">
|
<div className="min-h-full flex flex-col">
|
||||||
<DocumentMeta />
|
<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-[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">
|
<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. */}
|
{/* Logo → home; page-name text → scroll to top of the current page. */}
|
||||||
@@ -715,7 +715,7 @@ export function PublicLayout() {
|
|||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</main>
|
</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]">
|
<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="/"
|
||||||
|
|||||||
@@ -1,23 +1,24 @@
|
|||||||
/** Basename under `/assets/ark-library/media/svg/` for each category slug (matches ark-library-media/svg). */
|
/** Asset path under `/assets/ark-library/media/` for each category slug. */
|
||||||
const slugToSvg: Record<string, string> = {
|
const slugToAsset: Record<string, string> = {
|
||||||
"project-ppt": "project-details.svg",
|
"project-ppt": "svg/project-details.svg",
|
||||||
"daily-class": "everyday-class.svg",
|
"daily-class": "svg/everyday-class.svg",
|
||||||
"official-announcement": "official-announcement.svg",
|
"official-announcement": "svg/official-announcement.svg",
|
||||||
"academy-materials": "educational-clips.svg",
|
"academy-materials": "png/academy-materials.png",
|
||||||
"global-evangelism": "global-news.svg",
|
"global-evangelism": "svg/global-news.svg",
|
||||||
"daily-poster": "poster.svg",
|
"daily-poster": "svg/poster.svg",
|
||||||
"community-tweets": "community.svg",
|
"community-tweets": "svg/community.svg",
|
||||||
"video-hub": "videos.svg",
|
"video-hub": "svg/videos.svg",
|
||||||
"subsidy-policy": "gift.svg",
|
"subsidy-policy": "svg/gift.svg",
|
||||||
"how-to": "guidelines.svg",
|
"how-to": "svg/guidelines.svg",
|
||||||
"official-assets": "directory.svg",
|
"official-assets": "svg/directory.svg",
|
||||||
"media-coverage": "news-record.svg",
|
"media-coverage": "svg/news-record.svg",
|
||||||
"academy-video": "educational-clips.svg",
|
"academy-video": "png/academy-video.png",
|
||||||
general: "general.svg",
|
"acedemy-video": "png/academy-video.png",
|
||||||
|
general: "svg/general.svg",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function categorySvgUrlForSlug(slug: string): string | null {
|
export function categoryAssetUrlForSlug(slug: string): string | null {
|
||||||
const file = slugToSvg[slug];
|
const file = slugToAsset[slug];
|
||||||
if (!file) return null;
|
if (!file) return null;
|
||||||
return `/assets/ark-library/media/svg/${file}`;
|
return `/assets/ark-library/media/${file}`;
|
||||||
}
|
}
|
||||||
|
|||||||
93
src/utils/postResourceAdapter.test.ts
Normal file
93
src/utils/postResourceAdapter.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -24,12 +24,13 @@ function inferType(post: Post, att: Attachment | undefined): string {
|
|||||||
return "text";
|
return "text";
|
||||||
}
|
}
|
||||||
|
|
||||||
function coverFor(att: Attachment | undefined) {
|
function coverFor(post: Post, att: Attachment | undefined) {
|
||||||
if (!att) return "";
|
const linkPreviewImage = post.linkPreview?.imageUrl || "";
|
||||||
|
if (!att) return linkPreviewImage;
|
||||||
if (att.kind === "image" || att.mime.startsWith("image/")) {
|
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(
|
export function postToResource(
|
||||||
@@ -49,9 +50,10 @@ export function postToResource(
|
|||||||
categoryId: post.categoryId,
|
categoryId: post.categoryId,
|
||||||
categorySlug: post.categorySlug,
|
categorySlug: post.categorySlug,
|
||||||
categoryName: category?.name || post.categorySlug,
|
categoryName: category?.name || post.categorySlug,
|
||||||
coverImage: coverFor(first),
|
coverImage: coverFor(post, first),
|
||||||
fileUrl: first?.url,
|
fileUrl: first?.url,
|
||||||
previewUrl: first?.posterUrl || first?.thumbnailUrl,
|
previewUrl:
|
||||||
|
first?.thumbnailUrl || first?.posterUrl || post.linkPreview?.imageUrl,
|
||||||
externalUrl: undefined,
|
externalUrl: undefined,
|
||||||
bodyText: postDisplayText(post, lang),
|
bodyText: postDisplayText(post, lang),
|
||||||
badgeLabel: post.isRecommended ? "Recommended" : undefined,
|
badgeLabel: post.isRecommended ? "Recommended" : undefined,
|
||||||
|
|||||||
Reference in New Issue
Block a user