Add room participant moderation

This commit is contained in:
2026-09-03 11:35:05 +02:00
parent e30d2e61aa
commit 2f05e56089
14 changed files with 453 additions and 15 deletions
+130 -2
View File
@@ -1,6 +1,12 @@
<script setup lang="ts">
import { Link, useForm } from '@inertiajs/vue3';
import { DoorOpen, Plus, Settings as SettingsIcon, Trash2 } from '@lucide/vue';
import {
DoorOpen,
Plus,
Settings as SettingsIcon,
Trash2,
UserMinus,
} from '@lucide/vue';
import { ref } from 'vue';
import DashboardRoomCard from '@/components/dashboard/DashboardRoomCard.vue';
@@ -13,13 +19,23 @@ import {
DialogTitle,
} from '@/components/ui/dialog';
import { create, destroy, reopen, show } from '@/routes/room';
import { destroy as kickRoomPlayer } from '@/routes/room/player';
import { edit as roomSettings } from '@/routes/room/settings';
import type { DashboardRoom, OwnedRoomListProps } from '@/types/dashboard';
import type {
DashboardRoom,
DashboardRoomPlayer,
OwnedRoomListProps,
} from '@/types/dashboard';
defineProps<OwnedRoomListProps>();
const selectedRoom = ref<DashboardRoom | null>(null);
const selectedPlayer = ref<{
room: DashboardRoom;
player: DashboardRoomPlayer;
} | null>(null);
const deleteForm = useForm({});
const kickForm = useForm({});
function setDeleteDialogOpen(open: boolean): void {
if (!open && !deleteForm.processing) {
@@ -27,6 +43,12 @@ function setDeleteDialogOpen(open: boolean): void {
}
}
function setKickDialogOpen(open: boolean): void {
if (!open && !kickForm.processing) {
selectedPlayer.value = null;
}
}
function deleteRoom(): void {
if (selectedRoom.value === null || deleteForm.processing) {
return;
@@ -39,6 +61,25 @@ function deleteRoom(): void {
},
});
}
function kickPlayer(): void {
if (selectedPlayer.value === null || kickForm.processing) {
return;
}
kickForm.delete(
kickRoomPlayer({
room: selectedPlayer.value.room.id,
player: selectedPlayer.value.player.id,
}).url,
{
preserveScroll: true,
onSuccess: () => {
selectedPlayer.value = null;
},
},
);
}
</script>
<template>
@@ -108,6 +149,56 @@ function deleteRoom(): void {
<Trash2 class="size-4" />
Raum löschen
</button>
<div
class="mt-2 w-full rounded-md border border-slate-700 bg-slate-900/40 p-3"
>
<h3 class="text-sm font-semibold">Teilnehmer</h3>
<ul
v-if="room.players.length > 0"
class="mt-2 flex flex-col gap-2"
>
<li
v-for="player in room.players"
:key="player.id"
class="flex items-center justify-between gap-3 rounded-md bg-slate-800 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.is_owner"
class="shrink-0 text-xs text-slate-400"
>
Du
</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 = { room, player }"
>
<UserMinus class="size-3.5" />
Kicken
</button>
</li>
</ul>
<p v-else class="mt-2 text-sm text-slate-400">
Aktuell sind keine Teilnehmer im Raum.
</p>
</div>
</template>
</DashboardRoomCard>
</div>
@@ -173,5 +264,42 @@ function deleteRoom(): void {
</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"
>
<DialogHeader>
<DialogTitle>Teilnehmer wirklich kicken?</DialogTitle>
<DialogDescription class="text-slate-300">
{{ selectedPlayer?.player.name }} wird aus „{{
selectedPlayer?.room.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>
</section>
</template>
+7 -11
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { CircleUserRound, Crown } from '@lucide/vue';
import { CircleUserRound } from '@lucide/vue';
import type { RoomSeatProps } from '@/types/room';
@@ -75,22 +75,18 @@ function playerVote(): string | null {
:class="{ 'player-details-above': player.isTopSeat }"
>
<p
class="player-name max-w-32 text-center text-sm leading-tight font-semibold text-slate-100"
class="player-name max-w-32 text-center text-sm leading-tight font-semibold"
:class="
player.is_moderator ? 'text-amber-300' : 'text-slate-100'
"
>
{{ player.name }}
</p>
<div
v-if="player.is_moderator || isCurrentPlayer()"
v-if="isCurrentPlayer()"
class="flex flex-wrap items-center justify-center gap-x-2 gap-y-0.5 text-[11px]"
>
<span
v-if="player.is_moderator"
class="inline-flex items-center gap-1 text-amber-300"
>
<Crown class="size-3 fill-amber-300" aria-hidden="true" />
Moderator
</span>
<span v-if="isCurrentPlayer()" class="text-cyan-300">Du</span>
<span class="text-cyan-300">Du</span>
</div>
</div>
@@ -33,6 +33,15 @@ export function useRoomRealtime(
return;
}
if (
payload.change === 'player_kicked' &&
payload.affected_player_id === currentPlayer.value.id
) {
router.visit(home().url, { replace: true });
return;
}
if (payload.change === 'room_closed') {
router.visit(
join({ query: { join_code: room.value.join_code } }).url,
+8
View File
@@ -1,3 +1,10 @@
export type DashboardRoomPlayer = {
id: number;
name: string;
is_moderator: boolean;
is_owner: boolean;
};
export type DashboardRoom = {
id: string;
title: string | null;
@@ -5,6 +12,7 @@ export type DashboardRoom = {
current_round: number;
players_count: number;
active_player_count: number;
players: DashboardRoomPlayer[];
last_activity_at: string | null;
closed_at: string | null;
};
+2
View File
@@ -160,6 +160,7 @@ export type RoomVoteDeckEmits = {
export type RoomStateChange =
| 'player_joined'
| 'player_updated'
| 'player_kicked'
| 'vote_changed'
| 'cards_revealed'
| 'votes_reset'
@@ -173,6 +174,7 @@ export type RoomStateChangedPayload = {
version: number;
change: RoomStateChange;
state: RoomRealtimeState | null;
affected_player_id?: number;
};
export type RoomRealtimeState = Pick<