Simple Managment webapp [LLM]

This commit is contained in:
2025-12-03 19:03:19 +01:00
parent 7c88281f03
commit 8306992533
15 changed files with 233 additions and 162 deletions

View File

@@ -48,10 +48,7 @@
"tsConfig": "tsconfig.app.json", "tsConfig": "tsconfig.app.json",
"inlineStyleLanguage": "scss", "inlineStyleLanguage": "scss",
"assets": [ "assets": [
{ {"input": "src/assets", "output": ".", "glob": "**/*" }
"glob": "**/*",
"input": "public"
}
], ],
"styles": [ "styles": [
"src/styles.scss" "src/styles.scss"
@@ -96,30 +93,6 @@
} }
}, },
"defaultConfiguration": "development" "defaultConfiguration": "development"
},
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n"
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"polyfills": [
"zone.js",
"zone.js/testing"
],
"tsConfig": "tsconfig.spec.json",
"inlineStyleLanguage": "scss",
"assets": [
{
"glob": "**/*",
"input": "public"
}
],
"styles": [
"src/styles.scss"
],
"scripts": []
}
} }
} }
} }

View File

@@ -32,4 +32,5 @@ export interface MessageListResponse {
messages: Message[]; messages: Message[];
next_page_token: string; next_page_token: string;
page_size: number; page_size: number;
total_count: number;
} }

View File

@@ -1,9 +1,10 @@
export interface SenderNameStatistics { export interface SenderNameStatistics {
name: string; name: string;
first_timestamp: string;
last_timestamp: string; last_timestamp: string;
count: number; count: number;
} }
export interface SenderNameListResponse { export interface SenderNameListResponse {
senders: SenderNameStatistics[]; sender_names: SenderNameStatistics[];
} }

View File

@@ -1,7 +1,7 @@
<div class="login-container"> <div class="login-container">
<nz-card class="login-card"> <nz-card class="login-card">
<div class="login-header"> <div class="login-header">
<img src="assets/logo.png" alt="SimpleCloudNotifier" class="login-logo" /> <img src="/logo.png" alt="SimpleCloudNotifier" class="login-logo" />
<h1>SimpleCloudNotifier</h1> <h1>SimpleCloudNotifier</h1>
</div> </div>

View File

@@ -92,26 +92,22 @@
nzTooltipTitle="Copy" nzTooltipTitle="Copy"
[appCopyToClipboard]="channel()!.subscribe_key!" [appCopyToClipboard]="channel()!.subscribe_key!"
></span> ></span>
<span
nz-icon
nzType="qrcode"
class="action-icon"
nz-tooltip
nzTooltipTitle="Show QR Code"
(click)="showQrCode()"
></span>
</ng-template> </ng-template>
<div class="key-actions"> <div class="key-actions">
<button <button
nz-button nz-button
nzSize="small" nzSize="small"
nz-popconfirm nz-popconfirm
nzPopconfirmTitle="Regenerate subscribe key? Existing subscribers will need the new key." nzPopconfirmTitle="Regenerate subscribe key? The existing key will no longer be valid."
(nzOnConfirm)="regenerateSubscribeKey()" (nzOnConfirm)="regenerateSubscribeKey()"
> >
Regenerate Invalidate & Regenerate
</button> </button>
</div> </div>
<div class="qr-section">
<app-qr-code-display [data]="qrCodeData()"></app-qr-code-display>
<p class="qr-hint">Scan this QR code with the SimpleCloudNotifier app to subscribe to this channel.</p>
</div>
</div> </div>
} }
@@ -238,15 +234,3 @@
</ng-container> </ng-container>
</nz-modal> </nz-modal>
<!-- QR Code Modal -->
<nz-modal
[(nzVisible)]="showQrModal"
nzTitle="Subscribe QR Code"
(nzOnCancel)="closeQrModal()"
[nzFooter]="null"
>
<ng-container *nzModalContent>
<app-qr-code-display [data]="qrCodeData()"></app-qr-code-display>
<p class="qr-hint">Scan this QR code with the SimpleCloudNotifier app to subscribe to this channel.</p>
</ng-container>
</nz-modal>

View File

@@ -44,9 +44,28 @@
} }
} }
.qr-section {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid #f0f0f0;
label {
display: block;
font-weight: 500;
margin-bottom: 12px;
color: #333;
}
app-qr-code-display {
display: flex;
justify-content: center;
}
}
.qr-hint { .qr-hint {
text-align: center; text-align: center;
color: #666; color: #666;
font-size: 13px; font-size: 13px;
margin-top: 16px; margin-top: 12px;
margin-bottom: 0;
} }

