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"
2026-08-07 22:02:56 +02:00
import { ArrowLeft , Camera , ImagePlus , Lock , Send , Trash2 , X } from "lucide-react"
2026-07-19 22:26:45 +02:00
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
2026-08-07 22:02:56 +02:00
import { PostEngagement , type ReactionSummary } from "./post-engagement"
2026-07-19 22:26:45 +02:00
// --- 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
2026-08-07 22:02:56 +02:00
reactions : ReactionSummary [ ]
comment_count : number
2026-07-19 22:26:45 +02:00
}
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 )
2026-08-07 22:02:56 +02:00
const cameraInputRef = useRef < HTMLInputElement > ( null )
2026-07-19 22:26:45 +02:00
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 )
2026-08-07 22:02:56 +02:00
// Nyeste øverst (2026-08-06) -- legg til FØRST, ikke sist.
setMessages ( ( prev ) = > ( prev . some ( ( m ) = > m . id === msg . id ) ? prev : [ msg , . . . prev ] ) )
2026-07-19 22:26:45 +02:00
} catch {
// ignorer ugyldig payload
}
}
return ( ) = > socket . close ( )
} , [ organizationId , teamId , accessError ] )
2026-08-07 22:02:56 +02:00
// 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 )
}
2026-07-19 22:26:45 +02:00
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 ,
} )
2026-08-07 22:02:56 +02:00
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
}
2026-07-19 22:26:45 +02:00
const msg : ApiMessage = await res . json ( )
2026-08-07 22:02:56 +02:00
// Nyeste øverst (2026-08-06) -- legg til FØRST, ikke sist.
setMessages ( ( prev ) = > ( prev . some ( ( m ) = > m . id === msg . id ) ? prev : [ msg , . . . prev ] ) )
2026-07-19 22:26:45 +02:00
setText ( "" )
setImage ( null )
if ( fileInputRef . current ) fileInputRef . current . value = ""
2026-08-07 22:02:56 +02:00
if ( cameraInputRef . current ) cameraInputRef . current . value = ""
2026-07-19 22:26:45 +02:00
} catch {
2026-08-07 22:02:56 +02:00
setError ( "Klarte ikke å sende meldingen. Sjekk tilkoblingen og prøv igjen." )
2026-07-19 22:26:45 +02:00
} 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 >
2026-08-07 22:02:56 +02:00
< 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 >
2026-07-19 22:26:45 +02:00
< / 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 = ""
2026-08-07 22:02:56 +02:00
if ( cameraInputRef . current ) cameraInputRef . current . value = ""
2026-07-19 22:26:45 +02:00
} }
aria - label = "Fjern bilde"
>
< X aria-hidden = "true" className = "size-3.5" / >
< / button >
< / div >
) }
< div className = "flex items-center gap-2" >
2026-08-07 22:02:56 +02:00
{ / * T o a t s k i l t e i n p u t s - - A n d r o i d s i n s y s t e m b i l d e - v l g e r
( 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 >
2026-07-19 22:26:45 +02:00
< input
ref = { fileInputRef }
type = "file"
2026-08-07 22:02:56 +02:00
accept = "image/*"
2026-07-19 22:26:45 +02:00
className = "hidden"
2026-08-07 22:02:56 +02:00
onChange = { handleImageSelected }
2026-07-19 22:26:45 +02:00
/ >
< Button
type = "button"
variant = "ghost"
size = "icon"
onClick = { ( ) = > fileInputRef . current ? . click ( ) }
2026-08-07 22:02:56 +02:00
aria - label = "Velg bilde fra galleri"
2026-07-19 22:26:45 +02:00
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 >
)
}