296 lines
11 KiB
TypeScript
296 lines
11 KiB
TypeScript
|
|
"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(
|
||
|
|
"group relative max-w-[80%] rounded-2xl px-4 py-2.5 shadow-sm shadow-black/5",
|
||
|
|
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>
|
||
|
|
)
|
||
|
|
}
|