272 lines
9.5 KiB
TypeScript
272 lines
9.5 KiB
TypeScript
"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>
|
|
)
|
|
}
|