More webapp changes+fixes
This commit is contained in:
50
webapp/CLAUDE.md
Normal file
50
webapp/CLAUDE.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
This is the web application for SimpleCloudNotifier (SCN), a push notification service. It's an Angular 19 standalone component-based SPA using ng-zorro-antd (Ant Design) for UI components.
|
||||
|
||||
## Common Commands
|
||||
|
||||
- `npm start` - Start development server
|
||||
- `npm run build` - Production build (outputs to `dist/scn-webapp`)
|
||||
- `npm run watch` - Development build with watch mode
|
||||
- `npm test` - Run tests with Karma
|
||||
|
||||
## Architecture
|
||||
|
||||
### Application Structure
|
||||
|
||||
The app follows a feature-based module organization with standalone components:
|
||||
|
||||
- `src/app/core/` - Singleton services, guards, interceptors, and data models
|
||||
- `src/app/features/` - Feature modules (messages, channels, subscriptions, keys, clients, senders, account, auth)
|
||||
- `src/app/shared/` - Reusable components, directives, and pipes
|
||||
- `src/app/layout/` - Main layout component with sidebar navigation
|
||||
|
||||
### Key Patterns
|
||||
|
||||
**Authentication**: Uses a custom `SCN` token scheme. Credentials (user_id and admin_key) are stored in sessionStorage and attached via `authInterceptor`. The `authGuard` protects all routes except `/login`.
|
||||
|
||||
**API Communication**: All API calls go through `ApiService` (`src/app/core/services/api.service.ts`). The base URL is configured in `src/environments/environment.ts`.
|
||||
|
||||
**State Management**: Uses Angular signals throughout. No external state library - each component manages its own state with signals.
|
||||
|
||||
**Routing**: Lazy-loaded standalone components. All authenticated routes are children of `MainLayoutComponent`.
|
||||
|
||||
### Data Models
|
||||
|
||||
Models in `src/app/core/models/` correspond to SCN API entities:
|
||||
- User, Message, Channel, Subscription, KeyToken, Client, SenderName
|
||||
|
||||
### UI Framework
|
||||
|
||||
Uses ng-zorro-antd with explicit icon imports in `app.config.ts`. Icons must be added to the `icons` array before use.
|
||||
|
||||
### Project Configuration
|
||||
|
||||
- SCSS for styling
|
||||
- Strict TypeScript (`strict: true`)
|
||||
- Component generation skips tests by default (configured in `angular.json`)
|
||||
18
webapp/src/app/core/models/delivery.model.ts
Normal file
18
webapp/src/app/core/models/delivery.model.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export type DeliveryStatus = 'RETRY' | 'SUCCESS' | 'FAILED';
|
||||
|
||||
export interface Delivery {
|
||||
delivery_id: string;
|
||||
message_id: string;
|
||||
receiver_user_id: string;
|
||||
receiver_client_id: string;
|
||||
timestamp_created: string;
|
||||
timestamp_finalized: string | null;
|
||||
status: DeliveryStatus;
|
||||
retry_count: number;
|
||||
next_delivery: string | null;
|
||||
fcm_message_id: string | null;
|
||||
}
|
||||
|
||||
export interface DeliveryListResponse {
|
||||
deliveries: Delivery[];
|
||||
}
|
||||
@@ -5,4 +5,5 @@ export * from './subscription.model';
|
||||
export * from './key-token.model';
|
||||
export * from './client.model';
|
||||
export * from './sender-name.model';
|
||||
export * from './delivery.model';
|
||||
export * from './api-response.model';
|
||||
|
||||
@@ -26,7 +26,8 @@ export interface CreateSubscriptionRequest {
|
||||
}
|
||||
|
||||
export interface ConfirmSubscriptionRequest {
|
||||
confirmed: boolean;
|
||||
confirmed?: boolean;
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
export interface SubscriptionListResponse {
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
ClientListResponse,
|
||||
SenderNameStatistics,
|
||||
SenderNameListResponse,
|
||||
DeliveryListResponse,
|
||||
} from '../models';
|
||||
|
||||
@Injectable({
|
||||
@@ -167,6 +168,10 @@ export class ApiService {
|
||||
return this.http.delete<Message>(`${this.baseUrl}/messages/${messageId}`);
|
||||
}
|
||||
|
||||
getDeliveries(messageId: string): Observable<DeliveryListResponse> {
|
||||
return this.http.get<DeliveryListResponse>(`${this.baseUrl}/messages/${messageId}/deliveries`);
|
||||
}
|
||||
|
||||
// Subscription endpoints
|
||||
getSubscriptions(userId: string, filter?: SubscriptionFilter): Observable<SubscriptionListResponse> {
|
||||
let httpParams = new HttpParams();
|
||||
|
||||
32
webapp/src/app/core/services/settings.service.ts
Normal file
32
webapp/src/app/core/services/settings.service.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Injectable, signal } from '@angular/core';
|
||||
|
||||
const EXPERT_MODE_KEY = 'scn_expert_mode';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class SettingsService {
|
||||
private _expertMode = signal(false);
|
||||
|
||||
expertMode = this._expertMode.asReadonly();
|
||||
|
||||
constructor() {
|
||||
this.loadFromStorage();
|
||||
}
|
||||
|
||||
private loadFromStorage(): void {
|
||||
const stored = localStorage.getItem(EXPERT_MODE_KEY);
|
||||
if (stored === 'true') {
|
||||
this._expertMode.set(true);
|
||||
}
|
||||
}
|
||||
|
||||
setExpertMode(enabled: boolean): void {
|
||||
localStorage.setItem(EXPERT_MODE_KEY, String(enabled));
|
||||
this._expertMode.set(enabled);
|
||||
}
|
||||
|
||||
toggleExpertMode(): void {
|
||||
this.setExpertMode(!this._expertMode());
|
||||
}
|
||||
}
|
||||
@@ -13,36 +13,36 @@
|
||||
</div>
|
||||
} @else if (user()) {
|
||||
<nz-card nzTitle="User Information">
|
||||
<nz-descriptions nzBordered [nzColumn]="2">
|
||||
<nz-descriptions-item nzTitle="User ID" [nzSpan]="2">
|
||||
<scn-metadata-grid>
|
||||
<scn-metadata-value label="User ID">
|
||||
<span class="mono">{{ user()!.user_id }}</span>
|
||||
</nz-descriptions-item>
|
||||
<nz-descriptions-item nzTitle="Username">
|
||||
</scn-metadata-value>
|
||||
<scn-metadata-value label="Username">
|
||||
{{ user()!.username || '(Not set)' }}
|
||||
<button nz-button nzSize="small" nzType="link" (click)="openEditModal()">
|
||||
<span nz-icon nzType="edit"></span>
|
||||
</button>
|
||||
</nz-descriptions-item>
|
||||
<nz-descriptions-item nzTitle="Account Type">
|
||||
</scn-metadata-value>
|
||||
<scn-metadata-value label="Account Type">
|
||||
@if (user()!.is_pro) {
|
||||
<nz-tag nzColor="gold">Pro</nz-tag>
|
||||
} @else {
|
||||
<nz-tag>Free</nz-tag>
|
||||
}
|
||||
</nz-descriptions-item>
|
||||
<nz-descriptions-item nzTitle="Messages Sent">
|
||||
</scn-metadata-value>
|
||||
<scn-metadata-value label="Messages Sent">
|
||||
{{ user()!.messages_sent }}
|
||||
</nz-descriptions-item>
|
||||
<nz-descriptions-item nzTitle="Created">
|
||||
</scn-metadata-value>
|
||||
<scn-metadata-value label="Created">
|
||||
{{ user()!.timestamp_created | relativeTime }}
|
||||
</nz-descriptions-item>
|
||||
<nz-descriptions-item nzTitle="Last Read">
|
||||
</scn-metadata-value>
|
||||
<scn-metadata-value label="Last Read">
|
||||
{{ user()!.timestamp_lastread | relativeTime }}
|
||||
</nz-descriptions-item>
|
||||
<nz-descriptions-item nzTitle="Last Sent">
|
||||
</scn-metadata-value>
|
||||
<scn-metadata-value label="Last Sent">
|
||||
{{ user()!.timestamp_lastsent | relativeTime }}
|
||||
</nz-descriptions-item>
|
||||
</nz-descriptions>
|
||||
</scn-metadata-value>
|
||||
</scn-metadata-grid>
|
||||
</nz-card>
|
||||
|
||||
<nz-card nzTitle="Quota" class="mt-16">
|
||||
@@ -62,22 +62,39 @@
|
||||
|
||||
<nz-divider></nz-divider>
|
||||
|
||||
<nz-descriptions [nzColumn]="2" nzSize="small">
|
||||
<nz-descriptions-item nzTitle="Max Body Size">
|
||||
<scn-metadata-grid>
|
||||
<scn-metadata-value label="Max Body Size">
|
||||
{{ user()!.max_body_size | number }} bytes
|
||||
</nz-descriptions-item>
|
||||
<nz-descriptions-item nzTitle="Max Title Length">
|
||||
</scn-metadata-value>
|
||||
<scn-metadata-value label="Max Title Length">
|
||||
{{ user()!.max_title_length }} chars
|
||||
</nz-descriptions-item>
|
||||
<nz-descriptions-item nzTitle="Default Channel">
|
||||
</scn-metadata-value>
|
||||
<scn-metadata-value label="Default Channel">
|
||||
{{ user()!.default_channel }}
|
||||
</nz-descriptions-item>
|
||||
<nz-descriptions-item nzTitle="Default Priority">
|
||||
</scn-metadata-value>
|
||||
<scn-metadata-value label="Default Priority">
|
||||
{{ user()!.default_priority }}
|
||||
</nz-descriptions-item>
|
||||
</nz-descriptions>
|
||||
</scn-metadata-value>
|
||||
</scn-metadata-grid>
|
||||
</nz-card>
|
||||
|
||||
@if (expertMode()) {
|
||||
<nz-card nzTitle="Danger Zone" class="mt-16 danger-zone">
|
||||
<p class="danger-warning">Deleting your account is permanent and cannot be undone. All your data will be lost.</p>
|
||||
<button
|
||||
nz-button
|
||||
nzDanger
|
||||
nz-popconfirm
|
||||
nzPopconfirmTitle="Are you sure you want to delete your account? This action cannot be undone."
|
||||
(nzOnConfirm)="deleteAccount()"
|
||||
[nzLoading]="deleting()"
|
||||
>
|
||||
<span nz-icon nzType="delete"></span>
|
||||
Delete Account
|
||||
</button>
|
||||
</nz-card>
|
||||
}
|
||||
|
||||
}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -43,3 +43,17 @@
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.danger-zone {
|
||||
border-color: #ff4d4f !important;
|
||||
|
||||
:host ::ng-deep .ant-card-head {
|
||||
color: #ff4d4f;
|
||||
border-bottom-color: #ff4d4f;
|
||||
}
|
||||
|
||||
.danger-warning {
|
||||
color: #666;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Component, inject, signal, OnInit } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { NzCardModule } from 'ng-zorro-antd/card';
|
||||
import { NzButtonModule } from 'ng-zorro-antd/button';
|
||||
import { NzIconModule } from 'ng-zorro-antd/icon';
|
||||
@@ -12,11 +13,14 @@ import { NzModalModule } from 'ng-zorro-antd/modal';
|
||||
import { NzFormModule } from 'ng-zorro-antd/form';
|
||||
import { NzInputModule } from 'ng-zorro-antd/input';
|
||||
import { NzDividerModule } from 'ng-zorro-antd/divider';
|
||||
import { NzPopconfirmModule } from 'ng-zorro-antd/popconfirm';
|
||||
import { ApiService } from '../../../core/services/api.service';
|
||||
import { AuthService } from '../../../core/services/auth.service';
|
||||
import { NotificationService } from '../../../core/services/notification.service';
|
||||
import { SettingsService } from '../../../core/services/settings.service';
|
||||
import { UserWithExtra } from '../../../core/models';
|
||||
import { RelativeTimePipe } from '../../../shared/pipes/relative-time.pipe';
|
||||
import { MetadataGridComponent, MetadataValueComponent } from '../../../shared/components/metadata-grid';
|
||||
|
||||
@Component({
|
||||
selector: 'app-account-info',
|
||||
@@ -35,7 +39,10 @@ import { RelativeTimePipe } from '../../../shared/pipes/relative-time.pipe';
|
||||
NzFormModule,
|
||||
NzInputModule,
|
||||
NzDividerModule,
|
||||
NzPopconfirmModule,
|
||||
RelativeTimePipe,
|
||||
MetadataGridComponent,
|
||||
MetadataValueComponent,
|
||||
],
|
||||
templateUrl: './account-info.component.html',
|
||||
styleUrl: './account-info.component.scss'
|
||||
@@ -44,9 +51,13 @@ export class AccountInfoComponent implements OnInit {
|
||||
private apiService = inject(ApiService);
|
||||
private authService = inject(AuthService);
|
||||
private notification = inject(NotificationService);
|
||||
private settingsService = inject(SettingsService);
|
||||
private router = inject(Router);
|
||||
|
||||
user = signal<UserWithExtra | null>(null);
|
||||
loading = signal(true);
|
||||
deleting = signal(false);
|
||||
expertMode = this.settingsService.expertMode;
|
||||
|
||||
// Edit username modal
|
||||
showEditModal = signal(false);
|
||||
@@ -116,4 +127,21 @@ export class AccountInfoComponent implements OnInit {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
deleteAccount(): void {
|
||||
const userId = this.authService.getUserId();
|
||||
if (!userId) return;
|
||||
|
||||
this.deleting.set(true);
|
||||
this.apiService.deleteUser(userId).subscribe({
|
||||
next: () => {
|
||||
this.notification.success('Account deleted');
|
||||
this.authService.logout();
|
||||
this.router.navigate(['/login']);
|
||||
},
|
||||
error: () => {
|
||||
this.deleting.set(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,19 @@
|
||||
<span nz-icon nzType="edit"></span>
|
||||
Edit
|
||||
</button>
|
||||
@if (expertMode()) {
|
||||
<button
|
||||
nz-button
|
||||
nzDanger
|
||||
nz-popconfirm
|
||||
nzPopconfirmTitle="Are you sure you want to delete this channel? All messages and subscriptions will be lost."
|
||||
(nzOnConfirm)="deleteChannel()"
|
||||
[nzLoading]="deleting()"
|
||||
>
|
||||
<span nz-icon nzType="delete"></span>
|
||||
Delete
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -152,28 +165,76 @@
|
||||
<th>Subscriber</th>
|
||||
<th nzWidth="0">Status</th>
|
||||
<th nzWidth="0">Created</th>
|
||||
<th nzWidth="0">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (sub of subscriptions(); track sub.subscription_id) {
|
||||
<tr>
|
||||
<tr class="clickable-row">
|
||||
<td>
|
||||
<div class="cell-name">{{ getUserDisplayName(sub.subscriber_user_id) }}</div>
|
||||
<div class="cell-id mono">{{ sub.subscriber_user_id }}</div>
|
||||
<a class="cell-link" [routerLink]="['/subscriptions', sub.subscription_id]">
|
||||
<div class="cell-name">{{ getUserDisplayName(sub.subscriber_user_id) }}</div>
|
||||
<div class="cell-id mono">{{ sub.subscriber_user_id }}</div>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<nz-tag [nzColor]="sub.confirmed ? 'green' : 'orange'">
|
||||
{{ sub.confirmed ? 'Confirmed' : 'Pending' }}
|
||||
</nz-tag>
|
||||
<a class="cell-link" [routerLink]="['/subscriptions', sub.subscription_id]">
|
||||
<nz-tag [nzColor]="sub.confirmed ? 'green' : 'orange'">
|
||||
{{ sub.confirmed ? 'Confirmed' : 'Pending' }}
|
||||
</nz-tag>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<div class="timestamp-absolute">{{ sub.timestamp_created | date:'yyyy-MM-dd HH:mm:ss' }}</div>
|
||||
<div class="timestamp-relative">{{ sub.timestamp_created | relativeTime }}</div>
|
||||
<a class="cell-link" [routerLink]="['/subscriptions', sub.subscription_id]">
|
||||
<div class="timestamp-absolute">{{ sub.timestamp_created | date:'yyyy-MM-dd HH:mm:ss' }}</div>
|
||||
<div class="timestamp-relative">{{ sub.timestamp_created | relativeTime }}</div>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<div class="action-buttons">
|
||||
@if (!sub.confirmed) {
|
||||
<button
|
||||
nz-button
|
||||
nzSize="small"
|
||||
nzType="primary"
|
||||
nz-tooltip
|
||||
nzTooltipTitle="Accept"
|
||||
(click)="acceptSubscription(sub)"
|
||||
>
|
||||
<span nz-icon nzType="check"></span>
|
||||
</button>
|
||||
<button
|
||||
nz-button
|
||||
nzSize="small"
|
||||
nzDanger
|
||||
nz-tooltip
|
||||
nzTooltipTitle="Deny"
|
||||
nz-popconfirm
|
||||
nzPopconfirmTitle="Deny this subscription request?"
|
||||
(nzOnConfirm)="denySubscription(sub)"
|
||||
>
|
||||
<span nz-icon nzType="close"></span>
|
||||
</button>
|
||||
} @else {
|
||||
<button
|
||||
nz-button
|
||||
nzSize="small"
|
||||
nzDanger
|
||||
nz-tooltip
|
||||
nzTooltipTitle="Revoke"
|
||||
nz-popconfirm
|
||||
nzPopconfirmTitle="Revoke this subscription?"
|
||||
(nzOnConfirm)="revokeSubscription(sub)"
|
||||
>
|
||||
<span nz-icon nzType="delete"></span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="3">
|
||||
<td colspan="4">
|
||||
<nz-empty nzNotFoundContent="No subscriptions"></nz-empty>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -182,6 +243,84 @@
|
||||
</nz-table>
|
||||
</nz-card>
|
||||
}
|
||||
|
||||
<nz-card nzTitle="Messages" class="mt-16">
|
||||
<nz-table
|
||||
#messageTable
|
||||
[nzData]="messages()"
|
||||
[nzLoading]="loadingMessages()"
|
||||
[nzShowPagination]="false"
|
||||
[nzNoResult]="noMessagesResultTpl"
|
||||
nzSize="small"
|
||||
>
|
||||
<ng-template #noMessagesResultTpl></ng-template>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Title</th>
|
||||
<th>Content</th>
|
||||
<th nzWidth="0">Sender</th>
|
||||
<th nzWidth="0">Priority</th>
|
||||
<th nzWidth="0">Time</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (message of messages(); track message.message_id) {
|
||||
<tr class="clickable-row">
|
||||
<td>
|
||||
<a class="cell-link" [routerLink]="['/messages', message.message_id]">
|
||||
<div class="message-title">{{ message.title }}</div>
|
||||
<div class="message-id mono">{{ message.message_id }}</div>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a class="cell-link" [routerLink]="['/messages', message.message_id]">
|
||||
@if (message.content) {
|
||||
<div class="message-content">{{ message.content | slice:0:128 }}{{ message.content.length > 128 ? '...' : '' }}</div>
|
||||
} @else {
|
||||
<span class="text-muted"></span>
|
||||
}
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a class="cell-link" [routerLink]="['/messages', message.message_id]">
|
||||
<span style="white-space: pre">{{ message.sender_name || '-' }}</span>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a class="cell-link" [routerLink]="['/messages', message.message_id]">
|
||||
<nz-tag [nzColor]="getPriorityColor(message.priority)">
|
||||
{{ getPriorityLabel(message.priority) }}
|
||||
</nz-tag>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a class="cell-link" [routerLink]="['/messages', message.message_id]">
|
||||
<div class="timestamp-absolute">{{ message.timestamp | date:'yyyy-MM-dd HH:mm:ss' }}</div>
|
||||
<div class="timestamp-relative">{{ message.timestamp | relativeTime }}</div>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="5">
|
||||
<nz-empty nzNotFoundContent="No messages"></nz-empty>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</nz-table>
|
||||
@if (messagesTotalCount() > messagesPageSize) {
|
||||
<div class="pagination-controls">
|
||||
<nz-pagination
|
||||
[nzPageIndex]="messagesCurrentPage()"
|
||||
[nzPageSize]="messagesPageSize"
|
||||
[nzTotal]="messagesTotalCount()"
|
||||
[nzDisabled]="loadingMessages()"
|
||||
(nzPageIndexChange)="messagesGoToPage($event)"
|
||||
></nz-pagination>
|
||||
</div>
|
||||
}
|
||||
</nz-card>
|
||||
} @else {
|
||||
<nz-card>
|
||||
<div class="not-found">
|
||||
|
||||
@@ -68,9 +68,51 @@
|
||||
.timestamp-absolute {
|
||||
font-size: 13px;
|
||||
color: #333;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.timestamp-relative {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.clickable-row {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: #fafafa;
|
||||
}
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.message-title {
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.message-id {
|
||||
font-size: 11px;
|
||||
color: #999;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.message-content {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.pagination-controls {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Component, inject, signal, computed, OnInit } from '@angular/core';
|
||||
import { CommonModule, DatePipe } from '@angular/common';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { NzCardModule } from 'ng-zorro-antd/card';
|
||||
import { NzButtonModule } from 'ng-zorro-antd/button';
|
||||
@@ -15,11 +15,13 @@ import { NzFormModule } from 'ng-zorro-antd/form';
|
||||
import { NzTableModule } from 'ng-zorro-antd/table';
|
||||
import { NzToolTipModule } from 'ng-zorro-antd/tooltip';
|
||||
import { NzEmptyModule } from 'ng-zorro-antd/empty';
|
||||
import { NzPaginationModule } from 'ng-zorro-antd/pagination';
|
||||
import { ApiService } from '../../../core/services/api.service';
|
||||
import { AuthService } from '../../../core/services/auth.service';
|
||||
import { NotificationService } from '../../../core/services/notification.service';
|
||||
import { SettingsService } from '../../../core/services/settings.service';
|
||||
import { UserCacheService, ResolvedUser } from '../../../core/services/user-cache.service';
|
||||
import { ChannelWithSubscription, Subscription } from '../../../core/models';
|
||||
import { ChannelWithSubscription, Subscription, Message } from '../../../core/models';
|
||||
import { RelativeTimePipe } from '../../../shared/pipes/relative-time.pipe';
|
||||
import { CopyToClipboardDirective } from '../../../shared/directives/copy-to-clipboard.directive';
|
||||
import { QrCodeDisplayComponent } from '../../../shared/components/qr-code-display/qr-code-display.component';
|
||||
@@ -32,6 +34,7 @@ import { MetadataGridComponent, MetadataValueComponent } from '../../../shared/c
|
||||
CommonModule,
|
||||
DatePipe,
|
||||
FormsModule,
|
||||
RouterLink,
|
||||
NzCardModule,
|
||||
NzButtonModule,
|
||||
NzIconModule,
|
||||
@@ -45,6 +48,7 @@ import { MetadataGridComponent, MetadataValueComponent } from '../../../shared/c
|
||||
NzTableModule,
|
||||
NzToolTipModule,
|
||||
NzEmptyModule,
|
||||
NzPaginationModule,
|
||||
RelativeTimePipe,
|
||||
CopyToClipboardDirective,
|
||||
QrCodeDisplayComponent,
|
||||
@@ -60,13 +64,24 @@ export class ChannelDetailComponent implements OnInit {
|
||||
private apiService = inject(ApiService);
|
||||
private authService = inject(AuthService);
|
||||
private notification = inject(NotificationService);
|
||||
private settingsService = inject(SettingsService);
|
||||
private userCacheService = inject(UserCacheService);
|
||||
|
||||
channel = signal<ChannelWithSubscription | null>(null);
|
||||
subscriptions = signal<Subscription[]>([]);
|
||||
messages = signal<Message[]>([]);
|
||||
userNames = signal<Map<string, ResolvedUser>>(new Map());
|
||||
loading = signal(true);
|
||||
loadingSubscriptions = signal(false);
|
||||
loadingMessages = signal(false);
|
||||
deleting = signal(false);
|
||||
expertMode = this.settingsService.expertMode;
|
||||
|
||||
// Messages pagination
|
||||
messagesPageSize = 16;
|
||||
messagesNextPageToken = signal<string | null>(null);
|
||||
messagesTotalCount = signal(0);
|
||||
messagesCurrentPage = signal(1);
|
||||
// Edit modal
|
||||
showEditModal = signal(false);
|
||||
editDisplayName = '';
|
||||
@@ -107,6 +122,7 @@ export class ChannelDetailComponent implements OnInit {
|
||||
if (this.isOwner()) {
|
||||
this.loadSubscriptions(channelId);
|
||||
}
|
||||
this.loadMessages(channelId);
|
||||
},
|
||||
error: () => {
|
||||
this.loading.set(false);
|
||||
@@ -131,6 +147,69 @@ export class ChannelDetailComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
loadMessages(channelId: string, nextPageToken?: string): void {
|
||||
const userId = this.authService.getUserId();
|
||||
if (!userId) return;
|
||||
|
||||
this.loadingMessages.set(true);
|
||||
this.apiService.getChannelMessages(userId, channelId, {
|
||||
page_size: this.messagesPageSize,
|
||||
next_page_token: nextPageToken,
|
||||
trimmed: true
|
||||
}).subscribe({
|
||||
next: (response) => {
|
||||
this.messages.set(response.messages);
|
||||
this.messagesNextPageToken.set(response.next_page_token || null);
|
||||
this.messagesTotalCount.set(response.total_count);
|
||||
this.loadingMessages.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.loadingMessages.set(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
messagesGoToPage(page: number): void {
|
||||
const channel = this.channel();
|
||||
if (!channel) return;
|
||||
|
||||
this.messagesCurrentPage.set(page);
|
||||
// For pagination with tokens, we need to handle this differently
|
||||
// The API uses next_page_token, so we'll reload from the beginning for now
|
||||
// In a real implementation, you'd need to track tokens per page or use offset-based pagination
|
||||
if (page === 1) {
|
||||
this.loadMessages(channel.channel_id);
|
||||
} else {
|
||||
// For simplicity, use the next page token if going forward
|
||||
const token = this.messagesNextPageToken();
|
||||
if (token) {
|
||||
this.loadMessages(channel.channel_id, token);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
viewMessage(message: Message): void {
|
||||
this.router.navigate(['/messages', message.message_id]);
|
||||
}
|
||||
|
||||
getPriorityColor(priority: number): string {
|
||||
switch (priority) {
|
||||
case 0: return 'default';
|
||||
case 1: return 'blue';
|
||||
case 2: return 'orange';
|
||||
default: return 'default';
|
||||
}
|
||||
}
|
||||
|
||||
getPriorityLabel(priority: number): string {
|
||||
switch (priority) {
|
||||
case 0: return 'Low';
|
||||
case 1: return 'Normal';
|
||||
case 2: return 'High';
|
||||
default: return 'Unknown';
|
||||
}
|
||||
}
|
||||
|
||||
private resolveUserNames(subscriptions: Subscription[]): void {
|
||||
const userIds = new Set<string>();
|
||||
for (const sub of subscriptions) {
|
||||
@@ -245,4 +324,70 @@ export class ChannelDetailComponent implements OnInit {
|
||||
|
||||
return { label: 'Not Subscribed', color: 'default' };
|
||||
}
|
||||
|
||||
deleteChannel(): void {
|
||||
const channel = this.channel();
|
||||
const userId = this.authService.getUserId();
|
||||
if (!channel || !userId) return;
|
||||
|
||||
this.deleting.set(true);
|
||||
this.apiService.deleteChannel(userId, channel.channel_id).subscribe({
|
||||
next: () => {
|
||||
this.notification.success('Channel deleted');
|
||||
this.router.navigate(['/channels']);
|
||||
},
|
||||
error: () => {
|
||||
this.deleting.set(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
viewSubscription(sub: Subscription): void {
|
||||
this.router.navigate(['/subscriptions', sub.subscription_id]);
|
||||
}
|
||||
|
||||
acceptSubscription(sub: Subscription): void {
|
||||
const userId = this.authService.getUserId();
|
||||
if (!userId) return;
|
||||
|
||||
this.apiService.confirmSubscription(userId, sub.subscription_id, { confirmed: true }).subscribe({
|
||||
next: () => {
|
||||
this.notification.success('Subscription accepted');
|
||||
const channel = this.channel();
|
||||
if (channel) {
|
||||
this.loadSubscriptions(channel.channel_id);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
denySubscription(sub: Subscription): void {
|
||||
const userId = this.authService.getUserId();
|
||||
if (!userId) return;
|
||||
|
||||
this.apiService.deleteSubscription(userId, sub.subscription_id).subscribe({
|
||||
next: () => {
|
||||
this.notification.success('Subscription denied');
|
||||
const channel = this.channel();
|
||||
if (channel) {
|
||||
this.loadSubscriptions(channel.channel_id);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
revokeSubscription(sub: Subscription): void {
|
||||
const userId = this.authService.getUserId();
|
||||
if (!userId) return;
|
||||
|
||||
this.apiService.deleteSubscription(userId, sub.subscription_id).subscribe({
|
||||
next: () => {
|
||||
this.notification.success('Subscription revoked');
|
||||
const channel = this.channel();
|
||||
if (channel) {
|
||||
this.loadSubscriptions(channel.channel_id);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,45 +21,95 @@
|
||||
<ng-template #noResultTpl></ng-template>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th nzWidth="0">Internal Name</th>
|
||||
<th nzWidth="0">Owner</th>
|
||||
<th nzWidth="0">Status</th>
|
||||
<th nzWidth="0">Subscribers</th>
|
||||
<th nzWidth="0">Messages</th>
|
||||
<th nzWidth="0">Last Sent</th>
|
||||
<th style="width: auto">Name</th>
|
||||
<th style="width: auto">Internal Name</th>
|
||||
<th style="width: auto">Owner</th>
|
||||
<th style="width: 0">Status</th>
|
||||
<th style="width: 400px">Subscribers</th>
|
||||
<th style="width: 0">Messages</th>
|
||||
<th style="width: 0">Last Sent</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (channel of channels(); track channel.channel_id) {
|
||||
<tr [class.clickable-row]="isOwned(channel)" (click)="isOwned(channel) && viewChannel(channel)">
|
||||
<tr [class.clickable-row]="isOwned(channel)">
|
||||
<td>
|
||||
<div class="channel-name">{{ channel.display_name }}</div>
|
||||
<div class="channel-id mono">{{ channel.channel_id }}</div>
|
||||
</td>
|
||||
<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 }}
|
||||
</nz-tag>
|
||||
@if (isOwned(channel)) {
|
||||
<a class="cell-link" [routerLink]="['/channels', channel.channel_id]">
|
||||
<div class="channel-name">{{ channel.display_name }}</div>
|
||||
<div class="channel-id mono">{{ channel.channel_id }}</div>
|
||||
</a>
|
||||
} @else {
|
||||
<div class="channel-name">{{ channel.display_name }}</div>
|
||||
<div class="channel-id mono">{{ channel.channel_id }}</div>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
@if (isOwned(channel)) {
|
||||
<app-channel-subscribers [channelId]="channel.channel_id" />
|
||||
<a class="cell-link" [routerLink]="['/channels', channel.channel_id]">
|
||||
<span class="mono">{{ channel.internal_name }}</span>
|
||||
</a>
|
||||
} @else {
|
||||
<span class="mono">{{ channel.internal_name }}</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
@if (isOwned(channel)) {
|
||||
<a class="cell-link" [routerLink]="['/channels', channel.channel_id]">
|
||||
{{ getOwnerDisplayName(channel.owner_user_id) }}
|
||||
</a>
|
||||
} @else {
|
||||
{{ getOwnerDisplayName(channel.owner_user_id) }}
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
@if (isOwned(channel)) {
|
||||
<a class="cell-link" [routerLink]="['/channels', channel.channel_id]">
|
||||
<nz-tag [nzColor]="getSubscriptionStatus(channel).color">
|
||||
{{ getSubscriptionStatus(channel).label }}
|
||||
</nz-tag>
|
||||
</a>
|
||||
} @else {
|
||||
<nz-tag [nzColor]="getSubscriptionStatus(channel).color">
|
||||
{{ getSubscriptionStatus(channel).label }}
|
||||
</nz-tag>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
@if (isOwned(channel)) {
|
||||
<a class="cell-link" [routerLink]="['/channels', channel.channel_id]">
|
||||
<app-channel-subscribers [channelId]="channel.channel_id" />
|
||||
</a>
|
||||
} @else {
|
||||
<span class="text-muted">-</span>
|
||||
}
|
||||
</td>
|
||||
<td>{{ channel.messages_sent }}</td>
|
||||
<td>
|
||||
@if (channel.timestamp_lastsent) {
|
||||
<div class="timestamp-absolute">{{ channel.timestamp_lastsent | date:'yyyy-MM-dd HH:mm:ss' }}</div>
|
||||
<div class="timestamp-relative">{{ channel.timestamp_lastsent | relativeTime }}</div>
|
||||
@if (isOwned(channel)) {
|
||||
<a class="cell-link" [routerLink]="['/channels', channel.channel_id]">
|
||||
{{ channel.messages_sent }}
|
||||
</a>
|
||||
} @else {
|
||||
<span class="text-muted">Never</span>
|
||||
{{ channel.messages_sent }}
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
@if (isOwned(channel)) {
|
||||
<a class="cell-link" [routerLink]="['/channels', channel.channel_id]">
|
||||
@if (channel.timestamp_lastsent) {
|
||||
<div class="timestamp-absolute">{{ channel.timestamp_lastsent | date:'yyyy-MM-dd HH:mm:ss' }}</div>
|
||||
<div class="timestamp-relative">{{ channel.timestamp_lastsent | relativeTime }}</div>
|
||||
} @else {
|
||||
<span class="text-muted">Never</span>
|
||||
}
|
||||
</a>
|
||||
} @else {
|
||||
@if (channel.timestamp_lastsent) {
|
||||
<div class="timestamp-absolute">{{ channel.timestamp_lastsent | date:'yyyy-MM-dd HH:mm:ss' }}</div>
|
||||
<div class="timestamp-relative">{{ channel.timestamp_lastsent | relativeTime }}</div>
|
||||
} @else {
|
||||
<span class="text-muted">Never</span>
|
||||
}
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -36,11 +36,13 @@
|
||||
.timestamp-absolute {
|
||||
font-size: 13px;
|
||||
color: #333;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.timestamp-relative {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.clickable-row {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Component, inject, signal, OnInit } from '@angular/core';
|
||||
import { CommonModule, DatePipe } from '@angular/common';
|
||||
import { Router } from '@angular/router';
|
||||
import { Router, RouterLink } from '@angular/router';
|
||||
import { NzTableModule } from 'ng-zorro-antd/table';
|
||||
import { NzButtonModule } from 'ng-zorro-antd/button';
|
||||
import { NzIconModule } from 'ng-zorro-antd/icon';
|
||||
@@ -22,6 +22,7 @@ import { ChannelSubscribersComponent } from '../channel-subscribers/channel-subs
|
||||
imports: [
|
||||
CommonModule,
|
||||
DatePipe,
|
||||
RouterLink,
|
||||
NzTableModule,
|
||||
NzButtonModule,
|
||||
NzIconModule,
|
||||
|
||||
@@ -9,18 +9,20 @@
|
||||
<span nz-icon nzType="arrow-left" nzTheme="outline"></span>
|
||||
Back to Clients
|
||||
</button>
|
||||
<div class="header-actions">
|
||||
<button
|
||||
nz-button
|
||||
nzDanger
|
||||
nz-popconfirm
|
||||
nzPopconfirmTitle="Are you sure you want to delete this client?"
|
||||
(nzOnConfirm)="deleteClient()"
|
||||
>
|
||||
<span nz-icon nzType="delete"></span>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
@if (expertMode()) {
|
||||
<div class="header-actions">
|
||||
<button
|
||||
nz-button
|
||||
nzDanger
|
||||
nz-popconfirm
|
||||
nzPopconfirmTitle="Are you sure you want to delete this client?"
|
||||
(nzOnConfirm)="deleteClient()"
|
||||
>
|
||||
<span nz-icon nzType="delete"></span>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<nz-card>
|
||||
|
||||
@@ -59,9 +59,11 @@
|
||||
.timestamp-absolute {
|
||||
font-size: 13px;
|
||||
color: #333;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.timestamp-relative {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,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 { SettingsService } from '../../../core/services/settings.service';
|
||||
import { Client, ClientType, getClientTypeIcon } from '../../../core/models';
|
||||
import { RelativeTimePipe } from '../../../shared/pipes/relative-time.pipe';
|
||||
import { MetadataGridComponent, MetadataValueComponent } from '../../../shared/components/metadata-grid';
|
||||
@@ -41,9 +42,11 @@ export class ClientDetailComponent implements OnInit {
|
||||
private apiService = inject(ApiService);
|
||||
private authService = inject(AuthService);
|
||||
private notification = inject(NotificationService);
|
||||
private settingsService = inject(SettingsService);
|
||||
|
||||
client = signal<Client | null>(null);
|
||||
loading = signal(true);
|
||||
expertMode = this.settingsService.expertMode;
|
||||
|
||||
ngOnInit(): void {
|
||||
const clientId = this.route.snapshot.paramMap.get('id');
|
||||
|
||||
@@ -28,31 +28,41 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (client of clients(); track client.client_id) {
|
||||
<tr class="clickable-row" (click)="openClient(client.client_id)">
|
||||
<tr class="clickable-row">
|
||||
<td>
|
||||
<span
|
||||
nz-icon
|
||||
[nzType]="getClientIcon(client.type)"
|
||||
nzTheme="outline"
|
||||
class="client-icon"
|
||||
></span>
|
||||
<a class="cell-link" [routerLink]="['/clients', client.client_id]">
|
||||
<span
|
||||
nz-icon
|
||||
[nzType]="getClientIcon(client.type)"
|
||||
nzTheme="outline"
|
||||
class="client-icon"
|
||||
></span>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<div class="client-name">{{ client.name || '-' }}</div>
|
||||
<div class="client-id mono">{{ client.client_id }}</div>
|
||||
<a class="cell-link" [routerLink]="['/clients', client.client_id]">
|
||||
<div class="client-name">{{ client.name || '-' }}</div>
|
||||
<div class="client-id mono">{{ client.client_id }}</div>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<nz-tag>{{ getClientTypeLabel(client.type) }}</nz-tag>
|
||||
<a class="cell-link" [routerLink]="['/clients', client.client_id]">
|
||||
<nz-tag>{{ getClientTypeLabel(client.type) }}</nz-tag>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<div class="agent-info">
|
||||
<span>{{ client.agent_model }}</span>
|
||||
<span class="agent-version">v{{ client.agent_version }}</span>
|
||||
</div>
|
||||
<a class="cell-link" [routerLink]="['/clients', client.client_id]">
|
||||
<div class="agent-info">
|
||||
<span style="white-space: pre;">{{ client.agent_model }}</span>
|
||||
<span style="white-space: pre;" class="agent-version">v{{ client.agent_version }}</span>
|
||||
</div>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<div class="timestamp-absolute">{{ client.timestamp_created | date:'yyyy-MM-dd HH:mm:ss' }}</div>
|
||||
<div class="timestamp-relative">{{ client.timestamp_created | relativeTime }}</div>
|
||||
<a class="cell-link" [routerLink]="['/clients', client.client_id]">
|
||||
<div class="timestamp-absolute">{{ client.timestamp_created | date:'yyyy-MM-dd HH:mm:ss' }}</div>
|
||||
<div class="timestamp-relative">{{ client.timestamp_created | relativeTime }}</div>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
|
||||
@@ -46,9 +46,11 @@
|
||||
.timestamp-absolute {
|
||||
font-size: 13px;
|
||||
color: #333;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.timestamp-relative {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Component, inject, signal, OnInit } from '@angular/core';
|
||||
import { CommonModule, DatePipe } from '@angular/common';
|
||||
import { Router } from '@angular/router';
|
||||
import { Router, RouterLink } from '@angular/router';
|
||||
import { NzTableModule } from 'ng-zorro-antd/table';
|
||||
import { NzButtonModule } from 'ng-zorro-antd/button';
|
||||
import { NzIconModule } from 'ng-zorro-antd/icon';
|
||||
@@ -19,6 +19,7 @@ import { RelativeTimePipe } from '../../../shared/pipes/relative-time.pipe';
|
||||
imports: [
|
||||
CommonModule,
|
||||
DatePipe,
|
||||
RouterLink,
|
||||
NzTableModule,
|
||||
NzButtonModule,
|
||||
NzIconModule,
|
||||
|
||||
@@ -94,6 +94,91 @@
|
||||
</scn-metadata-value>
|
||||
</scn-metadata-grid>
|
||||
</nz-card>
|
||||
|
||||
<nz-card nzTitle="Messages" class="mt-16">
|
||||
<nz-table
|
||||
#messageTable
|
||||
[nzData]="messages()"
|
||||
[nzLoading]="loadingMessages()"
|
||||
[nzShowPagination]="false"
|
||||
[nzNoResult]="noMessagesResultTpl"
|
||||
nzSize="small"
|
||||
>
|
||||
<ng-template #noMessagesResultTpl></ng-template>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Title</th>
|
||||
<th>Content</th>
|
||||
<th nzWidth="0">Channel</th>
|
||||
<th nzWidth="0">Sender</th>
|
||||
<th nzWidth="0">Priority</th>
|
||||
<th nzWidth="0">Time</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (message of messages(); track message.message_id) {
|
||||
<tr class="clickable-row">
|
||||
<td>
|
||||
<a class="cell-link" [routerLink]="['/messages', message.message_id]">
|
||||
<div class="message-title">{{ message.title }}</div>
|
||||
<div class="message-id mono">{{ message.message_id }}</div>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a class="cell-link" [routerLink]="['/messages', message.message_id]">
|
||||
@if (message.content) {
|
||||
<div class="message-content">{{ message.content | slice:0:128 }}{{ message.content.length > 128 ? '...' : '' }}</div>
|
||||
} @else {
|
||||
<span class="text-muted"></span>
|
||||
}
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a class="cell-link" [routerLink]="['/messages', message.message_id]">
|
||||
<div class="cell-name">{{ message.channel_internal_name }}</div>
|
||||
<div class="cell-id mono">{{ message.channel_id }}</div>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a class="cell-link" [routerLink]="['/messages', message.message_id]">
|
||||
<span style="white-space: pre">{{ message.sender_name || '-' }}</span>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a class="cell-link" [routerLink]="['/messages', message.message_id]">
|
||||
<nz-tag [nzColor]="getPriorityColor(message.priority)">
|
||||
{{ getPriorityLabel(message.priority) }}
|
||||
</nz-tag>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a class="cell-link" [routerLink]="['/messages', message.message_id]">
|
||||
<div class="timestamp-absolute">{{ message.timestamp | date:'yyyy-MM-dd HH:mm:ss' }}</div>
|
||||
<div class="timestamp-relative">{{ message.timestamp | relativeTime }}</div>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="6">
|
||||
<nz-empty nzNotFoundContent="No messages sent with this key"></nz-empty>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</nz-table>
|
||||
@if (messagesTotalCount() > messagesPageSize) {
|
||||
<div class="pagination-controls">
|
||||
<nz-pagination
|
||||
[nzPageIndex]="messagesCurrentPage()"
|
||||
[nzPageSize]="messagesPageSize"
|
||||
[nzTotal]="messagesTotalCount()"
|
||||
[nzDisabled]="loadingMessages()"
|
||||
(nzPageIndexChange)="messagesGoToPage($event)"
|
||||
></nz-pagination>
|
||||
</div>
|
||||
}
|
||||
</nz-card>
|
||||
} @else {
|
||||
<nz-card>
|
||||
<div class="not-found">
|
||||
|
||||
@@ -90,9 +90,53 @@
|
||||
.timestamp-absolute {
|
||||
font-size: 13px;
|
||||
color: #333;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.timestamp-relative {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.clickable-row {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: #fafafa;
|
||||
}
|
||||
}
|
||||
|
||||
.message-title {
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.message-id {
|
||||
font-size: 11px;
|
||||
color: #999;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.message-content {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.cell-name {
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.cell-id {
|
||||
font-size: 11px;
|
||||
color: #999;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.pagination-controls {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Component, inject, signal, OnInit } from '@angular/core';
|
||||
import { CommonModule, DatePipe } from '@angular/common';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { NzCardModule } from 'ng-zorro-antd/card';
|
||||
import { NzButtonModule } from 'ng-zorro-antd/button';
|
||||
@@ -14,12 +14,15 @@ import { NzInputModule } from 'ng-zorro-antd/input';
|
||||
import { NzCheckboxModule } from 'ng-zorro-antd/checkbox';
|
||||
import { NzSelectModule } from 'ng-zorro-antd/select';
|
||||
import { NzToolTipModule } from 'ng-zorro-antd/tooltip';
|
||||
import { NzTableModule } from 'ng-zorro-antd/table';
|
||||
import { NzEmptyModule } from 'ng-zorro-antd/empty';
|
||||
import { NzPaginationModule } from 'ng-zorro-antd/pagination';
|
||||
import { ApiService } from '../../../core/services/api.service';
|
||||
import { AuthService } from '../../../core/services/auth.service';
|
||||
import { NotificationService } from '../../../core/services/notification.service';
|
||||
import { ChannelCacheService, ResolvedChannel } from '../../../core/services/channel-cache.service';
|
||||
import { UserCacheService, ResolvedUser } from '../../../core/services/user-cache.service';
|
||||
import { KeyToken, parsePermissions, TokenPermission, ChannelWithSubscription } from '../../../core/models';
|
||||
import { KeyToken, parsePermissions, TokenPermission, ChannelWithSubscription, Message } from '../../../core/models';
|
||||
import { RelativeTimePipe } from '../../../shared/pipes/relative-time.pipe';
|
||||
import { MetadataGridComponent, MetadataValueComponent } from '../../../shared/components/metadata-grid';
|
||||
|
||||
@@ -36,6 +39,7 @@ interface PermissionOption {
|
||||
CommonModule,
|
||||
DatePipe,
|
||||
FormsModule,
|
||||
RouterLink,
|
||||
NzCardModule,
|
||||
NzButtonModule,
|
||||
NzIconModule,
|
||||
@@ -48,6 +52,9 @@ interface PermissionOption {
|
||||
NzCheckboxModule,
|
||||
NzSelectModule,
|
||||
NzToolTipModule,
|
||||
NzTableModule,
|
||||
NzEmptyModule,
|
||||
NzPaginationModule,
|
||||
RelativeTimePipe,
|
||||
MetadataGridComponent,
|
||||
MetadataValueComponent,
|
||||
@@ -71,6 +78,14 @@ export class KeyDetailComponent implements OnInit {
|
||||
availableChannels = signal<ChannelWithSubscription[]>([]);
|
||||
resolvedOwner = signal<ResolvedUser | null>(null);
|
||||
|
||||
// Messages
|
||||
messages = signal<Message[]>([]);
|
||||
loadingMessages = signal(false);
|
||||
messagesPageSize = 16;
|
||||
messagesTotalCount = signal(0);
|
||||
messagesCurrentPage = signal(1);
|
||||
messagesNextPageToken = signal<string | null>(null);
|
||||
|
||||
// Edit modal
|
||||
showEditModal = signal(false);
|
||||
editKeyName = '';
|
||||
@@ -106,6 +121,7 @@ export class KeyDetailComponent implements OnInit {
|
||||
this.loading.set(false);
|
||||
this.resolveChannelNames(key);
|
||||
this.resolveOwner(key.owner_user_id);
|
||||
this.loadMessages(keyId);
|
||||
},
|
||||
error: () => {
|
||||
this.loading.set(false);
|
||||
@@ -113,6 +129,64 @@ export class KeyDetailComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
loadMessages(keyId: string, nextPageToken?: string): void {
|
||||
this.loadingMessages.set(true);
|
||||
// Load more messages than page size to ensure we get enough after filtering
|
||||
this.apiService.getMessages({
|
||||
page_size: 64,
|
||||
next_page_token: nextPageToken,
|
||||
trimmed: true
|
||||
}).subscribe({
|
||||
next: (response) => {
|
||||
// Filter messages by the key that was used to send them
|
||||
const filtered = response.messages.filter(m => m.used_key_id === keyId);
|
||||
this.messages.set(filtered.slice(0, this.messagesPageSize));
|
||||
this.messagesTotalCount.set(filtered.length);
|
||||
this.messagesNextPageToken.set(response.next_page_token || null);
|
||||
this.loadingMessages.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.loadingMessages.set(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
messagesGoToPage(page: number): void {
|
||||
const key = this.key();
|
||||
if (!key) return;
|
||||
this.messagesCurrentPage.set(page);
|
||||
if (page === 1) {
|
||||
this.loadMessages(key.keytoken_id);
|
||||
} else {
|
||||
const token = this.messagesNextPageToken();
|
||||
if (token) {
|
||||
this.loadMessages(key.keytoken_id, token);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
viewMessage(message: Message): void {
|
||||
this.router.navigate(['/messages', message.message_id]);
|
||||
}
|
||||
|
||||
getPriorityColor(priority: number): string {
|
||||
switch (priority) {
|
||||
case 0: return 'default';
|
||||
case 1: return 'blue';
|
||||
case 2: return 'orange';
|
||||
default: return 'default';
|
||||
}
|
||||
}
|
||||
|
||||
getPriorityLabel(priority: number): string {
|
||||
switch (priority) {
|
||||
case 0: return 'Low';
|
||||
case 1: return 'Normal';
|
||||
case 2: return 'High';
|
||||
default: return 'Unknown';
|
||||
}
|
||||
}
|
||||
|
||||
private resolveOwner(ownerId: string): void {
|
||||
this.userCacheService.resolveUser(ownerId).subscribe(resolved => {
|
||||
this.resolvedOwner.set(resolved);
|
||||
|
||||
@@ -34,50 +34,60 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (key of keys(); track key.keytoken_id) {
|
||||
<tr class="clickable-row" (click)="viewKey(key)">
|
||||
<tr class="clickable-row">
|
||||
<td>
|
||||
<div class="key-name">
|
||||
{{ key.name }}
|
||||
@if (isCurrentKey(key)) {
|
||||
<nz-tag nzColor="cyan" class="current-tag">Current</nz-tag>
|
||||
}
|
||||
</div>
|
||||
<div class="key-id mono">{{ key.keytoken_id }}</div>
|
||||
<a class="cell-link" [routerLink]="['/keys', key.keytoken_id]">
|
||||
<div class="key-name">
|
||||
{{ key.name }}
|
||||
@if (isCurrentKey(key)) {
|
||||
<nz-tag nzColor="cyan" class="current-tag">Current</nz-tag>
|
||||
}
|
||||
</div>
|
||||
<div class="key-id mono">{{ key.keytoken_id }}</div>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<div class="permissions">
|
||||
@for (perm of getPermissions(key); track perm) {
|
||||
<nz-tag
|
||||
[nzColor]="getPermissionColor(perm)"
|
||||
nz-tooltip
|
||||
[nzTooltipTitle]="getPermissionLabel(perm)"
|
||||
>
|
||||
{{ perm }}
|
||||
</nz-tag>
|
||||
}
|
||||
@if (key.all_channels) {
|
||||
<nz-tag nzColor="default" nz-tooltip nzTooltipTitle="Access to all channels">
|
||||
All Channels
|
||||
</nz-tag>
|
||||
} @else if (key.channels && key.channels.length > 0) {
|
||||
@for (channelId of key.channels; track channelId) {
|
||||
<nz-tag nzColor="orange" nz-tooltip [nzTooltipTitle]="channelId">
|
||||
{{ getChannelDisplayName(channelId) }}
|
||||
<a class="cell-link" [routerLink]="['/keys', key.keytoken_id]">
|
||||
<div class="permissions">
|
||||
@for (perm of getPermissions(key); track perm) {
|
||||
<nz-tag
|
||||
[nzColor]="getPermissionColor(perm)"
|
||||
nz-tooltip
|
||||
[nzTooltipTitle]="getPermissionLabel(perm)"
|
||||
>
|
||||
{{ perm }}
|
||||
</nz-tag>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
@if (key.all_channels) {
|
||||
<nz-tag nzColor="default" nz-tooltip nzTooltipTitle="Access to all channels">
|
||||
All Channels
|
||||
</nz-tag>
|
||||
} @else if (key.channels && key.channels.length > 0) {
|
||||
@for (channelId of key.channels; track channelId) {
|
||||
<nz-tag nzColor="orange" nz-tooltip [nzTooltipTitle]="channelId">
|
||||
{{ getChannelDisplayName(channelId) }}
|
||||
</nz-tag>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</a>
|
||||
</td>
|
||||
<td>{{ key.messages_sent }}</td>
|
||||
<td>
|
||||
@if (key.timestamp_lastused) {
|
||||
<div class="timestamp-absolute">{{ key.timestamp_lastused | date:'yyyy-MM-dd HH:mm:ss' }}</div>
|
||||
<div class="timestamp-relative">{{ key.timestamp_lastused | relativeTime }}</div>
|
||||
} @else {
|
||||
<span class="text-muted">Never</span>
|
||||
}
|
||||
<a class="cell-link" [routerLink]="['/keys', key.keytoken_id]">
|
||||
{{ key.messages_sent }}
|
||||
</a>
|
||||
</td>
|
||||
<td (click)="$event.stopPropagation()">
|
||||
<td>
|
||||
<a class="cell-link" [routerLink]="['/keys', key.keytoken_id]">
|
||||
@if (key.timestamp_lastused) {
|
||||
<div class="timestamp-absolute">{{ key.timestamp_lastused | date:'yyyy-MM-dd HH:mm:ss' }}</div>
|
||||
<div class="timestamp-relative">{{ key.timestamp_lastused | relativeTime }}</div>
|
||||
} @else {
|
||||
<span class="text-muted">Never</span>
|
||||
}
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<div class="action-buttons">
|
||||
<button
|
||||
nz-button
|
||||
|
||||
@@ -95,9 +95,11 @@
|
||||
.timestamp-absolute {
|
||||
font-size: 13px;
|
||||
color: #333;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.timestamp-relative {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Component, inject, signal, OnInit } from '@angular/core';
|
||||
import { CommonModule, DatePipe } from '@angular/common';
|
||||
import { Router } from '@angular/router';
|
||||
import { Router, RouterLink } from '@angular/router';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { NzTableModule } from 'ng-zorro-antd/table';
|
||||
import { NzButtonModule } from 'ng-zorro-antd/button';
|
||||
@@ -37,6 +37,7 @@ interface PermissionOption {
|
||||
CommonModule,
|
||||
DatePipe,
|
||||
FormsModule,
|
||||
RouterLink,
|
||||
NzTableModule,
|
||||
NzButtonModule,
|
||||
NzIconModule,
|
||||
|
||||
@@ -9,6 +9,21 @@
|
||||
<span nz-icon nzType="arrow-left" nzTheme="outline"></span>
|
||||
Back to Messages
|
||||
</button>
|
||||
@if (expertMode()) {
|
||||
<div class="header-actions">
|
||||
<button
|
||||
nz-button
|
||||
nzDanger
|
||||
nz-popconfirm
|
||||
nzPopconfirmTitle="Are you sure you want to delete this message?"
|
||||
(nzOnConfirm)="deleteMessage()"
|
||||
[nzLoading]="deleting()"
|
||||
>
|
||||
<span nz-icon nzType="delete"></span>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<nz-card [nzTitle]="message()!.title">
|
||||
@@ -32,6 +47,10 @@
|
||||
<div class="cell-id mono">{{ message()!.channel_id }}</div>
|
||||
</a>
|
||||
</scn-metadata-value>
|
||||
<scn-metadata-value label="Channel Owner">
|
||||
<div class="cell-name">{{ resolvedChannelOwner()?.displayName || message()!.channel_owner_user_id }}</div>
|
||||
<div class="cell-id mono">{{ message()!.channel_owner_user_id }}</div>
|
||||
</scn-metadata-value>
|
||||
<scn-metadata-value label="Priority">
|
||||
<nz-tag [nzColor]="getPriorityColor(message()!.priority)">
|
||||
{{ getPriorityLabel(message()!.priority) }}
|
||||
@@ -58,6 +77,48 @@
|
||||
</scn-metadata-value>
|
||||
</scn-metadata-grid>
|
||||
</nz-card>
|
||||
|
||||
@if (showDeliveries()) {
|
||||
<nz-card nzTitle="Deliveries">
|
||||
<nz-table
|
||||
#deliveriesTable
|
||||
[nzData]="deliveries()"
|
||||
[nzLoading]="loadingDeliveries()"
|
||||
[nzShowPagination]="deliveries().length > 10"
|
||||
[nzPageSize]="10"
|
||||
nzSize="small"
|
||||
>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Client ID</th>
|
||||
<th>Status</th>
|
||||
<th>Retries</th>
|
||||
<th>Created</th>
|
||||
<th>Finalized</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (delivery of deliveriesTable.data; track delivery.delivery_id) {
|
||||
<tr>
|
||||
<td>
|
||||
<a [routerLink]="['/clients', delivery.receiver_client_id]" class="mono">
|
||||
{{ delivery.receiver_client_id }}
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<nz-tag [nzColor]="getStatusColor(delivery.status)">
|
||||
{{ delivery.status }}
|
||||
</nz-tag>
|
||||
</td>
|
||||
<td>{{ delivery.retry_count }}</td>
|
||||
<td>{{ delivery.timestamp_created | date:'yyyy-MM-dd HH:mm:ss' }}</td>
|
||||
<td>{{ delivery.timestamp_finalized ? (delivery.timestamp_finalized | date:'yyyy-MM-dd HH:mm:ss') : '-' }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</nz-table>
|
||||
</nz-card>
|
||||
}
|
||||
} @else {
|
||||
<nz-card>
|
||||
<div class="not-found">
|
||||
|
||||
@@ -64,9 +64,11 @@ nz-card + nz-card {
|
||||
.timestamp-absolute {
|
||||
font-size: 13px;
|
||||
color: #333;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.timestamp-relative {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
white-space: pre;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component, inject, signal, OnInit } from '@angular/core';
|
||||
import { Component, inject, signal, OnInit, computed } from '@angular/core';
|
||||
import { CommonModule, DatePipe } from '@angular/common';
|
||||
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||
import { NzCardModule } from 'ng-zorro-antd/card';
|
||||
@@ -6,10 +6,15 @@ import { NzButtonModule } from 'ng-zorro-antd/button';
|
||||
import { NzIconModule } from 'ng-zorro-antd/icon';
|
||||
import { NzTagModule } from 'ng-zorro-antd/tag';
|
||||
import { NzSpinModule } from 'ng-zorro-antd/spin';
|
||||
import { NzPopconfirmModule } from 'ng-zorro-antd/popconfirm';
|
||||
import { NzTableModule } from 'ng-zorro-antd/table';
|
||||
import { ApiService } from '../../../core/services/api.service';
|
||||
import { AuthService } from '../../../core/services/auth.service';
|
||||
import { NotificationService } from '../../../core/services/notification.service';
|
||||
import { SettingsService } from '../../../core/services/settings.service';
|
||||
import { KeyCacheService, ResolvedKey } from '../../../core/services/key-cache.service';
|
||||
import { Message } from '../../../core/models';
|
||||
import { UserCacheService, ResolvedUser } from '../../../core/services/user-cache.service';
|
||||
import { Message, Delivery } from '../../../core/models';
|
||||
import { RelativeTimePipe } from '../../../shared/pipes/relative-time.pipe';
|
||||
import { MetadataGridComponent, MetadataValueComponent } from '../../../shared/components/metadata-grid';
|
||||
|
||||
@@ -24,6 +29,8 @@ import { MetadataGridComponent, MetadataValueComponent } from '../../../shared/c
|
||||
NzIconModule,
|
||||
NzTagModule,
|
||||
NzSpinModule,
|
||||
NzPopconfirmModule,
|
||||
NzTableModule,
|
||||
RouterLink,
|
||||
RelativeTimePipe,
|
||||
MetadataGridComponent,
|
||||
@@ -36,13 +43,28 @@ export class MessageDetailComponent implements OnInit {
|
||||
private route = inject(ActivatedRoute);
|
||||
private router = inject(Router);
|
||||
private apiService = inject(ApiService);
|
||||
private authService = inject(AuthService);
|
||||
private notification = inject(NotificationService);
|
||||
private settingsService = inject(SettingsService);
|
||||
private keyCacheService = inject(KeyCacheService);
|
||||
private userCacheService = inject(UserCacheService);
|
||||
|
||||
message = signal<Message | null>(null);
|
||||
resolvedKey = signal<ResolvedKey | null>(null);
|
||||
resolvedChannelOwner = signal<ResolvedUser | null>(null);
|
||||
deliveries = signal<Delivery[]>([]);
|
||||
loading = signal(true);
|
||||
deleting = signal(false);
|
||||
loadingDeliveries = signal(false);
|
||||
expertMode = this.settingsService.expertMode;
|
||||
|
||||
isChannelOwner = computed(() => {
|
||||
const msg = this.message();
|
||||
const userId = this.authService.getUserId();
|
||||
return msg !== null && userId !== null && msg.channel_owner_user_id === userId;
|
||||
});
|
||||
|
||||
showDeliveries = computed(() => this.expertMode() && this.isChannelOwner());
|
||||
|
||||
ngOnInit(): void {
|
||||
const messageId = this.route.snapshot.paramMap.get('id');
|
||||
@@ -58,6 +80,8 @@ export class MessageDetailComponent implements OnInit {
|
||||
this.message.set(message);
|
||||
this.loading.set(false);
|
||||
this.resolveKey(message.used_key_id);
|
||||
this.resolveChannelOwner(message.channel_owner_user_id);
|
||||
this.loadDeliveries(messageId);
|
||||
},
|
||||
error: () => {
|
||||
this.loading.set(false);
|
||||
@@ -65,12 +89,34 @@ export class MessageDetailComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
private loadDeliveries(messageId: string): void {
|
||||
if (!this.showDeliveries()) {
|
||||
return;
|
||||
}
|
||||
this.loadingDeliveries.set(true);
|
||||
this.apiService.getDeliveries(messageId).subscribe({
|
||||
next: (response) => {
|
||||
this.deliveries.set(response.deliveries);
|
||||
this.loadingDeliveries.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.loadingDeliveries.set(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private resolveKey(keyId: string): void {
|
||||
this.keyCacheService.resolveKey(keyId).subscribe({
|
||||
next: (resolved) => this.resolvedKey.set(resolved)
|
||||
});
|
||||
}
|
||||
|
||||
private resolveChannelOwner(userId: string): void {
|
||||
this.userCacheService.resolveUser(userId).subscribe({
|
||||
next: (resolved) => this.resolvedChannelOwner.set(resolved)
|
||||
});
|
||||
}
|
||||
|
||||
goBack(): void {
|
||||
this.router.navigate(['/messages']);
|
||||
}
|
||||
@@ -108,4 +154,13 @@ export class MessageDetailComponent implements OnInit {
|
||||
default: return 'default';
|
||||
}
|
||||
}
|
||||
|
||||
getStatusColor(status: string): string {
|
||||
switch (status) {
|
||||
case 'SUCCESS': return 'green';
|
||||
case 'FAILED': return 'red';
|
||||
case 'RETRY': return 'orange';
|
||||
default: return 'default';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,33 +107,45 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (message of messages(); track message.message_id) {
|
||||
<tr class="clickable-row" (click)="viewMessage(message)">
|
||||
<tr class="clickable-row">
|
||||
<td>
|
||||
<div class="message-title">{{ message.title }}</div>
|
||||
<div class="message-id mono">{{ message.message_id }}</div>
|
||||
<a class="cell-link" [routerLink]="['/messages', message.message_id]">
|
||||
<div class="message-title">{{ message.title }}</div>
|
||||
<div class="message-id mono">{{ message.message_id }}</div>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
@if (message.content) {
|
||||
<div class="message-content">{{ message.content | slice:0:128 }}{{ message.content.length > 128 ? '...' : '' }}</div>
|
||||
} @else {
|
||||
<span class="text-muted"></span>
|
||||
}
|
||||
<a class="cell-link" [routerLink]="['/messages', message.message_id]">
|
||||
@if (message.content) {
|
||||
<div class="message-content">{{ message.content | slice:0:128 }}{{ message.content.length > 128 ? '...' : '' }}</div>
|
||||
} @else {
|
||||
<span class="text-muted"></span>
|
||||
}
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<div class="cell-name">{{ message.channel_internal_name }}</div>
|
||||
<div class="cell-id">{{ message.channel_id }}</div>
|
||||
<a class="cell-link" [routerLink]="['/messages', message.message_id]">
|
||||
<div class="cell-name">{{ message.channel_internal_name }}</div>
|
||||
<div class="cell-id">{{ message.channel_id }}</div>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
{{ message.sender_name || '-' }}
|
||||
<a class="cell-link" [routerLink]="['/messages', message.message_id]">
|
||||
<span style="white-space: pre">{{ message.sender_name || '-' }}</span>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<nz-tag [nzColor]="getPriorityColor(message.priority)">
|
||||
{{ getPriorityLabel(message.priority) }}
|
||||
</nz-tag>
|
||||
<a class="cell-link" [routerLink]="['/messages', message.message_id]">
|
||||
<nz-tag [nzColor]="getPriorityColor(message.priority)">
|
||||
{{ getPriorityLabel(message.priority) }}
|
||||
</nz-tag>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<div class="timestamp-absolute">{{ message.timestamp | date:'yyyy-MM-dd HH:mm:ss' }}</div>
|
||||
<div class="timestamp-relative">{{ message.timestamp | relativeTime }}</div>
|
||||
<a class="cell-link" [routerLink]="['/messages', message.message_id]">
|
||||
<div class="timestamp-absolute">{{ message.timestamp | date:'yyyy-MM-dd HH:mm:ss' }}</div>
|
||||
<div class="timestamp-relative">{{ message.timestamp | relativeTime }}</div>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
} @empty {
|
||||
|
||||
@@ -70,11 +70,13 @@
|
||||
.timestamp-absolute {
|
||||
font-size: 13px;
|
||||
color: #333;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.timestamp-relative {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.pagination-controls {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Component, inject, signal, OnInit } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Router } from '@angular/router';
|
||||
import { Router, RouterLink } from '@angular/router';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { NzTableModule, NzTableFilterList } from 'ng-zorro-antd/table';
|
||||
import { NzButtonModule } from 'ng-zorro-antd/button';
|
||||
@@ -24,6 +24,7 @@ import { RelativeTimePipe } from '../../../shared/pipes/relative-time.pipe';
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
RouterLink,
|
||||
NzTableModule,
|
||||
NzButtonModule,
|
||||
NzInputModule,
|
||||
|
||||
@@ -16,9 +16,11 @@
|
||||
.timestamp-absolute {
|
||||
font-size: 13px;
|
||||
color: #333;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.timestamp-relative {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
@@ -27,16 +27,29 @@
|
||||
Deactivate
|
||||
</button>
|
||||
}
|
||||
<button
|
||||
nz-button
|
||||
nzDanger
|
||||
nz-popconfirm
|
||||
nzPopconfirmTitle="Are you sure you want to delete this subscription?"
|
||||
(nzOnConfirm)="deleteSubscription()"
|
||||
>
|
||||
<span nz-icon nzType="delete"></span>
|
||||
Delete
|
||||
</button>
|
||||
@if (expertMode() && isOutgoing() && subscription()!.confirmed && subscription()!.active) {
|
||||
<button
|
||||
nz-button
|
||||
nz-popconfirm
|
||||
nzPopconfirmTitle="Set this subscription to inactive? You will stop receiving messages."
|
||||
(nzOnConfirm)="setInactive()"
|
||||
>
|
||||
<span nz-icon nzType="pause-circle"></span>
|
||||
Set Inactive
|
||||
</button>
|
||||
}
|
||||
@if (expertMode() && isOutgoing()) {
|
||||
<button
|
||||
nz-button
|
||||
nzDanger
|
||||
nz-popconfirm
|
||||
nzPopconfirmTitle="Are you sure you want to delete this subscription?"
|
||||
(nzOnConfirm)="deleteSubscription()"
|
||||
>
|
||||
<span nz-icon nzType="delete"></span>
|
||||
Delete
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -58,9 +58,11 @@
|
||||
.timestamp-absolute {
|
||||
font-size: 13px;
|
||||
color: #333;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.timestamp-relative {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,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 { SettingsService } from '../../../core/services/settings.service';
|
||||
import { ChannelCacheService, ResolvedChannel } from '../../../core/services/channel-cache.service';
|
||||
import { UserCacheService, ResolvedUser } from '../../../core/services/user-cache.service';
|
||||
import { Subscription } from '../../../core/models';
|
||||
@@ -44,6 +45,7 @@ export class SubscriptionDetailComponent implements OnInit {
|
||||
private apiService = inject(ApiService);
|
||||
private authService = inject(AuthService);
|
||||
private notification = inject(NotificationService);
|
||||
private settingsService = inject(SettingsService);
|
||||
private channelCacheService = inject(ChannelCacheService);
|
||||
private userCacheService = inject(UserCacheService);
|
||||
|
||||
@@ -52,6 +54,7 @@ export class SubscriptionDetailComponent implements OnInit {
|
||||
resolvedChannel = signal<ResolvedChannel | null>(null);
|
||||
resolvedSubscriber = signal<ResolvedUser | null>(null);
|
||||
resolvedOwner = signal<ResolvedUser | null>(null);
|
||||
expertMode = this.settingsService.expertMode;
|
||||
|
||||
ngOnInit(): void {
|
||||
const subscriptionId = this.route.snapshot.paramMap.get('id');
|
||||
@@ -163,6 +166,19 @@ export class SubscriptionDetailComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
setInactive(): void {
|
||||
const sub = this.subscription();
|
||||
const userId = this.authService.getUserId();
|
||||
if (!sub || !userId) return;
|
||||
|
||||
this.apiService.confirmSubscription(userId, sub.subscription_id, { active: false }).subscribe({
|
||||
next: (updated) => {
|
||||
this.subscription.set(updated);
|
||||
this.notification.success('Subscription set to inactive');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
deleteSubscription(): void {
|
||||
const sub = this.subscription();
|
||||
const userId = this.authService.getUserId();
|
||||
|
||||
@@ -47,44 +47,66 @@
|
||||
<th>Channel</th>
|
||||
<th>Subscriber</th>
|
||||
<th>Owner</th>
|
||||
<th nzWidth="0">Status</th>
|
||||
<th nzWidth="0">Confirmation</th>
|
||||
<th nzWidth="0">Active</th>
|
||||
<th nzWidth="0">Created</th>
|
||||
<th nzWidth="0">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (sub of subscriptions(); track sub.subscription_id) {
|
||||
<tr class="clickable-row" (click)="viewSubscription(sub)">
|
||||
<tr class="clickable-row">
|
||||
<td>
|
||||
<span class="mono subscription-id">{{ sub.subscription_id }}</span>
|
||||
<a class="cell-link" [routerLink]="['/subscriptions', sub.subscription_id]">
|
||||
<span class="mono subscription-id">{{ sub.subscription_id }}</span>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<nz-tag [nzColor]="getTypeLabel(sub).color">
|
||||
{{ getTypeLabel(sub).label }}
|
||||
</nz-tag>
|
||||
<a class="cell-link" [routerLink]="['/subscriptions', sub.subscription_id]">
|
||||
<nz-tag [nzColor]="getTypeLabel(sub).color">
|
||||
{{ getTypeLabel(sub).label }}
|
||||
</nz-tag>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<div class="cell-name">{{ sub.channel_internal_name }}</div>
|
||||
<div class="cell-id mono">{{ sub.channel_id }}</div>
|
||||
<a class="cell-link" [routerLink]="['/subscriptions', sub.subscription_id]">
|
||||
<div class="cell-name">{{ sub.channel_internal_name }}</div>
|
||||
<div class="cell-id mono">{{ sub.channel_id }}</div>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<div class="cell-name">{{ getUserDisplayName(sub.subscriber_user_id) }}</div>
|
||||
<div class="cell-id mono">{{ sub.subscriber_user_id }}</div>
|
||||
<a class="cell-link" [routerLink]="['/subscriptions', sub.subscription_id]">
|
||||
<div class="cell-name">{{ getUserDisplayName(sub.subscriber_user_id) }}</div>
|
||||
<div class="cell-id mono">{{ sub.subscriber_user_id }}</div>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<div class="cell-name">{{ getUserDisplayName(sub.channel_owner_user_id) }}</div>
|
||||
<div class="cell-id mono">{{ sub.channel_owner_user_id }}</div>
|
||||
<a class="cell-link" [routerLink]="['/subscriptions', sub.subscription_id]">
|
||||
<div class="cell-name">{{ getUserDisplayName(sub.channel_owner_user_id) }}</div>
|
||||
<div class="cell-id mono">{{ sub.channel_owner_user_id }}</div>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<nz-tag [nzColor]="getStatusInfo(sub).color">
|
||||
{{ getStatusInfo(sub).label }}
|
||||
</nz-tag>
|
||||
<a class="cell-link" [routerLink]="['/subscriptions', sub.subscription_id]">
|
||||
<nz-tag [nzColor]="getConfirmationInfo(sub).color">
|
||||
{{ getConfirmationInfo(sub).label }}
|
||||
</nz-tag>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<div class="timestamp-absolute">{{ sub.timestamp_created | date:'yyyy-MM-dd HH:mm:ss' }}</div>
|
||||
<div class="timestamp-relative">{{ sub.timestamp_created | relativeTime }}</div>
|
||||
<a class="cell-link" [routerLink]="['/subscriptions', sub.subscription_id]">
|
||||
<nz-tag [nzColor]="getActiveInfo(sub).color">
|
||||
{{ getActiveInfo(sub).label }}
|
||||
</nz-tag>
|
||||
</a>
|
||||
</td>
|
||||
<td (click)="$event.stopPropagation()">
|
||||
<td>
|
||||
<a class="cell-link" [routerLink]="['/subscriptions', sub.subscription_id]">
|
||||
<div class="timestamp-absolute">{{ sub.timestamp_created | date:'yyyy-MM-dd HH:mm:ss' }}</div>
|
||||
<div class="timestamp-relative">{{ sub.timestamp_created | relativeTime }}</div>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<div class="action-buttons">
|
||||
@if (!sub.confirmed && isOwner(sub)) {
|
||||
<!-- Incoming unconfirmed: can accept or deny -->
|
||||
@@ -130,7 +152,7 @@
|
||||
</tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="8">
|
||||
<td colspan="9">
|
||||
<nz-empty nzNotFoundContent="No subscriptions found"></nz-empty>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -64,9 +64,11 @@
|
||||
.timestamp-absolute {
|
||||
font-size: 13px;
|
||||
color: #333;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.timestamp-relative {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Component, inject, signal, OnInit } from '@angular/core';
|
||||
import { CommonModule, DatePipe } from '@angular/common';
|
||||
import { Router } from '@angular/router';
|
||||
import { Router, RouterLink } from '@angular/router';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { NzTableModule } from 'ng-zorro-antd/table';
|
||||
import { NzButtonModule } from 'ng-zorro-antd/button';
|
||||
@@ -43,6 +43,7 @@ const TAB_CONFIGS: Record<SubscriptionTab, TabConfig> = {
|
||||
CommonModule,
|
||||
DatePipe,
|
||||
FormsModule,
|
||||
RouterLink,
|
||||
NzTableModule,
|
||||
NzButtonModule,
|
||||
NzIconModule,
|
||||
@@ -231,13 +232,20 @@ export class SubscriptionListComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
getStatusInfo(sub: Subscription): { label: string; color: string } {
|
||||
getConfirmationInfo(sub: Subscription): { label: string; color: string } {
|
||||
if (sub.confirmed) {
|
||||
return { label: 'Confirmed', color: 'green' };
|
||||
}
|
||||
return { label: 'Pending', color: 'orange' };
|
||||
}
|
||||
|
||||
getActiveInfo(sub: Subscription): { label: string; color: string } {
|
||||
if (sub.active) {
|
||||
return { label: 'Active', color: 'green' };
|
||||
}
|
||||
return { label: 'Inactive', color: 'default' };
|
||||
}
|
||||
|
||||
getTypeLabel(sub: Subscription): { label: string; color: string } {
|
||||
const userId = this.authService.getUserId();
|
||||
if (sub.subscriber_user_id === sub.channel_owner_user_id) {
|
||||
|
||||
@@ -56,7 +56,20 @@
|
||||
</span>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<span class="user-id mono">{{ userId }}</span>
|
||||
<div class="expert-mode-toggle">
|
||||
<nz-switch
|
||||
[ngModel]="expertMode()"
|
||||
(ngModelChange)="settingsService.setExpertMode($event)"
|
||||
nzSize="small"
|
||||
></nz-switch>
|
||||
<span class="expert-mode-label">Expert</span>
|
||||
</div>
|
||||
<div class="user-info">
|
||||
<span class="user-id mono">{{ userId }}</span>
|
||||
@if (currentKey()) {
|
||||
<span class="key-id mono">{{ currentKey()!.keytoken_id }}</span>
|
||||
}
|
||||
</div>
|
||||
<button nz-button nzType="text" nzDanger (click)="logout()">
|
||||
<span nz-icon nzType="logout"></span>
|
||||
Logout
|
||||
|
||||
@@ -51,6 +51,17 @@
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.expert-mode-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.expert-mode-label {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
|
||||
.header-trigger {
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
@@ -66,11 +77,23 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
line-height: 1.3;
|
||||
|
||||
.user-id {
|
||||
color: #666;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.key-id {
|
||||
color: #999;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.content-area {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { Component, inject, signal, OnInit } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { RouterOutlet, RouterLink, Router } from '@angular/router';
|
||||
import { NzLayoutModule } from 'ng-zorro-antd/layout';
|
||||
@@ -6,13 +6,19 @@ import { NzMenuModule } from 'ng-zorro-antd/menu';
|
||||
import { NzIconModule } from 'ng-zorro-antd/icon';
|
||||
import { NzButtonModule } from 'ng-zorro-antd/button';
|
||||
import { NzDropDownModule } from 'ng-zorro-antd/dropdown';
|
||||
import { NzSwitchModule } from 'ng-zorro-antd/switch';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { AuthService } from '../../core/services/auth.service';
|
||||
import { SettingsService } from '../../core/services/settings.service';
|
||||
import { ApiService } from '../../core/services/api.service';
|
||||
import { KeyToken } from '../../core/models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-main-layout',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
RouterOutlet,
|
||||
RouterLink,
|
||||
NzLayoutModule,
|
||||
@@ -20,16 +26,34 @@ import { AuthService } from '../../core/services/auth.service';
|
||||
NzIconModule,
|
||||
NzButtonModule,
|
||||
NzDropDownModule,
|
||||
NzSwitchModule,
|
||||
],
|
||||
templateUrl: './main-layout.component.html',
|
||||
styleUrl: './main-layout.component.scss'
|
||||
})
|
||||
export class MainLayoutComponent {
|
||||
export class MainLayoutComponent implements OnInit {
|
||||
private authService = inject(AuthService);
|
||||
private apiService = inject(ApiService);
|
||||
private router = inject(Router);
|
||||
settingsService = inject(SettingsService);
|
||||
|
||||
isCollapsed = signal(false);
|
||||
userId = this.authService.getUserId();
|
||||
currentKey = signal<KeyToken | null>(null);
|
||||
expertMode = this.settingsService.expertMode;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadCurrentKey();
|
||||
}
|
||||
|
||||
private loadCurrentKey(): void {
|
||||
const userId = this.userId;
|
||||
if (!userId) return;
|
||||
|
||||
this.apiService.getCurrentKey(userId).subscribe({
|
||||
next: (key) => this.currentKey.set(key)
|
||||
});
|
||||
}
|
||||
|
||||
toggleCollapsed(): void {
|
||||
this.isCollapsed.update(v => !v);
|
||||
|
||||
@@ -67,15 +67,28 @@ body {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
// Clickable row
|
||||
// Clickable row with anchor link for proper middle-click support
|
||||
.clickable-row {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: #fafafa;
|
||||
}
|
||||
}
|
||||
|
||||
td:has(> a.cell-link) {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
a.cell-link {
|
||||
display: block;
|
||||
padding: 8px;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
// Status colors
|
||||
.status-confirmed {
|
||||
color: #52c41a;
|
||||
@@ -150,3 +163,7 @@ nz-card {
|
||||
flex-grow: 1;
|
||||
}
|
||||
}
|
||||
|
||||
th {
|
||||
white-space: pre;
|
||||
}
|
||||
Reference in New Issue
Block a user