185 lines
7.1 KiB
TypeScript
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} />}
|
|
/>
|
|
</>
|
|
);
|
|
}
|