feat: add telegram-style resource stream

This commit is contained in:
TerryM
2026-05-25 05:25:57 +08:00
parent aaebd7ccd1
commit a784f159fe
45 changed files with 3201 additions and 1160 deletions

View File

@@ -0,0 +1,9 @@
export function DaySeparator({ label }: { label: string }) {
return (
<div className="sticky top-[58px] z-[5] flex justify-center py-2">
<span className="rounded-full bg-ark-panel/80 px-3 py-1 text-xs text-neutral-300 backdrop-blur">
{label}
</span>
</div>
);
}

View File

@@ -0,0 +1,80 @@
import { useI18n } from "../../i18n";
import { typeFilterLabel } from "../../resourceTypeLabels";
const TYPE_FILTERS = [
"all",
"image",
"video",
"ppt",
"pdf",
"text",
"link",
"archive",
] as const;
const LANG_FILTERS = ["", "zh-TW", "zh-CN", "en"] as const;
function langLabel(t: (k: string) => string, code: string) {
if (!code) return t("filterLanguageAll");
if (code === "zh-TW") return t("lang_zh_TW");
if (code === "zh-CN") return t("lang_zh_CN");
return t("lang_en");
}
export type FilterChipsProps = {
type: string;
language: string;
onTypeChange: (next: string) => void;
onLanguageChange: (next: string) => void;
};
export function FilterChips({
type,
language,
onTypeChange,
onLanguageChange,
}: FilterChipsProps) {
const { t } = useI18n();
return (
<div className="sticky top-0 z-10 -mx-3 border-b border-ark-line bg-ark-bg/90 px-3 py-2 backdrop-blur md:-mx-0 md:rounded-t-xl">
<div className="flex gap-1.5 overflow-x-auto whitespace-nowrap [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
{TYPE_FILTERS.map((tp) => {
const active = type === tp;
return (
<button
key={tp}
type="button"
onClick={() => onTypeChange(tp)}
className={`shrink-0 rounded-full border px-3 py-1 text-xs transition ${
active
? "border-ark-gold bg-ark-gold/10 text-ark-gold2"
: "border-ark-line text-neutral-300 hover:border-ark-gold/50"
}`}
>
{typeFilterLabel(t, tp)}
</button>
);
})}
</div>
<div className="mt-2 flex gap-1.5 overflow-x-auto whitespace-nowrap [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
{LANG_FILTERS.map((code) => {
const active = language === code;
return (
<button
key={code || "all"}
type="button"
onClick={() => onLanguageChange(code)}
className={`shrink-0 rounded-full border px-3 py-1 text-xs transition ${
active
? "border-ark-gold bg-ark-gold/10 text-ark-gold2"
: "border-ark-line text-neutral-300 hover:border-ark-gold/50"
}`}
>
{langLabel(t, code)}
</button>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,53 @@
import type { ComponentType } from "react";
import type { Post } from "../../types/post";
import { useI18n } from "../../i18n";
import { TextBubble } from "./bubbles/TextBubble";
import { FileDocBubble } from "./bubbles/FileDocBubble";
import { ImageBubble } from "./bubbles/ImageBubble";
import { ImageWithTextBubble } from "./bubbles/ImageWithTextBubble";
import { AlbumBubble } from "./bubbles/AlbumBubble";
import { VideoBubble } from "./bubbles/VideoBubble";
import { formatDateTime } from "./utils/formatTime";
type BubbleComponent = ComponentType<{ post: Post }>;
export function pickBubble(post: Post): BubbleComponent {
const a = post.attachments;
if (a.length === 0) return TextBubble;
if (a.length >= 2 && a.every((x) => x.kind === "image")) return AlbumBubble;
const only = a[0];
if (only.kind === "video") return VideoBubble;
if (only.kind === "image") {
return post.text ? ImageWithTextBubble : ImageBubble;
}
return FileDocBubble;
}
export function MessageBubble({ post }: { post: Post }) {
const { lang } = useI18n();
const Bubble = pickBubble(post);
const isTextOnly = post.attachments.length === 0;
const isVisual = post.attachments.some(
(a) => a.kind === "image" || a.kind === "video",
);
return (
<article
id={`post-${post.id}`}
className={`relative self-start rounded-2xl bg-ark-panel text-left shadow-sm ${
isVisual
? "w-[82vw] max-w-[320px] md:w-[52vw] md:max-w-[420px] lg:w-[46vw] lg:max-w-[520px]"
: "inline-block max-w-[92%] md:max-w-[680px]"
} ${isTextOnly ? "px-3 py-2" : "p-2"}`}
>
<Bubble post={post} />
<time
dateTime={post.publishedAt}
className="ml-2 mt-1 inline-block float-right text-[10.5px] leading-none text-neutral-500"
>
{formatDateTime(post.publishedAt, lang)}
</time>
<span className="block clear-both" />
</article>
);
}

View File

@@ -0,0 +1,115 @@
import { useEffect, useMemo, useRef } from "react";
import { useSearchParams } from "react-router-dom";
import { useI18n } from "../../i18n";
import type { PostScope } from "../../types/post";
import { FilterChips } from "./FilterChips";
import { MessageBubble } from "./MessageBubble";
import { useGroupedByDay } from "./hooks/useGroupedByDay";
import { usePostStream } from "./hooks/usePostStream";
export type MessageStreamProps = {
scope: PostScope;
};
export function MessageStream({ scope }: MessageStreamProps) {
const { t, lang } = useI18n();
const [sp, setSp] = useSearchParams();
const type = sp.get("type") || "all";
const language = sp.get("language") || "";
const params = useMemo(
() => ({ scope, type, language, lang }),
[scope, type, language, lang],
);
const { items, isLoading, error, hasMore, loadMore, reset } =
usePostStream(params);
const groups = useGroupedByDay(items, lang);
const retryLabel =
lang === "zh-TW" ? "重試" : lang === "zh-CN" ? "重试" : "Retry";
const sentinelRef = useRef<HTMLDivElement>(null);
const hasMoreRef = useRef(hasMore);
const isLoadingRef = useRef(isLoading);
useEffect(() => {
hasMoreRef.current = hasMore;
}, [hasMore]);
useEffect(() => {
isLoadingRef.current = isLoading;
}, [isLoading]);
useEffect(() => {
const el = sentinelRef.current;
if (!el) return;
const io = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (
entry.isIntersecting &&
hasMoreRef.current &&
!isLoadingRef.current
) {
loadMore();
}
}
},
{ rootMargin: "200px" },
);
io.observe(el);
return () => io.disconnect();
}, [loadMore]);
const updateParam = (key: string, value: string) => {
const n = new URLSearchParams(sp);
if (!value || value === "all") n.delete(key);
else n.set(key, value);
setSp(n, { replace: true });
};
return (
<div className="mx-auto max-w-full px-3 md:max-w-[820px] lg:max-w-[1080px] xl:max-w-[1180px]">
<FilterChips
type={type}
language={language}
onTypeChange={(v) => updateParam("type", v)}
onLanguageChange={(v) => updateParam("language", v)}
/>
<div className="flex flex-col gap-2 pb-10 pt-2">
{groups.map((group) => (
<div key={group.dayKey} className="flex flex-col gap-2">
{group.items.map((post) => (
<MessageBubble key={post.id} post={post} />
))}
</div>
))}
{!isLoading && !error && items.length === 0 ? (
<p className="py-10 text-center text-sm text-neutral-400">
{t("noResults")}
</p>
) : null}
{error ? (
<div className="my-4 flex items-center justify-between gap-3 rounded-xl border border-red-900 bg-red-950/40 px-4 py-3 text-sm text-red-200">
<span className="break-all">{error}</span>
<button
type="button"
onClick={() => reset()}
className="shrink-0 rounded-full border border-red-700 px-3 py-1 text-xs text-red-100 hover:border-red-500"
>
{retryLabel}
</button>
</div>
) : null}
{isLoading ? (
<div className="py-4 text-center text-xs text-neutral-500"></div>
) : null}
<div ref={sentinelRef} aria-hidden className="h-1" />
</div>
</div>
);
}

View File

@@ -0,0 +1,85 @@
import type { Attachment, Post } from "../../../types/post";
import { useLightbox } from "../overlays/ImageLightbox";
import { autolink } from "../utils/autolink";
const MAX_VISIBLE = 4;
function imageRatio(att: Attachment) {
return att.width && att.height ? `${att.width} / ${att.height}` : "4 / 3";
}
export function AlbumBubble({ post }: { post: Post }) {
const { openLightbox } = useLightbox();
const images = post.attachments;
const shouldMerge = images.length > MAX_VISIBLE;
if (!shouldMerge) {
return (
<div className="flex flex-col gap-1.5">
{images.map((att, i) => (
<button
key={att.id}
type="button"
onClick={() => openLightbox(images, i)}
className="relative block max-h-[180px] w-full overflow-hidden rounded-xl min-[440px]:max-h-[200px] md:max-h-[240px] lg:max-h-[280px]"
aria-label={att.filename}
>
<img
src={att.url}
alt={att.filename}
loading="lazy"
className="h-full w-full object-cover"
style={{ aspectRatio: imageRatio(att) }}
/>
</button>
))}
{post.text ? (
<div className="whitespace-pre-wrap break-words text-[14px] leading-snug text-neutral-100">
{autolink(post.text)}
</div>
) : null}
</div>
);
}
const visible = images.slice(0, MAX_VISIBLE);
const extra = images.length - MAX_VISIBLE;
return (
<div className="flex flex-col gap-1.5">
<div className="grid h-[220px] grid-cols-2 grid-rows-2 gap-[2px] overflow-hidden rounded-xl min-[440px]:h-[250px] md:h-[300px] lg:h-[340px]">
{visible.map((att, i) => {
const isLastSlot = i === MAX_VISIBLE - 1 && extra > 0;
return (
<button
key={att.id}
type="button"
onClick={() => openLightbox(images, i)}
className="relative block h-full w-full overflow-hidden"
aria-label={att.filename}
>
<img
src={att.thumbnailUrl ?? att.url}
alt={att.filename}
loading="lazy"
className={`h-full w-full object-cover ${
isLastSlot ? "blur-sm scale-105" : ""
}`}
/>
{isLastSlot ? (
<div className="absolute inset-0 flex items-center justify-center bg-black/45 text-3xl font-semibold text-white">
+{extra}
</div>
) : null}
</button>
);
})}
</div>
{post.text ? (
<div className="whitespace-pre-wrap break-words text-[14px] leading-snug text-neutral-100">
{autolink(post.text)}
</div>
) : null}
</div>
);
}

View File

@@ -0,0 +1,64 @@
import { Download } from "lucide-react";
import type { Attachment, Post } from "../../../types/post";
import { fileIcon } from "../utils/fileIcon";
import { formatBytes } from "../utils/formatBytes";
function AttachmentRow({ att }: { att: Attachment }) {
const isImageAsDoc = att.mime.startsWith("image/");
const { Icon, color } = fileIcon({ mime: att.mime, filename: att.filename });
return (
<a
href={att.url}
download={att.filename}
target="_blank"
rel="noopener noreferrer"
className="group flex items-center gap-2 rounded-xl px-1 py-0.5 transition hover:bg-white/5"
>
<div className="relative h-11 w-11 shrink-0 overflow-hidden rounded-full md:h-12 md:w-12">
{isImageAsDoc && att.thumbnailUrl ? (
<>
<img
src={att.thumbnailUrl}
alt=""
className="absolute inset-0 h-full w-full object-cover"
/>
<div className="absolute inset-0 flex items-center justify-center bg-black/35">
<Download className="h-4 w-4 text-white" />
</div>
</>
) : (
<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>
<div className="min-w-0 flex-1">
<div className="truncate text-[14px] font-medium text-ark-gold2 group-hover:text-ark-gold">
{att.filename}
</div>
<div className="text-[11px] text-neutral-400">
{formatBytes(att.sizeBytes)}
</div>
</div>
</a>
);
}
export function FileDocBubble({ post }: { post: Post }) {
return (
<div className="flex flex-col gap-1">
{post.attachments.map((att) => (
<AttachmentRow key={att.id} att={att} />
))}
{post.text ? (
<div className="mt-1 whitespace-pre-wrap break-words text-[14px] leading-snug text-neutral-100">
{post.text}
</div>
) : null}
</div>
);
}

View File

@@ -0,0 +1,27 @@
import type { Post } from "../../../types/post";
import { useLightbox } from "../overlays/ImageLightbox";
export function ImageBubble({ post }: { post: Post }) {
const { openLightbox } = useLightbox();
const att = post.attachments[0];
if (!att) return null;
const ratio =
att.width && att.height ? `${att.width} / ${att.height}` : "4 / 3";
return (
<button
type="button"
onClick={() => openLightbox([att], 0)}
className="relative block w-full overflow-hidden rounded-xl max-h-[240px] min-[440px]:max-h-[270px] md:max-h-[320px] lg:max-h-[360px]"
aria-label={att.filename}
>
<img
src={att.url}
alt={att.filename}
loading="lazy"
className="h-full w-full object-cover"
style={{ aspectRatio: ratio }}
/>
</button>
);
}

View File

@@ -0,0 +1,35 @@
import type { Post } from "../../../types/post";
import { useLightbox } from "../overlays/ImageLightbox";
import { autolink } from "../utils/autolink";
export function ImageWithTextBubble({ post }: { post: Post }) {
const { openLightbox } = useLightbox();
const att = post.attachments[0];
if (!att) return null;
const ratio =
att.width && att.height ? `${att.width} / ${att.height}` : "4 / 3";
return (
<div className="flex flex-col gap-1.5">
<button
type="button"
onClick={() => openLightbox([att], 0)}
className="relative block w-full overflow-hidden rounded-xl max-h-[240px] min-[440px]:max-h-[270px] md:max-h-[320px] lg:max-h-[360px]"
aria-label={att.filename}
>
<img
src={att.url}
alt={att.filename}
loading="lazy"
className="h-full w-full object-cover"
style={{ aspectRatio: ratio }}
/>
</button>
{post.text ? (
<div className="whitespace-pre-wrap break-words text-[14px] leading-snug text-neutral-100">
{autolink(post.text)}
</div>
) : null}
</div>
);
}

View File

@@ -0,0 +1,10 @@
import type { Post } from "../../../types/post";
import { autolink } from "../utils/autolink";
export function TextBubble({ post }: { post: Post }) {
return (
<div className="whitespace-pre-wrap break-words text-[14px] leading-snug text-neutral-100">
{autolink(post.text ?? "")}
</div>
);
}

View File

@@ -0,0 +1,81 @@
import { Play } from "lucide-react";
import { useRef, useState } from "react";
import type { Post } from "../../../types/post";
import { useVideoPlayer } from "../overlays/VideoPlayer";
import { autolink } from "../utils/autolink";
import { formatBytes } from "../utils/formatBytes";
function formatDuration(sec: number | undefined): string {
if (!sec || sec <= 0) return "";
const m = Math.floor(sec / 60);
const s = Math.floor(sec % 60);
return `${m}:${s.toString().padStart(2, "0")}`;
}
export function VideoBubble({ post }: { post: Post }) {
const { openVideo } = useVideoPlayer();
const att = post.attachments[0];
const [playing, setPlaying] = useState(false);
const videoRef = useRef<HTMLVideoElement>(null);
if (!att) return null;
const ratio =
att.width && att.height ? `${att.width} / ${att.height}` : "16 / 9";
return (
<div className="flex flex-col gap-1.5">
<div
className="relative max-h-[220px] w-full overflow-hidden rounded-xl bg-black min-[440px]:max-h-[250px] md:max-h-[300px] lg:max-h-[340px]"
style={{ aspectRatio: ratio }}
onClick={() => {
if (playing) {
const v = videoRef.current;
openVideo(att, v?.currentTime ?? 0);
}
}}
>
{playing ? (
<video
ref={videoRef}
src={att.url}
poster={att.posterUrl}
controls
playsInline
autoPlay
className="absolute inset-0 h-full w-full"
/>
) : (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setPlaying(true);
}}
className="absolute inset-0 flex items-center justify-center"
aria-label="Play video"
>
{att.posterUrl ? (
<img
src={att.posterUrl}
alt=""
className="absolute inset-0 h-full w-full object-cover"
/>
) : null}
<div className="absolute left-3 top-3 z-10 flex items-center gap-1.5 rounded-full bg-black/55 px-2.5 py-1 text-xs text-white">
<span>{formatDuration(att.durationSec)}</span>
<span className="opacity-70">·</span>
<span>{formatBytes(att.sizeBytes)}</span>
</div>
<div className="relative z-10 flex h-12 w-12 items-center justify-center rounded-full bg-black/55 text-white backdrop-blur md:h-14 md:w-14">
<Play className="h-5 w-5 translate-x-0.5 fill-white md:h-6 md:w-6" />
</div>
</button>
)}
</div>
{post.text ? (
<div className="whitespace-pre-wrap break-words text-[14px] leading-snug text-neutral-100">
{autolink(post.text)}
</div>
) : null}
</div>
);
}

View File

@@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import { renderHook } from "@testing-library/react";
import { useGroupedByDay } from "./useGroupedByDay";
import type { Post } from "../../../types/post";
function makePost(id: string, isoDate: string): Post {
return {
id,
categoryId: 1,
categorySlug: "x",
language: "zh-CN",
attachments: [],
isRecommended: false,
publishedAt: isoDate,
updatedAt: isoDate,
text: id,
};
}
describe("useGroupedByDay", () => {
it("groups posts by local date", () => {
const posts: Post[] = [
makePost("a", "2026-02-27T10:00:00.000Z"),
makePost("b", "2026-02-27T23:00:00.000Z"),
makePost("c", "2026-02-28T01:00:00.000Z"),
makePost("d", "2026-05-16T12:00:00.000Z"),
];
const { result } = renderHook(() => useGroupedByDay(posts, "zh-CN"));
expect(result.current.length).toBeGreaterThanOrEqual(2);
const allIds = result.current.flatMap((g) => g.items.map((p) => p.id));
expect(allIds).toEqual(["a", "b", "c", "d"]);
});
it("preserves input order within groups", () => {
const posts: Post[] = [
makePost("first", "2026-03-01T10:00:00.000Z"),
makePost("second", "2026-03-01T11:00:00.000Z"),
makePost("third", "2026-03-01T12:00:00.000Z"),
];
const { result } = renderHook(() => useGroupedByDay(posts, "en"));
expect(result.current).toHaveLength(1);
expect(result.current[0].items.map((p) => p.id)).toEqual([
"first",
"second",
"third",
]);
});
it("returns empty array for empty input", () => {
const { result } = renderHook(() => useGroupedByDay([], "zh-CN"));
expect(result.current).toEqual([]);
});
});

View File

@@ -0,0 +1,62 @@
import { useMemo } from "react";
import type { Post } from "../../../types/post";
export type DayGroup = {
dayKey: string;
dayLabel: string;
items: Post[];
};
function localeFor(lang: string): string {
if (lang === "zh-TW") return "zh-TW";
if (lang === "zh-CN") return "zh-CN";
return "en-US";
}
function dayKey(iso: string): string {
const d = new Date(iso);
return `${d.getFullYear()}-${d.getMonth() + 1}-${d.getDate()}`;
}
function dayLabel(iso: string, lang: string): string {
const d = new Date(iso);
const today = new Date();
const yesterday = new Date();
yesterday.setDate(today.getDate() - 1);
const isSameDay = (a: Date, b: Date) =>
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate();
if (isSameDay(d, today)) {
if (lang === "en") return "Today";
return "今天";
}
if (isSameDay(d, yesterday)) {
if (lang === "en") return "Yesterday";
return "昨天";
}
return new Intl.DateTimeFormat(localeFor(lang), {
month: "long",
day: "numeric",
}).format(d);
}
export function useGroupedByDay(posts: Post[], lang: string): DayGroup[] {
return useMemo(() => {
const groups: DayGroup[] = [];
const seen = new Map<string, DayGroup>();
for (const p of posts) {
const k = dayKey(p.publishedAt);
let g = seen.get(k);
if (!g) {
g = { dayKey: k, dayLabel: dayLabel(p.publishedAt, lang), items: [] };
seen.set(k, g);
groups.push(g);
}
g.items.push(p);
}
return groups;
}, [posts, lang]);
}
export { dayKey as _dayKey, dayLabel as _dayLabel };

View File

@@ -0,0 +1,160 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { getJSON } from "../../../api";
import { MOCK_POSTS } from "../../../mocks/mockPosts";
import type { Post, PostListResponse, PostScope } from "../../../types/post";
const PAGE_SIZE = 20;
const MOCK_DELAY_MS = 200;
const USE_MOCK = import.meta.env.VITE_USE_MOCK_POSTS !== "false";
export type PostStreamParams = {
scope: PostScope;
type?: string;
language?: string;
lang: string;
};
export type PostStreamResult = {
items: Post[];
isLoading: boolean;
error: string | null;
hasMore: boolean;
loadMore: () => void;
reset: () => void;
};
function postMatchesType(post: Post, type: string): boolean {
if (!type || type === "all") return true;
if (type === "text" || type === "link") {
return !!post.text && post.text.length > 0;
}
return post.attachments.some((a) => {
const ext = a.filename.split(".").pop()?.toLowerCase() ?? "";
if (type === "image")
return a.kind === "image" || a.mime.startsWith("image/");
if (type === "video")
return a.kind === "video" || a.mime.startsWith("video/");
if (type === "pdf") return ext === "pdf" || a.mime === "application/pdf";
if (type === "ppt")
return (
["ppt", "pptx", "key"].includes(ext) || a.mime.includes("presentation")
);
if (type === "archive")
return ["zip", "rar", "7z", "tar", "gz"].includes(ext);
return false;
});
}
function filterMock(params: PostStreamParams): Post[] {
return MOCK_POSTS.filter((p) => {
if (
params.scope.kind === "category" &&
p.categorySlug !== params.scope.slug
)
return false;
if (params.language && p.language !== params.language) return false;
if (!postMatchesType(p, params.type ?? "all")) return false;
return true;
}).sort(
(a, b) =>
new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime(),
);
}
function buildRealUrl(params: PostStreamParams, cursor?: string): string {
const sp = new URLSearchParams();
sp.set("lang", params.lang);
sp.set("limit", String(PAGE_SIZE));
if (params.scope.kind === "category") sp.set("category", params.scope.slug);
if (params.type && params.type !== "all") sp.set("type", params.type);
if (params.language) sp.set("language", params.language);
if (cursor) sp.set("cursor", cursor);
return `/api/posts?${sp.toString()}`;
}
export function usePostStream(params: PostStreamParams): PostStreamResult {
const [items, setItems] = useState<Post[]>([]);
const [hasMore, setHasMore] = useState(true);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const reqIdRef = useRef(0);
const cursorRef = useRef<string | undefined>(undefined);
const hasMoreRef = useRef(true);
const loadingRef = useRef(false);
const fetchPage = useCallback(
async (resetting: boolean) => {
if (loadingRef.current) return;
if (!resetting && !hasMoreRef.current) return;
loadingRef.current = true;
setIsLoading(true);
setError(null);
const myReq = ++reqIdRef.current;
try {
if (USE_MOCK) {
await new Promise((r) => setTimeout(r, MOCK_DELAY_MS));
const all = filterMock(params);
const offset = resetting ? 0 : Number(cursorRef.current ?? "0");
const slice = all.slice(offset, offset + PAGE_SIZE);
const nextOffset = offset + slice.length;
const more = nextOffset < all.length;
if (myReq !== reqIdRef.current) return;
setItems((prev) => (resetting ? slice : [...prev, ...slice]));
const nextCursor = more ? String(nextOffset) : undefined;
cursorRef.current = nextCursor;
setHasMore(more);
hasMoreRef.current = more;
} else {
const url = buildRealUrl(
params,
resetting ? undefined : cursorRef.current,
);
const res = await getJSON<PostListResponse>(url);
if (myReq !== reqIdRef.current) return;
setItems((prev) => (resetting ? res.items : [...prev, ...res.items]));
cursorRef.current = res.nextCursor;
const more = !!res.nextCursor;
setHasMore(more);
hasMoreRef.current = more;
}
} catch (e) {
if (myReq !== reqIdRef.current) return;
setError(String(e));
} finally {
if (myReq === reqIdRef.current) setIsLoading(false);
loadingRef.current = false;
}
},
[params],
);
useEffect(() => {
setItems([]);
cursorRef.current = undefined;
setHasMore(true);
hasMoreRef.current = true;
fetchPage(true);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
params.scope.kind,
params.scope.kind === "category" ? params.scope.slug : "",
params.type,
params.language,
params.lang,
]);
const loadMore = useCallback(() => {
fetchPage(false);
}, [fetchPage]);
const reset = useCallback(() => {
fetchPage(true);
}, [fetchPage]);
return { items, isLoading, error, hasMore, loadMore, reset };
}
export { USE_MOCK as POST_STREAM_USES_MOCK };

View File

@@ -0,0 +1,180 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useRef,
useState,
type PropsWithChildren,
} from "react";
import { createPortal } from "react-dom";
import { ChevronLeft, ChevronRight, Download, X } from "lucide-react";
import type { Attachment } from "../../../types/post";
type LightboxState = {
images: Attachment[];
index: number;
} | null;
type Ctx = {
openLightbox: (images: Attachment[], startIndex?: number) => void;
closeLightbox: () => void;
};
const LightboxContext = createContext<Ctx | null>(null);
export function useLightbox(): Ctx {
const ctx = useContext(LightboxContext);
if (!ctx)
throw new Error("useLightbox must be used inside ImageLightboxProvider");
return ctx;
}
export function ImageLightboxProvider({ children }: PropsWithChildren) {
const [state, setState] = useState<LightboxState>(null);
const openLightbox = useCallback((images: Attachment[], startIndex = 0) => {
if (!images.length) return;
const i = Math.min(Math.max(0, startIndex), images.length - 1);
setState({ images, index: i });
}, []);
const closeLightbox = useCallback(() => setState(null), []);
return (
<LightboxContext.Provider value={{ openLightbox, closeLightbox }}>
{children}
{state ? (
<LightboxView
images={state.images}
startIndex={state.index}
onClose={closeLightbox}
/>
) : null}
</LightboxContext.Provider>
);
}
function LightboxView({
images,
startIndex,
onClose,
}: {
images: Attachment[];
startIndex: number;
onClose: () => void;
}) {
const [index, setIndex] = useState(startIndex);
const touchStartX = useRef<number | null>(null);
const goPrev = useCallback(
() => setIndex((i) => (i - 1 + images.length) % images.length),
[images.length],
);
const goNext = useCallback(
() => setIndex((i) => (i + 1) % images.length),
[images.length],
);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
if (e.key === "ArrowLeft") goPrev();
if (e.key === "ArrowRight") goNext();
};
window.addEventListener("keydown", onKey);
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
window.removeEventListener("keydown", onKey);
document.body.style.overflow = prevOverflow;
};
}, [goPrev, goNext, onClose]);
const current = images[index];
if (!current) return null;
return createPortal(
<div
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/95 backdrop-blur-sm"
onClick={onClose}
role="dialog"
aria-modal="true"
>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onClose();
}}
className="absolute right-4 top-4 z-10 flex h-10 w-10 items-center justify-center rounded-full bg-white/10 text-white transition hover:bg-white/20"
aria-label="Close"
>
<X className="h-5 w-5" />
</button>
<a
href={current.url}
download={current.filename}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="absolute right-16 top-4 z-10 flex h-10 w-10 items-center justify-center rounded-full bg-white/10 text-white transition hover:bg-white/20"
aria-label="Download"
>
<Download className="h-5 w-5" />
</a>
{images.length > 1 ? (
<>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
goPrev();
}}
className="absolute left-2 top-1/2 z-10 flex h-12 w-12 -translate-y-1/2 items-center justify-center rounded-full bg-white/10 text-white transition hover:bg-white/20 md:left-6"
aria-label="Previous"
>
<ChevronLeft className="h-6 w-6" />
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
goNext();
}}
className="absolute right-2 top-1/2 z-10 flex h-12 w-12 -translate-y-1/2 items-center justify-center rounded-full bg-white/10 text-white transition hover:bg-white/20 md:right-6"
aria-label="Next"
>
<ChevronRight className="h-6 w-6" />
</button>
<div className="absolute bottom-6 left-1/2 z-10 -translate-x-1/2 rounded-full bg-white/10 px-3 py-1 text-xs text-white">
{index + 1} / {images.length}
</div>
</>
) : null}
<img
src={current.url}
alt={current.filename}
className="max-h-[92vh] max-w-[92vw] object-contain select-none"
draggable={false}
onClick={(e) => e.stopPropagation()}
onTouchStart={(e) => {
touchStartX.current = e.touches[0].clientX;
}}
onTouchEnd={(e) => {
if (touchStartX.current == null) return;
const dx = e.changedTouches[0].clientX - touchStartX.current;
if (Math.abs(dx) > 40) {
if (dx > 0) goPrev();
else goNext();
}
touchStartX.current = null;
}}
/>
</div>,
document.body,
);
}

