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

352 lines
14 KiB
TypeScript

"use client"
import type React from "react"
import { useEffect, useRef, useState } from "react"
import Link from "next/link"
import { ArrowLeft, Camera, ImagePlus, Lock, Send, Trash2, X } from "lucide-react"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
import { PostEngagement, type ReactionSummary } from "./post-engagement"
// --- Types (matcher API-kontrakten i app/routers/messaging.py) -------------
type ApiMessage = {
id: string
author_user_id: string
author_display_name: string
body: string | null
image_url: string | null
created_at: string
reactions: ReactionSummary[]
comment_count: number
}
const TIME_FMT = new Intl.DateTimeFormat("no-NO", { hour: "2-digit", minute: "2-digit" })
function wsUrl(path: string): string {
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"
return `${protocol}//${window.location.host}${path}`
}
export function TeamChat({
organizationId,
tournamentId,
teamId,
teamName,
tournamentName,
}: {
organizationId: string
tournamentId: string
teamId: string
teamName: string
tournamentName: string
}) {
const [currentUserId, setCurrentUserId] = useState<string | null>(null)
const [messages, setMessages] = useState<ApiMessage[]>([])
const [loading, setLoading] = useState(true)
const [accessError, setAccessError] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [text, setText] = useState("")
const [image, setImage] = useState<File | null>(null)
const [sending, setSending] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const cameraInputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
let cancelled = false
async function load() {
try {
const [meRes, messagesRes] = await Promise.all([
fetch("/auth/me", { credentials: "include" }),
fetch(`/orgs/${organizationId}/teams/${teamId}/messages`, { credentials: "include" }),
])
if (meRes.ok) {
const me: { id: string } = await meRes.json()
if (!cancelled) setCurrentUserId(me.id)
}
if (messagesRes.status === 403) {
if (!cancelled) {
setAccessError(
"Du er ikke rostret på dette laget -- lagchatten er privat, kun for spillerne på laget (ikke engang organisasjonens eiere/administratorer har tilgang).",
)
}
return
}
if (!messagesRes.ok) throw new Error(`messages: ${messagesRes.status}`)
const data: ApiMessage[] = await messagesRes.json()
if (!cancelled) setMessages(data)
} catch {
if (!cancelled) setAccessError("Klarte ikke å laste chatten. Prøv å laste siden på nytt.")
} finally {
if (!cancelled) setLoading(false)
}
}
void load()
return () => {
cancelled = true
}
}, [organizationId, teamId])
useEffect(() => {
if (accessError) return
const socket = new WebSocket(wsUrl(`/ws/orgs/${organizationId}/teams/${teamId}/messages`))
socket.onmessage = (event) => {
try {
const msg: ApiMessage = JSON.parse(event.data)
// Nyeste øverst (2026-08-06) -- legg til FØRST, ikke sist.
setMessages((prev) => (prev.some((m) => m.id === msg.id) ? prev : [msg, ...prev]))
} catch {
// ignorer ugyldig payload
}
}
return () => socket.close()
}, [organizationId, teamId, accessError])
// Samme grense som storage.MAX_UPLOAD_BYTES (server-siden) -- sjekket
// klient-side for en umiddelbar, presis feilmelding.
function handleImageSelected(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
if (!file) return
if (file.size > 20 * 1024 * 1024) {
setError("Bildet er for stort (maks 20 MB). Velg et mindre bilde.")
e.target.value = ""
return
}
setError(null)
setImage(file)
}
async function send(e: React.FormEvent) {
e.preventDefault()
if (!text.trim() && !image) return
setSending(true)
setError(null)
try {
const form = new FormData()
if (text.trim()) form.set("body", text.trim())
if (image) form.set("image", image)
const res = await fetch(`/orgs/${organizationId}/teams/${teamId}/messages`, {
method: "POST",
credentials: "include",
body: form,
})
if (!res.ok) {
let message = "Klarte ikke å sende meldingen. Prøv igjen."
try {
const data = await res.json()
if (typeof data?.detail?.message === "string") message = data.detail.message
} catch {
// Ikke JSON -- behold standardmeldingen.
}
setError(message)
return
}
const msg: ApiMessage = await res.json()
// Nyeste øverst (2026-08-06) -- legg til FØRST, ikke sist.
setMessages((prev) => (prev.some((m) => m.id === msg.id) ? prev : [msg, ...prev]))
setText("")
setImage(null)
if (fileInputRef.current) fileInputRef.current.value = ""
if (cameraInputRef.current) cameraInputRef.current.value = ""
} catch {
setError("Klarte ikke å sende meldingen. Sjekk tilkoblingen og prøv igjen.")
} finally {
setSending(false)
}
}
async function remove(messageId: string) {
setError(null)
try {
const res = await fetch(`/orgs/${organizationId}/teams/${teamId}/messages/${messageId}`, {
method: "DELETE",
credentials: "include",
})
if (!res.ok && res.status !== 204) throw new Error(`delete: ${res.status}`)
setMessages((prev) => prev.filter((m) => m.id !== messageId))
} catch {
setError("Klarte ikke å slette meldingen. Prøv igjen.")
}
}
const backHref = `/tournaments/${tournamentId}?org=${organizationId}&name=${encodeURIComponent(tournamentName)}`
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-2xl items-center gap-3 px-5 py-4">
<Link
href={backHref}
aria-label="Tilbake til lag og spillere"
className="flex size-10 shrink-0 items-center justify-center rounded-xl border border-border bg-card text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
>
<ArrowLeft aria-hidden="true" className="size-5" />
</Link>
<div className="flex min-w-0 flex-1 flex-col">
<span className="flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
<Lock aria-hidden="true" className="size-3" />
Privat lagchat
</span>
<h1 className="truncate text-xl font-extrabold tracking-tight text-foreground">{teamName}</h1>
</div>
</div>
</header>
{loading ? (
<div className="flex flex-1 items-center justify-center">
<div
aria-hidden="true"
className="size-10 animate-spin rounded-full border-4 border-primary/20 border-t-primary"
/>
</div>
) : accessError ? (
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-5 text-center">
<div className="flex size-14 items-center justify-center rounded-2xl bg-muted">
<Lock aria-hidden="true" className="size-7 text-muted-foreground" />
</div>
<p className="max-w-sm text-sm leading-relaxed text-muted-foreground text-pretty">{accessError}</p>
</div>
) : (
<>
<main className="mx-auto flex w-full max-w-2xl flex-1 flex-col gap-3 px-5 py-6">
{messages.length === 0 && (
<p className="mt-8 text-center text-sm text-muted-foreground">
Ingen meldinger ennå -- her er kun laget deres, ingen andre kan lese med.
</p>
)}
{messages.map((m) => {
const own = m.author_user_id === currentUserId
return (
<div key={m.id} className={cn("flex flex-col gap-1", own ? "items-end" : "items-start")}>
<div
className={cn(
"group relative max-w-[80%] rounded-2xl px-4 py-2.5 shadow-md shadow-black/8",
own ? "bg-primary text-primary-foreground" : "border border-border bg-card text-foreground",
)}
>
{!own && (
<span className="mb-0.5 block text-xs font-bold opacity-70">{m.author_display_name}</span>
)}
{m.image_url && (
// eslint-disable-next-line @next/next/no-img-element -- images.unoptimized er alt satt
<img
src={m.image_url}
alt=""
className="mb-1.5 max-h-64 w-full rounded-xl object-cover"
/>
)}
{m.body && <p className="text-[15px] leading-relaxed text-pretty">{m.body}</p>}
{own && (
<button
type="button"
onClick={() => remove(m.id)}
aria-label="Slett melding"
className="absolute -left-9 top-1/2 flex size-7 -translate-y-1/2 items-center justify-center rounded-lg text-muted-foreground opacity-0 transition-opacity hover:text-destructive group-hover:opacity-100"
>
<Trash2 aria-hidden="true" className="size-4" />
</button>
)}
</div>
<span className="px-1 text-[11px] text-muted-foreground">
{TIME_FMT.format(new Date(m.created_at))}
</span>
<div className="w-full px-1">
<PostEngagement
apiBase={`/orgs/${organizationId}/teams/${teamId}/messages/${m.id}`}
currentUserId={currentUserId}
initialReactions={m.reactions}
initialCommentCount={m.comment_count}
canModerate={false}
/>
</div>
</div>
)
})}
</main>
<div className="sticky bottom-0 border-t border-border bg-background/95 backdrop-blur">
<form onSubmit={send} className="mx-auto flex w-full max-w-2xl flex-col gap-2 px-5 py-3">
{error && (
<p role="alert" className="text-xs font-medium text-destructive">
{error}
</p>
)}
{image && (
<div className="flex items-center gap-2 self-start rounded-xl bg-muted px-3 py-1.5 text-xs font-medium text-foreground">
{image.name}
<button
type="button"
onClick={() => {
setImage(null)
if (fileInputRef.current) fileInputRef.current.value = ""
if (cameraInputRef.current) cameraInputRef.current.value = ""
}}
aria-label="Fjern bilde"
>
<X aria-hidden="true" className="size-3.5" />
</button>
</div>
)}
<div className="flex items-center gap-2">
{/* To atskilte inputs -- Android sin systembilde-vlger
(Photo Picker) viser ALDRI et kamera-alternativ når
accept="image/*" mangler capture, uansett nettleser
(funnet 2026-08-06, ekte enhet). */}
<input
ref={cameraInputRef}
type="file"
accept="image/*"
capture="environment"
className="hidden"
onChange={handleImageSelected}
/>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => cameraInputRef.current?.click()}
aria-label="Ta bilde"
className="size-11 shrink-0 rounded-2xl text-muted-foreground"
>
<Camera aria-hidden="true" className="size-5" />
</Button>
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleImageSelected}
/>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => fileInputRef.current?.click()}
aria-label="Velg bilde fra galleri"
className="size-11 shrink-0 rounded-2xl text-muted-foreground"
>
<ImagePlus aria-hidden="true" className="size-5" />
</Button>
<input
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Skriv en melding …"
className="h-11 flex-1 rounded-2xl border border-input bg-background px-4 text-base outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
<Button
type="submit"
size="icon"
disabled={sending || (!text.trim() && !image)}
aria-label="Send"
className="size-11 shrink-0 rounded-2xl"
>
<Send aria-hidden="true" className="size-5" />
</Button>
</div>
</form>
</div>
</>
)}
</div>
)
}