feat: server image

This commit is contained in:
2026-04-29 18:54:08 +02:00
parent 3d81c34159
commit e1ac1d1bc0
27 changed files with 1340 additions and 615 deletions

View File

@@ -6,8 +6,16 @@
>
@if (panelMode() === 'channels') {
<div class="flex items-center gap-3">
<div class="grid h-9 w-9 place-items-center rounded-md bg-secondary text-sm font-semibold text-foreground">
{{ currentRoom()?.name?.charAt(0)?.toUpperCase() || '#' }}
<div class="grid h-9 w-9 place-items-center overflow-hidden rounded-md bg-secondary text-sm font-semibold text-foreground">
@if (currentRoom()?.icon) {
<img
[src]="currentRoom()!.icon"
[alt]="currentRoom()!.name + ' icon'"
class="h-full w-full object-cover"
/>
} @else {
{{ currentRoom()?.name?.charAt(0)?.toUpperCase() || '#' }}
}
</div>
<div class="min-w-0 flex-1">

View File

@@ -43,8 +43,8 @@
<div class="h-full w-full overflow-hidden rounded-[inherit]">
@if (room.icon) {
<img
[ngSrc]="room.icon"
[alt]="room.name"
[src]="room.icon"
[alt]="room.name + ' icon'"
class="h-full w-full object-cover"
/>
} @else {

View File

@@ -1,31 +1,13 @@
/* eslint-disable @typescript-eslint/member-ordering */
import {
Component,
DestroyRef,
Type,
computed,
effect,
inject,
signal
} from '@angular/core';
import { Component, DestroyRef, Type, computed, effect, inject, signal } from '@angular/core';
import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
import { CommonModule, NgOptimizedImage } from '@angular/common';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Store } from '@ngrx/store';
import { NavigationEnd, Router } from '@angular/router';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucidePlus } from '@ng-icons/lucide';
import {
EMPTY,
Subject,
catchError,
filter,
firstValueFrom,
from,
map,
switchMap,
tap
} from 'rxjs';
import { EMPTY, Subject, catchError, filter, firstValueFrom, from, map, switchMap, tap } from 'rxjs';
import { Room, User } from '../../../shared-kernel';
import { UserBarComponent } from '../../../domains/authentication/feature/user-bar/user-bar.component';
@@ -38,11 +20,7 @@ import { NotificationsFacade } from '../../../domains/notifications';
import { type ServerInfo, ServerDirectoryFacade } from '../../../domains/server-directory';
import { ThemeNodeDirective } from '../../../domains/theme';
import { hasRoomBanForUser } from '../../../domains/access-control';
import {
ConfirmDialogComponent,
ContextMenuComponent,
LeaveServerDialogComponent
} from '../../../shared';
import { ConfirmDialogComponent, ContextMenuComponent, LeaveServerDialogComponent } from '../../../shared';
@Component({
selector: 'app-servers-rail',
@@ -54,7 +32,6 @@ import {
ConfirmDialogComponent,
ContextMenuComponent,
LeaveServerDialogComponent,
NgOptimizedImage,
ThemeNodeDirective,
UserBarComponent
],
@@ -166,8 +143,7 @@ export class ServersRailComponent {
}
initial(name?: string): string {
if (!name)
return '?';
if (!name) return '?';
const ch = name.trim()[0]?.toUpperCase();
@@ -219,8 +195,7 @@ export class ServersRailComponent {
confirmPasswordJoin(): void {
const room = this.passwordPromptRoom();
if (!room)
return;
if (!room) return;
this.joinPasswordError.set(null);
this.savedRoomJoinRequests.next({ room, password: this.joinPassword() });
@@ -260,8 +235,7 @@ export class ServersRailComponent {
confirmLeave(result: { nextOwnerKey?: string }): void {
const ctx = this.contextRoom();
if (!ctx)
return;
if (!ctx) return;
const isCurrentRoom = this.currentRoom()?.id === ctx.id;
@@ -364,8 +338,7 @@ export class ServersRailComponent {
const currentUserId = localStorage.getItem('metoyou_currentUserId');
const currentUser = this.currentUser();
if (!currentUserId)
return EMPTY;
if (!currentUserId) return EMPTY;
this.joinPasswordError.set(null);

View File

@@ -3,11 +3,74 @@
<section>
<h4 class="text-sm font-semibold text-foreground mb-3">Room Settings</h4>
@if (!isAdmin()) {
<p class="text-xs text-muted-foreground mb-3">
You are viewing this server's settings as a non-admin. Only the server owner can make changes.
</p>
<p class="text-xs text-muted-foreground mb-3">You are viewing this server's details without server-management permission.</p>
}
<div class="space-y-4">
<div class="rounded-lg border border-border bg-secondary/40 p-4">
<div class="flex items-center gap-3">
<div class="grid h-14 w-14 shrink-0 place-items-center overflow-hidden rounded-lg bg-secondary text-base font-semibold text-foreground">
@if (serverData()?.icon) {
<img
[src]="serverData()!.icon"
[alt]="serverData()!.name + ' icon'"
class="h-full w-full object-cover"
/>
} @else {
<ng-icon
name="lucideImage"
class="h-6 w-6 text-muted-foreground"
/>
}
</div>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-foreground">Server Image</p>
<p class="text-xs text-muted-foreground">Synced to members and shown in server discovery.</p>
@if (iconError()) {
<p class="mt-1 text-xs text-destructive">{{ iconError() }}</p>
}
</div>
@if (canManageIcon()) {
<div class="flex shrink-0 items-center gap-2">
<label
for="server-icon-upload"
class="grid h-9 w-9 cursor-pointer place-items-center rounded-lg border border-border bg-card text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
title="Upload image"
aria-label="Upload server image"
>
<ng-icon
name="lucideUpload"
class="h-4 w-4"
/>
</label>
<input
id="server-icon-upload"
type="file"
accept="image/*"
class="sr-only"
(change)="onServerIconSelected($event)"
/>
@if (serverData()?.icon) {
<button
type="button"
class="grid h-9 w-9 place-items-center rounded-lg border border-border bg-card text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
title="Remove image"
aria-label="Remove server image"
(click)="removeServerIcon()"
>
<ng-icon
name="lucideX"
class="h-4 w-4"
/>
</button>
}
</div>
}
</div>
</div>
<div>
<label
for="room-name"
@@ -191,21 +254,23 @@
{{ saveSuccess() === 'server' ? 'Saved!' : 'Save Settings' }}
</button>
<!-- Danger Zone -->
<div class="pt-4 border-t border-border">
<h4 class="text-sm font-medium text-destructive mb-3">Danger Zone</h4>
<button
(click)="confirmDeleteRoom()"
type="button"
class="w-full px-4 py-2 bg-destructive/10 text-destructive border border-destructive/20 rounded-lg hover:bg-destructive/20 transition-colors flex items-center justify-center gap-2 text-sm"
>
<ng-icon
name="lucideTrash2"
class="w-4 h-4"
/>
Delete Room
</button>
</div>
@if (canDeleteServer()) {
<!-- Danger Zone -->
<div class="pt-4 border-t border-border">
<h4 class="text-sm font-medium text-destructive mb-3">Danger Zone</h4>
<button
(click)="confirmDeleteRoom()"
type="button"
class="w-full px-4 py-2 bg-destructive/10 text-destructive border border-destructive/20 rounded-lg hover:bg-destructive/20 transition-colors flex items-center justify-center gap-2 text-sm"
>
<ng-icon
name="lucideTrash2"
class="w-4 h-4"
/>
Delete Room
</button>
</div>
}
}
</div>

View File

@@ -12,9 +12,12 @@ import { NgIcon, provideIcons } from '@ng-icons/core';
import { Store } from '@ngrx/store';
import {
lucideCheck,
lucideImage,
lucideTrash2,
lucideLock,
lucideUnlock
lucideUnlock,
lucideUpload,
lucideX
} from '@ng-icons/lucide';
import { Room } from '../../../../shared-kernel';
@@ -34,9 +37,12 @@ import { SettingsModalService } from '../../../../core/services/settings-modal.s
viewProviders: [
provideIcons({
lucideCheck,
lucideImage,
lucideTrash2,
lucideLock,
lucideUnlock
lucideUnlock,
lucideUpload,
lucideX
})
],
templateUrl: './server-settings.component.html'
@@ -49,6 +55,10 @@ export class ServerSettingsComponent {
server = input<Room | null>(null);
/** Whether the current user is admin of this server. */
isAdmin = input(false);
/** Whether the current user can manage this server's icon. */
canManageIcon = input(false);
/** Whether the current user can delete this server. */
canDeleteServer = input(false);
roomName = '';
roomDescription = '';
@@ -59,6 +69,7 @@ export class ServerSettingsComponent {
roomPassword = '';
maxUsers = 0;
showDeleteConfirm = signal(false);
iconError = signal<string | null>(null);
saveSuccess = signal<string | null>(null);
private saveTimeout: ReturnType<typeof setTimeout> | null = null;
@@ -170,6 +181,64 @@ export class ServerSettingsComponent {
this.modal.navigate('network');
}
onServerIconSelected(event: Event): void {
const inputElement = event.target as HTMLInputElement;
const file = inputElement.files?.[0];
inputElement.value = '';
if (!file || !this.canManageIcon()) {
return;
}
if (!file.type.startsWith('image/')) {
this.iconError.set('Choose an image file.');
return;
}
if (file.size > 512 * 1024) {
this.iconError.set('Choose an image smaller than 512 KB.');
return;
}
const reader = new FileReader();
reader.onload = () => {
const room = this.server();
const icon = typeof reader.result === 'string' ? reader.result : '';
if (!room || !icon) {
this.iconError.set('Could not read that image.');
return;
}
this.iconError.set(null);
this.store.dispatch(RoomsActions.updateServerIcon({
roomId: room.id,
icon
}));
this.showSaveSuccess('icon');
};
reader.onerror = () => this.iconError.set('Could not read that image.');
reader.readAsDataURL(file);
}
removeServerIcon(): void {
const room = this.server();
if (!room || !this.canManageIcon()) {
return;
}
this.iconError.set(null);
this.store.dispatch(RoomsActions.updateServerIcon({
roomId: room.id,
icon: ''
}));
this.showSaveSuccess('icon');
}
private showSaveSuccess(key: string): void {
this.saveSuccess.set(key);

View File

@@ -316,7 +316,9 @@
@case ('server') {
<app-server-settings
[server]="selectedServer()"
[isAdmin]="isSelectedServerOwner()"
[isAdmin]="canManageSelectedServerSettings()"
[canManageIcon]="canManageSelectedServerIcon()"
[canDeleteServer]="isSelectedServerOwner()"
/>
}
@case ('serverPlugins') {

View File

@@ -165,6 +165,7 @@ export class SettingsModalComponent {
resolveRoomPermission(viewedRoom, user, 'manageServer') ||
resolveRoomPermission(viewedRoom, user, 'manageRoles') ||
resolveRoomPermission(viewedRoom, user, 'manageChannels') ||
resolveRoomPermission(viewedRoom, user, 'manageIcon') ||
resolveRoomPermission(viewedRoom, user, 'manageBans') ||
resolveRoomPermission(viewedRoom, user, 'kickMembers') ||
resolveRoomPermission(viewedRoom, user, 'banMembers')
@@ -208,6 +209,7 @@ export class SettingsModalComponent {
resolveRoomPermission(server, user, 'manageServer') ||
resolveRoomPermission(server, user, 'manageRoles') ||
resolveRoomPermission(server, user, 'manageChannels') ||
resolveRoomPermission(server, user, 'manageIcon') ||
resolveRoomPermission(server, user, 'manageBans') ||
resolveRoomPermission(server, user, 'kickMembers') ||
resolveRoomPermission(server, user, 'banMembers'))
@@ -252,6 +254,20 @@ export class SettingsModalComponent {
return this.selectedServerRole() === 'host';
});
canManageSelectedServerSettings = computed(() => {
const server = this.selectedServer();
const user = this.currentUser();
return !!server && !!user && (resolveLegacyRole(server, user) === 'host' || resolveRoomPermission(server, user, 'manageServer'));
});
canManageSelectedServerIcon = computed(() => {
const server = this.selectedServer();
const user = this.currentUser();
return !!server && !!user && (resolveLegacyRole(server, user) === 'host' || resolveRoomPermission(server, user, 'manageIcon'));
});
isSelectedServerCurrent = computed(() => {
const selectedServerId = this.selectedServerId();
const currentRoomId = this.currentRoom()?.id ?? null;