teecup/frontend/components/feed.tsx
2026-08-07 22:02:56 +02:00

251 lines
8.9 KiB
TypeScript

"use client"
import { useCallback, useEffect, useState } from "react"
import Link from "next/link"
import { cn } from "@/lib/utils"
import { PostEngagement, type ReactionSummary } from "./post-engagement"
import { BottomTabBar } from "@/components/dashboard"
/** Rå feed-oppføring fra backend. */
export type ApiFeedEntry = {
id: string
round_id: string
round_owner_user_id: string
round_owner_display_name: string
course_name: string
played_at: string // dato, "YYYY-MM-DD"
author_user_id: string
author_display_name: string
body: string | null
image_url: string | null
created_at: string // ISO 8601
reactions: ReactionSummary[]
comment_count: number
}
const PAGE_SIZE = 20
const MAX_IMAGE_HEIGHT = 300
/**
* Formaterer "YYYY-MM-DD" til kort norsk dato, f.eks. "3. aug".
* Parses som lokal dato (ikke UTC) for å unngå at datoen hopper en dag.
*/
function formatPlayedAt(ymd: string): string {
const [y, m, d] = ymd.split("-").map(Number)
if (!y || !m || !d) return ymd
const date = new Date(y, m - 1, d)
return date.toLocaleDateString("nb-NO", { day: "numeric", month: "short" })
}
export function Feed() {
const [currentUserId, setCurrentUserId] = useState<string | null>(null)
const [entries, setEntries] = useState<ApiFeedEntry[]>([])
const [status, setStatus] = useState<"loading" | "ready" | "error">("loading")
const [loadingMore, setLoadingMore] = useState(false)
const [moreError, setMoreError] = useState<string | null>(null)
// Om det finnes flere sider å hente (forrige respons var full side).
const [hasMore, setHasMore] = useState(false)
// Innlogget bruker (samme mønster som round-detail.tsx) -- kun brukt for
// å avgjøre "{navn} sin runde"-linjen per kort, ikke for selve
// autorisasjonen (den håndheves av /feed-endepunktet via sesjonscookien).
useEffect(() => {
let cancelled = false
fetch("/auth/me", { credentials: "include" })
.then((res) => (res.ok ? res.json() : null))
.then((data: { id: string } | null) => {
if (!cancelled && data) setCurrentUserId(data.id)
})
.catch(() => {})
return () => {
cancelled = true
}
}, [])
// Første innlasting ved mount.
useEffect(() => {
const controller = new AbortController()
async function loadInitial() {
setStatus("loading")
try {
const res = await fetch(`/feed?limit=${PAGE_SIZE}`, {
credentials: "include",
signal: controller.signal,
})
if (!res.ok) throw new Error(`Uventet svar (${res.status})`)
const data: ApiFeedEntry[] = await res.json()
setEntries(data)
setHasMore(data.length >= PAGE_SIZE)
setStatus("ready")
} catch (err) {
if (controller.signal.aborted) return
console.log("[v0] Feil ved lasting av feed:", err)
setStatus("error")
}
}
loadInitial()
return () => controller.abort()
}, [])
const loadMore = useCallback(async () => {
if (loadingMore || entries.length === 0) return
const oldest = entries[entries.length - 1]?.created_at
if (!oldest) return
setLoadingMore(true)
setMoreError(null)
try {
const res = await fetch(
`/feed?limit=${PAGE_SIZE}&before=${encodeURIComponent(oldest)}`,
{ credentials: "include" },
)
if (!res.ok) throw new Error(`Uventet svar (${res.status})`)
const data: ApiFeedEntry[] = await res.json()
setEntries((prev) => [...prev, ...data])
setHasMore(data.length >= PAGE_SIZE)
} catch (err) {
console.log("[v0] Feil ved lasting av flere innlegg:", err)
setMoreError("Kunne ikke laste flere innlegg. Prøv igjen.")
} finally {
setLoadingMore(false)
}
}, [entries, loadingMore])
return (
<div className="min-h-dvh bg-background text-foreground">
{/* Sticky sideoverskrift */}
<header className="sticky top-0 z-10 border-b border-border bg-background/95 pt-[env(safe-area-inset-top)] backdrop-blur">
<div className="mx-auto w-full max-w-xl px-4 py-4">
<h1 className="text-2xl font-bold">Feed</h1>
</div>
</header>
<main className="mx-auto w-full max-w-xl px-4 pb-24">
{status === "loading" ? (
<div className="flex justify-center py-16" aria-live="polite" aria-busy="true">
<span
aria-hidden="true"
className="size-8 animate-spin rounded-full border-4 border-primary/20 border-t-primary"
/>
<span className="sr-only">Laster feed...</span>
</div>
) : status === "error" ? (
<p role="alert" className="py-16 text-center text-base font-semibold text-destructive text-pretty">
Kunne ikke laste feeden. Sjekk tilkoblingen og prøv igjen.
</p>
) : entries.length === 0 ? (
<div className="flex flex-col items-center gap-2 py-16 text-center">
<p className="text-base font-semibold text-foreground">Ingen aktivitet ennå</p>
<p className="max-w-xs text-sm leading-relaxed text-muted-foreground text-pretty">
Kommentarer og bilder fra dine runder og venners runder dukker opp her.
</p>
</div>
) : (
<>
<ul className="divide-y divide-border">
{entries.map((entry) => (
<li key={entry.id}>
<FeedCard entry={entry} currentUserId={currentUserId} />
</li>
))}
</ul>
{hasMore && (
<div className="pt-4">
<button
type="button"
onClick={loadMore}
disabled={loadingMore}
className="flex min-h-11 w-full items-center justify-center gap-2 rounded-xl border border-border bg-card px-4 text-base font-bold text-foreground transition-colors hover:border-primary/50 hover:bg-accent/40 active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-60"
>
{loadingMore ? (
<>
<span
aria-hidden="true"
className="size-4 animate-spin rounded-full border-2 border-primary/30 border-t-primary"
/>
Laster...
</>
) : (
"Last inn flere"
)}
</button>
</div>
)}
{moreError && (
<p role="alert" className="pt-3 text-center text-sm font-semibold text-destructive text-pretty">
{moreError}
</p>
)}
</>
)}
</main>
<BottomTabBar active="more" />
</div>
)
}
function FeedCard({ entry, currentUserId }: { entry: ApiFeedEntry; currentUserId: string | null }) {
const isOthersRound = entry.round_owner_user_id !== currentUserId
return (
<div className="flex flex-col gap-3 py-5">
{/* Selve innholdet er lenken til runden -- reaksjons-/kommentarkontrollene
under må IKKE havne inni en <a> (ugyldig nøstet interaktivt innhold,
og ville trigget navigering ved hvert tastetrykk/klikk der). */}
<Link
href={isOthersRound ? `/watch/${entry.round_id}` : `/my-rounds/${entry.round_id}`}
className="flex flex-col gap-3 rounded-xl transition-colors hover:bg-accent/30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
{/* Kontekstlinje */}
<p className="text-sm text-muted-foreground text-pretty">
<span className="font-semibold text-foreground">{entry.author_display_name}</span>
{" på "}
{entry.course_name}
{" · "}
{formatPlayedAt(entry.played_at)}
</p>
{/* Eierkontekst kun for andres runder */}
{isOthersRound && (
<p className="-mt-1.5 text-sm text-muted-foreground">
{entry.round_owner_display_name} sin runde
</p>
)}
{/* Bilde */}
{entry.image_url && (
<img
src={entry.image_url || "/placeholder.svg"}
alt=""
className="w-full rounded-xl object-cover"
style={{ maxHeight: MAX_IMAGE_HEIGHT }}
loading="lazy"
/>
)}
{/* Tekst */}
{entry.body && (
<p className={cn("text-base leading-relaxed text-foreground text-pretty whitespace-pre-wrap")}>
{entry.body}
</p>
)}
</Link>
<PostEngagement
apiBase={`/rounds/${entry.round_id}/messages/${entry.id}`}
currentUserId={currentUserId}
initialReactions={entry.reactions}
initialCommentCount={entry.comment_count}
canModerate={entry.round_owner_user_id === currentUserId}
/>
</div>
)
}
export default Feed