diff --git a/package-lock.json b/package-lock.json index 7f8544c..98a2697 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "frontend-helper", "version": "0.1.0", "dependencies": { + "hls.js": "1.7.3", "next": "15.5.28", "react": "19.0.0", "react-dom": "19.0.0" @@ -4321,6 +4322,12 @@ "node": ">= 0.4" } }, + "node_modules/hls.js": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.7.3.tgz", + "integrity": "sha512-MsPlx6yVW4Qv4C7mEVou4/gk/5cN2dVxOTnrNjPSmyH2f0Ln+/UkG10Ax1PE6OvrmTVPKgQYozTtBaPOuCzq7A==", + "license": "Apache-2.0" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", diff --git a/package.json b/package.json index e37a838..e50b341 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "test": "vitest run" }, "dependencies": { + "hls.js": "1.7.3", "next": "15.5.28", "react": "19.0.0", "react-dom": "19.0.0" diff --git a/src/app/api/feed/route.ts b/src/app/api/feed/route.ts new file mode 100644 index 0000000..8b81e80 --- /dev/null +++ b/src/app/api/feed/route.ts @@ -0,0 +1,21 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { SESSION_COOKIE } from "@/lib/cookie"; +import { clampLimit, getHomeFeedPage } from "@/lib/feed"; + +// GET /api/feed?cursor=&limit= +// Same-origin proxy for the Minnect home feed. Returns { items, nextCursor }. +// 401 { error: "sign_in" } when the session is missing or dead. + +export const runtime = "nodejs"; + +export async function GET(req: NextRequest) { + const sessionId = req.cookies.get(SESSION_COOKIE)?.value; + const cursor = req.nextUrl.searchParams.get("cursor") || null; + const limit = clampLimit(req.nextUrl.searchParams.get("limit")); + + const result = await getHomeFeedPage(sessionId, cursor, limit); + if (!result.ok) { + return NextResponse.json({ error: result.error }, { status: result.status }); + } + return NextResponse.json({ items: result.items, nextCursor: result.nextCursor }); +} diff --git a/src/app/globals.css b/src/app/globals.css index 1aafb4c..f586a20 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -13,3 +13,25 @@ button:disabled { opacity: .5; cursor: not-allowed; } .shell header { display: flex; align-items: center; gap: 12px; padding: 12px 16px; background: #fff; border-bottom: 1px solid var(--line); } .shell header div { display: flex; flex-direction: column; font-size: 14px; } .shell header span { color: var(--grey); } +.shell header { position: sticky; top: 0; z-index: 5; } +.shell header button { margin: 0 0 0 auto; padding: 8px 14px; background: #fff; color: var(--ink); border: 1px solid var(--ink); } +.feed { max-width: 640px; margin: 0 auto; padding: 16px; display: flex; flex-direction: column; gap: 16px; } +.post { background: #fff; border: 1px solid var(--line); border-radius: 8px; padding: 16px; display: flex; flex-direction: column; gap: 12px; } +.post-head { display: flex; align-items: center; gap: 12px; } +.avatar { width: 40px; height: 40px; border-radius: 8px; object-fit: cover; background: var(--line); flex: none; } +.avatar-initials { display: grid; place-items: center; background: var(--ink); color: #fff; font-weight: 500; font-size: 14px; } +.post-who { display: flex; flex-direction: column; font-size: 14px; min-width: 0; } +.post-who time { color: var(--grey); font-size: 13px; } +.post-text { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; line-height: 1.5; } +.post-media img { display: block; width: 100%; height: auto; border-radius: 8px; } +.post-video { background: #000; border-radius: 8px; overflow: hidden; } +.post-video video { display: block; width: 100%; max-height: 520px; background: #000; } +.post-stats { display: flex; gap: 16px; color: var(--grey); font-size: 14px; } +.heart { color: var(--red); } +.feed-message { text-align: center; color: var(--grey); padding: 32px 16px; } +.feed-message button { margin-top: 8px; } +.feed-center { display: grid; place-items: center; padding: 16px; } +.spinner { width: 24px; height: 24px; border: 3px solid var(--line); border-top-color: var(--red); border-radius: 50%; animation: spin .8s linear infinite; } +@keyframes spin { to { transform: rotate(360deg); } } +.new-posts { position: sticky; top: 68px; z-index: 4; align-self: center; margin: 0; padding: 8px 16px; border-radius: 999px; } +.sentinel { height: 1px; } diff --git a/src/components/App.tsx b/src/components/App.tsx index d578158..d2fb73c 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -4,6 +4,7 @@ import { useEffect, useState } from "react"; import { loadSession, signOut, type Person } from "@/lib/auth-client"; import { SignInForm } from "./SignInForm"; import { Logo } from "./Logo"; +import { Feed } from "./Feed"; type State = { kind: "loading" } | { kind: "out" } | { kind: "in"; person: Person }; @@ -39,7 +40,6 @@ export function App() { if (state.kind === "out") { return setState({ kind: "in", person })} />; } - // Placeholder shell. The home feed is a later task; sign-out is wired here. return (
@@ -52,6 +52,7 @@ export function App() { {signingOut ? "Signing out..." : "Sign out"}
+ setState({ kind: "out" })} />
); } diff --git a/src/components/Feed.tsx b/src/components/Feed.tsx new file mode 100644 index 0000000..1721273 --- /dev/null +++ b/src/components/Feed.tsx @@ -0,0 +1,142 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { fetchFeedPage, FeedLoadError } from "@/lib/feed-client"; +import { mergePosts, toCardPosts, type CardPost } from "@/lib/feed-model"; +import { FeedView, type FeedStatus } from "./FeedView"; + +const POLL_MS = 45_000; + +export function Feed({ onSignedOut }: { onSignedOut: () => void }) { + const [posts, setPosts] = useState([]); + const [status, setStatus] = useState("loading"); + const [nextCursor, setNextCursor] = useState(null); + const [loadingMore, setLoadingMore] = useState(false); + const [loadMoreFailed, setLoadMoreFailed] = useState(false); + const [pending, setPending] = useState([]); + const sentinel = useRef(null); + const busy = useRef(false); + const shown = useRef>(new Set()); + + useEffect(() => { + shown.current = new Set(posts.map((p) => p.key)); + }, [posts]); + + const fail = useCallback( + (e: unknown) => { + if (e instanceof FeedLoadError && e.signedOut) onSignedOut(); + }, + [onSignedOut], + ); + + const loadFirst = useCallback(async () => { + if (busy.current) return; + busy.current = true; + setStatus("loading"); + setLoadMoreFailed(false); + try { + const page = await fetchFeedPage(null); + setPosts(mergePosts(toCardPosts(page.items))); + setNextCursor(page.nextCursor); + setPending([]); + setStatus("ready"); + } catch (e) { + fail(e); + setStatus("error"); + } finally { + busy.current = false; + } + }, [fail]); + + const loadMore = useCallback(async () => { + if (busy.current || !nextCursor) return; + busy.current = true; + setLoadingMore(true); + setLoadMoreFailed(false); + try { + const page = await fetchFeedPage(nextCursor); + setPosts((cur) => mergePosts(cur, toCardPosts(page.items))); + setNextCursor(page.nextCursor); + } catch (e) { + fail(e); + setLoadMoreFailed(true); + } finally { + busy.current = false; + setLoadingMore(false); + } + }, [nextCursor, fail]); + + useEffect(() => { + void loadFirst(); + }, [loadFirst]); + + // Infinite scroll. A fresh observer per change re-checks a sentinel that is + // still in view after a page lands. + useEffect(() => { + const el = sentinel.current; + if (!el || !nextCursor || status !== "ready" || loadMoreFailed) return; + const io = new IntersectionObserver( + (entries) => { + if (entries.some((e) => e.isIntersecting)) void loadMore(); + }, + { rootMargin: "400px" }, + ); + io.observe(el); + return () => io.disconnect(); + }, [nextCursor, status, loadMoreFailed, loadMore, posts.length]); + + // Real-time: poll the first page while the tab is visible. + useEffect(() => { + if (status !== "ready") return; + let timer: ReturnType | null = null; + const poll = async () => { + try { + const page = await fetchFeedPage(null); + const fresh = toCardPosts(page.items).filter((p) => !shown.current.has(p.key)); + if (fresh.length) setPending((cur) => mergePosts(cur, fresh)); + } catch (e) { + fail(e); + } + }; + const start = () => { + if (timer === null) timer = setInterval(() => void poll(), POLL_MS); + }; + const stop = () => { + if (timer !== null) clearInterval(timer); + timer = null; + }; + const onVisibility = () => { + if (document.visibilityState === "visible") { + void poll(); + start(); + } else { + stop(); + } + }; + if (document.visibilityState === "visible") start(); + document.addEventListener("visibilitychange", onVisibility); + return () => { + stop(); + document.removeEventListener("visibilitychange", onVisibility); + }; + }, [status, fail]); + + function showNew() { + setPosts((cur) => mergePosts(cur, pending)); + setPending([]); + window.scrollTo({ top: 0, behavior: "smooth" }); + } + + return ( + (status === "error" ? void loadFirst() : void loadMore())} + onShowNew={showNew} + sentinelRef={sentinel} + /> + ); +} diff --git a/src/components/FeedView.tsx b/src/components/FeedView.tsx new file mode 100644 index 0000000..3b57da4 --- /dev/null +++ b/src/components/FeedView.tsx @@ -0,0 +1,69 @@ +import type { Ref } from "react"; +import type { CardPost } from "@/lib/feed-model"; +import { PostCard } from "./PostCard"; + +export type FeedStatus = "loading" | "ready" | "error"; + +export interface FeedViewProps { + posts: CardPost[]; + status: FeedStatus; + loadingMore: boolean; + loadMoreFailed: boolean; + newCount: number; + onRetry: () => void; + onShowNew: () => void; + sentinelRef?: Ref; + now?: number; +} + +function Spinner() { + return ; +} + +export function FeedView(p: FeedViewProps) { + if (p.status === "loading") { + return ( +
+
+ +
+
+ ); + } + if (p.status === "error") { + return ( +
+
+

The feed did not load.

+ +
+
+ ); + } + return ( +
+ {p.newCount > 0 ? ( + + ) : null} + {p.posts.length === 0 ? ( +

No posts yet.

+ ) : ( + p.posts.map((post) => ) + )} + {p.loadMoreFailed ? ( +
+

The feed did not load.

+ +
+ ) : null} + {p.loadingMore ? ( +
+ +
+ ) : null} +
+ ); +} diff --git a/src/components/PostCard.tsx b/src/components/PostCard.tsx new file mode 100644 index 0000000..f36d04a --- /dev/null +++ b/src/components/PostCard.tsx @@ -0,0 +1,42 @@ +import { VideoPlayer } from "@/components/VideoPlayer"; +import { initials, relativeTime, type CardPost } from "@/lib/feed-model"; + +export function PostCard({ post, now }: { post: CardPost; now?: number }) { + return ( +
+
+ {post.authorPictureUrl ? ( + // eslint-disable-next-line @next/next/no-img-element + + ) : ( + + )} +
+ {post.authorName} + +
+
+ {post.text ?

{post.text}

: null} + {post.media.map((m, i) => + m.kind === "video" && m.hlsUrl ? ( +
+ +
+ ) : m.src ? ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {m.kind +
+ ) : null, + )} +
+ + {post.likesCount} + + {post.commentsCount} comments +
+
+ ); +} diff --git a/src/components/VideoPlayer.tsx b/src/components/VideoPlayer.tsx new file mode 100644 index 0000000..be96a78 --- /dev/null +++ b/src/components/VideoPlayer.tsx @@ -0,0 +1,69 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { choosePlayback, supportsNativeHls } from "@/lib/video-playback"; + +export interface VideoPlayerProps { + hlsUrl: string; + poster: string | null; + label: string; +} + +/** + * Real