Simple Managment webapp [LLM]
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './auth.service';
|
||||
export * from './api.service';
|
||||
export * from './notification.service';
|
||||
export * from './user-cache.service';
|
||||
|
||||
55
webapp/src/app/core/services/user-cache.service.ts
Normal file
55
webapp/src/app/core/services/user-cache.service.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -159,8 +159,10 @@
|
||||
[nzData]="subscriptions()"
|
||||
[nzLoading]="loadingSubscriptions()"
|
||||
[nzShowPagination]="false"
|
||||
[nzNoResult]="noResultTpl"
|
||||
nzSize="small"
|
||||
>
|
||||
<ng-template #noResultTpl></ng-template>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Subscriber</th>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user