2026-07-19 22:26:45 +02:00
"use client"
import type React from "react"
import { useEffect , useRef , useState } from "react"
import Link from "next/link"
import { ArrowLeft , ImagePlus , Lock , Send , Trash2 , X } from "lucide-react"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
// --- 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
}
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 bottomRef = useRef < HTMLDivElement > ( null )
const fileInputRef = 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 )
setMessages ( ( prev ) = > ( prev . some ( ( m ) = > m . id === msg . id ) ? prev : [ . . . prev , msg ] ) )
} catch {
// ignorer ugyldig payload
}
}
return ( ) = > socket . close ( )
} , [ organizationId , teamId , accessError ] )
useEffect ( ( ) = > {
bottomRef . current ? . scrollIntoView ( { block : "end" } )
} , [ messages . length ] )
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 ) throw new Error ( ` send: ${ res . status } ` )
const msg : ApiMessage = await res . json ( )
setMessages ( ( prev ) = > ( prev . some ( ( m ) = > m . id === msg . id ) ? prev : [ . . . prev , msg ] ) )
setText ( "" )
setImage ( null )
if ( fileInputRef . current ) fileInputRef . current . value = ""
} catch {
setError ( "Klarte ikke å sende meldingen. 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 (
2026-07-29 12:44:51 +02:00
"group relative max-w-[80%] rounded-2xl px-4 py-2.5 shadow-md shadow-black/8" ,
2026-07-19 22:26:45 +02:00
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 >
)
} ) }
< div ref = { bottomRef } / >
< / 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 = ""
} }
aria - label = "Fjern bilde"
>
< X aria-hidden = "true" className = "size-3.5" / >
< / button >
< / div >
) }
< div className = "flex items-center gap-2" >
< input
ref = { fileInputRef }
type = "file"
accept = "image/jpeg,image/png,image/webp,image/gif"
className = "hidden"
onChange = { ( e ) = > setImage ( e . target . files ? . [ 0 ] ? ? null ) }
/ >
< Button
type = "button"
variant = "ghost"
size = "icon"
onClick = { ( ) = > fileInputRef . current ? . click ( ) }
aria - label = "Legg ved bilde"
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 >
)
}