View File

@@ -1,4 +1,4 @@
import { Component, inject, signal, OnInit } from '@angular/core'; import { Component, inject, signal, computed, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
@@ -70,9 +70,20 @@ export class ChannelDetailComponent implements OnInit {
editDescription = ''; editDescription = '';
saving = signal(false); saving = signal(false);
// QR modal // QR code data (computed from channel)
showQrModal = signal(false); qrCodeData = computed(() => {
qrCodeData = signal(''); const channel = this.channel();
if (!channel || !channel.subscribe_key) return '';
return [
'@scn.channel.subscribe',
'v1',
channel.display_name,
channel.owner_user_id,
channel.channel_id,
channel.subscribe_key
].join('\n');
});
ngOnInit(): void { ngOnInit(): void {
const channelId = this.route.snapshot.paramMap.get('id'); const channelId = this.route.snapshot.paramMap.get('id');
@@ -211,28 +222,6 @@ export class ChannelDetailComponent implements OnInit {
}); });
} }
// QR Code
showQrCode(): void {
const channel = this.channel();
if (!channel || !channel.subscribe_key) return;
const qrText = [
'@scn.channel.subscribe',
'v1',
channel.display_name,
channel.owner_user_id,
channel.channel_id,
channel.subscribe_key
].join('\n');
this.qrCodeData.set(qrText);
this.showQrModal.set(true);
}
closeQrModal(): void {
this.showQrModal.set(false);
}
getSubscriptionStatus(): { label: string; color: string } { getSubscriptionStatus(): { label: string; color: string } {
const channel = this.channel(); const channel = this.channel();
if (!channel) return { label: 'Unknown', color: 'default' }; if (!channel) return { label: 'Unknown', color: 'default' };

View File

@@ -110,12 +110,14 @@
</tbody> </tbody>
</nz-table> </nz-table>
@if (nextPageToken()) { <div class="pagination-controls">
<div class="load-more"> <nz-pagination
<button nz-button nzType="default" (click)="loadMore()" [nzLoading]="loading()"> [nzPageIndex]="currentPage()"
Load More [nzPageSize]="pageSize"
</button> [nzTotal]="totalCount()"
</div> [nzDisabled]="loading()"
} (nzPageIndexChange)="goToPage($event)"
></nz-pagination>
</div>
</nz-card> </nz-card>
</div> </div>

View File

@@ -42,8 +42,17 @@
margin-top: 4px; margin-top: 4px;
} }
.load-more { .pagination-controls {
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center;
gap: 16px;
padding: 16px 0; padding: 16px 0;
.page-indicator {
font-size: 14px;
color: #666;
min-width: 80px;
text-align: center;
}
} }

View File

