round.visibility_mode (privat som trygg standard) + ny tabell for hvilke venne-kategorier som får se en gitt runde. Kjernesjekken (_can_view_round) viste seg å være en ren utvidelse av den eksisterende eier/medspiller-sjekken, så fire lese-endepunkter ble omstrukturert til delte funksjoner og gjenbrukt av sju nye offentlige endepunkter + et nytt offentlig sanntids-WS — i stedet for å bygge alt parallelt fra bunnen. Ny vennprofil-side (/my-friends/[id]) — navn/avatar/HCP/hjemmeklubb + liste over personens synlige runder, "pågår nå" øverst. Ny read-only live-visning (/watch/[id]) for tredjeparter — matchstatus, skins-tavle eller individuell rangering avhengig av spilleform. Synlighetsvelger lagt til både i opprett-runde og rediger-runde. Verifisert grundig: 191 automatiserte sjekker (inkl. full regresjon av to eksisterende testsuiter) + en fullstendig nettleser-gjennomgang med tre reelle brukere i separate innloggingskontekster — inkludert en helt anonym leser som beviste at "offentlig" faktisk betyr offentlig, og en reell venn/kategori-negativ-kontroll som beviste at feil kategori korrekt nekter tilgang. Migrasjon kjørt mot ekte database (kun additiv), begge containere rullet ut, teeoff.no upåvirket.
567 lines
24 KiB
TypeScript
567 lines
24 KiB
TypeScript
"use client"
|
|
|
|
// Venner-kjernen (ADR-036, fase 1). Presentasjon fra V0 (zip 19), datalag
|
|
// skrevet om fra mock til ekte fetch mot /people/search + /friends.
|
|
// Ruten er bevisst /my-friends, IKKE /friends -- det er nå API-prefikset
|
|
// (samme kollisjonsfelle unngått som /rounds -> /my-rounds tidligere).
|
|
|
|
import type React from "react"
|
|
import { useCallback, useEffect, useState } from "react"
|
|
import { useRouter } from "next/navigation"
|
|
import Link from "next/link"
|
|
import {
|
|
ArrowLeft,
|
|
Check,
|
|
ChevronDown,
|
|
Search,
|
|
Tag,
|
|
Trash2,
|
|
UserPlus,
|
|
Users,
|
|
X,
|
|
} from "lucide-react"
|
|
import { Input } from "@/components/ui/input"
|
|
import { Wordmark } from "@/components/wordmark"
|
|
import { cn } from "@/lib/utils"
|
|
|
|
// --- Kategorier --------------------------------------------------------------
|
|
// Rekkefølge og koder MÅ matche `Category` i app/routers/friends.py nøyaktig.
|
|
|
|
const CATEGORY_OPTIONS = [
|
|
{ code: "spouse", label: "Make" },
|
|
{ code: "close_family", label: "Nær familie" },
|
|
{ code: "extended_family", label: "Storfamilie" },
|
|
{ code: "close_friends", label: "Nære venner" },
|
|
{ code: "golf_friends", label: "Golfvenner" },
|
|
{ code: "colleagues", label: "Kollegaer" },
|
|
{ code: "business", label: "Forretningsforbindelser" },
|
|
{ code: "classmates", label: "Studiekamerater" },
|
|
{ code: "acquaintances", label: "Perifere bekjente" },
|
|
{ code: "other", label: "Ymse" },
|
|
] as const
|
|
|
|
function categoryLabel(code: string): string {
|
|
return CATEGORY_OPTIONS.find((c) => c.code === code)?.label ?? code
|
|
}
|
|
|
|
// --- API-typer ---------------------------------------------------------------
|
|
|
|
type ApiPersonMatch = { id: string; first_name: string; last_name: string; avatar_url: string | null; home_club: string | null }
|
|
type ApiFriendEntry = {
|
|
friendship_id: string
|
|
user_id: string
|
|
first_name: string | null
|
|
last_name: string | null
|
|
avatar_url: string | null
|
|
home_club: string | null
|
|
categories: string[]
|
|
}
|
|
type ApiPendingRequest = {
|
|
friendship_id: string
|
|
user_id: string
|
|
first_name: string | null
|
|
last_name: string | null
|
|
avatar_url: string | null
|
|
created_at: string
|
|
}
|
|
type ApiFriendsOut = { friends: ApiFriendEntry[]; incoming_requests: ApiPendingRequest[]; outgoing_requests: ApiPendingRequest[] }
|
|
|
|
function fullName(first: string | null, last: string | null): string {
|
|
const name = [first, last].filter(Boolean).join(" ").trim()
|
|
return name || "Ukjent"
|
|
}
|
|
|
|
// --- Page ------------------------------------------------------------------
|
|
|
|
export function Friends() {
|
|
const router = useRouter()
|
|
const [data, setData] = useState<ApiFriendsOut | null>(null)
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
const loadFriends = useCallback(async () => {
|
|
try {
|
|
const res = await fetch("/friends", { credentials: "include" })
|
|
if (res.status === 401) {
|
|
router.replace("/")
|
|
return
|
|
}
|
|
if (!res.ok) throw new Error(`friends: ${res.status}`)
|
|
setData(await res.json())
|
|
} catch {
|
|
setError("Klarte ikke å hente vennelisten din. Prøv igjen om litt.")
|
|
}
|
|
}, [router])
|
|
|
|
useEffect(() => {
|
|
void loadFriends()
|
|
}, [loadFriends])
|
|
|
|
const friendUserIds = new Set(data?.friends.map((f) => f.user_id) ?? [])
|
|
const outgoingUserIds = new Set(data?.outgoing_requests.map((r) => r.user_id) ?? [])
|
|
|
|
return (
|
|
<div className="flex min-h-[100dvh] flex-col bg-background">
|
|
<header className="sticky top-0 z-10 border-b border-border bg-background/80 backdrop-blur">
|
|
<div className="mx-auto flex w-full max-w-3xl items-center justify-between gap-4 px-5 py-4">
|
|
<Link
|
|
href="/dashboard"
|
|
className="inline-flex min-h-[44px] items-center gap-2 rounded-xl px-2 py-2 text-base font-semibold text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
|
|
>
|
|
<ArrowLeft aria-hidden="true" className="size-5" />
|
|
Til dashbord
|
|
</Link>
|
|
<Wordmark compact />
|
|
</div>
|
|
</header>
|
|
|
|
<main className="mx-auto w-full max-w-3xl flex-1 px-5 py-8 sm:py-10">
|
|
<div className="flex flex-col gap-2">
|
|
<h1 className="text-3xl font-extrabold tracking-tight text-foreground text-balance">
|
|
Mine venner
|
|
</h1>
|
|
<p className="max-w-prose text-lg leading-relaxed text-muted-foreground text-pretty">
|
|
Finn folk du spiller med, håndter forespørsler, og organiser vennene dine i egne
|
|
kategorier.
|
|
</p>
|
|
</div>
|
|
|
|
{error && (
|
|
<p role="alert" className="mt-4 text-base font-medium text-destructive">
|
|
{error}
|
|
</p>
|
|
)}
|
|
|
|
<div className="mt-8 flex flex-col gap-10">
|
|
<SearchSection friendUserIds={friendUserIds} outgoingUserIds={outgoingUserIds} onRequestSent={loadFriends} />
|
|
<RequestsSection incoming={data?.incoming_requests ?? []} outgoing={data?.outgoing_requests ?? []} onChanged={loadFriends} />
|
|
<FriendsSection friends={data?.friends ?? []} onChanged={loadFriends} />
|
|
</div>
|
|
</main>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// --- Section header --------------------------------------------------------
|
|
|
|
function SectionHeading({ children }: { children: React.ReactNode }) {
|
|
return <h2 className="text-xl font-extrabold tracking-tight text-foreground">{children}</h2>
|
|
}
|
|
|
|
// --- 1. Search ---------------------------------------------------------------
|
|
|
|
function SearchSection({
|
|
friendUserIds,
|
|
outgoingUserIds,
|
|
onRequestSent,
|
|
}: {
|
|
friendUserIds: Set<string>
|
|
outgoingUserIds: Set<string>
|
|
onRequestSent: () => void
|
|
}) {
|
|
const [query, setQuery] = useState("")
|
|
const [results, setResults] = useState<ApiPersonMatch[]>([])
|
|
const [searching, setSearching] = useState(false)
|
|
const [justSent, setJustSent] = useState<Record<string, boolean>>({})
|
|
|
|
useEffect(() => {
|
|
const trimmed = query.trim()
|
|
if (trimmed.length < 2) {
|
|
setResults([])
|
|
return
|
|
}
|
|
let cancelled = false
|
|
setSearching(true)
|
|
const timer = setTimeout(async () => {
|
|
try {
|
|
const res = await fetch(`/people/search?q=${encodeURIComponent(trimmed)}`, { credentials: "include" })
|
|
if (res.ok && !cancelled) setResults(await res.json())
|
|
} catch {
|
|
// Stille -- listen blir bare uendret.
|
|
} finally {
|
|
if (!cancelled) setSearching(false)
|
|
}
|
|
}, 250)
|
|
return () => {
|
|
cancelled = true
|
|
clearTimeout(timer)
|
|
}
|
|
}, [query])
|
|
|
|
async function sendRequest(personId: string) {
|
|
setJustSent((prev) => ({ ...prev, [personId]: true }))
|
|
try {
|
|
const res = await fetch("/friends", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
credentials: "include",
|
|
body: JSON.stringify({ addressee_user_id: personId }),
|
|
})
|
|
if (res.ok) onRequestSent()
|
|
} catch {
|
|
setJustSent((prev) => ({ ...prev, [personId]: false }))
|
|
}
|
|
}
|
|
|
|
return (
|
|
<section aria-label="Søk etter venner" className="flex flex-col gap-4">
|
|
<SectionHeading>Finn venner</SectionHeading>
|
|
|
|
<div className="relative">
|
|
<Search
|
|
aria-hidden="true"
|
|
className="pointer-events-none absolute left-4 top-1/2 size-5 -translate-y-1/2 text-muted-foreground"
|
|
/>
|
|
<Input
|
|
value={query}
|
|
onChange={(e) => setQuery(e.target.value)}
|
|
placeholder="Søk etter navn …"
|
|
aria-label="Søk etter navn"
|
|
className="h-14 rounded-2xl pl-12 text-lg"
|
|
/>
|
|
</div>
|
|
|
|
{query.trim().length < 2 ? (
|
|
<p className="rounded-2xl border border-dashed border-border bg-muted/40 px-5 py-6 text-center text-base text-muted-foreground">
|
|
Skriv minst to tegn for å søke etter spillere.
|
|
</p>
|
|
) : searching ? (
|
|
<p className="text-center text-base text-muted-foreground">Søker …</p>
|
|
) : results.length === 0 ? (
|
|
<p className="rounded-2xl border border-border bg-muted/50 px-5 py-6 text-center text-base text-muted-foreground">
|
|
Ingen spillere matcher «{query.trim()}».
|
|
</p>
|
|
) : (
|
|
<ul className="flex flex-col gap-3">
|
|
{results.map((person) => {
|
|
const isFriend = friendUserIds.has(person.id)
|
|
const isSent = outgoingUserIds.has(person.id) || justSent[person.id]
|
|
return (
|
|
<li
|
|
key={person.id}
|
|
className="flex items-center gap-4 rounded-2xl border border-border bg-card p-4 shadow-sm shadow-black/5"
|
|
>
|
|
<Avatar name={fullName(person.first_name, person.last_name)} />
|
|
<div className="flex min-w-0 flex-1 flex-col">
|
|
<span className="truncate text-lg font-bold text-foreground">
|
|
{fullName(person.first_name, person.last_name)}
|
|
</span>
|
|
<span className="truncate text-base text-muted-foreground">
|
|
{person.home_club ?? "Ingen hjemmeklubb"}
|
|
</span>
|
|
</div>
|
|
{isFriend ? (
|
|
<span className="inline-flex min-h-[44px] shrink-0 items-center gap-1.5 rounded-xl bg-muted px-3 text-sm font-semibold text-muted-foreground">
|
|
<Check aria-hidden="true" className="size-4" />
|
|
Venn
|
|
</span>
|
|
) : isSent ? (
|
|
<span className="inline-flex min-h-[44px] shrink-0 items-center gap-1.5 rounded-xl bg-brand-orange/15 px-3 text-sm font-semibold text-foreground">
|
|
<Check aria-hidden="true" className="size-4" />
|
|
Sendt
|
|
</span>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
onClick={() => sendRequest(person.id)}
|
|
className="inline-flex min-h-[44px] shrink-0 items-center gap-2 rounded-xl bg-primary px-4 text-base font-bold text-primary-foreground shadow-sm transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
|
|
>
|
|
<UserPlus aria-hidden="true" className="size-5" />
|
|
Send forespørsel
|
|
</button>
|
|
)}
|
|
</li>
|
|
)
|
|
})}
|
|
</ul>
|
|
)}
|
|
</section>
|
|
)
|
|
}
|
|
|
|
// --- 2. Requests -------------------------------------------------------------
|
|
|
|
function RequestsSection({
|
|
incoming,
|
|
outgoing,
|
|
onChanged,
|
|
}: {
|
|
incoming: ApiPendingRequest[]
|
|
outgoing: ApiPendingRequest[]
|
|
onChanged: () => void
|
|
}) {
|
|
async function respond(friendshipId: string, action: "accept" | "remove") {
|
|
if (action === "accept") {
|
|
await fetch(`/friends/${friendshipId}/accept`, { method: "POST", credentials: "include" })
|
|
} else {
|
|
await fetch(`/friends/${friendshipId}`, { method: "DELETE", credentials: "include" })
|
|
}
|
|
onChanged()
|
|
}
|
|
|
|
const hasAny = incoming.length > 0 || outgoing.length > 0
|
|
if (!hasAny) return null
|
|
|
|
return (
|
|
<section aria-label="Forespørsler" className="flex flex-col gap-4">
|
|
<SectionHeading>Forespørsler</SectionHeading>
|
|
|
|
{incoming.length > 0 && (
|
|
<div className="flex flex-col gap-3">
|
|
<h3 className="text-sm font-bold uppercase tracking-wide text-muted-foreground">Mottatt</h3>
|
|
<ul className="flex flex-col gap-3">
|
|
{incoming.map((person) => (
|
|
<li
|
|
key={person.friendship_id}
|
|
className="flex flex-wrap items-center gap-4 rounded-2xl border border-border bg-card p-4 shadow-sm shadow-black/5"
|
|
>
|
|
<Avatar name={fullName(person.first_name, person.last_name)} />
|
|
<div className="flex min-w-0 flex-1 basis-40 flex-col">
|
|
<span className="truncate text-lg font-bold text-foreground">{fullName(person.first_name, person.last_name)}</span>
|
|
</div>
|
|
<div className="flex flex-1 basis-full items-center gap-3 sm:basis-auto">
|
|
<button
|
|
type="button"
|
|
onClick={() => respond(person.friendship_id, "accept")}
|
|
className="inline-flex min-h-[44px] flex-1 items-center justify-center gap-2 rounded-xl bg-primary px-4 text-base font-bold text-primary-foreground shadow-sm transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background sm:flex-none"
|
|
>
|
|
<Check aria-hidden="true" className="size-5" />
|
|
Godta
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => respond(person.friendship_id, "remove")}
|
|
className="inline-flex min-h-[44px] flex-1 items-center justify-center gap-2 rounded-xl border border-border bg-card px-4 text-base font-bold text-foreground transition-colors hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background sm:flex-none"
|
|
>
|
|
<X aria-hidden="true" className="size-5" />
|
|
Avslå
|
|
</button>
|
|
</div>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
|
|
{outgoing.length > 0 && (
|
|
<div className="flex flex-col gap-3">
|
|
<h3 className="text-sm font-bold uppercase tracking-wide text-muted-foreground">Sendt</h3>
|
|
<ul className="flex flex-col gap-3">
|
|
{outgoing.map((person) => (
|
|
<li
|
|
key={person.friendship_id}
|
|
className="flex flex-wrap items-center gap-4 rounded-2xl border border-border bg-card p-4 shadow-sm shadow-black/5"
|
|
>
|
|
<Avatar name={fullName(person.first_name, person.last_name)} />
|
|
<div className="flex min-w-0 flex-1 basis-40 flex-col">
|
|
<span className="truncate text-lg font-bold text-foreground">{fullName(person.first_name, person.last_name)}</span>
|
|
<span className="truncate text-base text-muted-foreground">Venter på svar</span>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => respond(person.friendship_id, "remove")}
|
|
className="inline-flex min-h-[44px] shrink-0 items-center justify-center gap-2 rounded-xl border border-border bg-card px-4 text-base font-bold text-foreground transition-colors hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
|
|
>
|
|
<X aria-hidden="true" className="size-5" />
|
|
Kanseller
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
</section>
|
|
)
|
|
}
|
|
|
|
// --- 3. Friends ----------------------------------------------------------------
|
|
|
|
function FriendsSection({ friends, onChanged }: { friends: ApiFriendEntry[]; onChanged: () => void }) {
|
|
return (
|
|
<section aria-label="Venner" className="flex flex-col gap-4">
|
|
<SectionHeading>Venner</SectionHeading>
|
|
|
|
{friends.length === 0 ? (
|
|
<div className="flex flex-col items-center gap-4 rounded-2xl border border-dashed border-border bg-muted/40 px-6 py-12 text-center">
|
|
<span className="flex size-14 items-center justify-center rounded-2xl bg-primary/15">
|
|
<Users aria-hidden="true" className="size-7 text-primary" />
|
|
</span>
|
|
<div className="flex flex-col gap-1.5">
|
|
<p className="text-lg font-bold text-foreground">Ingen venner ennå</p>
|
|
<p className="mx-auto max-w-sm text-base leading-relaxed text-muted-foreground text-pretty">
|
|
Bruk søkefeltet øverst til å finne folk du spiller med, og send dem en forespørsel.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<ul className="flex flex-col gap-3">
|
|
{friends.map((friend) => (
|
|
<li key={friend.friendship_id}>
|
|
<FriendRow friend={friend} onChanged={onChanged} />
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</section>
|
|
)
|
|
}
|
|
|
|
function FriendRow({ friend, onChanged }: { friend: ApiFriendEntry; onChanged: () => void }) {
|
|
const [expanded, setExpanded] = useState(false)
|
|
const [confirming, setConfirming] = useState(false)
|
|
// Lokal, optimistisk kopi -- lagres til backend ved hver endring (PUT
|
|
// erstatter HELE settet, matcher backend-kontrakten), men holdes lokalt
|
|
// for umiddelbar UI-respons uten en full refetch per klikk.
|
|
const [categories, setCategories] = useState<string[]>(friend.categories)
|
|
|
|
async function toggleCategory(code: string) {
|
|
const next = categories.includes(code) ? categories.filter((c) => c !== code) : [...categories, code]
|
|
setCategories(next)
|
|
await fetch(`/friends/${friend.user_id}/categories`, {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
credentials: "include",
|
|
body: JSON.stringify({ categories: next }),
|
|
})
|
|
}
|
|
|
|
async function removeFriend() {
|
|
await fetch(`/friends/${friend.friendship_id}`, { method: "DELETE", credentials: "include" })
|
|
onChanged()
|
|
}
|
|
|
|
const name = fullName(friend.first_name, friend.last_name)
|
|
|
|
return (
|
|
<div className="flex flex-col gap-4 rounded-2xl border border-border bg-card p-4 shadow-sm shadow-black/5">
|
|
<Link
|
|
href={`/my-friends/${friend.user_id}`}
|
|
className="flex items-center gap-4 rounded-xl transition-colors hover:bg-accent/30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
>
|
|
<Avatar name={name} />
|
|
<div className="flex min-w-0 flex-1 flex-col">
|
|
<span className="truncate text-lg font-bold text-foreground">{name}</span>
|
|
<span className="truncate text-base text-muted-foreground">{friend.home_club ?? "Ingen hjemmeklubb"}</span>
|
|
</div>
|
|
</Link>
|
|
|
|
{categories.length > 0 && (
|
|
<ul className="flex flex-wrap gap-2" aria-label={`Kategorier for ${name}`}>
|
|
{categories.map((code) => (
|
|
<li key={code} className="inline-flex items-center gap-1.5 rounded-full bg-primary/15 px-3 py-1 text-sm font-semibold text-foreground">
|
|
<Tag aria-hidden="true" className="size-3.5 text-primary" />
|
|
{categoryLabel(code)}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
|
|
<div className="overflow-hidden rounded-xl border border-border">
|
|
<button
|
|
type="button"
|
|
onClick={() => setExpanded((v) => !v)}
|
|
aria-expanded={expanded}
|
|
className="flex min-h-[44px] w-full items-center justify-between gap-2 px-4 py-3 text-left text-base font-bold text-foreground transition-colors hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
|
|
>
|
|
<span className="flex items-center gap-2">
|
|
<Tag aria-hidden="true" className="size-5 text-primary" />
|
|
Kategorier
|
|
{categories.length > 0 && (
|
|
<span className="rounded-full bg-primary px-2 py-0.5 text-xs font-bold text-primary-foreground tabular-nums">
|
|
{categories.length}
|
|
</span>
|
|
)}
|
|
</span>
|
|
<ChevronDown aria-hidden="true" className={cn("size-5 shrink-0 text-muted-foreground transition-transform", expanded && "rotate-180")} />
|
|
</button>
|
|
|
|
{expanded && (
|
|
<div className="flex flex-col gap-3 border-t border-border p-4">
|
|
<ul className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
|
{CATEGORY_OPTIONS.map(({ code, label }) => {
|
|
const checked = categories.includes(code)
|
|
return (
|
|
<li key={code}>
|
|
<button
|
|
type="button"
|
|
role="checkbox"
|
|
aria-checked={checked}
|
|
onClick={() => toggleCategory(code)}
|
|
className={cn(
|
|
"flex min-h-[44px] w-full items-center gap-3 rounded-xl border px-3 py-2 text-left text-base font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
|
|
checked ? "border-primary bg-primary/10 text-foreground" : "border-border bg-card text-foreground hover:bg-accent/50",
|
|
)}
|
|
>
|
|
<span
|
|
aria-hidden="true"
|
|
className={cn(
|
|
"flex size-6 shrink-0 items-center justify-center rounded-md border-2 transition-colors",
|
|
checked ? "border-primary bg-primary text-primary-foreground" : "border-border",
|
|
)}
|
|
>
|
|
{checked && <Check className="size-4" strokeWidth={3} />}
|
|
</span>
|
|
{label}
|
|
</button>
|
|
</li>
|
|
)
|
|
})}
|
|
</ul>
|
|
<p className="text-sm leading-relaxed text-muted-foreground">Kun du ser hvilke kategorier du har satt en venn i.</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{confirming ? (
|
|
<div className="flex flex-col gap-3 rounded-xl border border-destructive/40 bg-destructive/5 p-4">
|
|
<p className="text-base font-semibold text-foreground">Fjerne {name} som venn?</p>
|
|
<div className="flex items-center gap-3">
|
|
<button
|
|
type="button"
|
|
onClick={removeFriend}
|
|
className="inline-flex min-h-[44px] flex-1 items-center justify-center gap-2 rounded-xl bg-destructive px-4 text-base font-bold text-destructive-foreground shadow-sm transition-colors hover:bg-destructive/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
|
|
>
|
|
<Trash2 aria-hidden="true" className="size-5" />
|
|
Ja, fjern venn
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setConfirming(false)}
|
|
className="inline-flex min-h-[44px] flex-1 items-center justify-center rounded-xl border border-border bg-card px-4 text-base font-bold text-foreground transition-colors hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
|
|
>
|
|
Avbryt
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
onClick={() => setConfirming(true)}
|
|
className="inline-flex min-h-[44px] items-center justify-center gap-2 self-start rounded-xl px-3 text-base font-semibold text-destructive transition-colors hover:bg-destructive/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
|
|
>
|
|
<Trash2 aria-hidden="true" className="size-5" />
|
|
Fjern venn
|
|
</button>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// --- Avatar ------------------------------------------------------------------
|
|
|
|
function initials(name: string) {
|
|
const parts = name.trim().split(/\s+/)
|
|
const first = parts[0]?.[0] ?? ""
|
|
const last = parts.length > 1 ? parts[parts.length - 1][0] : ""
|
|
return (first + last).toUpperCase()
|
|
}
|
|
|
|
function Avatar({ name }: { name: string }) {
|
|
return (
|
|
<span
|
|
aria-hidden="true"
|
|
className="flex size-12 shrink-0 items-center justify-center rounded-full bg-primary/15 text-base font-bold text-foreground"
|
|
>
|
|
{initials(name)}
|
|
</span>
|
|
)
|
|
}
|