new frontend

ik ben zo cool
This commit is contained in:
ben
2026-08-19 09:33:48 +02:00
parent 1822dea7e3
commit bf601846db
104 changed files with 16741 additions and 0 deletions
@@ -0,0 +1,271 @@
"use client"
import { useState } from "react"
import {
Copy,
Check,
RefreshCw,
Loader2,
Crown,
Shield,
UserMinus,
ArrowUp,
ArrowDown,
LogOut,
Trash2,
} from "lucide-react"
import { GlassModal, fieldClass, labelClass } from "./glass-modal"
import * as api from "@/lib/api"
import { ApiError } from "@/lib/api"
import type { Room } from "@/lib/types"
import { useRoomMembers } from "@/lib/hooks"
const ROLE_LEVEL = { owner: 3, admin: 2, member: 1 } as const
interface RoomSettingsDialogProps {
room: Room
currentUserId: number
onClose: () => void
onChanged: () => void
onLeftOrDeleted: () => void
}
export function RoomSettingsDialog({
room,
currentUserId,
onClose,
onChanged,
onLeftOrDeleted,
}: RoomSettingsDialogProps) {
const { members, mutate: mutateMembers } = useRoomMembers(room.id)
const [name, setName] = useState(room.name)
const [inviteCode, setInviteCode] = useState(room.invite_code)
const [copied, setCopied] = useState(false)
const [busy, setBusy] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const isOwner = room.role === "owner"
const isAdmin = room.role === "admin" || isOwner
const myLevel = ROLE_LEVEL[room.role]
const run = async (key: string, fn: () => Promise<unknown>, opts?: { refetchMembers?: boolean }) => {
setBusy(key)
setError(null)
try {
await fn()
if (opts?.refetchMembers) await mutateMembers()
onChanged()
} catch (err) {
setError(err instanceof ApiError ? err.message : "Action failed.")
} finally {
setBusy(null)
}
}
const copyCode = async () => {
try {
await navigator.clipboard.writeText(inviteCode)
setCopied(true)
setTimeout(() => setCopied(false), 1500)
} catch {
/* clipboard unavailable */
}
}
const regenerate = () =>
run("regen", async () => {
const res = await api.regenerateInviteCode(room.id)
setInviteCode(res.invite_code)
})
const rename = () => run("rename", () => api.renameRoom(room.id, name.trim()))
const changeRole = (userId: number, role: "member" | "admin") =>
run(`role-${userId}`, () => api.updateMemberRole(room.id, userId, role), {
refetchMembers: true,
})
const remove = (userId: number) =>
run(`remove-${userId}`, () => api.removeMember(room.id, userId), { refetchMembers: true })
const leave = () =>
run("leave", async () => {
await api.leaveRoom(room.id)
onLeftOrDeleted()
onClose()
})
const destroy = () =>
run("delete", async () => {
await api.deleteRoom(room.id)
onLeftOrDeleted()
onClose()
})
return (
<GlassModal title="Room settings" onClose={onClose} maxWidth="max-w-lg">
<div className="flex flex-col gap-5">
{/* Name */}
<div>
<label className={labelClass} htmlFor="room-rename">
Room name
</label>
<div className="flex gap-2">
<input
id="room-rename"
value={name}
onChange={(e) => setName(e.target.value)}
disabled={!isAdmin}
className={fieldClass}
/>
{isAdmin && (
<button
onClick={rename}
disabled={busy === "rename" || name.trim() === room.name || !name.trim()}
className="shrink-0 rounded-xl bg-blue-500 px-4 text-sm font-medium transition-colors hover:bg-blue-600 disabled:opacity-50"
>
{busy === "rename" ? <Loader2 className="h-4 w-4 animate-spin" /> : "Save"}
</button>
)}
</div>
</div>
{/* Invite code */}
<div>
<label className={labelClass}>Invite code</label>
<div className="flex gap-2">
<div className={`${fieldClass} flex items-center justify-between font-mono`}>
<span>{inviteCode}</span>
<button onClick={copyCode} className="text-white/70 hover:text-white" aria-label="Copy code">
{copied ? <Check className="h-4 w-4 text-emerald-300" /> : <Copy className="h-4 w-4" />}
</button>
</div>
{isAdmin && (
<button
onClick={regenerate}
disabled={busy === "regen"}
className="flex shrink-0 items-center gap-1.5 rounded-xl border border-white/20 bg-white/10 px-3 text-sm transition-colors hover:bg-white/20 disabled:opacity-70"
>
{busy === "regen" ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RefreshCw className="h-4 w-4" />
)}
</button>
)}
</div>
</div>
{/* Members */}
<div>
<label className={labelClass}>Members ({members.length})</label>
<div className="max-h-56 space-y-1.5 overflow-y-auto pr-1">
{members.map((m) => {
const targetLevel = ROLE_LEVEL[m.role]
const isSelf = m.id === currentUserId
const canManageTarget = isAdmin && !isSelf && myLevel > targetLevel
return (
<div
key={m.id}
className="flex items-center gap-2 rounded-xl border border-white/15 bg-white/5 px-3 py-2"
>
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-blue-500 text-xs font-bold">
{m.username.charAt(0).toUpperCase()}
</div>
<span className="flex-1 truncate text-sm">
{m.username}
{isSelf && <span className="text-white/50"> (you)</span>}
</span>
<span className="flex items-center gap-1 text-xs capitalize text-white/70">
{m.role === "owner" && <Crown className="h-3.5 w-3.5 text-amber-300" />}
{m.role === "admin" && <Shield className="h-3.5 w-3.5 text-blue-200" />}
{m.role}
</span>
{/* Owner can promote/demote between member and admin */}
{isOwner && !isSelf && m.role === "member" && (
<button
onClick={() => changeRole(m.id, "admin")}
disabled={busy === `role-${m.id}`}
className="rounded-lg p-1 text-white/70 hover:bg-white/20 hover:text-white"
aria-label="Promote to admin"
title="Promote to admin"
>
{busy === `role-${m.id}` ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<ArrowUp className="h-4 w-4" />
)}
</button>
)}
{isOwner && !isSelf && m.role === "admin" && (
<button
onClick={() => changeRole(m.id, "member")}
disabled={busy === `role-${m.id}`}
className="rounded-lg p-1 text-white/70 hover:bg-white/20 hover:text-white"
aria-label="Demote to member"
title="Demote to member"
>
{busy === `role-${m.id}` ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<ArrowDown className="h-4 w-4" />
)}
</button>
)}
{canManageTarget && (
<button
onClick={() => remove(m.id)}
disabled={busy === `remove-${m.id}`}
className="rounded-lg p-1 text-red-300 hover:bg-red-500/20 hover:text-red-200"
aria-label={`Remove ${m.username}`}
title="Remove member"
>
{busy === `remove-${m.id}` ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<UserMinus className="h-4 w-4" />
)}
</button>
)}
</div>
)
})}
</div>
</div>
{error && (
<div className="rounded-xl border border-red-400/40 bg-red-500/20 px-4 py-2.5 text-sm">
{error}
</div>
)}
{/* Danger zone */}
<div className="flex flex-wrap gap-2 border-t border-white/15 pt-4">
<button
onClick={leave}
disabled={busy === "leave"}
className="flex items-center gap-1.5 rounded-xl bg-white/10 px-3 py-2 text-sm font-medium transition-colors hover:bg-white/20 disabled:opacity-70"
>
{busy === "leave" ? <Loader2 className="h-4 w-4 animate-spin" /> : <LogOut className="h-4 w-4" />}
Leave room
</button>
{isOwner && (
<button
onClick={destroy}
disabled={busy === "delete"}
className="ml-auto flex items-center gap-1.5 rounded-xl bg-red-500/80 px-3 py-2 text-sm font-medium transition-colors hover:bg-red-500 disabled:opacity-70"
>
{busy === "delete" ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="h-4 w-4" />
)}
Delete room
</button>
)}
</div>
</div>
</GlassModal>
)
}