teecup/frontend/components/feed.tsx
Erol Haagenrud f55a51e9e2 e reelle bugger (500 på /feeds paginering, en rute-/rewrite-kollisjon på /feed, og manglende WS-refetch-kobling for kommentarer) — se ADR-044/CHANGELOG for detaljer. Før jeg ruller dette ut mot ekte teecup_db, her er planen:
Kommandoer jeg vil kjøre mot ekte teecup_db/teecup_api/teecup_frontend:

psql migrasjon 058_round_messages.sql mot ekte teecup_db (ny round_message-tabell, ingen endring i eksisterende tabeller).
docker compose up -d --build teecup_api teecup_frontend (begge containere, siden dette er backend+frontend sammen).
Etterpå: bekreft round_message-tabellen finnes, /health//my-rounds/new//my-feed → 200 over https, teeoff.no upåvirket.
2026-08-06 07:01:22 +02:00

232 lines
8 KiB
TypeScript

"use client"
import { useCallback, useEffect, useState } from "react"
import Link from "next/link"
import { cn } from "@/lib/utils"
/** 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
}
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-10">
{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>
</div>
)
}
function FeedCard({ entry, currentUserId }: { entry: ApiFeedEntry; currentUserId: string | null }) {
const isOthersRound = entry.round_owner_user_id !== currentUserId
return (
<Link
href={`/my-rounds/${entry.round_id}`}
className="flex flex-col gap-3 py-5 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>
)
}
export default Feed