@@ -11,6 +11,7 @@ import { NzEmptyModule } from 'ng-zorro-antd/empty';
import { NzSpinModule } from 'ng-zorro-antd/spin'; import { NzSpinModule } from 'ng-zorro-antd/spin';
import { NzCardModule } from 'ng-zorro-antd/card'; import { NzCardModule } from 'ng-zorro-antd/card';
import { NzToolTipModule } from 'ng-zorro-antd/tooltip'; import { NzToolTipModule } from 'ng-zorro-antd/tooltip';
import { NzPaginationModule } from 'ng-zorro-antd/pagination';
import { ApiService } from '../../../core/services/api.service'; import { ApiService } from '../../../core/services/api.service';
import { AuthService } from '../../../core/services/auth.service'; import { AuthService } from '../../../core/services/auth.service';
import { Message, MessageListParams } from '../../../core/models'; import { Message, MessageListParams } from '../../../core/models';
@@ -31,6 +32,7 @@ import { RelativeTimePipe } from '../../../shared/pipes/relative-time.pipe';
NzSpinModule, NzSpinModule,
NzCardModule, NzCardModule,
NzToolTipModule, NzToolTipModule,
NzPaginationModule,
RelativeTimePipe, RelativeTimePipe,
], ],
templateUrl: './message-list.component.html', templateUrl: './message-list.component.html',
@@ -43,7 +45,11 @@ export class MessageListComponent implements OnInit {
messages = signal<Message[]>([]); messages = signal<Message[]>([]);
loading = signal(false); loading = signal(false);
nextPageToken = signal<string | null>(null);
// Pagination
currentPage = signal(1);
pageSize = 50;
totalCount = signal(0);
// Filters // Filters
searchText = ''; searchText = '';
@@ -80,11 +86,11 @@ export class MessageListComponent implements OnInit {
}); });
} }
loadMessages(append = false): void { loadMessages(): void {
this.loading.set(true); this.loading.set(true);
const params: MessageListParams = { const params: MessageListParams = {
page_size: 50, page_size: this.pageSize,
trimmed: true, trimmed: true,
}; };
@@ -97,22 +103,17 @@ export class MessageListComponent implements OnInit {
if (this.channelFilter.length > 0) { if (this.channelFilter.length > 0) {
params.channel = this.channelFilter.join(','); params.channel = this.channelFilter.join(',');
} }
if (append && this.nextPageToken()) {
params.next_page_token = this.nextPageToken()!; // Use page-index based pagination: $1 = page 1, $2 = page 2, etc.
const page = this.currentPage();
if (page > 1) {
params.next_page_token = `$${page}`;
} }
this.apiService.getMessages(params).subscribe({ this.apiService.getMessages(params).subscribe({
next: (response) => { next: (response) => {
if (append) { this.messages.set(response.messages);
this.messages.update(msgs => [...msgs, ...response.messages]); this.totalCount.set(response.total_count);
} else {
this.messages.set(response.messages);
}
this.nextPageToken.set(
response.next_page_token && response.next_page_token !== '@end'
? response.next_page_token
: null
);
this.loading.set(false); this.loading.set(false);
}, },
error: () => { error: () => {
@@ -123,37 +124,44 @@ export class MessageListComponent implements OnInit {
applyFilters(): void { applyFilters(): void {
this.appliedSearchText = this.searchText; this.appliedSearchText = this.searchText;
this.currentPage.set(1);
this.loadMessages(); this.loadMessages();
} }
onPriorityFilterChange(filters: string[] | null): void { onPriorityFilterChange(filters: string[] | null): void {
this.priorityFilter = filters ?? []; this.priorityFilter = filters ?? [];
this.currentPage.set(1);
this.loadMessages(); this.loadMessages();
} }
onChannelFilterChange(filters: string[] | null): void { onChannelFilterChange(filters: string[] | null): void {
this.channelFilter = filters ?? []; this.channelFilter = filters ?? [];
this.currentPage.set(1);
this.loadMessages(); this.loadMessages();
} }
clearSearch(): void { clearSearch(): void {
this.searchText = ''; this.searchText = '';
this.appliedSearchText = ''; this.appliedSearchText = '';
this.currentPage.set(1);
this.loadMessages(); this.loadMessages();
} }
clearChannelFilter(): void { clearChannelFilter(): void {
this.channelFilter = []; this.channelFilter = [];
this.currentPage.set(1);
this.loadMessages(); this.loadMessages();
} }
removeChannelFilter(channel: string): void { removeChannelFilter(channel: string): void {
this.channelFilter = this.channelFilter.filter(c => c !== channel); this.channelFilter = this.channelFilter.filter(c => c !== channel);
this.currentPage.set(1);
this.loadMessages(); this.loadMessages();
} }
clearPriorityFilter(): void { clearPriorityFilter(): void {
this.priorityFilter = []; this.priorityFilter = [];
this.currentPage.set(1);
this.loadMessages(); this.loadMessages();
} }
@@ -162,6 +170,7 @@ export class MessageListComponent implements OnInit {
this.appliedSearchText = ''; this.appliedSearchText = '';
this.channelFilter = []; this.channelFilter = [];
this.priorityFilter = []; this.priorityFilter = [];
this.currentPage.set(1);
this.loadMessages(); this.loadMessages();
} }
@@ -175,10 +184,9 @@ export class MessageListComponent implements OnInit {
return channel?.text?.toString() ?? internalName; return channel?.text?.toString() ?? internalName;
} }
loadMore(): void { goToPage(page: number): void {
if (this.nextPageToken()) { this.currentPage.set(page);
this.loadMessages(true); this.loadMessages();
}
} }
viewMessage(message: Message): void { viewMessage(message: Message): void {

View File

@@ -1,50 +1,95 @@
<div class="page-content"> <div class="page-content">
<div class="page-header"> <div class="page-header">
<h2>Senders</h2> <h2>Senders</h2>
<button nz-button (click)="loadSenders()"> <button nz-button (click)="refresh()">
<span nz-icon nzType="reload"></span> <span nz-icon nzType="reload"></span>
Refresh Refresh
</button> </button>
</div> </div>
<nz-card> <nz-card>
<nz-table <nz-tabset (nzSelectedIndexChange)="onTabChange($event)">
#senderTable <nz-tab nzTitle="My Senders">
[nzData]="senders()" <nz-table
[nzLoading]="loading()" #mySenderTable
[nzShowPagination]="false" [nzData]="mySenders()"
[nzNoResult]="noResultTpl" [nzLoading]="loadingMy()"
nzSize="middle" [nzShowPagination]="false"
> [nzNoResult]="noResultTpl"
<ng-template #noResultTpl></ng-template> nzSize="middle"
<thead> >
<tr> <ng-template #noResultTpl></ng-template>
<th nzWidth="40%">Sender Name</th> <thead>
<th nzWidth="20%">Message Count</th> <tr>
<th nzWidth="40%">Last Used</th> <th nzWidth="40%">Sender Name</th>
</tr> <th nzWidth="20%">Message Count</th>
</thead> <th nzWidth="40%">Last Used</th>
<tbody> </tr>
@for (sender of senders(); track sender.name) { </thead>
<tr> <tbody>
<td> @for (sender of mySenders(); track sender.name) {
<span class="sender-name">{{ sender.name || '(No name)' }}</span> <tr>
</td> <td>
<td>{{ sender.count }}</td> <span class="sender-name">{{ sender.name || '(No name)' }}</span>
<td> </td>
<span nz-tooltip [nzTooltipTitle]="sender.last_timestamp"> <td>{{ sender.count }}</td>
{{ sender.last_timestamp | relativeTime }} <td>
</span> <span nz-tooltip [nzTooltipTitle]="sender.last_timestamp">
</td> {{ sender.last_timestamp | relativeTime }}
</tr> </span>
} @empty { </td>
<tr> </tr>
<td colspan="3"> } @empty {
<nz-empty nzNotFoundContent="No senders found"></nz-empty> <tr>
</td> <td colspan="3">
</tr> <nz-empty nzNotFoundContent="No senders found"></nz-empty>
} </td>
</tbody> </tr>
</nz-table> }
</tbody>
</nz-table>
</nz-tab>
<nz-tab nzTitle="All Senders">
<nz-table
#allSenderTable
[nzData]="allSenders()"
[nzLoading]="loadingAll()"
[nzShowPagination]="false"
[nzNoResult]="noResultTpl2"
nzSize="middle"
>
<ng-template #noResultTpl2></ng-template>
<thead>
<tr>
<th nzWidth="40%">Sender Name</th>
<th nzWidth="20%">Message Count</th>
<th nzWidth="40%">Last Used</th>
</tr>
</thead>
<tbody>
@for (sender of allSenders(); track sender.name) {
<tr>
<td>
<span class="sender-name">{{ sender.name || '(No name)' }}</span>
</td>
<td>{{ sender.count }}</td>
<td>
<span nz-tooltip [nzTooltipTitle]="sender.last_timestamp">
{{ sender.last_timestamp | relativeTime }}
</span>
</td>
</tr>
} @empty {
<tr>
<td colspan="3">
<nz-empty nzNotFoundContent="No senders found"></nz-empty>
</td>
</tr>
}
</tbody>
</nz-table>
</nz-tab>
</nz-tabset>
</nz-card> </nz-card>
</div> </div>

View File

@@ -6,7 +6,9 @@ import { NzIconModule } from 'ng-zorro-antd/icon';
import { NzEmptyModule } from 'ng-zorro-antd/empty'; import { NzEmptyModule } from 'ng-zorro-antd/empty';
import { NzCardModule } from 'ng-zorro-antd/card'; import { NzCardModule } from 'ng-zorro-antd/card';
import { NzToolTipModule } from 'ng-zorro-antd/tooltip'; import { NzToolTipModule } from 'ng-zorro-antd/tooltip';
import { NzTabsModule } from 'ng-zorro-antd/tabs';
import { ApiService } from '../../../core/services/api.service'; import { ApiService } from '../../../core/services/api.service';
import { AuthService } from '../../../core/services/auth.service';
import { SenderNameStatistics } from '../../../core/models'; import { SenderNameStatistics } from '../../../core/models';
import { RelativeTimePipe } from '../../../shared/pipes/relative-time.pipe'; import { RelativeTimePipe } from '../../../shared/pipes/relative-time.pipe';
@@ -21,6 +23,7 @@ import { RelativeTimePipe } from '../../../shared/pipes/relative-time.pipe';
NzEmptyModule, NzEmptyModule,
NzCardModule, NzCardModule,
NzToolTipModule, NzToolTipModule,
NzTabsModule,
RelativeTimePipe, RelativeTimePipe,
], ],
templateUrl: './sender-list.component.html', templateUrl: './sender-list.component.html',
@@ -28,24 +31,61 @@ import { RelativeTimePipe } from '../../../shared/pipes/relative-time.pipe';
}) })
export class SenderListComponent implements OnInit { export class SenderListComponent implements OnInit {
private apiService = inject(ApiService); private apiService = inject(ApiService);
private authService = inject(AuthService);
senders = signal<SenderNameStatistics[]>([]); mySenders = signal<SenderNameStatistics[]>([]);
loading = signal(false); allSenders = signal<SenderNameStatistics[]>([]);
loadingMy = signal(false);
loadingAll = signal(false);
activeTab = signal(0);
ngOnInit(): void { ngOnInit(): void {
this.loadSenders(); this.loadMySenders();
} }
loadSenders(): void { onTabChange(index: number): void {
this.loading.set(true); this.activeTab.set(index);
this.apiService.getSenderNames().subscribe({ if (index === 0 && this.mySenders().length === 0) {
this.loadMySenders();
} else if (index === 1 && this.allSenders().length === 0) {
this.loadAllSenders();
}
}
loadMySenders(): void {
const userId = this.authService.getUserId();
if (!userId) return;
this.loadingMy.set(true);
this.apiService.getUserSenderNames(userId).subscribe({
next: (response) => { next: (response) => {
this.senders.set(response.senders); this.mySenders.set(response.sender_names);
this.loading.set(false); this.loadingMy.set(false);
}, },
error: () => { error: () => {
this.loading.set(false); this.loadingMy.set(false);
} }
}); });
} }
loadAllSenders(): void {
this.loadingAll.set(true);
this.apiService.getSenderNames().subscribe({
next: (response) => {
this.allSenders.set(response.sender_names);
this.loadingAll.set(false);
},
error: () => {
this.loadingAll.set(false);
}
});
}
refresh(): void {
if (this.activeTab() === 0) {
this.loadMySenders();
} else {
this.loadAllSenders();
}
}
} }

View File

@@ -9,10 +9,9 @@
[nzCollapsedWidth]="80" [nzCollapsedWidth]="80"
> >
<div class="sidebar-logo"> <div class="sidebar-logo">
<img src="/logo.png" alt="SCN" class="sidebar-logo-img" />
@if (!isCollapsed()) { @if (!isCollapsed()) {
<span>SimpleCloudNotifier</span> <span>SimpleCloudNotifier</span>
} @else {
<span>SCN</span>
} }
</div> </div>
<ul nz-menu nzTheme="dark" nzMode="inline" [nzInlineCollapsed]="isCollapsed()"> <ul nz-menu nzTheme="dark" nzMode="inline" [nzInlineCollapsed]="isCollapsed()">
@@ -62,7 +61,6 @@
<span nz-icon nzType="logout"></span> <span nz-icon nzType="logout"></span>
Logout Logout
</button> </button>
<img src="assets/logo.png" alt="SCN" class="header-logo" />
</div> </div>
</nz-header> </nz-header>
<nz-content class="content-area"> <nz-content class="content-area">

View File

@@ -18,6 +18,7 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 10px;
padding: 0 16px; padding: 0 16px;
background: #001529; background: #001529;
color: #fff; color: #fff;
@@ -25,6 +26,12 @@
font-weight: 600; font-weight: 600;
overflow: hidden; overflow: hidden;
white-space: nowrap; white-space: nowrap;
.sidebar-logo-img {
height: 32px;
width: auto;
flex-shrink: 0;
}
} }
.app-header { .app-header {
@@ -64,11 +71,6 @@
color: #666; color: #666;
font-size: 13px; font-size: 13px;
} }
.header-logo {
height: 36px;
width: auto;
}
} }
.content-area { .content-area {

View File

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 97 KiB