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",
"inlineStyleLanguage": "scss",
"assets": [
{
"glob": "**/*",
"input": "public"
}
{"input": "src/assets", "output": ".", "glob": "**/*" }
],
"styles": [
"src/styles.scss"
@@ -96,30 +93,6 @@
}
},
"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[];
next_page_token: string;
page_size: number;
total_count: number;
}

View File

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

View File

@@ -1,7 +1,7 @@
<div class="login-container">
<nz-card class="login-card">
<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>
</div>

View File

@@ -92,26 +92,22 @@
nzTooltipTitle="Copy"
[appCopyToClipboard]="channel()!.subscribe_key!"
></span>
<span
nz-icon
nzType="qrcode"
class="action-icon"
nz-tooltip
nzTooltipTitle="Show QR Code"
(click)="showQrCode()"
></span>
</ng-template>
<div class="key-actions">
<button
nz-button
nzSize="small"
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()"
>
Regenerate
Invalidate & Regenerate
</button>
</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>
}
@@ -238,15 +234,3 @@
</ng-container>
</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 {
text-align: center;
color: #666;
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 { ActivatedRoute, Router } from '@angular/router';
import { FormsModule } from '@angular/forms';
@@ -70,9 +70,20 @@ export class ChannelDetailComponent implements OnInit {
editDescription = '';
saving = signal(false);
// QR modal
showQrModal = signal(false);
qrCodeData = signal('');
// QR code data (computed from channel)
qrCodeData = computed(() => {
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 {
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 } {
const channel = this.channel();
if (!channel) return { label: 'Unknown', color: 'default' };

View File

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

View File

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

View File

@@ -1,50 +1,95 @@
<div class="page-content">
<div class="page-header">
<h2>Senders</h2>
<button nz-button (click)="loadSenders()">
<button nz-button (click)="refresh()">
<span nz-icon nzType="reload"></span>
Refresh
</button>
</div>
<nz-card>
<nz-table
#senderTable
[nzData]="senders()"
[nzLoading]="loading()"
[nzShowPagination]="false"
[nzNoResult]="noResultTpl"
nzSize="middle"
>
<ng-template #noResultTpl></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 senders(); 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-tabset (nzSelectedIndexChange)="onTabChange($event)">
<nz-tab nzTitle="My Senders">
<nz-table
#mySenderTable
[nzData]="mySenders()"
[nzLoading]="loadingMy()"
[nzShowPagination]="false"
[nzNoResult]="noResultTpl"
nzSize="middle"
>
<ng-template #noResultTpl></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 mySenders(); 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-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>
</div>

View File

@@ -6,7 +6,9 @@ import { NzIconModule } from 'ng-zorro-antd/icon';
import { NzEmptyModule } from 'ng-zorro-antd/empty';
import { NzCardModule } from 'ng-zorro-antd/card';
import { NzToolTipModule } from 'ng-zorro-antd/tooltip';
import { NzTabsModule } from 'ng-zorro-antd/tabs';
import { ApiService } from '../../../core/services/api.service';
import { AuthService } from '../../../core/services/auth.service';
import { SenderNameStatistics } from '../../../core/models';
import { RelativeTimePipe } from '../../../shared/pipes/relative-time.pipe';
@@ -21,6 +23,7 @@ import { RelativeTimePipe } from '../../../shared/pipes/relative-time.pipe';
NzEmptyModule,
NzCardModule,
NzToolTipModule,
NzTabsModule,
RelativeTimePipe,
],
templateUrl: './sender-list.component.html',
@@ -28,24 +31,61 @@ import { RelativeTimePipe } from '../../../shared/pipes/relative-time.pipe';
})
export class SenderListComponent implements OnInit {
private apiService = inject(ApiService);
private authService = inject(AuthService);
senders = signal<SenderNameStatistics[]>([]);
loading = signal(false);
mySenders = signal<SenderNameStatistics[]>([]);
allSenders = signal<SenderNameStatistics[]>([]);
loadingMy = signal(false);
loadingAll = signal(false);
activeTab = signal(0);
ngOnInit(): void {
this.loadSenders();
this.loadMySenders();
}
loadSenders(): void {
this.loading.set(true);
this.apiService.getSenderNames().subscribe({
onTabChange(index: number): void {
this.activeTab.set(index);
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) => {
this.senders.set(response.senders);
this.loading.set(false);
this.mySenders.set(response.sender_names);
this.loadingMy.set(false);
},
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"
>
<div class="sidebar-logo">
<img src="/logo.png" alt="SCN" class="sidebar-logo-img" />
@if (!isCollapsed()) {
<span>SimpleCloudNotifier</span>
} @else {
<span>SCN</span>
}
</div>
<ul nz-menu nzTheme="dark" nzMode="inline" [nzInlineCollapsed]="isCollapsed()">
@@ -62,7 +61,6 @@
<span nz-icon nzType="logout"></span>
Logout
</button>
<img src="assets/logo.png" alt="SCN" class="header-logo" />
</div>
</nz-header>
<nz-content class="content-area">

View File

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

View File

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 97 KiB