View File

@@ -0,0 +1,118 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useRef,
useState,
type PropsWithChildren,
} from "react";
import { createPortal } from "react-dom";
import { X } from "lucide-react";
import type { Attachment } from "../../../types/post";
type PlayerState = {
attachment: Attachment;
currentTime: number;
} | null;
type Ctx = {
openVideo: (attachment: Attachment, currentTime?: number) => void;
closeVideo: () => void;
};
const VideoPlayerContext = createContext<Ctx | null>(null);
export function useVideoPlayer(): Ctx {
const ctx = useContext(VideoPlayerContext);
if (!ctx)
throw new Error("useVideoPlayer must be used inside VideoPlayerProvider");
return ctx;
}
export function VideoPlayerProvider({ children }: PropsWithChildren) {
const [state, setState] = useState<PlayerState>(null);
const openVideo = useCallback(
(attachment: Attachment, currentTime = 0) =>
setState({ attachment, currentTime }),
[],
);
const closeVideo = useCallback(() => setState(null), []);
return (
<VideoPlayerContext.Provider value={{ openVideo, closeVideo }}>
{children}
{state ? (
<PlayerView
attachment={state.attachment}
startAt={state.currentTime}
onClose={closeVideo}
/>
) : null}
</VideoPlayerContext.Provider>
);
}
function PlayerView({
attachment,
startAt,
onClose,
}: {
attachment: Attachment;
startAt: number;
onClose: () => void;
}) {
const videoRef = useRef<HTMLVideoElement>(null);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
window.removeEventListener("keydown", onKey);
document.body.style.overflow = prevOverflow;
};
}, [onClose]);
useEffect(() => {
const v = videoRef.current;
if (!v) return;
if (startAt > 0) v.currentTime = startAt;
v.play().catch(() => {});
}, [startAt]);
return createPortal(
<div
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/95"
onClick={onClose}
role="dialog"
aria-modal="true"
>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onClose();
}}
className="absolute right-4 top-4 z-10 flex h-10 w-10 items-center justify-center rounded-full bg-white/10 text-white transition hover:bg-white/20"
aria-label="Close"
>
<X className="h-5 w-5" />
</button>
<video
ref={videoRef}
src={attachment.url}
poster={attachment.posterUrl}
controls
playsInline
className="max-h-[92vh] max-w-[96vw] outline-none"
onClick={(e) => e.stopPropagation()}
/>
</div>,
document.body,
);
}

