Files
frontend-helper/src/dev/FeedPreview.tsx
T

185 lines
7.1 KiB
TypeScript

"use client";
// Dev-only: the real signed-in shell with fixture data and no network calls.
// Likes, search and the composer work on local state so the panels can be
// checked by hand. Used by src/app/dev/feed/page.tsx.
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { AppShell } from "@/components/AppShell";
import { DiscoverRailsView } from "@/components/DiscoverRails";
import { ExpertSearchView } from "@/components/ExpertSearch";
import { FeedView } from "@/components/FeedView";
import { toComments } from "@/lib/comment-model";
import { type CardPost } from "@/lib/feed-model";
import {
FIXTURE_CATEGORIES,
FIXTURE_COMMENTS,
FIXTURE_DISCOVER,
FIXTURE_FEED_ITEMS,
fixtureCards,
FIXTURE_NOW,
FIXTURE_PERSON,
FIXTURE_SEARCH_RESULTS,
} from "./fixtures";
export function FeedPreview() {
const [posts, setPosts] = useState<CardPost[]>(() => fixtureCards(FIXTURE_FEED_ITEMS));
const [draft, setDraft] = useState("");
const [file, setFile] = useState<File | null>(null);
const [query, setQuery] = useState("");
const [category, setCategory] = useState<string | null>(null);
// Fixture switches for the new-posts pill and the loading-more skeleton.
const [newCount, setNewCount] = useState(0);
const [loadingMore, setLoadingMore] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const list = useRef<HTMLDivElement | null>(null);
// The list element swaps between the panel and the phone reel; paging re-attaches to it.
const [listEl, setListEl] = useState<HTMLDivElement | null>(null);
const listRef = useCallback((el: HTMLDivElement | null) => {
list.current = el;
setListEl(el);
}, []);
// Paging: two more fixture pages load when the sentinel scrolls into view,
// the same way Feed pages (the observer root is the list or the phone reel).
const sentinel = useRef<HTMLDivElement | null>(null);
const [page, setPage] = useState(0);
const [paging, setPaging] = useState(false);
const hasMore = page < 2;
useEffect(() => {
const el = sentinel.current;
if (!el || !hasMore || paging) return;
const io = new IntersectionObserver((entries) => {
if (!entries.some((e) => e.isIntersecting)) return;
setPaging(true);
window.setTimeout(() => {
const next = page + 1;
const items = FIXTURE_FEED_ITEMS.map((raw) => {
const it = raw as { post: { id: string } };
return { ...it, post: { ...it.post, id: `${it.post.id}-page${next}` } };
});
setPosts((cur) => [...cur, ...fixtureCards(items)]);
setPage(next);
setPaging(false);
}, 900);
}, { root: listEl, rootMargin: "400px" });
io.observe(el);
return () => io.disconnect();
}, [hasMore, paging, page, posts.length, listEl]);
// The comments calls answer from the fixtures; the preview never reaches the network.
useEffect(() => {
const real = window.fetch;
const lists: Record<string, unknown[]> = { ...FIXTURE_COMMENTS };
window.fetch = async (input, init) => {
const m = /\/api\/posts\/([^/]+)\/comments/.exec(String(input));
if (!m) return real(input, init);
const id = decodeURIComponent(m[1]);
if (init?.method === "POST") {
const { content } = JSON.parse(String(init.body)) as { content: string };
const made = { id: `local-${Date.now()}`, content, createdAt: new Date(FIXTURE_NOW).toISOString(), author: { displayName: FIXTURE_PERSON.name } };
lists[id] = [...(lists[id] ?? []), made];
return Response.json({ comment: toComments([made])[0] });
}
return Response.json({ comments: toComments(lists[id] ?? []) });
};
return () => { window.fetch = real; };
}, []);
// With no query or category, show every fixture result so the list scrolls.
const results = useMemo(() => {
const q = query.trim().toLowerCase();
return q ? FIXTURE_SEARCH_RESULTS.filter((e) => e.displayName.toLowerCase().includes(q)) : FIXTURE_SEARCH_RESULTS;
}, [query]);
const toggleLike = (post: CardPost) =>
setPosts((cur) =>
cur.map((p) =>
p.key === post.key ? { ...p, isLiked: !p.isLiked, likesCount: p.likesCount + (p.isLiked ? -1 : 1) } : p,
),
);
return (
<>
<div style={{ position: "fixed", left: "50%", bottom: 8, zIndex: 50, display: "flex", gap: 12, padding: "6px 10px", font: "12px sans-serif", background: "var(--tile-2)", color: "var(--ink)", border: "1px solid var(--line-2)", borderRadius: 8 }}>
<label style={{ margin: 0, fontSize: 12 }}>
<input type="checkbox" data-testid="fx-new" checked={newCount > 0} onChange={(e) => setNewCount(e.target.checked ? 3 : 0)} style={{ padding: 0 }} /> New posts (3)
</label>
<label style={{ margin: 0, fontSize: 12 }}>
<input type="checkbox" data-testid="fx-more" checked={loadingMore} onChange={(e) => setLoadingMore(e.target.checked)} style={{ padding: 0 }} /> Loading more
</label>
</div>
<AppShell
person={FIXTURE_PERSON}
search={
<ExpertSearchView
query={query}
onQuery={setQuery}
categories={FIXTURE_CATEGORIES}
activeCategory={category}
onCategory={setCategory}
status="ready"
results={results}
/>
}
feed={
<FeedView
posts={posts}
status="ready"
loadingMore={loadingMore || paging}
hasMore={hasMore}
featuredExperts={FIXTURE_DISCOVER.featuredExperts}
trendingMinnects={FIXTURE_DISCOVER.trendingMinnects}
searchPanel={
<ExpertSearchView
query={query}
onQuery={setQuery}
categories={FIXTURE_CATEGORIES}
activeCategory={category}
onCategory={setCategory}
status="ready"
results={results}
/>
}
sentinelRef={sentinel}
listRef={listRef}
loadMoreFailed={false}
newCount={newCount}
onRetry={() => {}}
onShowNew={() => {
setNewCount(0);
list.current?.scrollTo({ top: 0, behavior: "smooth" });
}}
refreshing={refreshing}
onRefresh={() => {
list.current?.scrollTo({ top: 0, behavior: "smooth" });
setRefreshing(true);
window.setTimeout(() => {
setPosts(fixtureCards(FIXTURE_FEED_ITEMS));
setRefreshing(false);
}, 600);
}}
onToggleLike={toggleLike}
feedUserId={FIXTURE_PERSON.feedUserId}
onDelete={(post) => setPosts((cur) => cur.filter((p) => p.key !== post.key))}
composer={{
value: draft,
busy: false,
error: false,
file,
onChange: setDraft,
onFile: setFile,
// No network here: "publishing" just clears the composer.
onSubmit: () => {
setDraft("");
setFile(null);
},
person: FIXTURE_PERSON,
}}
now={FIXTURE_NOW}
/>
}
discover={<DiscoverRailsView data={FIXTURE_DISCOVER} />}
/>
</>
);
}