feat: added settings in room to kick

This commit is contained in:
2026-09-06 16:06:12 +02:00
parent 1e0f5cf143
commit 817b1ce1cb
10 changed files with 439 additions and 107 deletions
@@ -67,7 +67,13 @@ const {
:title="realtimeRoom.title"
:join_code="realtimeRoom.join_code"
:is_favorite="realtimeRoom.is_favorite"
:players="realtimeRoom.players"
:can-favorite="canFavorite"
:can-manage-participants="
realtimeCurrentPlayer.is_moderator ||
realtimeRoom.allow_participants_to_manage_rounds
"
:current-player-id="realtimeCurrentPlayer.id"
:embedded="embedded"
:player-name="realtimeCurrentPlayer.name"
v-model:presenter-mode="presenterMode"
+268 -74
View File
@@ -1,17 +1,65 @@
<script setup lang="ts">
import { Link } from '@inertiajs/vue3';
import { Check, Copy, LogOut } from '@lucide/vue';
import { Link, useForm } from '@inertiajs/vue3';
import { Check, Copy, LogOut, Settings, UserMinus, Users } from '@lucide/vue';
import { ref } from 'vue';
import { Checkbox } from '@/components/ui/checkbox';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { logout as embeddedLogout } from '@/routes/embedded';
import { favorite } from '@/routes/room';
import type { RoomHeaderEmits, RoomHeaderProps } from '@/types/room';
import { destroy as kickRoomPlayer } from '@/routes/room/player';
import type {
RoomHeaderEmits,
RoomHeaderProps,
RoomPlayer,
} from '@/types/room';
const props = defineProps<RoomHeaderProps>();
const emit = defineEmits<RoomHeaderEmits>();
const copied = ref(false);
const participantsDialogOpen = ref(false);
const selectedPlayer = ref<RoomPlayer | null>(null);
const kickForm = useForm({});
function kickPlayer(): void {
if (selectedPlayer.value === null || kickForm.processing) {
return;
}
kickForm.delete(
kickRoomPlayer({
room: props.id,
player: selectedPlayer.value.id,
}).url,
{
preserveScroll: true,
onSuccess: () => {
selectedPlayer.value = null;
},
},
);
}
function setKickDialogOpen(open: boolean): void {
if (!open && !kickForm.processing) {
selectedPlayer.value = null;
}
}
async function copyInviteLink(): Promise<void> {
const inviteUrl = new URL('/room/join', window.location.origin);
@@ -26,79 +74,225 @@ async function copyInviteLink(): Promise<void> {
</script>
<template>
<header
class="mx-auto flex w-full max-w-6xl flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"
>
<div>
<h1 class="text-2xl font-bold sm:text-3xl">
{{ title ?? 'Scrum Poker' }}
</h1>
<div class="contents">
<header
class="mx-auto flex w-full max-w-6xl flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"
>
<div>
<h1 class="text-2xl font-bold sm:text-3xl">
{{ title ?? 'Scrum Poker' }}
</h1>
<Link
v-if="canFavorite"
:href="favorite(id)"
method="post"
as="button"
class="mt-2 inline-flex items-center gap-2"
>
<span>Favorit</span>
<span class="material-icons" aria-hidden="true">
{{ is_favorite ? 'favorite' : 'favorite_border' }}
</span>
</Link>
</div>
<div class="flex flex-col items-end gap-2">
<Link
v-if="embedded"
:href="
embeddedLogout({
query: {
join_code,
player_name: playerName,
},
})
"
method="post"
as="button"
class="inline-flex items-center gap-2 text-sm text-slate-300 transition hover:text-white"
>
<LogOut class="size-4" />
Abmelden
</Link>
<button
type="button"
class="flex w-fit items-center gap-3 rounded-xl border border-slate-700 bg-slate-800 px-4 py-2.5 text-left transition hover:bg-slate-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-cyan-300"
@click="copyInviteLink"
>
<span>
<span
class="block text-sm tracking-[0.18em] text-slate-400"
>
Raumcode
<Link
v-if="canFavorite"
:href="favorite(id)"
method="post"
as="button"
class="mt-2 inline-flex items-center gap-2"
>
<span>Favorit</span>
<span class="material-icons" aria-hidden="true">
{{ is_favorite ? 'favorite' : 'favorite_border' }}
</span>
<strong class="font-mono text-lg tracking-[0.2em]">
{{ join_code }}
</strong>
</span>
<Check v-if="copied" class="size-5 text-emerald-300" />
<Copy v-else class="size-5 text-slate-300" />
</button>
</Link>
</div>
<label
for="presenter-mode"
class="flex cursor-pointer items-center gap-2 text-sm text-slate-300"
>
<Checkbox
id="presenter-mode"
:model-value="presenterMode"
@update:model-value="
emit('update:presenterMode', $event === true)
<div class="flex flex-col items-end gap-2">
<Link
v-if="embedded"
:href="
embeddedLogout({
query: {
join_code,
player_name: playerName,
},
})
"
/>
<span>Präsentatormodus</span>
</label>
</div>
</header>
method="post"
as="button"
class="inline-flex items-center gap-2 text-sm text-slate-300 transition hover:text-white"
>
<LogOut class="size-4" />
Abmelden
</Link>
<div class="flex items-center gap-2">
<DropdownMenu v-if="canManageParticipants">
<DropdownMenuTrigger :as-child="true">
<button
type="button"
class="inline-flex size-11 items-center justify-center rounded-xl border border-slate-700 bg-slate-800 text-slate-300 transition hover:bg-slate-700 hover:text-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-cyan-300"
aria-label="Raumeinstellungen öffnen"
>
<Settings class="size-5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
class="w-52 border-slate-700 bg-slate-800 text-slate-100"
>
<DropdownMenuLabel class="text-slate-400">
Einstellungen
</DropdownMenuLabel>
<DropdownMenuItem
class="focus:bg-slate-700 focus:text-white"
@select="participantsDialogOpen = true"
>
<Users class="size-4" />
Teilnehmer
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<button
type="button"
class="flex w-fit items-center gap-3 rounded-xl border border-slate-700 bg-slate-800 px-4 py-2.5 text-left transition hover:bg-slate-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-cyan-300"
@click="copyInviteLink"
>
<span>
<span
class="block text-sm tracking-[0.18em] text-slate-400"
>
Raumcode
</span>
<strong class="font-mono text-lg tracking-[0.2em]">
{{ join_code }}
</strong>
</span>
<Check v-if="copied" class="size-5 text-emerald-300" />
<Copy v-else class="size-5 text-slate-300" />
</button>
</div>
<label
for="presenter-mode"
class="flex cursor-pointer items-center gap-2 text-sm text-slate-300"
>
<Checkbox
id="presenter-mode"
:model-value="presenterMode"
@update:model-value="
emit('update:presenterMode', $event === true)
"
/>
<span>Präsentatormodus</span>
</label>
</div>
</header>
<Dialog v-model:open="participantsDialogOpen">
<DialogContent
class="max-h-[calc(100dvh-2rem)] overflow-hidden border-slate-700 bg-slate-800 text-slate-100 sm:max-w-xl"
overlay-class="bg-black/70 backdrop-blur-sm"
>
<DialogHeader>
<DialogTitle>Teilnehmer</DialogTitle>
<DialogDescription class="text-slate-300">
Verwalte die aktiven Teilnehmer in diesem Raum.
</DialogDescription>
</DialogHeader>
<ul
v-if="players.length > 0"
class="flex max-h-[min(60dvh,32rem)] flex-col gap-2 overflow-y-auto pr-1"
>
<li
v-for="player in players"
:key="player.id"
class="flex items-center justify-between gap-3 rounded-md bg-slate-900/60 px-3 py-2"
>
<span class="min-w-0">
<span
class="block truncate text-sm"
:class="
player.is_moderator
? 'text-amber-300'
: 'text-slate-100'
"
>
{{ player.name }}
</span>
</span>
<span
v-if="
player.id === currentPlayerId ||
player.is_moderator
"
class="shrink-0 text-xs text-slate-400"
>
{{
player.id === currentPlayerId
? player.is_moderator
? 'Du · Admin'
: 'Du'
: 'Admin'
}}
</span>
<button
v-else
type="button"
class="inline-flex shrink-0 items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs font-semibold text-rose-300 transition hover:bg-rose-500/20 hover:text-rose-200"
@click="selectedPlayer = player"
>
<UserMinus class="size-3.5" />
Kicken
</button>
</li>
</ul>
<p v-else class="text-sm text-slate-400">
Aktuell sind keine Teilnehmer im Raum.
</p>
<DialogFooter>
<button
type="button"
class="rounded-md border border-slate-600 px-4 py-2 text-sm font-semibold text-slate-200 transition hover:bg-slate-700"
@click="participantsDialogOpen = false"
>
Schließen
</button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog
:open="selectedPlayer !== null"
@update:open="setKickDialogOpen"
>
<DialogContent
class="border-slate-700 bg-slate-800 text-slate-100 sm:max-w-md"
overlay-class="bg-black/70 backdrop-blur-sm"
>
<DialogHeader>
<DialogTitle>Teilnehmer wirklich kicken?</DialogTitle>
<DialogDescription class="text-slate-300">
{{ selectedPlayer?.name }} wird aus „{{
title ?? 'Scrum Poker'
}}“ entfernt und zur Startseite weitergeleitet.
</DialogDescription>
</DialogHeader>
<DialogFooter class="gap-6 space-x-4 sm:gap-0">
<button
type="button"
class="rounded-md border border-slate-600 px-4 py-2 text-sm font-semibold text-slate-200 transition hover:bg-slate-700"
:disabled="kickForm.processing"
@click="selectedPlayer = null"
>
Abbrechen
</button>
<button
type="button"
class="rounded-md bg-rose-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-rose-500 disabled:opacity-50"
:disabled="kickForm.processing"
@click="kickPlayer"
>
{{ kickForm.processing ? 'Wird entfernt …' : 'Kicken' }}
</button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</template>
@@ -16,19 +16,26 @@ defineOptions({
inheritAttrs: false,
})
const props = withDefaults(defineProps<DialogContentProps & { class?: HTMLAttributes["class"], showCloseButton?: boolean }>(), {
showCloseButton: true,
})
const props = withDefaults(
defineProps<DialogContentProps & {
class?: HTMLAttributes["class"]
overlayClass?: HTMLAttributes["class"]
showCloseButton?: boolean
}>(),
{
showCloseButton: true,
},
)
const emits = defineEmits<DialogContentEmits>()
const delegatedProps = reactiveOmit(props, "class")
const delegatedProps = reactiveOmit(props, "class", "overlayClass", "showCloseButton")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<DialogPortal>
<DialogOverlay />
<DialogOverlay :class="props.overlayClass" />
<DialogContent
data-slot="dialog-content"
v-bind="{ ...$attrs, ...forwarded }"
+8 -6
View File
@@ -48,9 +48,10 @@ function submit(): void {
class="flex flex-col gap-6 rounded-xl border border-slate-700 bg-slate-800 p-6"
@submit.prevent="submit"
>
<label class="flex cursor-pointer items-start gap-4">
<label class="flex cursor-pointer items-center gap-4">
<Checkbox
:model-value="form.allow_participants_to_manage_rounds"
class="border-white"
@update:model-value="
form.allow_participants_to_manage_rounds =
$event === true
@@ -58,12 +59,12 @@ function submit(): void {
/>
<span>
<strong class="block"
>Alle dürfen Runden steuern</strong
>Alle dürfen den Raum verwalten</strong
>
<span class="mt-1 block text-sm text-slate-400">
Alle aktiven Teilnehmer, auch Gäste, dürfen Karten
aufdecken, Votes zurücksetzen und die nächste Runde
starten.
Alle aktiven Teilnehmer, auch Gäste, dürfen Runden
steuern und andere Teilnehmer aus dem Raum
entfernen.
</span>
</span>
</label>
@@ -71,9 +72,10 @@ function submit(): void {
:message="form.errors.allow_participants_to_manage_rounds"
/>
<label class="flex cursor-pointer items-start gap-4">
<label class="flex cursor-pointer items-center gap-4">
<Checkbox
:model-value="form.is_persistent"
class="border-white"
@update:model-value="
form.is_persistent = $event === true
"
+3 -1
View File
@@ -110,9 +110,11 @@ export type RoomRetentionNoticeProps = {
export type RoomHeaderProps = Pick<
PokerRoom,
'id' | 'title' | 'join_code' | 'is_favorite'
'id' | 'title' | 'join_code' | 'is_favorite' | 'players'
> & {
canFavorite: boolean;
canManageParticipants: boolean;
currentPlayerId: number;
presenterMode: boolean;
embedded: boolean;
playerName: string;