Change klippy window behavour, Fix user management behavour, clean up search server page

This commit is contained in:
2026-03-08 00:00:17 +01:00
parent 90f067e662
commit d20509566d
56 changed files with 1783 additions and 489 deletions

View File

@@ -1,17 +1,25 @@
/* eslint-disable @typescript-eslint/member-ordering */
import {
Component,
effect,
inject,
input
input,
signal
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { CommonModule } from '@angular/common';
import {
Actions,
ofType
} from '@ngrx/effects';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { Store } from '@ngrx/store';
import { lucideX } from '@ng-icons/lucide';
import { Room, BanEntry } from '../../../../core/models/index';
import { DatabaseService } from '../../../../core/services/database.service';
import { RoomsActions } from '../../../../store/rooms/rooms.actions';
import { UsersActions } from '../../../../store/users/users.actions';
import { selectBannedUsers } from '../../../../store/users/users.selectors';
@Component({
selector: 'app-bans-settings',
@@ -26,16 +34,54 @@ import { selectBannedUsers } from '../../../../store/users/users.selectors';
})
export class BansSettingsComponent {
private store = inject(Store);
private actions$ = inject(Actions);
private db = inject(DatabaseService);
/** The currently selected server, passed from the parent. */
server = input<Room | null>(null);
/** Whether the current user is admin of this server. */
isAdmin = input(false);
bannedUsers = this.store.selectSignal(selectBannedUsers);
bannedUsers = signal<BanEntry[]>([]);
constructor() {
effect(() => {
const roomId = this.server()?.id;
if (!roomId) {
this.bannedUsers.set([]);
return;
}
void this.loadBansForServer(roomId);
});
this.actions$
.pipe(
ofType(
UsersActions.banUserSuccess,
UsersActions.unbanUserSuccess,
UsersActions.loadBansSuccess,
RoomsActions.updateRoom
),
takeUntilDestroyed()
)
.subscribe(() => {
const roomId = this.server()?.id;
if (roomId) {
void this.loadBansForServer(roomId);
}
});
}
unbanUser(ban: BanEntry): void {
this.store.dispatch(UsersActions.unbanUser({ oderId: ban.oderId }));
this.store.dispatch(UsersActions.unbanUser({ roomId: ban.roomId,
oderId: ban.oderId }));
}
private async loadBansForServer(roomId: string): Promise<void> {
this.bannedUsers.set(await this.db.getBansForRoom(roomId));
}
formatExpiry(timestamp: number): string {

View File

@@ -1,59 +1,68 @@
@if (server()) {
<div class="space-y-3 max-w-xl">
@if (membersFiltered().length === 0) {
<p class="text-sm text-muted-foreground text-center py-8">No other members online</p>
@if (members().length === 0) {
<p class="text-sm text-muted-foreground text-center py-8">No other members found for this server</p>
} @else {
@for (user of membersFiltered(); track user.id) {
@for (member of members(); track member.oderId || member.id) {
<div class="flex items-center gap-3 p-3 bg-secondary/50 rounded-lg">
<app-user-avatar
[name]="user.displayName || '?'"
[name]="member.displayName || '?'"
size="sm"
/>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-1.5">
<p class="text-sm font-medium text-foreground truncate">
{{ user.displayName }}
{{ member.displayName }}
</p>
@if (user.role === 'host') {
@if (member.isOnline) {
<span class="text-[10px] bg-emerald-500/20 text-emerald-400 px-1 py-0.5 rounded">Online</span>
}
@if (member.role === 'host') {
<span class="text-[10px] bg-yellow-500/20 text-yellow-400 px-1 py-0.5 rounded">Owner</span>
} @else if (user.role === 'admin') {
} @else if (member.role === 'admin') {
<span class="text-[10px] bg-blue-500/20 text-blue-400 px-1 py-0.5 rounded">Admin</span>
} @else if (user.role === 'moderator') {
} @else if (member.role === 'moderator') {
<span class="text-[10px] bg-green-500/20 text-green-400 px-1 py-0.5 rounded">Mod</span>
}
</div>
</div>
@if (user.role !== 'host' && isAdmin()) {
@if (member.role !== 'host' && isAdmin()) {
<div class="flex items-center gap-1">
<select
[ngModel]="user.role"
(ngModelChange)="changeRole(user, $event)"
class="text-xs px-2 py-1 bg-secondary rounded border border-border text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
>
<option value="member">Member</option>
<option value="moderator">Moderator</option>
<option value="admin">Admin</option>
</select>
<button
(click)="kickMember(user)"
class="p-1 rounded hover:bg-destructive/20 text-muted-foreground hover:text-destructive transition-colors"
title="Kick"
>
<ng-icon
name="lucideUserX"
class="w-4 h-4"
/>
</button>
<button
(click)="banMember(user)"
class="p-1 rounded hover:bg-destructive/20 text-muted-foreground hover:text-destructive transition-colors"
title="Ban"
>
<ng-icon
name="lucideBan"
class="w-4 h-4"
/>
</button>
@if (canChangeRoles()) {
<select
[ngModel]="member.role"
(ngModelChange)="changeRole(member, $event)"
class="text-xs px-2 py-1 bg-secondary rounded border border-border text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
>
<option value="member">Member</option>
<option value="moderator">Moderator</option>
<option value="admin">Admin</option>
</select>
}
@if (canKickMembers()) {
<button
(click)="kickMember(member)"
class="p-1 rounded hover:bg-destructive/20 text-muted-foreground hover:text-destructive transition-colors"
title="Kick"
>
<ng-icon
name="lucideUserX"
class="w-4 h-4"
/>
</button>
}
@if (canBanMembers()) {
<button
(click)="banMember(member)"
class="p-1 rounded hover:bg-destructive/20 text-muted-foreground hover:text-destructive transition-colors"
title="Ban"
>
<ng-icon
name="lucideBan"
class="w-4 h-4"
/>
</button>
}
</div>
}
</div>

View File

@@ -1,6 +1,7 @@
/* eslint-disable @typescript-eslint/member-ordering */
import {
Component,
computed,
inject,
input
} from '@angular/core';
@@ -10,12 +11,23 @@ import { NgIcon, provideIcons } from '@ng-icons/core';
import { Store } from '@ngrx/store';
import { lucideUserX, lucideBan } from '@ng-icons/lucide';
import { Room, User } from '../../../../core/models/index';
import {
Room,
RoomMember,
User,
UserRole
} from '../../../../core/models/index';
import { RoomsActions } from '../../../../store/rooms/rooms.actions';
import { UsersActions } from '../../../../store/users/users.actions';
import { WebRTCService } from '../../../../core/services/webrtc.service';
import { selectCurrentUser, selectOnlineUsers } from '../../../../store/users/users.selectors';
import { selectCurrentUser, selectUsersEntities } from '../../../../store/users/users.selectors';
import { selectCurrentRoom } from '../../../../store/rooms/rooms.selectors';
import { UserAvatarComponent } from '../../../../shared';
interface ServerMemberView extends RoomMember {
isOnline: boolean;
}
@Component({
selector: 'app-members-settings',
standalone: true,
@@ -41,45 +53,104 @@ export class MembersSettingsComponent {
server = input<Room | null>(null);
/** Whether the current user is admin of this server. */
isAdmin = input(false);
accessRole = input<UserRole | null>(null);
currentUser = this.store.selectSignal(selectCurrentUser);
onlineUsers = this.store.selectSignal(selectOnlineUsers);
currentRoom = this.store.selectSignal(selectCurrentRoom);
usersEntities = this.store.selectSignal(selectUsersEntities);
membersFiltered(): User[] {
members = computed<ServerMemberView[]>(() => {
const room = this.server();
const me = this.currentUser();
const currentRoom = this.currentRoom();
const usersEntities = this.usersEntities();
return this.onlineUsers().filter((user) => user.id !== me?.id && user.oderId !== me?.oderId);
if (!room)
return [];
return (room.members ?? [])
.filter((member) => member.id !== me?.id && member.oderId !== me?.oderId)
.map((member) => {
const liveUser = currentRoom?.id === room.id
? (usersEntities[member.id]
|| Object.values(usersEntities).find((user) => !!user && user.oderId === member.oderId)
|| null)
: null;
return {
...member,
avatarUrl: liveUser?.avatarUrl || member.avatarUrl,
displayName: liveUser?.displayName || member.displayName,
isOnline: !!liveUser && (liveUser.isOnline === true || liveUser.status !== 'offline')
};
});
});
canChangeRoles(): boolean {
const role = this.accessRole();
return role === 'host' || role === 'admin';
}
changeRole(user: User, role: 'admin' | 'moderator' | 'member'): void {
const roomId = this.server()?.id;
canKickMembers(): boolean {
const role = this.accessRole();
this.store.dispatch(UsersActions.updateUserRole({ userId: user.id,
role }));
return role === 'host' || role === 'admin' || role === 'moderator';
}
canBanMembers(): boolean {
const role = this.accessRole();
return role === 'host' || role === 'admin';
}
changeRole(member: ServerMemberView, role: 'admin' | 'moderator' | 'member'): void {
const room = this.server();
if (!room)
return;
const members = (room.members ?? []).map((existingMember) =>
existingMember.id === member.id || existingMember.oderId === member.oderId
? { ...existingMember,
role }
: existingMember
);
this.store.dispatch(RoomsActions.updateRoom({ roomId: room.id,
changes: { members } }));
if (this.currentRoom()?.id === room.id) {
this.store.dispatch(UsersActions.updateUserRole({ userId: member.id,
role }));
}
this.webrtcService.broadcastMessage({
type: 'role-change',
roomId,
targetUserId: user.id,
roomId: room.id,
targetUserId: member.id,
role
});
}
kickMember(user: User): void {
this.store.dispatch(UsersActions.kickUser({ userId: user.id }));
this.webrtcService.broadcastMessage({
type: 'kick',
targetUserId: user.id,
kickedBy: this.currentUser()?.id
});
kickMember(member: ServerMemberView): void {
const room = this.server();
if (!room)
return;
this.store.dispatch(UsersActions.kickUser({ userId: member.id,
roomId: room.id }));
}
banMember(user: User): void {
this.store.dispatch(UsersActions.banUser({ userId: user.id }));
this.webrtcService.broadcastMessage({
type: 'ban',
targetUserId: user.id,
bannedBy: this.currentUser()?.id
});
banMember(member: ServerMemberView): void {
const room = this.server();
if (!room)
return;
this.store.dispatch(UsersActions.banUser({ userId: member.id,
roomId: room.id,
displayName: member.displayName }));
}
}

View File

@@ -87,9 +87,9 @@ export class ServerSettingsComponent {
return;
this.store.dispatch(
RoomsActions.updateRoom({
RoomsActions.updateRoomSettings({
roomId: room.id,
changes: {
settings: {
name: this.roomName,
description: this.roomDescription,
isPrivate: this.isPrivate(),

View File

@@ -57,7 +57,7 @@
}
<!-- Server section -->
@if (savedRooms().length > 0) {
@if (manageableRooms().length > 0) {
<div class="mt-3 pt-3 border-t border-border">
<p class="px-4 py-1.5 text-[11px] font-semibold text-muted-foreground/70 uppercase tracking-wider">Server</p>
@@ -69,13 +69,13 @@
(change)="onServerSelect($event)"
>
<option value="">Select a server…</option>
@for (room of savedRooms(); track room.id) {
@for (room of manageableRooms(); track room.id) {
<option [value]="room.id">{{ room.name }}</option>
}
</select>
</div>
@if (selectedServerId() && isSelectedServerAdmin()) {
@if (selectedServerId() && canAccessSelectedServer()) {
@for (page of serverPages; track page.id) {
<button
(click)="navigate(page.id)"
@@ -166,26 +166,27 @@
@case ('server') {
<app-server-settings
[server]="selectedServer()"
[isAdmin]="isSelectedServerAdmin()"
[isAdmin]="isSelectedServerOwner()"
/>
}
@case ('members') {
<app-members-settings
[server]="selectedServer()"
[isAdmin]="isSelectedServerAdmin()"
[isAdmin]="canManageSelectedMembers()"
[accessRole]="selectedServerRole()"
/>
}
@case ('bans') {
<app-bans-settings
[server]="selectedServer()"
[isAdmin]="isSelectedServerAdmin()"
[isAdmin]="canManageSelectedBans()"
/>
}
@case ('permissions') {
<app-permissions-settings
#permissionsComp
[server]="selectedServer()"
[isAdmin]="isSelectedServerAdmin()"
[isAdmin]="isSelectedServerOwner()"
/>
}
}

View File

@@ -26,7 +26,12 @@ import {
import { SettingsModalService, SettingsPage } from '../../../core/services/settings-modal.service';
import { selectSavedRooms, selectCurrentRoom } from '../../../store/rooms/rooms.selectors';
import { selectCurrentUser } from '../../../store/users/users.selectors';
import { Room } from '../../../core/models/index';
import {
Room,
UserRole
} from '../../../core/models/index';
import { findRoomMember } from '../../../store/rooms/room-members.helpers';
import { WebRTCService } from '../../../core/services/webrtc.service';
import { NetworkSettingsComponent } from './network-settings/network-settings.component';
import { VoiceSettingsComponent } from './voice-settings/voice-settings.component';
@@ -69,7 +74,9 @@ import { THIRD_PARTY_LICENSES, type ThirdPartyLicense } from './third-party-lice
export class SettingsModalComponent {
readonly modal = inject(SettingsModalService);
private store = inject(Store);
private webrtc = inject(WebRTCService);
readonly thirdPartyLicenses: readonly ThirdPartyLicense[] = THIRD_PARTY_LICENSES;
private lastRequestedServerId: string | null = null;
private permissionsComponent = viewChild<PermissionsSettingsComponent>('permissionsComp');
@@ -106,6 +113,19 @@ export class SettingsModalComponent {
icon: 'lucideShield' }
];
manageableRooms = computed<Room[]>(() => {
const user = this.currentUser();
if (!user)
return [];
return this.savedRooms().filter((room) => {
const role = this.getUserRoleForRoom(room, user.id, user.oderId, this.currentRoom()?.id === room.id ? user.role : null);
return role === 'host' || role === 'admin' || role === 'moderator';
});
});
selectedServerId = signal<string | null>(null);
selectedServer = computed<Room | null>(() => {
const id = this.selectedServerId();
@@ -113,21 +133,48 @@ export class SettingsModalComponent {
if (!id)
return null;
return this.savedRooms().find((room) => room.id === id) ?? null;
return this.manageableRooms().find((room) => room.id === id) ?? null;
});
showServerTabs = computed(() => {
return this.savedRooms().length > 0 && !!this.selectedServerId();
return this.manageableRooms().length > 0 && !!this.selectedServerId();
});
isSelectedServerAdmin = computed(() => {
selectedServerRole = computed<UserRole | null>(() => {
const server = this.selectedServer();
const user = this.currentUser();
if (!server || !user)
return false;
return null;
return server.hostId === user.id || server.hostId === user.oderId;
return this.getUserRoleForRoom(
server,
user.id,
user.oderId,
this.currentRoom()?.id === server.id ? user.role : null
);
});
canAccessSelectedServer = computed(() => {
const role = this.selectedServerRole();
return role === 'host' || role === 'admin' || role === 'moderator';
});
canManageSelectedMembers = computed(() => {
const role = this.selectedServerRole();
return role === 'host' || role === 'admin' || role === 'moderator';
});
canManageSelectedBans = computed(() => {
const role = this.selectedServerRole();
return role === 'host' || role === 'admin';
});
isSelectedServerOwner = computed(() => {
return this.selectedServerRole() === 'host';
});
animating = signal(false);
@@ -135,21 +182,27 @@ export class SettingsModalComponent {
constructor() {
effect(() => {
if (this.isOpen()) {
const targetId = this.modal.targetServerId();
if (targetId) {
this.selectedServerId.set(targetId);
} else {
const currentRoom = this.currentRoom();
if (currentRoom) {
this.selectedServerId.set(currentRoom.id);
}
}
this.animating.set(true);
if (!this.isOpen()) {
this.lastRequestedServerId = null;
return;
}
const rooms = this.manageableRooms();
const targetId = this.modal.targetServerId();
const currentRoomId = this.currentRoom()?.id ?? null;
const selectedId = this.selectedServerId();
const hasSelected = !!selectedId && rooms.some((room) => room.id === selectedId);
if (!hasSelected) {
const fallbackId = [targetId, currentRoomId].find((candidateId) =>
!!candidateId && rooms.some((room) => room.id === candidateId)
) ?? rooms[0]?.id ?? null;
this.selectedServerId.set(fallbackId);
}
this.animating.set(true);
});
effect(() => {
@@ -163,7 +216,48 @@ export class SettingsModalComponent {
}
}
});
effect(() => {
if (!this.isOpen())
return;
const serverId = this.selectedServerId();
if (!serverId || this.lastRequestedServerId === serverId)
return;
this.lastRequestedServerId = serverId;
for (const peerId of this.webrtc.getConnectedPeers()) {
try {
this.webrtc.sendToPeer(peerId, {
type: 'server-state-request',
roomId: serverId
});
} catch {
/* peer may have disconnected */
}
}
});
}
private getUserRoleForRoom(
room: Room,
userId: string,
userOderId: string,
currentRole: UserRole | null
): UserRole | null {
if (room.hostId === userId || room.hostId === userOderId)
return 'host';
if (currentRole)
return currentRole;
return findRoomMember(room.members ?? [], userId)?.role
|| findRoomMember(room.members ?? [], userOderId)?.role
|| null;
}
@HostListener('document:keydown.escape')
onEscapeKey(): void {
if (this.showThirdPartyLicenses()) {