2026-08-06 07:01:22 +02:00
|
|
|
"use client"
|
|
|
|
|
|
|
|
|
|
import { useCallback, useEffect, useState } from "react"
|
|
|
|
|
import Link from "next/link"
|
|
|
|
|
|
|
|
|
|
import { cn } from "@/lib/utils"
|
2026-08-07 22:02:56 +02:00
|
|
|
import { PostEngagement, type ReactionSummary } from "./post-engagement"
|
@-tagging (frontend): egen-implementert etter V0-credits tomme
Fullfører ADR-063 -- backend var allerede committet (206beef). Bruker
ba eksplisitt om at Claude selv implementerer frontend-promptet siden
V0-kreditter var oppbrukt.
lib/mentions.ts: ren offset-/diff-logikk (deteksjon, innsetting,
diff-justering ved redigering, tekst-segmentering), 15 enhetstester.
components/mention-input.tsx: MentionTextarea (autocomplete-dropdown,
tastaturnavigasjon) + TaggedText (lenker til /my-friends/{id}). Koblet
inn i round-messages.tsx (innlegg) og post-engagement.tsx (kommentarer,
delt av flere meldingssystemer -- roundId/tags gjort valgfrie for å
ikke bryte de org-scopede callerne uten tagging).
Rettet også et hull oppdaget underveis: backendens GET /feed manglet
tags (kun list_round_messages/comments hadde det) -- det er nettopp
/my-feed-siden som var det opprinnelige brukseksempelet.
Scratch-verifisert (egen DB/rolle/MinIO/API-/frontend-container, to
ekte testbrukere, venn-relasjon, felles runde): autocomplete, tagging
i både innlegg og kommentar, rendring som lenke i rundevisning OG i
aggregert feed, varsel opprettet, personvern-scoping bekreftet (kun
faktiske relasjoner tilbys, ikke seg selv, ikke fremmede), lys+mørk.
tsc --noEmit rent, 45/45 vitest grønt. teecup_db urørt.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-11 10:15:02 +02:00
|
|
|
import { TaggedText } from "./mention-input"
|
|
|
|
|
import type { Tag } from "@/lib/mentions"
|
2026-08-08 12:34:03 +02:00
|
|
|
import { BottomNav } from "@/components/teecup/bottom-nav"
|
2026-08-06 07:01:22 +02:00
|
|
|
|
|
|
|
|
/** 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
|
2026-08-07 22:02:56 +02:00
|
|
|
reactions: ReactionSummary[]
|
|
|
|
|
comment_count: number
|
@-tagging (frontend): egen-implementert etter V0-credits tomme
Fullfører ADR-063 -- backend var allerede committet (206beef). Bruker
ba eksplisitt om at Claude selv implementerer frontend-promptet siden
V0-kreditter var oppbrukt.
lib/mentions.ts: ren offset-/diff-logikk (deteksjon, innsetting,
diff-justering ved redigering, tekst-segmentering), 15 enhetstester.
components/mention-input.tsx: MentionTextarea (autocomplete-dropdown,
tastaturnavigasjon) + TaggedText (lenker til /my-friends/{id}). Koblet
inn i round-messages.tsx (innlegg) og post-engagement.tsx (kommentarer,
delt av flere meldingssystemer -- roundId/tags gjort valgfrie for å
ikke bryte de org-scopede callerne uten tagging).
Rettet også et hull oppdaget underveis: backendens GET /feed manglet
tags (kun list_round_messages/comments hadde det) -- det er nettopp
/my-feed-siden som var det opprinnelige brukseksempelet.
Scratch-verifisert (egen DB/rolle/MinIO/API-/frontend-container, to
ekte testbrukere, venn-relasjon, felles runde): autocomplete, tagging
i både innlegg og kommentar, rendring som lenke i rundevisning OG i
aggregert feed, varsel opprettet, personvern-scoping bekreftet (kun
faktiske relasjoner tilbys, ikke seg selv, ikke fremmede), lys+mørk.
tsc --noEmit rent, 45/45 vitest grønt. teecup_db urørt.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-11 10:15:02 +02:00
|
|
|
tags: Tag[]
|
2026-08-06 07:01:22 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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>
|
|
|
|
|
|
2026-08-07 22:02:56 +02:00
|
|
|
<main className="mx-auto w-full max-w-xl px-4 pb-24">
|
2026-08-06 07:01:22 +02:00
|
|
|
{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>
|
2026-08-07 22:02:56 +02:00
|
|
|
|
2026-08-08 12:34:03 +02:00
|
|
|
<BottomNav />
|
2026-08-06 07:01:22 +02:00
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function FeedCard({ entry, currentUserId }: { entry: ApiFeedEntry; currentUserId: string | null }) {
|
|
|
|
|
const isOthersRound = entry.round_owner_user_id !== currentUserId
|
|
|
|
|
|
|
|
|
|
return (
|
2026-08-07 22:02:56 +02:00
|
|
|
<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)}
|
2026-08-06 07:01:22 +02:00
|
|
|
</p>
|
2026-08-07 22:02:56 +02:00
|
|
|
|
|
|
|
|
{/* 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")}>
|
@-tagging (frontend): egen-implementert etter V0-credits tomme
Fullfører ADR-063 -- backend var allerede committet (206beef). Bruker
ba eksplisitt om at Claude selv implementerer frontend-promptet siden
V0-kreditter var oppbrukt.
lib/mentions.ts: ren offset-/diff-logikk (deteksjon, innsetting,
diff-justering ved redigering, tekst-segmentering), 15 enhetstester.
components/mention-input.tsx: MentionTextarea (autocomplete-dropdown,
tastaturnavigasjon) + TaggedText (lenker til /my-friends/{id}). Koblet
inn i round-messages.tsx (innlegg) og post-engagement.tsx (kommentarer,
delt av flere meldingssystemer -- roundId/tags gjort valgfrie for å
ikke bryte de org-scopede callerne uten tagging).
Rettet også et hull oppdaget underveis: backendens GET /feed manglet
tags (kun list_round_messages/comments hadde det) -- det er nettopp
/my-feed-siden som var det opprinnelige brukseksempelet.
Scratch-verifisert (egen DB/rolle/MinIO/API-/frontend-container, to
ekte testbrukere, venn-relasjon, felles runde): autocomplete, tagging
i både innlegg og kommentar, rendring som lenke i rundevisning OG i
aggregert feed, varsel opprettet, personvern-scoping bekreftet (kun
faktiske relasjoner tilbys, ikke seg selv, ikke fremmede), lys+mørk.
tsc --noEmit rent, 45/45 vitest grønt. teecup_db urørt.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-11 10:15:02 +02:00
|
|
|
<TaggedText body={entry.body} tags={entry.tags} />
|
2026-08-07 22:02:56 +02:00
|
|
|
</p>
|
|
|
|
|
)}
|
|
|
|
|
</Link>
|
|
|
|
|
|
|
|
|
|
<PostEngagement
|
|
|
|
|
apiBase={`/rounds/${entry.round_id}/messages/${entry.id}`}
|
@-tagging (frontend): egen-implementert etter V0-credits tomme
Fullfører ADR-063 -- backend var allerede committet (206beef). Bruker
ba eksplisitt om at Claude selv implementerer frontend-promptet siden
V0-kreditter var oppbrukt.
lib/mentions.ts: ren offset-/diff-logikk (deteksjon, innsetting,
diff-justering ved redigering, tekst-segmentering), 15 enhetstester.
components/mention-input.tsx: MentionTextarea (autocomplete-dropdown,
tastaturnavigasjon) + TaggedText (lenker til /my-friends/{id}). Koblet
inn i round-messages.tsx (innlegg) og post-engagement.tsx (kommentarer,
delt av flere meldingssystemer -- roundId/tags gjort valgfrie for å
ikke bryte de org-scopede callerne uten tagging).
Rettet også et hull oppdaget underveis: backendens GET /feed manglet
tags (kun list_round_messages/comments hadde det) -- det er nettopp
/my-feed-siden som var det opprinnelige brukseksempelet.
Scratch-verifisert (egen DB/rolle/MinIO/API-/frontend-container, to
ekte testbrukere, venn-relasjon, felles runde): autocomplete, tagging
i både innlegg og kommentar, rendring som lenke i rundevisning OG i
aggregert feed, varsel opprettet, personvern-scoping bekreftet (kun
faktiske relasjoner tilbys, ikke seg selv, ikke fremmede), lys+mørk.
tsc --noEmit rent, 45/45 vitest grønt. teecup_db urørt.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-11 10:15:02 +02:00
|
|
|
roundId={entry.round_id}
|
2026-08-07 22:02:56 +02:00
|
|
|
currentUserId={currentUserId}
|
|
|
|
|
initialReactions={entry.reactions}
|
|
|
|
|
initialCommentCount={entry.comment_count}
|
|
|
|
|
canModerate={entry.round_owner_user_id === currentUserId}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
2026-08-06 07:01:22 +02:00
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export default Feed
|