feat: wire public posts api
This commit is contained in:
@@ -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(() => {
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user