View File

@@ -0,0 +1,42 @@
import { describe, expect, it } from "vitest";
import { render } from "@testing-library/react";
import { autolink } from "./autolink";
describe("autolink", () => {
it("returns empty array for empty input", () => {
expect(autolink("")).toEqual([]);
});
it("returns plain text when no urls", () => {
const { container } = render(<>{autolink("普通文本,没有链接")}</>);
expect(container.textContent).toBe("普通文本,没有链接");
expect(container.querySelector("a")).toBeNull();
});
it("wraps a single https url in an anchor with safe attrs", () => {
const { container } = render(<>{autolink("点 https://x.com/path 看")}</>);
const anchor = container.querySelector("a");
expect(anchor).not.toBeNull();
expect(anchor?.getAttribute("href")).toBe("https://x.com/path");
expect(anchor?.getAttribute("target")).toBe("_blank");
expect(anchor?.getAttribute("rel")).toBe("noopener noreferrer");
expect(container.textContent).toBe("点 https://x.com/path 看");
});
it("handles multiple urls in one string", () => {
const { container } = render(
<>{autolink("a https://a.com b https://b.com c")}</>,
);
const anchors = container.querySelectorAll("a");
expect(anchors).toHaveLength(2);
expect(anchors[0].getAttribute("href")).toBe("https://a.com");
expect(anchors[1].getAttribute("href")).toBe("https://b.com");
});
it("trims trailing punctuation outside the url", () => {
const { container } = render(<>{autolink("see https://x.com.")}</>);
const anchor = container.querySelector("a");
expect(anchor?.getAttribute("href")).toBe("https://x.com");
expect(container.textContent).toBe("see https://x.com.");
});
});

