feat: wire public posts api

This commit is contained in:
TerryM
2026-05-26 12:07:13 +08:00
parent f482a2ec38
commit d3c30795dc
19 changed files with 299 additions and 163 deletions

View File

@@ -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<Category[]>([]);
const [rec, setRec] = useState<Resource[]>([]);
const [latest, setLatest] = useState<Resource[]>([]);
const [rec, setRec] = useState<PostBackedResource[]>([]);
const [latest, setLatest] = useState<PostBackedResource[]>([]);
const [err, setErr] = useState<string | null>(null);
const recRowRef = useRef<HTMLDivElement>(null);
const [canScrollRec, setCanScrollRec] = useState(false);
@@ -26,18 +31,26 @@ export function Home() {
const q = `?lang=${encodeURIComponent(langQuery(lang))}`;
Promise.all([
getJSON<Category[]>(`/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(() => {

View File

@@ -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/<categorySlug>#post-<id>. 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 <Navigate to="/browse" replace />;
}
if (!post) return <Navigate to="/browse" replace />;
return (
<Navigate to={`/category/${post.categorySlug}#post-${post.id}`} replace />
);
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<Post>(
`/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 <div className="text-neutral-400"></div>;
}

View File

@@ -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 = (
<div className="flex items-center gap-3 rounded-xl border border-ark-line bg-ark-panel p-3 transition hover:border-ark-gold/55">
{r.coverImage ? (
<img
src={assetUrl(r.coverImage)}
alt=""
className="h-14 w-14 shrink-0 rounded-lg object-cover"
/>
) : (
<div className="h-14 w-14 shrink-0 rounded-lg bg-ark-bg" />
)}
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-semibold text-ark-gold2">
{r.title}
</div>
{r.description ? (
<div className="mt-0.5 line-clamp-2 text-xs text-neutral-400">
{r.description}
</div>
) : null}
</div>
</div>
);
if (target) {
return (
<a
href={target}
target="_blank"
rel="noopener noreferrer"
className="block"
>
{inner}
</a>
);
}
return (
<Link to={`/category/${r.categorySlug}`} className="block">
{inner}
</Link>
);
}
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<Resource[]>([]);
const [items, setItems] = useState<Post[]>([]);
const [err, setErr] = useState<string | null>(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}
<div className="flex flex-col gap-2">
{items.map((r) => (
<ResultRow key={r.id} r={r} />
{items.map((post) => (
<MessageBubble key={post.id} post={post} />
))}
</div>
</div>