new frontend
ik ben zo cool
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user