View File

@@ -0,0 +1,42 @@
import { Fragment, type ReactNode } from "react";
const URL_REGEX = /(https?:\/\/[^\s<>"]+[^\s<>".,;:!?)\]}'])/gi;
export function autolink(text: string): ReactNode[] {
if (!text) return [];
const parts: ReactNode[] = [];
let lastIndex = 0;
let match: RegExpExecArray | null;
URL_REGEX.lastIndex = 0;
while ((match = URL_REGEX.exec(text)) !== null) {
if (match.index > lastIndex) {
parts.push(
<Fragment key={`t-${lastIndex}`}>
{text.slice(lastIndex, match.index)}
</Fragment>,
);
}
const url = match[0];
parts.push(
<a
key={`a-${match.index}`}
href={url}
target="_blank"
rel="noopener noreferrer"
className="text-ark-gold underline underline-offset-2 break-all hover:text-ark-gold2"
>
{url}
</a>,
);
lastIndex = match.index + url.length;
}
if (lastIndex < text.length) {
parts.push(
<Fragment key={`t-${lastIndex}`}>{text.slice(lastIndex)}</Fragment>,
);
}
return parts;
}

View File

@@ -0,0 +1,56 @@
import {
FileText,
FileImage,
FileVideo,
FileArchive,
File as FileIcon,
Presentation,
type LucideIcon,
} from "lucide-react";
export type FileIconInfo = {
Icon: LucideIcon;
color: string;
};
const PDF = { Icon: FileText, color: "#ef4444" };
const AI = { Icon: FileImage, color: "#f97316" };
const PPT = { Icon: Presentation, color: "#dc2626" };
const DOC = { Icon: FileText, color: "#2563eb" };
const VIDEO = { Icon: FileVideo, color: "#8b5cf6" };
const IMAGE = { Icon: FileImage, color: "#10b981" };
const ARCHIVE = { Icon: FileArchive, color: "#a16207" };
const GENERIC = { Icon: FileIcon, color: "#6b7280" };
export function fileIcon(input: {
mime: string;
filename: string;
}): FileIconInfo {
const ext = input.filename.split(".").pop()?.toLowerCase() ?? "";
const mime = (input.mime || "").toLowerCase();
if (mime === "application/pdf" || ext === "pdf") return PDF;
if (ext === "ai" || mime === "application/illustrator") return AI;
if (
mime.includes("presentation") ||
ext === "ppt" ||
ext === "pptx" ||
ext === "key"
)
return PPT;
if (mime.includes("word") || ext === "doc" || ext === "docx") return DOC;
if (mime.startsWith("video/")) return VIDEO;
if (mime.startsWith("image/")) return IMAGE;
if (
mime.includes("zip") ||
mime.includes("rar") ||
mime.includes("tar") ||
ext === "zip" ||
ext === "rar" ||
ext === "7z" ||
ext === "tar" ||
ext === "gz"
)
return ARCHIVE;
return GENERIC;
}

