From d3c30795dcb74cc1fe303dcc1e9e401a510c33e2 Mon Sep 17 00:00:00 2001 From: TerryM Date: Tue, 26 May 2026 12:07:13 +0800 Subject: [PATCH] feat: wire public posts api --- src/api.ts | 5 ++ src/components/RecommendedCard.tsx | 17 ++++- src/components/messageStream/FilterChips.tsx | 31 +------- .../messageStream/MessageStream.tsx | 13 +--- .../messageStream/bubbles/AlbumBubble.tsx | 16 ++-- .../messageStream/bubbles/FileDocBubble.tsx | 16 +++- .../messageStream/bubbles/ImageBubble.tsx | 2 +- .../bubbles/ImageWithTextBubble.tsx | 10 ++- .../messageStream/bubbles/TextBubble.tsx | 5 +- .../messageStream/bubbles/VideoBubble.tsx | 8 +- .../messageStream/hooks/usePostStream.ts | 3 +- .../messageStream/overlays/ImageLightbox.tsx | 68 +++++++++++++---- .../messageStream/utils/postText.ts | 13 ++++ src/i18nLanguages.ts | 8 ++ src/pages/Home.tsx | 29 +++++-- src/pages/PostRedirect.tsx | 49 +++++++----- src/pages/SearchPage.tsx | 76 ++++--------------- src/types/post.ts | 30 +++++++- src/utils/postResourceAdapter.ts | 63 +++++++++++++++ 19 files changed, 299 insertions(+), 163 deletions(-) create mode 100644 src/components/messageStream/utils/postText.ts create mode 100644 src/utils/postResourceAdapter.ts diff --git a/src/api.ts b/src/api.ts index 193d254..21afe8f 100644 --- a/src/api.ts +++ b/src/api.ts @@ -44,6 +44,11 @@ export async function postJSON( return res.json() as Promise; } +export async function postNoBody(path: string): Promise { + const res = await fetch(`${apiBase}${path}`, { method: "POST" }); + if (!res.ok) throw new Error(await res.text()); +} + export async function putJSON( path: string, body: unknown, diff --git a/src/components/RecommendedCard.tsx b/src/components/RecommendedCard.tsx index 8e8e070..d90d2a2 100644 --- a/src/components/RecommendedCard.tsx +++ b/src/components/RecommendedCard.tsx @@ -1,7 +1,7 @@ import { Download } from "lucide-react"; import { Link } from "react-router-dom"; import type { Resource } from "../api"; -import { assetUrl, postJSON } from "../api"; +import { assetUrl, postJSON, postNoBody } from "../api"; import { useI18n } from "../i18n"; import { useMemo } from "react"; import { formatDateYmd } from "../utils/format"; @@ -14,11 +14,16 @@ function isPlaceholderAsset(path: string | undefined | null) { const CARD_CLASS = "group flex w-[232px] shrink-0 flex-col overflow-hidden rounded-xl border border-ark-line bg-ark-panel transition hover:border-ark-gold/55 max-[439px]:w-[232px] min-[440px]:w-[230px] sm:w-[240px] lg:w-[246.4px] min-[1100px]:max-xl:w-[273px] xl:w-[246.4px]"; +type RecommendedResource = Resource & { + downloadPostId?: string; + downloadAttachmentId?: string; +}; + export function RecommendedCard({ r, visualIndex = 0, }: { - r: Resource; + r: RecommendedResource; visualIndex?: number; }) { const { t } = useI18n(); @@ -83,7 +88,13 @@ export function RecommendedCard({ e.preventDefault(); e.stopPropagation(); try { - await postJSON(`/api/resources/${r.id}/download`, {}); + if (r.downloadPostId && r.downloadAttachmentId) { + await postNoBody( + `/api/posts/${r.downloadPostId}/attachments/${r.downloadAttachmentId}/download`, + ); + } else { + await postJSON(`/api/resources/${r.id}/download`, {}); + } } catch { /* ignore */ } diff --git a/src/components/messageStream/FilterChips.tsx b/src/components/messageStream/FilterChips.tsx index 37bc863..2477975 100644 --- a/src/components/messageStream/FilterChips.tsx +++ b/src/components/messageStream/FilterChips.tsx @@ -1,5 +1,4 @@ import { useI18n } from "../../i18n"; -import { LANG_OPTIONS, languageLabel } from "../../i18nLanguages"; import { typeFilterLabel } from "../../resourceTypeLabels"; const TYPE_FILTERS = [ @@ -14,21 +13,12 @@ const TYPE_FILTERS = [ "archive", ] as const; -const LANG_FILTERS = ["", ...LANG_OPTIONS.map((x) => x.code)] as const; - export type FilterChipsProps = { type: string; - language: string; onTypeChange: (next: string) => void; - onLanguageChange: (next: string) => void; }; -export function FilterChips({ - type, - language, - onTypeChange, - onLanguageChange, -}: FilterChipsProps) { +export function FilterChips({ type, onTypeChange }: FilterChipsProps) { const { t } = useI18n(); return (
@@ -51,25 +41,6 @@ export function FilterChips({ ); })}
-
- {LANG_FILTERS.map((code) => { - const active = language === code; - return ( - - ); - })} -
); } diff --git a/src/components/messageStream/MessageStream.tsx b/src/components/messageStream/MessageStream.tsx index e938473..a297365 100644 --- a/src/components/messageStream/MessageStream.tsx +++ b/src/components/messageStream/MessageStream.tsx @@ -16,12 +16,8 @@ export function MessageStream({ scope }: MessageStreamProps) { 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 params = useMemo(() => ({ scope, type, lang }), [scope, type, lang]); const { items, isLoading, error, hasMore, loadMore, reset } = usePostStream(params); @@ -68,12 +64,7 @@ export function MessageStream({ scope }: MessageStreamProps) { return (
- updateParam("type", v)} - onLanguageChange={(v) => updateParam("language", v)} - /> + updateParam("type", v)} />
{groups.map((group) => ( diff --git a/src/components/messageStream/bubbles/AlbumBubble.tsx b/src/components/messageStream/bubbles/AlbumBubble.tsx index 42972bb..c747ea0 100644 --- a/src/components/messageStream/bubbles/AlbumBubble.tsx +++ b/src/components/messageStream/bubbles/AlbumBubble.tsx @@ -1,6 +1,8 @@ +import { useI18n } from "../../../i18n"; import type { Attachment, Post } from "../../../types/post"; import { useLightbox } from "../overlays/ImageLightbox"; import { autolink } from "../utils/autolink"; +import { postDisplayText } from "../utils/postText"; const MAX_VISIBLE = 4; @@ -10,7 +12,9 @@ function imageRatio(att: Attachment) { export function AlbumBubble({ post }: { post: Post }) { const { openLightbox } = useLightbox(); + const { lang } = useI18n(); const images = post.attachments; + const text = postDisplayText(post, lang); const shouldMerge = images.length > MAX_VISIBLE; if (!shouldMerge) { @@ -20,7 +24,7 @@ export function AlbumBubble({ post }: { post: Post }) { ))} - {post.text ? ( + {text ? (
- {autolink(post.text)} + {autolink(text)}
) : null}
@@ -54,7 +58,7 @@ export function AlbumBubble({ post }: { post: Post }) {
- {post.text ? ( + {text ? (
- {autolink(post.text)} + {autolink(text)}
) : null} diff --git a/src/components/messageStream/bubbles/FileDocBubble.tsx b/src/components/messageStream/bubbles/FileDocBubble.tsx index a97b899..8bfee8c 100644 --- a/src/components/messageStream/bubbles/FileDocBubble.tsx +++ b/src/components/messageStream/bubbles/FileDocBubble.tsx @@ -1,9 +1,12 @@ import { Download } from "lucide-react"; +import { postNoBody } from "../../../api"; +import { useI18n } from "../../../i18n"; import type { Attachment, Post } from "../../../types/post"; import { fileIcon } from "../utils/fileIcon"; import { formatBytes } from "../utils/formatBytes"; +import { postDisplayText } from "../utils/postText"; -function AttachmentRow({ att }: { att: Attachment }) { +function AttachmentRow({ postId, att }: { postId: string; att: Attachment }) { const isImageAsDoc = att.mime.startsWith("image/"); const { Icon, color } = fileIcon({ mime: att.mime, filename: att.filename }); @@ -14,6 +17,9 @@ function AttachmentRow({ att }: { att: Attachment }) { target="_blank" rel="noopener noreferrer" className="group flex items-center gap-2 rounded-xl px-1 py-0.5 transition hover:bg-white/5" + onClick={() => { + void postNoBody(`/api/posts/${postId}/attachments/${att.id}/download`); + }} >
{isImageAsDoc && att.thumbnailUrl ? ( @@ -49,14 +55,16 @@ function AttachmentRow({ att }: { att: Attachment }) { } export function FileDocBubble({ post }: { post: Post }) { + const { lang } = useI18n(); + const text = postDisplayText(post, lang); return (
{post.attachments.map((att) => ( - + ))} - {post.text ? ( + {text ? (
- {post.text} + {text}
) : null}
diff --git a/src/components/messageStream/bubbles/ImageBubble.tsx b/src/components/messageStream/bubbles/ImageBubble.tsx index 4bed06d..6b81804 100644 --- a/src/components/messageStream/bubbles/ImageBubble.tsx +++ b/src/components/messageStream/bubbles/ImageBubble.tsx @@ -11,7 +11,7 @@ export function ImageBubble({ post }: { post: Post }) { return ( - {post.text ? ( + {text ? (
- {autolink(post.text)} + {autolink(text)}
) : null}
diff --git a/src/components/messageStream/bubbles/TextBubble.tsx b/src/components/messageStream/bubbles/TextBubble.tsx index 28c133e..22da71f 100644 --- a/src/components/messageStream/bubbles/TextBubble.tsx +++ b/src/components/messageStream/bubbles/TextBubble.tsx @@ -1,10 +1,13 @@ import type { Post } from "../../../types/post"; +import { useI18n } from "../../../i18n"; import { autolink } from "../utils/autolink"; +import { postDisplayText } from "../utils/postText"; export function TextBubble({ post }: { post: Post }) { + const { lang } = useI18n(); return (
- {autolink(post.text ?? "")} + {autolink(postDisplayText(post, lang))}
); } diff --git a/src/components/messageStream/bubbles/VideoBubble.tsx b/src/components/messageStream/bubbles/VideoBubble.tsx index ebd4724..5a00e74 100644 --- a/src/components/messageStream/bubbles/VideoBubble.tsx +++ b/src/components/messageStream/bubbles/VideoBubble.tsx @@ -1,9 +1,11 @@ import { Play } from "lucide-react"; import { useRef, useState } from "react"; +import { useI18n } from "../../../i18n"; import type { Post } from "../../../types/post"; import { useVideoPlayer } from "../overlays/VideoPlayer"; import { autolink } from "../utils/autolink"; import { formatBytes } from "../utils/formatBytes"; +import { postDisplayText } from "../utils/postText"; function formatDuration(sec: number | undefined): string { if (!sec || sec <= 0) return ""; @@ -14,9 +16,11 @@ function formatDuration(sec: number | undefined): string { export function VideoBubble({ post }: { post: Post }) { const { openVideo } = useVideoPlayer(); + const { lang } = useI18n(); const att = post.attachments[0]; const [playing, setPlaying] = useState(false); const videoRef = useRef(null); + const text = postDisplayText(post, lang); if (!att) return null; const ratio = att.width && att.height ? `${att.width} / ${att.height}` : "16 / 9"; @@ -71,9 +75,9 @@ export function VideoBubble({ post }: { post: Post }) { )} - {post.text ? ( + {text ? (
- {autolink(post.text)} + {autolink(text)}
) : null} diff --git a/src/components/messageStream/hooks/usePostStream.ts b/src/components/messageStream/hooks/usePostStream.ts index 11e5f15..321209a 100644 --- a/src/components/messageStream/hooks/usePostStream.ts +++ b/src/components/messageStream/hooks/usePostStream.ts @@ -1,6 +1,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { getJSON } from "../../../api"; import { langQuery, type Lang } from "../../../i18n"; +import { sourceLanguageQuery } from "../../../i18nLanguages"; import { MOCK_POSTS } from "../../../mocks/mockPosts"; import type { Post, PostListResponse, PostScope } from "../../../types/post"; @@ -70,7 +71,7 @@ function buildRealUrl(params: PostStreamParams, cursor?: string): string { 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 (params.language) sp.set("language", sourceLanguageQuery(params.language)); if (cursor) sp.set("cursor", cursor); return `/api/posts?${sp.toString()}`; } diff --git a/src/components/messageStream/overlays/ImageLightbox.tsx b/src/components/messageStream/overlays/ImageLightbox.tsx index d7374ac..9bcae66 100644 --- a/src/components/messageStream/overlays/ImageLightbox.tsx +++ b/src/components/messageStream/overlays/ImageLightbox.tsx @@ -9,15 +9,24 @@ import { } from "react"; import { createPortal } from "react-dom"; import { ChevronLeft, ChevronRight, Download, X } from "lucide-react"; +import { postNoBody } from "../../../api"; import type { Attachment } from "../../../types/post"; +import { autolink } from "../utils/autolink"; type LightboxState = { images: Attachment[]; index: number; + caption?: string; + postId?: string; } | null; type Ctx = { - openLightbox: (images: Attachment[], startIndex?: number) => void; + openLightbox: ( + images: Attachment[], + startIndex?: number, + caption?: string, + postId?: string, + ) => void; closeLightbox: () => void; }; @@ -33,11 +42,19 @@ export function useLightbox(): Ctx { export function ImageLightboxProvider({ children }: PropsWithChildren) { const [state, setState] = useState(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 openLightbox = useCallback( + ( + images: Attachment[], + startIndex = 0, + caption?: string, + postId?: string, + ) => { + if (!images.length) return; + const i = Math.min(Math.max(0, startIndex), images.length - 1); + setState({ images, index: i, caption, postId }); + }, + [], + ); const closeLightbox = useCallback(() => setState(null), []); @@ -48,6 +65,8 @@ export function ImageLightboxProvider({ children }: PropsWithChildren) { ) : null} @@ -58,10 +77,14 @@ export function ImageLightboxProvider({ children }: PropsWithChildren) { function LightboxView({ images, startIndex, + caption: captionText, + postId, onClose, }: { images: Attachment[]; startIndex: number; + caption?: string; + postId?: string; onClose: () => void; }) { const [index, setIndex] = useState(startIndex); @@ -92,6 +115,7 @@ function LightboxView({ }, [goPrev, goNext, onClose]); const current = images[index]; + const caption = captionText?.trim(); if (!current) return null; return createPortal( @@ -118,7 +142,14 @@ function LightboxView({ download={current.filename} target="_blank" rel="noopener noreferrer" - onClick={(e) => e.stopPropagation()} + onClick={(e) => { + e.stopPropagation(); + if (postId) { + void postNoBody( + `/api/posts/${postId}/attachments/${current.id}/download`, + ); + } + }} 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" > @@ -155,11 +186,8 @@ function LightboxView({ ) : null} - {current.filename} e.stopPropagation()} onTouchStart={(e) => { touchStartX.current = e.touches[0].clientX; @@ -173,7 +201,21 @@ function LightboxView({ } touchStartX.current = null; }} - /> + > + {current.filename} + {caption ? ( +
+
+ {autolink(caption)} +
+
+ ) : null} + , document.body, ); diff --git a/src/components/messageStream/utils/postText.ts b/src/components/messageStream/utils/postText.ts new file mode 100644 index 0000000..2ba5bd2 --- /dev/null +++ b/src/components/messageStream/utils/postText.ts @@ -0,0 +1,13 @@ +import { localizationKey } from "../../../i18nLanguages"; +import type { Post } from "../../../types/post"; + +export function postDisplayText(post: Post, lang: string): string { + const key = localizationKey(lang); + return ( + post.localizations?.[ + key as keyof typeof post.localizations + ]?.text?.trim() || + post.text?.trim() || + "" + ); +} diff --git a/src/i18nLanguages.ts b/src/i18nLanguages.ts index c5997d3..4cb82f2 100644 --- a/src/i18nLanguages.ts +++ b/src/i18nLanguages.ts @@ -16,3 +16,11 @@ export function languageLabel(t: (key: string) => string, code: string) { const label = t(key); return label === key ? code : label; } + +export function sourceLanguageQuery(code: string) { + return code === "zh-CN" ? "zh" : code; +} + +export function localizationKey(code: string) { + return code === "zh-CN" || code.startsWith("zh") ? "zh" : code; +} diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index 5db93c5..7a78464 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -1,7 +1,7 @@ import { ChevronRight } from "lucide-react"; import { Link } from "react-router-dom"; import { useEffect, useRef, useState } from "react"; -import { getJSON, itemsOrEmpty, type Category, type Resource } from "../api"; +import { getJSON, itemsOrEmpty, type Category } from "../api"; import { CategoryIcon } from "../components/CategoryIcon"; import { FigmaBanner } from "../components/FigmaBanner"; import { @@ -12,12 +12,17 @@ import { RecommendedCard } from "../components/RecommendedCard"; import { SectionHeader } from "../components/SectionHeader"; import { langQuery, useI18n } from "../i18n"; import { categoryCardLines } from "../utils/categoryDisplay"; +import { + postToResource, + type PostBackedResource, +} from "../utils/postResourceAdapter"; +import type { Post } from "../types/post"; export function Home() { const { t, lang } = useI18n(); const [cats, setCats] = useState([]); - const [rec, setRec] = useState([]); - const [latest, setLatest] = useState([]); + const [rec, setRec] = useState([]); + const [latest, setLatest] = useState([]); const [err, setErr] = useState(null); const recRowRef = useRef(null); const [canScrollRec, setCanScrollRec] = useState(false); @@ -26,18 +31,26 @@ export function Home() { const q = `?lang=${encodeURIComponent(langQuery(lang))}`; Promise.all([ getJSON(`/api/categories${q}`), - getJSON<{ items: Resource[] }>(`/api/resources/recommended${q}&limit=12`), - getJSON<{ items: Resource[] }>(`/api/resources/latest${q}&limit=8`), + getJSON<{ items: Post[] }>(`/api/posts/recommended${q}&limit=12`), + getJSON<{ items: Post[] }>(`/api/posts/latest${q}&limit=8`), ]) .then(([c, r, l]) => { setCats(itemsOrEmpty(c)); - setRec(itemsOrEmpty(r.items)); - setLatest(itemsOrEmpty(l.items)); + setRec( + itemsOrEmpty(r.items).map((post) => + postToResource(post, lang, itemsOrEmpty(c)), + ), + ); + setLatest( + itemsOrEmpty(l.items).map((post) => + postToResource(post, lang, itemsOrEmpty(c)), + ), + ); }) .catch((e) => setErr(String(e))); }, [lang]); - const iconKeyForResource = (r: Resource) => + const iconKeyForResource = (r: PostBackedResource) => cats.find((c) => c.id === r.categoryId)?.iconKey ?? "folder"; useEffect(() => { diff --git a/src/pages/PostRedirect.tsx b/src/pages/PostRedirect.tsx index c7fcc6a..65460ea 100644 --- a/src/pages/PostRedirect.tsx +++ b/src/pages/PostRedirect.tsx @@ -1,30 +1,41 @@ import { useEffect } from "react"; -import { Navigate, useParams } from "react-router-dom"; +import { useNavigate, useParams } from "react-router-dom"; +import { getJSON } from "../api"; +import { langQuery, useI18n } from "../i18n"; import { MOCK_POSTS } from "../mocks/mockPosts"; import { POST_STREAM_USES_MOCK } from "../components/messageStream/hooks/usePostStream"; +import type { Post } from "../types/post"; export function PostRedirect() { const { id } = useParams(); - // Real-API branch placeholder: when backend ships /api/posts/:id, fetch and - // navigate to /category/#post-. For now mock lookup. - const post = id ? MOCK_POSTS.find((p) => p.id === id) : undefined; + const { lang } = useI18n(); + const navigate = useNavigate(); useEffect(() => { - if (post) { - requestAnimationFrame(() => { - document - .getElementById(`post-${post.id}`) - ?.scrollIntoView({ behavior: "smooth", block: "center" }); - }); + if (!id) { + navigate("/browse", { replace: true }); + return; } - }, [post]); - if (!POST_STREAM_USES_MOCK && !post) { - // TODO: replace with real fetch when /api/posts/:id ships. - return ; - } - if (!post) return ; - return ( - - ); + if (POST_STREAM_USES_MOCK) { + const post = MOCK_POSTS.find((p) => p.id === id); + navigate( + post ? `/category/${post.categorySlug}#post-${post.id}` : "/browse", + { replace: true }, + ); + return; + } + + getJSON( + `/api/posts/${id}?lang=${encodeURIComponent(langQuery(lang))}`, + ) + .then((post) => { + navigate(`/category/${post.categorySlug}#post-${post.id}`, { + replace: true, + }); + }) + .catch(() => navigate("/browse", { replace: true })); + }, [id, lang, navigate]); + + return
; } diff --git a/src/pages/SearchPage.tsx b/src/pages/SearchPage.tsx index 9e065e3..83efa0c 100644 --- a/src/pages/SearchPage.tsx +++ b/src/pages/SearchPage.tsx @@ -1,15 +1,15 @@ import { useEffect, useMemo, useState } from "react"; -import { Link, useSearchParams } from "react-router-dom"; -import { - assetUrl, - getJSON, - itemsOrEmpty, - postJSON, - type Resource, -} from "../api"; +import { useSearchParams } from "react-router-dom"; +import { getJSON, itemsOrEmpty, postJSON } from "../api"; import { langQuery, useI18n } from "../i18n"; -import { LANG_OPTIONS, languageLabel } from "../i18nLanguages"; +import { + LANG_OPTIONS, + languageLabel, + sourceLanguageQuery, +} from "../i18nLanguages"; +import { MessageBubble } from "../components/messageStream/MessageBubble"; import { typeFilterLabel } from "../resourceTypeLabels"; +import type { Post } from "../types/post"; const types = [ "all", @@ -24,50 +24,6 @@ const types = [ ] as const; const resourceLangCodes = ["", ...LANG_OPTIONS.map((x) => x.code)] as const; -function ResultRow({ r }: { r: Resource }) { - const target = r.externalUrl || (r.fileUrl ? assetUrl(r.fileUrl) : null); - const inner = ( -
- {r.coverImage ? ( - - ) : ( -
- )} -
-
- {r.title} -
- {r.description ? ( -
- {r.description} -
- ) : null} -
-
- ); - if (target) { - return ( - - {inner} - - ); - } - return ( - - {inner} - - ); -} - export function SearchPage() { const { t, lang } = useI18n(); const [sp, setSp] = useSearchParams(); @@ -75,27 +31,27 @@ export function SearchPage() { const type = sp.get("type") || "all"; const resourceLang = sp.get("language") || ""; - const [items, setItems] = useState([]); + const [items, setItems] = useState([]); const [err, setErr] = useState(null); const query = useMemo(() => { const p = new URLSearchParams(); p.set("lang", langQuery(lang)); p.set("limit", "50"); - if (q) p.set("q", q); + p.set("q", q); if (type && type !== "all") p.set("type", type); - if (resourceLang) p.set("language", resourceLang); + if (resourceLang) p.set("language", sourceLanguageQuery(resourceLang)); return p.toString(); }, [lang, q, type, resourceLang]); useEffect(() => { setErr(null); - if (!q) { + if (!q.trim()) { setItems([]); return; } postJSON("/api/search-log", { query: q }).catch(() => {}); - getJSON<{ items: Resource[] }>(`/api/resources?${query}`) + getJSON<{ items: Post[] }>(`/api/posts/search?${query}`) .then((r) => setItems(itemsOrEmpty(r.items))) .catch((e) => setErr(String(e))); }, [query, q]); @@ -161,8 +117,8 @@ export function SearchPage() { ) : null}
- {items.map((r) => ( - + {items.map((post) => ( + ))}
diff --git a/src/types/post.ts b/src/types/post.ts index 9fe9010..170d1d6 100644 --- a/src/types/post.ts +++ b/src/types/post.ts @@ -1,5 +1,24 @@ +export type PostLocaleCode = "zh" | "en" | "ja" | "ko" | "vi" | "id" | "ms"; + +export type PostType = + | "image" + | "video" + | "music" + | "ppt" + | "pdf" + | "link" + | "text" + | "archive"; + +export type PostTypeFilter = PostType | "all"; export type AttachmentKind = "image" | "video" | "document"; +export type PostLocaleTexts = { + text: string; +}; + +export type PostLocalizations = Record; + export type Attachment = { id: string; kind: AttachmentKind; @@ -16,14 +35,19 @@ export type Attachment = { export type Post = { id: string; + postType?: PostType | string; categoryId: number; categorySlug: string; language: string; + sourceLanguage?: string; text?: string; + localizations?: Partial; attachments: Attachment[]; isRecommended: boolean; publishedAt: string; - updatedAt: string; + updatedAt?: string; + createdAt?: string; + tags?: string[]; }; export type PostListResponse = { @@ -31,4 +55,8 @@ export type PostListResponse = { nextCursor?: string; }; +export type PostDownloadResponse = { + ok: true; +}; + export type PostScope = { kind: "all" } | { kind: "category"; slug: string }; diff --git a/src/utils/postResourceAdapter.ts b/src/utils/postResourceAdapter.ts new file mode 100644 index 0000000..8417436 --- /dev/null +++ b/src/utils/postResourceAdapter.ts @@ -0,0 +1,63 @@ +import type { Category, Resource } from "../api"; +import type { Attachment, Post } from "../types/post"; +import { postDisplayText } from "../components/messageStream/utils/postText"; + +export type PostBackedResource = Resource & { + downloadPostId?: string; + downloadAttachmentId?: string; +}; + +function inferType(post: Post, att: Attachment | undefined): string { + if (post.postType) return post.postType; + if (!att) return post.text?.includes("http") ? "link" : "text"; + if (att.kind === "video") return "video"; + if (att.kind === "image") return "image"; + const ext = att.filename.split(".").pop()?.toLowerCase() ?? ""; + if (["ppt", "pptx", "key"].includes(ext) || att.mime.includes("presentation")) + return "ppt"; + if (ext === "pdf" || att.mime === "application/pdf") return "pdf"; + if (att.mime.startsWith("audio/") || ext === "mp3") return "music"; + if (["zip", "rar", "7z", "tar", "gz"].includes(ext)) return "archive"; + return "text"; +} + +function coverFor(att: Attachment | undefined) { + if (!att) return ""; + if (att.kind === "image") return att.thumbnailUrl || att.url; + if (att.kind === "video") return att.posterUrl || att.thumbnailUrl || ""; + if (att.mime.startsWith("image/")) return att.thumbnailUrl || att.url; + return ""; +} + +export function postToResource( + post: Post, + lang: string, + categories: Category[] = [], +): PostBackedResource { + const first = post.attachments[0]; + const title = postDisplayText(post, lang) || first?.filename || post.id; + const category = categories.find((c) => c.id === post.categoryId); + return { + id: post.id, + title, + description: postDisplayText(post, lang), + type: inferType(post, first), + language: post.language, + categoryId: post.categoryId, + categorySlug: post.categorySlug, + categoryName: category?.name || post.categorySlug, + coverImage: coverFor(first), + fileUrl: first?.url, + previewUrl: first?.posterUrl || first?.thumbnailUrl, + externalUrl: undefined, + bodyText: postDisplayText(post, lang), + badgeLabel: post.isRecommended ? "Recommended" : undefined, + isDownloadable: !!first, + isRecommended: post.isRecommended, + publishedAt: post.publishedAt, + updatedAt: post.updatedAt || post.publishedAt, + tags: post.tags, + downloadPostId: post.id, + downloadAttachmentId: first?.id, + }; +}