233 lines
8 KiB
TypeScript
233 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
|