View File

@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { formatBytes } from "./formatBytes";
describe("formatBytes", () => {
it("returns bytes under 1 KB unchanged", () => {
expect(formatBytes(0)).toBe("0 B");
expect(formatBytes(512)).toBe("512 B");
expect(formatBytes(1023)).toBe("1023 B");
});
it("formats KB with one decimal when small", () => {
expect(formatBytes(1024)).toBe("1 KB");
expect(formatBytes(1536)).toBe("1.5 KB");
});
it("formats MB with one decimal", () => {
expect(formatBytes(3_549_239)).toBe("3.4 MB");
expect(formatBytes(4_800_000)).toBe("4.6 MB");
});
it("drops decimals once value >= 100", () => {
expect(formatBytes(150 * 1024 * 1024)).toBe("150 MB");
});
it("handles GB and TB", () => {
expect(formatBytes(2 * 1024 ** 3)).toBe("2 GB");
expect(formatBytes(3 * 1024 ** 4)).toBe("3 TB");
});
it("guards against invalid input", () => {
expect(formatBytes(-1)).toBe("0 B");
expect(formatBytes(Number.NaN)).toBe("0 B");
});
});

View File

@@ -0,0 +1,15 @@
const UNITS = ["B", "KB", "MB", "GB", "TB"] as const;
export function formatBytes(bytes: number): string {
if (!Number.isFinite(bytes) || bytes < 0) return "0 B";
if (bytes < 1024) return `${bytes} B`;
let value = bytes;
let unitIndex = 0;
while (value >= 1024 && unitIndex < UNITS.length - 1) {
value /= 1024;
unitIndex += 1;
}
const rounded =
value >= 100 ? Math.round(value) : Math.round(value * 10) / 10;
return `${rounded} ${UNITS[unitIndex]}`;
}

View File

@@ -0,0 +1,27 @@
function localeFor(lang: string): string {
if (lang === "zh-TW") return "zh-TW";
if (lang === "zh-CN") return "zh-CN";
return "en-US";
}
function formatDate(iso: string, lang: string): string {
const d = new Date(iso);
return new Intl.DateTimeFormat(localeFor(lang), {
year: "numeric",
month: lang === "en" ? "short" : "numeric",
day: "numeric",
}).format(d);
}
export function formatTime(iso: string, lang: string): string {
const d = new Date(iso);
return new Intl.DateTimeFormat(localeFor(lang), {
hour: "numeric",
minute: "2-digit",
hour12: lang === "en",
}).format(d);
}
export function formatDateTime(iso: string, lang: string): string {
return `${formatDate(iso, lang)} ${formatTime(iso, lang)}`;
}