Simple Managment webapp [LLM]

This commit is contained in:
2025-12-03 18:00:42 +01:00
parent e7f613b5dc
commit c860ef9c30
14 changed files with 153 additions and 126 deletions

View File

@@ -5,6 +5,7 @@ import { environment } from '../../../environments/environment';
import {
User,
UserWithExtra,
UserPreview,
Message,
MessageListParams,
MessageListResponse,
@@ -41,6 +42,10 @@ export class ApiService {
return this.http.get<UserWithExtra>(`${this.baseUrl}/users/${userId}`);
}
getUserPreview(userId: string): Observable<UserPreview> {
return this.http.get<UserPreview>(`${this.baseUrl}/preview/users/${userId}`);
}
updateUser(userId: string, data: { username?: string; pro_token?: string }): Observable<User> {
return this.http.patch<User>(`${this.baseUrl}/users/${userId}`, data);
}

View File

@@ -1,3 +1,4 @@
export * from './auth.service';
export * from './api.service';
export * from './notification.service';
export * from './user-cache.service';

View File

@@ -0,0 +1,55 @@
import { Injectable, inject, signal } from '@angular/core';
import { Observable, of, tap, catchError, map, shareReplay } from 'rxjs';
import { ApiService } from './api.service';
import { AuthService } from './auth.service';
import { UserPreview } from '../models';
export interface ResolvedUser {
userId: string;
displayName: string;
isCurrentUser: boolean;
}
@Injectable({
providedIn: 'root'
})
export class UserCacheService {
private apiService = inject(ApiService);
private authService = inject(AuthService);
private cache = new Map<string, Observable<UserPreview | null>>();
resolveUser(userId: string): Observable<ResolvedUser> {
const currentUserId = this.authService.getUserId();
const isCurrentUser = userId === currentUserId;
return this.getUserPreview(userId).pipe(
map(preview => {
let displayName = preview?.username || userId;
if (isCurrentUser) {
displayName += ' (you)';
}
return {
userId,
displayName,
isCurrentUser
};
})
);
}
private getUserPreview(userId: string): Observable<UserPreview | null> {
if (!this.cache.has(userId)) {
const request$ = this.apiService.getUserPreview(userId).pipe(
catchError(() => of(null)),
shareReplay(1)
);
this.cache.set(userId, request$);
}
return this.cache.get(userId)!;
}
clearCache(): void {
this.cache.clear();
}
}

View File

@@ -2,7 +2,6 @@
<nz-card class="login-card">
<div class="login-header">
<h1>SimpleCloudNotifier</h1>
<p>Sign in to manage your notifications</p>
</div>
@if (error()) {
@@ -14,10 +13,10 @@
></nz-alert>
}
<form (ngSubmit)="login()">
<form nz-form nzLayout="horizontal" (ngSubmit)="login()">
<nz-form-item>
<nz-form-label>User ID</nz-form-label>
<nz-form-control>
<nz-form-label [nzSpan]="7">User ID</nz-form-label>
<nz-form-control [nzSpan]="17">
<nz-input-group nzPrefixIcon="user">
<input
type="text"
@@ -32,8 +31,8 @@
</nz-form-item>
<nz-form-item>
<nz-form-label>Admin Key</nz-form-label>
<nz-form-control>
<nz-form-label [nzSpan]="7">Admin Key</nz-form-label>
<nz-form-control [nzSpan]="17">
<nz-input-group nzPrefixIcon="key" [nzSuffix]="keySuffix">
<input
[type]="showKey() ? 'text' : 'password'"
@@ -69,7 +68,7 @@
</form>
<div class="login-footer">
<p>You need an admin key (with "A" permission) to access the dashboard.</p>
<p>You need an admin key to access the dashboard.</p>
</div>
</nz-card>
</div>

View File

@@ -159,8 +159,10 @@
[nzData]="subscriptions()"
[nzLoading]="loadingSubscriptions()"
[nzShowPagination]="false"
[nzNoResult]="noResultTpl"
nzSize="small"
>
<ng-template #noResultTpl></ng-template>
<thead>
<tr>
<th>Subscriber</th>

View File

@@ -6,37 +6,27 @@
<span nz-icon nzType="reload"></span>
Refresh
</button>
<button nz-button nzType="primary" (click)="openCreateModal()">
<span nz-icon nzType="plus"></span>
Create Channel
</button>
</div>
</div>
<nz-card class="filter-card">
<nz-radio-group [(ngModel)]="selector" (ngModelChange)="onSelectorChange()">
<label nz-radio-button nzValue="all">All</label>
<label nz-radio-button nzValue="owned">Owned</label>
<label nz-radio-button nzValue="subscribed">Subscribed</label>
<label nz-radio-button nzValue="subscribed_any">Subscribed (Any)</label>
</nz-radio-group>
</nz-card>
<nz-card>
<nz-table
#channelTable
[nzData]="channels()"
[nzLoading]="loading()"
[nzShowPagination]="false"
[nzNoResult]="noResultTpl"
nzSize="middle"
>
<ng-template #noResultTpl></ng-template>
<thead>
<tr>
<th nzWidth="25%">Name</th>
<th nzWidth="20%">Internal Name</th>
<th nzWidth="20%">Name</th>
<th nzWidth="15%">Internal Name</th>
<th nzWidth="15%">Owner</th>
<th nzWidth="15%">Status</th>
<th nzWidth="15%">Messages</th>
<th nzWidth="25%">Last Sent</th>
<th nzWidth="20%">Last Sent</th>
</tr>
</thead>
<tbody>
@@ -51,6 +41,7 @@
<td>
<span class="mono">{{ channel.internal_name }}</span>
</td>
<td>{{ getOwnerDisplayName(channel.owner_user_id) }}</td>
<td>
<nz-tag [nzColor]="getSubscriptionStatus(channel).color">
{{ getSubscriptionStatus(channel).label }}
@@ -69,7 +60,7 @@
</tr>
} @empty {
<tr>
<td colspan="5">
<td colspan="6">
<nz-empty nzNotFoundContent="No channels found"></nz-empty>
</td>
</tr>
@@ -79,32 +70,3 @@
</nz-card>
</div>
<!-- Create Channel Modal -->
<nz-modal
[(nzVisible)]="showCreateModal"
nzTitle="Create Channel"
(nzOnCancel)="closeCreateModal()"
(nzOnOk)="createChannel()"
[nzOkLoading]="creating()"
[nzOkDisabled]="!newChannelName.trim()"
>
<ng-container *nzModalContent>
<nz-form-item>
<nz-form-label>Channel Name</nz-form-label>
<nz-form-control>
<input
type="text"
nz-input
placeholder="Enter channel name"
[(ngModel)]="newChannelName"
/>
</nz-form-control>
</nz-form-item>
<nz-form-item class="mb-0">
<label nz-checkbox [(ngModel)]="newChannelSubscribe">
Subscribe to this channel
</label>
</nz-form-item>
</ng-container>
</nz-modal>

View File

@@ -1,7 +1,6 @@
import { Component, inject, signal, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Router } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { NzTableModule } from 'ng-zorro-antd/table';
import { NzButtonModule } from 'ng-zorro-antd/button';
import { NzIconModule } from 'ng-zorro-antd/icon';
@@ -9,16 +8,11 @@ import { NzTagModule } from 'ng-zorro-antd/tag';
import { NzBadgeModule } from 'ng-zorro-antd/badge';
import { NzEmptyModule } from 'ng-zorro-antd/empty';
import { NzCardModule } from 'ng-zorro-antd/card';
import { NzRadioModule } from 'ng-zorro-antd/radio';
import { NzModalModule, NzModalService } from 'ng-zorro-antd/modal';
import { NzFormModule } from 'ng-zorro-antd/form';
import { NzInputModule } from 'ng-zorro-antd/input';
import { NzCheckboxModule } from 'ng-zorro-antd/checkbox';
import { NzToolTipModule } from 'ng-zorro-antd/tooltip';
import { ApiService } from '../../../core/services/api.service';
import { AuthService } from '../../../core/services/auth.service';
import { NotificationService } from '../../../core/services/notification.service';
import { ChannelWithSubscription, ChannelSelector } from '../../../core/models';
import { UserCacheService, ResolvedUser } from '../../../core/services/user-cache.service';
import { ChannelWithSubscription } from '../../../core/models';
import { RelativeTimePipe } from '../../../shared/pipes/relative-time.pipe';
@Component({
@@ -26,7 +20,6 @@ import { RelativeTimePipe } from '../../../shared/pipes/relative-time.pipe';
standalone: true,
imports: [
CommonModule,
FormsModule,
NzTableModule,
NzButtonModule,
NzIconModule,
@@ -34,11 +27,6 @@ import { RelativeTimePipe } from '../../../shared/pipes/relative-time.pipe';
NzBadgeModule,
NzEmptyModule,
NzCardModule,
NzRadioModule,
NzModalModule,
NzFormModule,
NzInputModule,
NzCheckboxModule,
NzToolTipModule,
RelativeTimePipe,
],
@@ -48,19 +36,12 @@ import { RelativeTimePipe } from '../../../shared/pipes/relative-time.pipe';
export class ChannelListComponent implements OnInit {
private apiService = inject(ApiService);
private authService = inject(AuthService);
private notification = inject(NotificationService);
private modal = inject(NzModalService);
private userCacheService = inject(UserCacheService);
private router = inject(Router);
channels = signal<ChannelWithSubscription[]>([]);
ownerNames = signal<Map<string, ResolvedUser>>(new Map());
loading = signal(false);
selector: ChannelSelector = 'all';
// Create channel modal
showCreateModal = signal(false);
newChannelName = '';
newChannelSubscribe = true;
creating = signal(false);
ngOnInit(): void {
this.loadChannels();
@@ -71,10 +52,11 @@ export class ChannelListComponent implements OnInit {
if (!userId) return;
this.loading.set(true);
this.apiService.getChannels(userId, this.selector).subscribe({
this.apiService.getChannels(userId, 'all_any').subscribe({
next: (response) => {
this.channels.set(response.channels);
this.loading.set(false);
this.resolveOwnerNames(response.channels);
},
error: () => {
this.loading.set(false);
@@ -82,45 +64,24 @@ export class ChannelListComponent implements OnInit {
});
}
onSelectorChange(): void {
this.loadChannels();
private resolveOwnerNames(channels: ChannelWithSubscription[]): void {
const uniqueOwnerIds = [...new Set(channels.map(c => c.owner_user_id))];
for (const ownerId of uniqueOwnerIds) {
this.userCacheService.resolveUser(ownerId).subscribe(resolved => {
this.ownerNames.update(map => new Map(map).set(ownerId, resolved));
});
}
}
getOwnerDisplayName(ownerId: string): string {
const resolved = this.ownerNames().get(ownerId);
return resolved?.displayName || ownerId;
}
viewChannel(channel: ChannelWithSubscription): void {
this.router.navigate(['/channels', channel.channel_id]);
}
openCreateModal(): void {
this.newChannelName = '';
this.newChannelSubscribe = true;
this.showCreateModal.set(true);
}
closeCreateModal(): void {
this.showCreateModal.set(false);
}
createChannel(): void {
const userId = this.authService.getUserId();
if (!userId || !this.newChannelName.trim()) return;
this.creating.set(true);
this.apiService.createChannel(userId, {
name: this.newChannelName.trim(),
subscribe: this.newChannelSubscribe
}).subscribe({
next: (channel) => {
this.notification.success('Channel created successfully');
this.closeCreateModal();
this.creating.set(false);
this.loadChannels();
},
error: () => {
this.creating.set(false);
}
});
}
getSubscriptionStatus(channel: ChannelWithSubscription): { label: string; color: string } {
const userId = this.authService.getUserId();

View File

@@ -13,8 +13,10 @@
[nzData]="clients()"
[nzLoading]="loading()"
[nzShowPagination]="false"
[nzNoResult]="noResultTpl"
nzSize="middle"
>
<ng-template #noResultTpl></ng-template>
<thead>
<tr>
<th nzWidth="5%"></th>

View File

@@ -19,8 +19,10 @@
[nzData]="keys()"
[nzLoading]="loading()"
[nzShowPagination]="false"
[nzNoResult]="noResultTpl"
nzSize="middle"
>
<ng-template #noResultTpl></ng-template>
<thead>
<tr>
<th nzWidth="25%">Name</th>

View File

@@ -60,8 +60,10 @@
[nzData]="messages()"
[nzLoading]="loading()"
[nzShowPagination]="false"
[nzNoResult]="noResultTpl"
nzSize="middle"
>
<ng-template #noResultTpl></ng-template>
<thead>
<tr>
<th nzWidth="40%">Title</th>

View File

@@ -13,8 +13,10 @@
[nzData]="senders()"
[nzLoading]="loading()"
[nzShowPagination]="false"
[nzNoResult]="noResultTpl"
nzSize="middle"
>
<ng-template #noResultTpl></ng-template>
<thead>
<tr>
<th nzWidth="40%">Sender Name</th>

View File

@@ -25,16 +25,19 @@
[nzData]="subscriptions()"
[nzLoading]="loading()"
[nzShowPagination]="false"
[nzNoResult]="noResultTpl"
nzSize="middle"
>
<ng-template #noResultTpl></ng-template>
<thead>
<tr>
<th nzWidth="15%">Direction</th>
<th nzWidth="25%">Channel</th>
<th nzWidth="20%">Subscriber / Owner</th>
<th nzWidth="15%">Status</th>
<th nzWidth="15%">Created</th>
<th nzWidth="10%">Actions</th>
<th nzWidth="10%">Direction</th>
<th nzWidth="20%">Channel</th>
<th nzWidth="20%">Subscriber</th>
<th nzWidth="20%">Owner</th>
<th nzWidth="10%">Status</th>
<th nzWidth="12%">Created</th>
<th nzWidth="8%">Actions</th>
</tr>
</thead>
<tbody>
@@ -48,15 +51,8 @@
<td>
<span class="mono">{{ sub.channel_internal_name }}</span>
</td>
<td>
@if (isOutgoing(sub)) {
<span class="label">Owner:</span>
<span class="mono">{{ sub.channel_owner_user_id }}</span>
} @else {
<span class="label">Subscriber:</span>
<span class="mono">{{ sub.subscriber_user_id }}</span>
}
</td>
<td>{{ getUserDisplayName(sub.subscriber_user_id) }}</td>
<td>{{ getUserDisplayName(sub.channel_owner_user_id) }}</td>
<td>
<nz-tag [nzColor]="getStatusInfo(sub).color">
{{ getStatusInfo(sub).label }}
@@ -113,7 +109,7 @@
</tr>
} @empty {
<tr>
<td colspan="6">
<td colspan="7">
<nz-empty nzNotFoundContent="No subscriptions found"></nz-empty>
</td>
</tr>

View File

@@ -16,6 +16,7 @@ import { NzToolTipModule } from 'ng-zorro-antd/tooltip';
import { ApiService } from '../../../core/services/api.service';
import { AuthService } from '../../../core/services/auth.service';
import { NotificationService } from '../../../core/services/notification.service';
import { UserCacheService, ResolvedUser } from '../../../core/services/user-cache.service';
import { Subscription, SubscriptionFilter } from '../../../core/models';
import { RelativeTimePipe } from '../../../shared/pipes/relative-time.pipe';
@@ -48,8 +49,10 @@ export class SubscriptionListComponent implements OnInit {
private apiService = inject(ApiService);
private authService = inject(AuthService);
private notification = inject(NotificationService);
private userCacheService = inject(UserCacheService);
subscriptions = signal<Subscription[]>([]);
userNames = signal<Map<string, ResolvedUser>>(new Map());
loading = signal(false);
direction: TabDirection = 'both';
@@ -78,6 +81,7 @@ export class SubscriptionListComponent implements OnInit {
next: (response) => {
this.subscriptions.set(response.subscriptions);
this.loading.set(false);
this.resolveUserNames(response.subscriptions);
},
error: () => {
this.loading.set(false);
@@ -85,6 +89,24 @@ export class SubscriptionListComponent implements OnInit {
});
}
private resolveUserNames(subscriptions: Subscription[]): void {
const userIds = new Set<string>();
for (const sub of subscriptions) {
userIds.add(sub.subscriber_user_id);
userIds.add(sub.channel_owner_user_id);
}
for (const id of userIds) {
this.userCacheService.resolveUser(id).subscribe(resolved => {
this.userNames.update(map => new Map(map).set(id, resolved));
});
}
}
getUserDisplayName(userId: string): string {
const resolved = this.userNames().get(userId);
return resolved?.displayName || userId;
}
onTabChange(index: number): void {
const directions: TabDirection[] = ['both', 'outgoing', 'incoming'];
this.direction = directions[index];

View File

@@ -134,3 +134,19 @@ body {
height: auto;
}
}
nz-card {
border: 1px solid #CCC !important;
box-shadow: 0 0 6px #CCC;
> .ant-card-head {
border-bottom: 1px solid #CCC;
}
display: flex;
flex-direction: column;
> .ant-card-body {
flex-grow: 1;
}
}