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 './key-token.model';
|
||||||
export * from './client.model';
|
export * from './client.model';
|
||||||
export * from './sender-name.model';
|
export * from './sender-name.model';
|
||||||
|
export * from './delivery.model';
|
||||||
export * from './api-response.model';
|
export * from './api-response.model';
|
||||||
|
|||||||
@@ -26,7 +26,8 @@ export interface CreateSubscriptionRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ConfirmSubscriptionRequest {
|
export interface ConfirmSubscriptionRequest {
|
||||||
confirmed: boolean;
|
confirmed?: boolean;
|
||||||
|
active?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SubscriptionListResponse {
|
export interface SubscriptionListResponse {
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
ClientListResponse,
|
ClientListResponse,
|
||||||
SenderNameStatistics,
|
SenderNameStatistics,
|
||||||
SenderNameListResponse,
|
SenderNameListResponse,
|
||||||
|
DeliveryListResponse,
|
||||||
} from '../models';
|
} from '../models';
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
@@ -167,6 +168,10 @@ export class ApiService {
|
|||||||
return this.http.delete<Message>(`${this.baseUrl}/messages/${messageId}`);
|
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
|
// Subscription endpoints
|
||||||
getSubscriptions(userId: string, filter?: SubscriptionFilter): Observable<SubscriptionListResponse> {
|
getSubscriptions(userId: string, filter?: SubscriptionFilter): Observable<SubscriptionListResponse> {
|
||||||
let httpParams = new HttpParams();
|
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>
|
</div>
|
||||||
} @else if (user()) {
|
} @else if (user()) {
|
||||||
<nz-card nzTitle="User Information">
|
<nz-card nzTitle="User Information">
|
||||||
<nz-descriptions nzBordered [nzColumn]="2">
|
<scn-metadata-grid>
|
||||||
<nz-descriptions-item nzTitle="User ID" [nzSpan]="2">
|
<scn-metadata-value label="User ID">
|
||||||
<span class="mono">{{ user()!.user_id }}</span>
|
<span class="mono">{{ user()!.user_id }}</span>
|
||||||
</nz-descriptions-item>
|
</scn-metadata-value>
|
||||||
<nz-descriptions-item nzTitle="Username">
|
<scn-metadata-value label="Username">
|
||||||
{{ user()!.username || '(Not set)' }}
|
{{ user()!.username || '(Not set)' }}
|
||||||
<button nz-button nzSize="small" nzType="link" (click)="openEditModal()">
|
<button nz-button nzSize="small" nzType="link" (click)="openEditModal()">
|
||||||
<span nz-icon nzType="edit"></span>
|
<span nz-icon nzType="edit"></span>
|
||||||
</button>
|
</button>
|
||||||
</nz-descriptions-item>
|
</scn-metadata-value>
|
||||||
<nz-descriptions-item nzTitle="Account Type">
|
<scn-metadata-value label="Account Type">
|
||||||
@if (user()!.is_pro) {
|
@if (user()!.is_pro) {
|
||||||
<nz-tag nzColor="gold">Pro</nz-tag>
|
<nz-tag nzColor="gold">Pro</nz-tag>
|
||||||
} @else {
|
} @else {
|
||||||
<nz-tag>Free</nz-tag>
|
<nz-tag>Free</nz-tag>
|
||||||
}
|
}
|
||||||
</nz-descriptions-item>
|
</scn-metadata-value>
|
||||||
<nz-descriptions-item nzTitle="Messages Sent">
|
<scn-metadata-value label="Messages Sent">
|
||||||
{{ user()!.messages_sent }}
|
{{ user()!.messages_sent }}
|
||||||
</nz-descriptions-item>
|
</scn-metadata-value>
|
||||||
<nz-descriptions-item nzTitle="Created">
|
<scn-metadata-value label="Created">
|
||||||
{{ user()!.timestamp_created | relativeTime }}
|
{{ user()!.timestamp_created | relativeTime }}
|
||||||
</nz-descriptions-item>
|
</scn-metadata-value>
|
||||||
<nz-descriptions-item nzTitle="Last Read">
|
<scn-metadata-value label="Last Read">
|
||||||
{{ user()!.timestamp_lastread | relativeTime }}
|
{{ user()!.timestamp_lastread | relativeTime }}
|
||||||
</nz-descriptions-item>
|
</scn-metadata-value>
|
||||||
<nz-descriptions-item nzTitle="Last Sent">
|
<scn-metadata-value label="Last Sent">
|
||||||
{{ user()!.timestamp_lastsent | relativeTime }}
|
{{ user()!.timestamp_lastsent | relativeTime }}
|
||||||
</nz-descriptions-item>
|
</scn-metadata-value>
|
||||||
</nz-descriptions>
|
</scn-metadata-grid>
|
||||||
</nz-card>
|
</nz-card>
|
||||||
|
|
||||||
<nz-card nzTitle="Quota" class="mt-16">
|
<nz-card nzTitle="Quota" class="mt-16">
|
||||||
@@ -62,22 +62,39 @@
|
|||||||
|
|
||||||
<nz-divider></nz-divider>
|
<nz-divider></nz-divider>
|
||||||
|
|
||||||
<nz-descriptions [nzColumn]="2" nzSize="small">
|
<scn-metadata-grid>
|
||||||
<nz-descriptions-item nzTitle="Max Body Size">
|
<scn-metadata-value label="Max Body Size">
|
||||||
{{ user()!.max_body_size | number }} bytes
|
{{ user()!.max_body_size | number }} bytes
|
||||||
</nz-descriptions-item>
|
</scn-metadata-value>
|
||||||
<nz-descriptions-item nzTitle="Max Title Length">
|
<scn-metadata-value label="Max Title Length">
|
||||||
{{ user()!.max_title_length }} chars
|
{{ user()!.max_title_length }} chars
|
||||||
</nz-descriptions-item>
|
</scn-metadata-value>
|
||||||
<nz-descriptions-item nzTitle="Default Channel">
|
<scn-metadata-value label="Default Channel">
|
||||||
{{ user()!.default_channel }}
|
{{ user()!.default_channel }}
|
||||||
</nz-descriptions-item>
|
</scn-metadata-value>
|
||||||
<nz-descriptions-item nzTitle="Default Priority">
|
<scn-metadata-value label="Default Priority">
|
||||||
{{ user()!.default_priority }}
|
{{ user()!.default_priority }}
|
||||||
</nz-descriptions-item>
|
</scn-metadata-value>
|
||||||
</nz-descriptions>
|
</scn-metadata-grid>
|
||||||
</nz-card>
|
</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>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -43,3 +43,17 @@
|
|||||||
margin-bottom: 16px;
|
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 { Component, inject, signal, OnInit } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
import { NzCardModule } from 'ng-zorro-antd/card';
|
import { NzCardModule } from 'ng-zorro-antd/card';
|
||||||
import { NzButtonModule } from 'ng-zorro-antd/button';
|
import { NzButtonModule } from 'ng-zorro-antd/button';
|
||||||
import { NzIconModule } from 'ng-zorro-antd/icon';
|
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 { NzFormModule } from 'ng-zorro-antd/form';
|
||||||
import { NzInputModule } from 'ng-zorro-antd/input';
|
import { NzInputModule } from 'ng-zorro-antd/input';
|
||||||
import { NzDividerModule } from 'ng-zorro-antd/divider';
|
import { NzDividerModule } from 'ng-zorro-antd/divider';
|
||||||
|
import { NzPopconfirmModule } from 'ng-zorro-antd/popconfirm';
|
||||||
import { ApiService } from '../../../core/services/api.service';
|
import { ApiService } from '../../../core/services/api.service';
|
||||||
import { AuthService } from '../../../core/services/auth.service';
|
import { AuthService } from '../../../core/services/auth.service';
|
||||||
import { NotificationService } from '../../../core/services/notification.service';
|
import { NotificationService } from '../../../core/services/notification.service';
|
||||||
|
import { SettingsService } from '../../../core/services/settings.service';
|
||||||
import { UserWithExtra } from '../../../core/models';
|
import { UserWithExtra } from '../../../core/models';
|
||||||
import { RelativeTimePipe } from '../../../shared/pipes/relative-time.pipe';
|
import { RelativeTimePipe } from '../../../shared/pipes/relative-time.pipe';
|
||||||
|
import { MetadataGridComponent, MetadataValueComponent } from '../../../shared/components/metadata-grid';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-account-info',
|
selector: 'app-account-info',
|
||||||
@@ -35,7 +39,10 @@ import { RelativeTimePipe } from '../../../shared/pipes/relative-time.pipe';
|
|||||||
NzFormModule,
|
NzFormModule,
|
||||||
NzInputModule,
|
NzInputModule,
|
||||||
NzDividerModule,
|
NzDividerModule,
|
||||||
|
NzPopconfirmModule,
|
||||||
RelativeTimePipe,
|
RelativeTimePipe,
|
||||||
|
MetadataGridComponent,
|
||||||
|
MetadataValueComponent,
|
||||||
],
|
],
|
||||||
templateUrl: './account-info.component.html',
|
templateUrl: './account-info.component.html',
|
||||||
styleUrl: './account-info.component.scss'
|
styleUrl: './account-info.component.scss'
|
||||||
@@ -44,9 +51,13 @@ export class AccountInfoComponent implements OnInit {
|
|||||||
private apiService = inject(ApiService);
|
private apiService = inject(ApiService);
|
||||||
private authService = inject(AuthService);
|
private authService = inject(AuthService);
|
||||||
private notification = inject(NotificationService);
|
private notification = inject(NotificationService);
|
||||||
|
private settingsService = inject(SettingsService);
|
||||||
|
private router = inject(Router);
|
||||||
|
|
||||||
user = signal<UserWithExtra | null>(null);
|
user = signal<UserWithExtra | null>(null);
|
||||||
loading = signal(true);
|
loading = signal(true);
|
||||||
|
deleting = signal(false);
|
||||||
|
expertMode = this.settingsService.expertMode;
|
||||||
|
|
||||||
// Edit username modal
|
// Edit username modal
|
||||||
showEditModal = signal(false);
|
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>
|
<span nz-icon nzType="edit"></span>
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</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>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -152,28 +165,76 @@
|
|||||||
<th>Subscriber</th>
|
<th>Subscriber</th>
|
||||||
<th nzWidth="0">Status</th>
|
<th nzWidth="0">Status</th>
|
||||||
<th nzWidth="0">Created</th>
|
<th nzWidth="0">Created</th>
|
||||||
|
<th nzWidth="0">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@for (sub of subscriptions(); track sub.subscription_id) {
|
@for (sub of subscriptions(); track sub.subscription_id) {
|
||||||
<tr>
|
<tr class="clickable-row">
|
||||||
<td>
|
<td>
|
||||||
<div class="cell-name">{{ getUserDisplayName(sub.subscriber_user_id) }}</div>
|
<a class="cell-link" [routerLink]="['/subscriptions', sub.subscription_id]">
|
||||||
<div class="cell-id mono">{{ sub.subscriber_user_id }}</div>
|
<div class="cell-name">{{ getUserDisplayName(sub.subscriber_user_id) }}</div>
|
||||||
|
<div class="cell-id mono">{{ sub.subscriber_user_id }}</div>
|
||||||
|
</a>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<nz-tag [nzColor]="sub.confirmed ? 'green' : 'orange'">
|
<a class="cell-link" [routerLink]="['/subscriptions', sub.subscription_id]">
|
||||||
{{ sub.confirmed ? 'Confirmed' : 'Pending' }}
|
<nz-tag [nzColor]="sub.confirmed ? 'green' : 'orange'">
|
||||||
</nz-tag>
|
{{ sub.confirmed ? 'Confirmed' : 'Pending' }}
|
||||||
|
</nz-tag>
|
||||||
|
</a>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="timestamp-absolute">{{ sub.timestamp_created | date:'yyyy-MM-dd HH:mm:ss' }}</div>
|
<a class="cell-link" [routerLink]="['/subscriptions', sub.subscription_id]">
|
||||||
<div class="timestamp-relative">{{ sub.timestamp_created | relativeTime }}</div>
|
<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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
} @empty {
|
} @empty {
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="3">
|
<td colspan="4">
|
||||||
<nz-empty nzNotFoundContent="No subscriptions"></nz-empty>
|
<nz-empty nzNotFoundContent="No subscriptions"></nz-empty>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -182,6 +243,84 @@
|
|||||||
</nz-table>
|
</nz-table>
|
||||||
</nz-card>
|
</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 {
|
} @else {
|
||||||
<nz-card>
|
<nz-card>
|
||||||
<div class="not-found">
|
<div class="not-found">
|
||||||
|
|||||||
@@ -68,9 +68,51 @@
|
|||||||
.timestamp-absolute {
|
.timestamp-absolute {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: #333;
|
color: #333;
|
||||||
|
white-space: pre;
|
||||||
}
|
}
|
||||||
|
|
||||||
.timestamp-relative {
|
.timestamp-relative {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #999;
|
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 { Component, inject, signal, computed, OnInit } from '@angular/core';
|
||||||
import { CommonModule, DatePipe } from '@angular/common';
|
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 { FormsModule } from '@angular/forms';
|
||||||
import { NzCardModule } from 'ng-zorro-antd/card';
|
import { NzCardModule } from 'ng-zorro-antd/card';
|
||||||
import { NzButtonModule } from 'ng-zorro-antd/button';
|
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 { NzTableModule } from 'ng-zorro-antd/table';
|
||||||
import { NzToolTipModule } from 'ng-zorro-antd/tooltip';
|
import { NzToolTipModule } from 'ng-zorro-antd/tooltip';
|
||||||
import { NzEmptyModule } from 'ng-zorro-antd/empty';
|
import { NzEmptyModule } from 'ng-zorro-antd/empty';
|
||||||
|
import { NzPaginationModule } from 'ng-zorro-antd/pagination';
|
||||||
import { ApiService } from '../../../core/services/api.service';
|
import { ApiService } from '../../../core/services/api.service';
|
||||||
import { AuthService } from '../../../core/services/auth.service';
|
import { AuthService } from '../../../core/services/auth.service';
|
||||||
import { NotificationService } from '../../../core/services/notification.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 { 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 { RelativeTimePipe } from '../../../shared/pipes/relative-time.pipe';
|
||||||
import { CopyToClipboardDirective } from '../../../shared/directives/copy-to-clipboard.directive';
|
import { CopyToClipboardDirective } from '../../../shared/directives/copy-to-clipboard.directive';
|
||||||
import { QrCodeDisplayComponent } from '../../../shared/components/qr-code-display/qr-code-display.component';
|
import { QrCodeDisplayComponent } from '../../../shared/components/qr-code-display/qr-code-display.component';
|
||||||
@@ -32,6 +34,7 @@ import { MetadataGridComponent, MetadataValueComponent } from '../../../shared/c
|
|||||||
CommonModule,
|
CommonModule,
|
||||||
DatePipe,
|
DatePipe,
|
||||||
FormsModule,
|
FormsModule,
|
||||||
|
RouterLink,
|
||||||
NzCardModule,
|
NzCardModule,
|
||||||
NzButtonModule,
|
NzButtonModule,
|
||||||
NzIconModule,
|
NzIconModule,
|
||||||
@@ -45,6 +48,7 @@ import { MetadataGridComponent, MetadataValueComponent } from '../../../shared/c
|
|||||||
NzTableModule,
|
NzTableModule,
|
||||||
NzToolTipModule,
|
NzToolTipModule,
|
||||||
NzEmptyModule,
|
NzEmptyModule,
|
||||||
|
NzPaginationModule,
|
||||||
RelativeTimePipe,
|
RelativeTimePipe,
|
||||||
CopyToClipboardDirective,
|
CopyToClipboardDirective,
|
||||||
QrCodeDisplayComponent,
|
QrCodeDisplayComponent,
|
||||||
@@ -60,13 +64,24 @@ export class ChannelDetailComponent implements OnInit {
|
|||||||
private apiService = inject(ApiService);
|
private apiService = inject(ApiService);
|
||||||
private authService = inject(AuthService);
|
private authService = inject(AuthService);
|
||||||
private notification = inject(NotificationService);
|
private notification = inject(NotificationService);
|
||||||
|
private settingsService = inject(SettingsService);
|
||||||
private userCacheService = inject(UserCacheService);
|
private userCacheService = inject(UserCacheService);
|
||||||
|
|
||||||
channel = signal<ChannelWithSubscription | null>(null);
|
channel = signal<ChannelWithSubscription | null>(null);
|
||||||
subscriptions = signal<Subscription[]>([]);
|
subscriptions = signal<Subscription[]>([]);
|
||||||
|
messages = signal<Message[]>([]);
|
||||||
userNames = signal<Map<string, ResolvedUser>>(new Map());
|
userNames = signal<Map<string, ResolvedUser>>(new Map());
|
||||||
loading = signal(true);
|
loading = signal(true);
|
||||||
loadingSubscriptions = signal(false);
|
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
|
// Edit modal
|
||||||
showEditModal = signal(false);
|
showEditModal = signal(false);
|
||||||
editDisplayName = '';
|
editDisplayName = '';
|
||||||
@@ -107,6 +122,7 @@ export class ChannelDetailComponent implements OnInit {
|
|||||||
if (this.isOwner()) {
|
if (this.isOwner()) {
|
||||||
this.loadSubscriptions(channelId);
|
this.loadSubscriptions(channelId);
|
||||||
}
|
}
|
||||||
|
this.loadMessages(channelId);
|
||||||
},
|
},
|
||||||
error: () => {
|
error: () => {
|
||||||
this.loading.set(false);
|
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 {
|
private resolveUserNames(subscriptions: Subscription[]): void {
|
||||||
const userIds = new Set<string>();
|
const userIds = new Set<string>();
|
||||||
for (const sub of subscriptions) {
|
for (const sub of subscriptions) {
|
||||||
@@ -245,4 +324,70 @@ export class ChannelDetailComponent implements OnInit {
|
|||||||
|
|
||||||
return { label: 'Not Subscribed', color: 'default' };
|
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>
|
<ng-template #noResultTpl></ng-template>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Name</th>
|
<th style="width: auto">Name</th>
|
||||||
<th nzWidth="0">Internal Name</th>
|
<th style="width: auto">Internal Name</th>
|
||||||
<th nzWidth="0">Owner</th>
|
<th style="width: auto">Owner</th>
|
||||||
<th nzWidth="0">Status</th>
|
<th style="width: 0">Status</th>
|
||||||
<th nzWidth="0">Subscribers</th>
|
<th style="width: 400px">Subscribers</th>
|
||||||
<th nzWidth="0">Messages</th>
|
<th style="width: 0">Messages</th>
|
||||||
<th nzWidth="0">Last Sent</th>
|
<th style="width: 0">Last Sent</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@for (channel of channels(); track channel.channel_id) {
|
@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>
|
<td>
|
||||||
<div class="channel-name">{{ channel.display_name }}</div>
|
@if (isOwned(channel)) {
|
||||||
<div class="channel-id mono">{{ channel.channel_id }}</div>
|
<a class="cell-link" [routerLink]="['/channels', channel.channel_id]">
|
||||||
</td>
|
<div class="channel-name">{{ channel.display_name }}</div>
|
||||||
<td>
|
<div class="channel-id mono">{{ channel.channel_id }}</div>
|
||||||
<span class="mono">{{ channel.internal_name }}</span>
|
</a>
|
||||||
</td>
|
} @else {
|
||||||
<td>{{ getOwnerDisplayName(channel.owner_user_id) }}</td>
|
<div class="channel-name">{{ channel.display_name }}</div>
|
||||||
<td>
|
<div class="channel-id mono">{{ channel.channel_id }}</div>
|
||||||
<nz-tag [nzColor]="getSubscriptionStatus(channel).color">
|
}
|
||||||
{{ getSubscriptionStatus(channel).label }}
|
|
||||||
</nz-tag>
|
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
@if (isOwned(channel)) {
|
@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 {
|
} @else {
|
||||||
<span class="text-muted">-</span>
|
<span class="text-muted">-</span>
|
||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
<td>{{ channel.messages_sent }}</td>
|
|
||||||
<td>
|
<td>
|
||||||
@if (channel.timestamp_lastsent) {
|
@if (isOwned(channel)) {
|
||||||
<div class="timestamp-absolute">{{ channel.timestamp_lastsent | date:'yyyy-MM-dd HH:mm:ss' }}</div>
|
<a class="cell-link" [routerLink]="['/channels', channel.channel_id]">
|
||||||
<div class="timestamp-relative">{{ channel.timestamp_lastsent | relativeTime }}</div>
|
{{ channel.messages_sent }}
|
||||||
|
</a>
|
||||||
} @else {
|
} @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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -36,11 +36,13 @@
|
|||||||
.timestamp-absolute {
|
.timestamp-absolute {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: #333;
|
color: #333;
|
||||||
|
white-space: pre;
|
||||||
}
|
}
|
||||||
|
|
||||||
.timestamp-relative {
|
.timestamp-relative {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #999;
|
color: #999;
|
||||||
|
white-space: pre;
|
||||||
}
|
}
|
||||||
|
|
||||||
.clickable-row {
|
.clickable-row {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Component, inject, signal, OnInit } from '@angular/core';
|
import { Component, inject, signal, OnInit } from '@angular/core';
|
||||||
import { CommonModule, DatePipe } from '@angular/common';
|
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 { NzTableModule } from 'ng-zorro-antd/table';
|
||||||
import { NzButtonModule } from 'ng-zorro-antd/button';
|
import { NzButtonModule } from 'ng-zorro-antd/button';
|
||||||
import { NzIconModule } from 'ng-zorro-antd/icon';
|
import { NzIconModule } from 'ng-zorro-antd/icon';
|
||||||
@@ -22,6 +22,7 @@ import { ChannelSubscribersComponent } from '../channel-subscribers/channel-subs
|
|||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
DatePipe,
|
DatePipe,
|
||||||
|
RouterLink,
|
||||||
NzTableModule,
|
NzTableModule,
|
||||||
NzButtonModule,
|
NzButtonModule,
|
||||||
NzIconModule,
|
NzIconModule,
|
||||||
|
|||||||
@@ -9,18 +9,20 @@
|
|||||||
<span nz-icon nzType="arrow-left" nzTheme="outline"></span>
|
<span nz-icon nzType="arrow-left" nzTheme="outline"></span>
|
||||||
Back to Clients
|
Back to Clients
|
||||||
</button>
|
</button>
|
||||||
<div class="header-actions">
|
@if (expertMode()) {
|
||||||
<button
|
<div class="header-actions">
|
||||||
nz-button
|
<button
|
||||||
nzDanger
|
nz-button
|
||||||
nz-popconfirm
|
nzDanger
|
||||||
nzPopconfirmTitle="Are you sure you want to delete this client?"
|
nz-popconfirm
|
||||||
(nzOnConfirm)="deleteClient()"
|
nzPopconfirmTitle="Are you sure you want to delete this client?"
|
||||||
>
|
(nzOnConfirm)="deleteClient()"
|
||||||
<span nz-icon nzType="delete"></span>
|
>
|
||||||
Delete
|
<span nz-icon nzType="delete"></span>
|
||||||
</button>
|
Delete
|
||||||
</div>
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nz-card>
|
<nz-card>
|
||||||
|
|||||||
@@ -59,9 +59,11 @@
|
|||||||
.timestamp-absolute {
|
.timestamp-absolute {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: #333;
|
color: #333;
|
||||||
|
white-space: pre;
|
||||||
}
|
}
|
||||||
|
|
||||||
.timestamp-relative {
|
.timestamp-relative {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #999;
|
color: #999;
|
||||||
|
white-space: pre;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { NzToolTipModule } from 'ng-zorro-antd/tooltip';
|
|||||||
import { ApiService } from '../../../core/services/api.service';
|
import { ApiService } from '../../../core/services/api.service';
|
||||||
import { AuthService } from '../../../core/services/auth.service';
|
import { AuthService } from '../../../core/services/auth.service';
|
||||||
import { NotificationService } from '../../../core/services/notification.service';
|
import { NotificationService } from '../../../core/services/notification.service';
|
||||||
|
import { SettingsService } from '../../../core/services/settings.service';
|
||||||
import { Client, ClientType, getClientTypeIcon } from '../../../core/models';
|
import { Client, ClientType, getClientTypeIcon } from '../../../core/models';
|
||||||
import { RelativeTimePipe } from '../../../shared/pipes/relative-time.pipe';
|
import { RelativeTimePipe } from '../../../shared/pipes/relative-time.pipe';
|
||||||
import { MetadataGridComponent, MetadataValueComponent } from '../../../shared/components/metadata-grid';
|
import { MetadataGridComponent, MetadataValueComponent } from '../../../shared/components/metadata-grid';
|
||||||
@@ -41,9 +42,11 @@ export class ClientDetailComponent implements OnInit {
|
|||||||
private apiService = inject(ApiService);
|
private apiService = inject(ApiService);
|
||||||
private authService = inject(AuthService);
|
private authService = inject(AuthService);
|
||||||
private notification = inject(NotificationService);
|
private notification = inject(NotificationService);
|
||||||
|
private settingsService = inject(SettingsService);
|
||||||
|
|
||||||
client = signal<Client | null>(null);
|
client = signal<Client | null>(null);
|
||||||
loading = signal(true);
|
loading = signal(true);
|
||||||
|
expertMode = this.settingsService.expertMode;
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
const clientId = this.route.snapshot.paramMap.get('id');
|
const clientId = this.route.snapshot.paramMap.get('id');
|
||||||
|
|||||||
@@ -28,31 +28,41 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@for (client of clients(); track client.client_id) {
|
@for (client of clients(); track client.client_id) {
|
||||||
<tr class="clickable-row" (click)="openClient(client.client_id)">
|
<tr class="clickable-row">
|
||||||
<td>
|
<td>
|
||||||
<span
|
<a class="cell-link" [routerLink]="['/clients', client.client_id]">
|
||||||
nz-icon
|
<span
|
||||||
[nzType]="getClientIcon(client.type)"
|
nz-icon
|
||||||
nzTheme="outline"
|
[nzType]="getClientIcon(client.type)"
|
||||||
class="client-icon"
|
nzTheme="outline"
|
||||||
></span>
|
class="client-icon"
|
||||||
|
></span>
|
||||||
|
</a>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="client-name">{{ client.name || '-' }}</div>
|
<a class="cell-link" [routerLink]="['/clients', client.client_id]">
|
||||||
<div class="client-id mono">{{ client.client_id }}</div>
|
<div class="client-name">{{ client.name || '-' }}</div>
|
||||||
|
<div class="client-id mono">{{ client.client_id }}</div>
|
||||||
|
</a>
|
||||||
</td>
|
</td>
|
||||||
<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>
|
||||||
<td>
|
<td>
|
||||||
<div class="agent-info">
|
<a class="cell-link" [routerLink]="['/clients', client.client_id]">
|
||||||
<span>{{ client.agent_model }}</span>
|
<div class="agent-info">
|
||||||
<span class="agent-version">v{{ client.agent_version }}</span>
|
<span style="white-space: pre;">{{ client.agent_model }}</span>
|
||||||
</div>
|
<span style="white-space: pre;" class="agent-version">v{{ client.agent_version }}</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="timestamp-absolute">{{ client.timestamp_created | date:'yyyy-MM-dd HH:mm:ss' }}</div>
|
<a class="cell-link" [routerLink]="['/clients', client.client_id]">
|
||||||
<div class="timestamp-relative">{{ client.timestamp_created | relativeTime }}</div>
|
<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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
} @empty {
|
} @empty {
|
||||||
|
|||||||
@@ -46,9 +46,11 @@
|
|||||||
.timestamp-absolute {
|
.timestamp-absolute {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: #333;
|
color: #333;
|
||||||
|
white-space: pre;
|
||||||
}
|
}
|
||||||
|
|
||||||
.timestamp-relative {
|
.timestamp-relative {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #999;
|
color: #999;
|
||||||
|
white-space: pre;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Component, inject, signal, OnInit } from '@angular/core';
|
import { Component, inject, signal, OnInit } from '@angular/core';
|
||||||
import { CommonModule, DatePipe } from '@angular/common';
|
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 { NzTableModule } from 'ng-zorro-antd/table';
|
||||||
import { NzButtonModule } from 'ng-zorro-antd/button';
|
import { NzButtonModule } from 'ng-zorro-antd/button';
|
||||||
import { NzIconModule } from 'ng-zorro-antd/icon';
|
import { NzIconModule } from 'ng-zorro-antd/icon';
|
||||||
@@ -19,6 +19,7 @@ import { RelativeTimePipe } from '../../../shared/pipes/relative-time.pipe';
|
|||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
DatePipe,
|
DatePipe,
|
||||||
|
RouterLink,
|
||||||
NzTableModule,
|
NzTableModule,
|
||||||
NzButtonModule,
|
NzButtonModule,
|
||||||
NzIconModule,
|
NzIconModule,
|
||||||
|
|||||||
@@ -94,6 +94,91 @@
|
|||||||
</scn-metadata-value>
|
</scn-metadata-value>
|
||||||
</scn-metadata-grid>
|
</scn-metadata-grid>
|
||||||
</nz-card>
|
</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 {
|
} @else {
|
||||||
<nz-card>
|
<nz-card>
|
||||||
<div class="not-found">
|
<div class="not-found">
|
||||||
|
|||||||
@@ -90,9 +90,53 @@
|
|||||||
.timestamp-absolute {
|
.timestamp-absolute {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: #333;
|
color: #333;
|
||||||
|
white-space: pre;
|
||||||
}
|
}
|
||||||
|
|
||||||
.timestamp-relative {
|
.timestamp-relative {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #999;
|
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 { Component, inject, signal, OnInit } from '@angular/core';
|
||||||
import { CommonModule, DatePipe } from '@angular/common';
|
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 { FormsModule } from '@angular/forms';
|
||||||
import { NzCardModule } from 'ng-zorro-antd/card';
|
import { NzCardModule } from 'ng-zorro-antd/card';
|
||||||
import { NzButtonModule } from 'ng-zorro-antd/button';
|
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 { NzCheckboxModule } from 'ng-zorro-antd/checkbox';
|
||||||
import { NzSelectModule } from 'ng-zorro-antd/select';
|
import { NzSelectModule } from 'ng-zorro-antd/select';
|
||||||
import { NzToolTipModule } from 'ng-zorro-antd/tooltip';
|
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 { ApiService } from '../../../core/services/api.service';
|
||||||
import { AuthService } from '../../../core/services/auth.service';
|
import { AuthService } from '../../../core/services/auth.service';
|
||||||
import { NotificationService } from '../../../core/services/notification.service';
|
import { NotificationService } from '../../../core/services/notification.service';
|
||||||
import { ChannelCacheService, ResolvedChannel } from '../../../core/services/channel-cache.service';
|
import { ChannelCacheService, ResolvedChannel } from '../../../core/services/channel-cache.service';
|
||||||
import { UserCacheService, ResolvedUser } from '../../../core/services/user-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 { RelativeTimePipe } from '../../../shared/pipes/relative-time.pipe';
|
||||||
import { MetadataGridComponent, MetadataValueComponent } from '../../../shared/components/metadata-grid';
|
import { MetadataGridComponent, MetadataValueComponent } from '../../../shared/components/metadata-grid';
|
||||||
|
|
||||||
@@ -36,6 +39,7 @@ interface PermissionOption {
|
|||||||
CommonModule,
|
CommonModule,
|
||||||
DatePipe,
|
DatePipe,
|
||||||
FormsModule,
|
FormsModule,
|
||||||
|
RouterLink,
|
||||||
NzCardModule,
|
NzCardModule,
|
||||||
NzButtonModule,
|
NzButtonModule,
|
||||||
NzIconModule,
|
NzIconModule,
|
||||||
@@ -48,6 +52,9 @@ interface PermissionOption {
|
|||||||
NzCheckboxModule,
|
NzCheckboxModule,
|
||||||
NzSelectModule,
|
NzSelectModule,
|
||||||
NzToolTipModule,
|
NzToolTipModule,
|
||||||
|
NzTableModule,
|
||||||
|
NzEmptyModule,
|
||||||
|
NzPaginationModule,
|
||||||
RelativeTimePipe,
|
RelativeTimePipe,
|
||||||
MetadataGridComponent,
|
MetadataGridComponent,
|
||||||
MetadataValueComponent,
|
MetadataValueComponent,
|
||||||
@@ -71,6 +78,14 @@ export class KeyDetailComponent implements OnInit {
|
|||||||
availableChannels = signal<ChannelWithSubscription[]>([]);
|
availableChannels = signal<ChannelWithSubscription[]>([]);
|
||||||
resolvedOwner = signal<ResolvedUser | null>(null);
|
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
|
// Edit modal
|
||||||
showEditModal = signal(false);
|
showEditModal = signal(false);
|
||||||
editKeyName = '';
|
editKeyName = '';
|
||||||
@@ -106,6 +121,7 @@ export class KeyDetailComponent implements OnInit {
|
|||||||
this.loading.set(false);
|
this.loading.set(false);
|
||||||
this.resolveChannelNames(key);
|
this.resolveChannelNames(key);
|
||||||
this.resolveOwner(key.owner_user_id);
|
this.resolveOwner(key.owner_user_id);
|
||||||
|
this.loadMessages(keyId);
|
||||||
},
|
},
|
||||||
error: () => {
|
error: () => {
|
||||||
this.loading.set(false);
|
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 {
|
private resolveOwner(ownerId: string): void {
|
||||||
this.userCacheService.resolveUser(ownerId).subscribe(resolved => {
|
this.userCacheService.resolveUser(ownerId).subscribe(resolved => {
|
||||||
this.resolvedOwner.set(resolved);
|
this.resolvedOwner.set(resolved);
|
||||||
|
|||||||
@@ -34,50 +34,60 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@for (key of keys(); track key.keytoken_id) {
|
@for (key of keys(); track key.keytoken_id) {
|
||||||
<tr class="clickable-row" (click)="viewKey(key)">
|
<tr class="clickable-row">
|
||||||
<td>
|
<td>
|
||||||
<div class="key-name">
|
<a class="cell-link" [routerLink]="['/keys', key.keytoken_id]">
|
||||||
{{ key.name }}
|
<div class="key-name">
|
||||||
@if (isCurrentKey(key)) {
|
{{ key.name }}
|
||||||
<nz-tag nzColor="cyan" class="current-tag">Current</nz-tag>
|
@if (isCurrentKey(key)) {
|
||||||
}
|
<nz-tag nzColor="cyan" class="current-tag">Current</nz-tag>
|
||||||
</div>
|
}
|
||||||
<div class="key-id mono">{{ key.keytoken_id }}</div>
|
</div>
|
||||||
|
<div class="key-id mono">{{ key.keytoken_id }}</div>
|
||||||
|
</a>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="permissions">
|
<a class="cell-link" [routerLink]="['/keys', key.keytoken_id]">
|
||||||
@for (perm of getPermissions(key); track perm) {
|
<div class="permissions">
|
||||||
<nz-tag
|
@for (perm of getPermissions(key); track perm) {
|
||||||
[nzColor]="getPermissionColor(perm)"
|
<nz-tag
|
||||||
nz-tooltip
|
[nzColor]="getPermissionColor(perm)"
|
||||||
[nzTooltipTitle]="getPermissionLabel(perm)"
|
nz-tooltip
|
||||||
>
|
[nzTooltipTitle]="getPermissionLabel(perm)"
|
||||||
{{ perm }}
|
>
|
||||||
</nz-tag>
|
{{ perm }}
|
||||||
}
|
|
||||||
@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>
|
</nz-tag>
|
||||||
}
|
}
|
||||||
}
|
@if (key.all_channels) {
|
||||||
</div>
|
<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>
|
||||||
<td>{{ key.messages_sent }}</td>
|
|
||||||
<td>
|
<td>
|
||||||
@if (key.timestamp_lastused) {
|
<a class="cell-link" [routerLink]="['/keys', key.keytoken_id]">
|
||||||
<div class="timestamp-absolute">{{ key.timestamp_lastused | date:'yyyy-MM-dd HH:mm:ss' }}</div>
|
{{ key.messages_sent }}
|
||||||
<div class="timestamp-relative">{{ key.timestamp_lastused | relativeTime }}</div>
|
</a>
|
||||||
} @else {
|
|
||||||
<span class="text-muted">Never</span>
|
|
||||||
}
|
|
||||||
</td>
|
</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">
|
<div class="action-buttons">
|
||||||
<button
|
<button
|
||||||
nz-button
|
nz-button
|
||||||
|
|||||||
@@ -95,9 +95,11 @@
|
|||||||
.timestamp-absolute {
|
.timestamp-absolute {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: #333;
|
color: #333;
|
||||||
|
white-space: pre;
|
||||||
}
|
}
|
||||||
|
|
||||||
.timestamp-relative {
|
.timestamp-relative {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #999;
|
color: #999;
|
||||||
|
white-space: pre;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Component, inject, signal, OnInit } from '@angular/core';
|
import { Component, inject, signal, OnInit } from '@angular/core';
|
||||||
import { CommonModule, DatePipe } from '@angular/common';
|
import { CommonModule, DatePipe } from '@angular/common';
|
||||||
import { Router } from '@angular/router';
|
import { Router, RouterLink } from '@angular/router';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { NzTableModule } from 'ng-zorro-antd/table';
|
import { NzTableModule } from 'ng-zorro-antd/table';
|
||||||
import { NzButtonModule } from 'ng-zorro-antd/button';
|
import { NzButtonModule } from 'ng-zorro-antd/button';
|
||||||
@@ -37,6 +37,7 @@ interface PermissionOption {
|
|||||||
CommonModule,
|
CommonModule,
|
||||||
DatePipe,
|
DatePipe,
|
||||||
FormsModule,
|
FormsModule,
|
||||||
|
RouterLink,
|
||||||
NzTableModule,
|
NzTableModule,
|
||||||
NzButtonModule,
|
NzButtonModule,
|
||||||
NzIconModule,
|
NzIconModule,
|
||||||
|
|||||||
@@ -9,6 +9,21 @@
|
|||||||
<span nz-icon nzType="arrow-left" nzTheme="outline"></span>
|
<span nz-icon nzType="arrow-left" nzTheme="outline"></span>
|
||||||
Back to Messages
|
Back to Messages
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
<nz-card [nzTitle]="message()!.title">
|
<nz-card [nzTitle]="message()!.title">
|
||||||
@@ -32,6 +47,10 @@
|
|||||||
<div class="cell-id mono">{{ message()!.channel_id }}</div>
|
<div class="cell-id mono">{{ message()!.channel_id }}</div>
|
||||||
</a>
|
</a>
|
||||||
</scn-metadata-value>
|
</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">
|
<scn-metadata-value label="Priority">
|
||||||
<nz-tag [nzColor]="getPriorityColor(message()!.priority)">
|
<nz-tag [nzColor]="getPriorityColor(message()!.priority)">
|
||||||
{{ getPriorityLabel(message()!.priority) }}
|
{{ getPriorityLabel(message()!.priority) }}
|
||||||
@@ -58,6 +77,48 @@
|
|||||||
</scn-metadata-value>
|
</scn-metadata-value>
|
||||||
</scn-metadata-grid>
|
</scn-metadata-grid>
|
||||||
</nz-card>
|
</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 {
|
} @else {
|
||||||
<nz-card>
|
<nz-card>
|
||||||
<div class="not-found">
|
<div class="not-found">
|
||||||
|
|||||||
@@ -64,9 +64,11 @@ nz-card + nz-card {
|
|||||||
.timestamp-absolute {
|
.timestamp-absolute {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: #333;
|
color: #333;
|
||||||
|
white-space: pre;
|
||||||
}
|
}
|
||||||
|
|
||||||
.timestamp-relative {
|
.timestamp-relative {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #999;
|
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 { CommonModule, DatePipe } from '@angular/common';
|
||||||
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||||
import { NzCardModule } from 'ng-zorro-antd/card';
|
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 { NzIconModule } from 'ng-zorro-antd/icon';
|
||||||
import { NzTagModule } from 'ng-zorro-antd/tag';
|
import { NzTagModule } from 'ng-zorro-antd/tag';
|
||||||
import { NzSpinModule } from 'ng-zorro-antd/spin';
|
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 { ApiService } from '../../../core/services/api.service';
|
||||||
|
import { AuthService } from '../../../core/services/auth.service';
|
||||||
import { NotificationService } from '../../../core/services/notification.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 { 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 { RelativeTimePipe } from '../../../shared/pipes/relative-time.pipe';
|
||||||
import { MetadataGridComponent, MetadataValueComponent } from '../../../shared/components/metadata-grid';
|
import { MetadataGridComponent, MetadataValueComponent } from '../../../shared/components/metadata-grid';
|
||||||
|
|
||||||
@@ -24,6 +29,8 @@ import { MetadataGridComponent, MetadataValueComponent } from '../../../shared/c
|
|||||||
NzIconModule,
|
NzIconModule,
|
||||||
NzTagModule,
|
NzTagModule,
|
||||||
NzSpinModule,
|
NzSpinModule,
|
||||||
|
NzPopconfirmModule,
|
||||||
|
NzTableModule,
|
||||||
RouterLink,
|
RouterLink,
|
||||||
RelativeTimePipe,
|
RelativeTimePipe,
|
||||||
MetadataGridComponent,
|
MetadataGridComponent,
|
||||||
@@ -36,13 +43,28 @@ export class MessageDetailComponent implements OnInit {
|
|||||||
private route = inject(ActivatedRoute);
|
private route = inject(ActivatedRoute);
|
||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
private apiService = inject(ApiService);
|
private apiService = inject(ApiService);
|
||||||
|
private authService = inject(AuthService);
|
||||||
private notification = inject(NotificationService);
|
private notification = inject(NotificationService);
|
||||||
|
private settingsService = inject(SettingsService);
|
||||||
private keyCacheService = inject(KeyCacheService);
|
private keyCacheService = inject(KeyCacheService);
|
||||||
|
private userCacheService = inject(UserCacheService);
|
||||||
|
|
||||||
message = signal<Message | null>(null);
|
message = signal<Message | null>(null);
|
||||||
resolvedKey = signal<ResolvedKey | null>(null);
|
resolvedKey = signal<ResolvedKey | null>(null);
|
||||||
|
resolvedChannelOwner = signal<ResolvedUser | null>(null);
|
||||||
|
deliveries = signal<Delivery[]>([]);
|
||||||
loading = signal(true);
|
loading = signal(true);
|
||||||
deleting = signal(false);
|
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 {
|
ngOnInit(): void {
|
||||||
const messageId = this.route.snapshot.paramMap.get('id');
|
const messageId = this.route.snapshot.paramMap.get('id');
|
||||||
@@ -58,6 +80,8 @@ export class MessageDetailComponent implements OnInit {
|
|||||||
this.message.set(message);
|
this.message.set(message);
|
||||||
this.loading.set(false);
|
this.loading.set(false);
|
||||||
this.resolveKey(message.used_key_id);
|
this.resolveKey(message.used_key_id);
|
||||||
|
this.resolveChannelOwner(message.channel_owner_user_id);
|
||||||
|
this.loadDeliveries(messageId);
|
||||||
},
|
},
|
||||||
error: () => {
|
error: () => {
|
||||||
this.loading.set(false);
|
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 {
|
private resolveKey(keyId: string): void {
|
||||||
this.keyCacheService.resolveKey(keyId).subscribe({
|
this.keyCacheService.resolveKey(keyId).subscribe({
|
||||||
next: (resolved) => this.resolvedKey.set(resolved)
|
next: (resolved) => this.resolvedKey.set(resolved)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private resolveChannelOwner(userId: string): void {
|
||||||
|
this.userCacheService.resolveUser(userId).subscribe({
|
||||||
|
next: (resolved) => this.resolvedChannelOwner.set(resolved)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
goBack(): void {
|
goBack(): void {
|
||||||
this.router.navigate(['/messages']);
|
this.router.navigate(['/messages']);
|
||||||
}
|
}
|
||||||
@@ -108,4 +154,13 @@ export class MessageDetailComponent implements OnInit {
|
|||||||
default: return 'default';
|
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>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@for (message of messages(); track message.message_id) {
|
@for (message of messages(); track message.message_id) {
|
||||||
<tr class="clickable-row" (click)="viewMessage(message)">
|
<tr class="clickable-row">
|
||||||
<td>
|
<td>
|
||||||
<div class="message-title">{{ message.title }}</div>
|
<a class="cell-link" [routerLink]="['/messages', message.message_id]">
|
||||||
<div class="message-id mono">{{ message.message_id }}</div>
|
<div class="message-title">{{ message.title }}</div>
|
||||||
|
<div class="message-id mono">{{ message.message_id }}</div>
|
||||||
|
</a>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
@if (message.content) {
|
<a class="cell-link" [routerLink]="['/messages', message.message_id]">
|
||||||
<div class="message-content">{{ message.content | slice:0:128 }}{{ message.content.length > 128 ? '...' : '' }}</div>
|
@if (message.content) {
|
||||||
} @else {
|
<div class="message-content">{{ message.content | slice:0:128 }}{{ message.content.length > 128 ? '...' : '' }}</div>
|
||||||
<span class="text-muted"></span>
|
} @else {
|
||||||
}
|
<span class="text-muted"></span>
|
||||||
|
}
|
||||||
|
</a>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="cell-name">{{ message.channel_internal_name }}</div>
|
<a class="cell-link" [routerLink]="['/messages', message.message_id]">
|
||||||
<div class="cell-id">{{ message.channel_id }}</div>
|
<div class="cell-name">{{ message.channel_internal_name }}</div>
|
||||||
|
<div class="cell-id">{{ message.channel_id }}</div>
|
||||||
|
</a>
|
||||||
</td>
|
</td>
|
||||||
<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>
|
||||||
<td>
|
<td>
|
||||||
<nz-tag [nzColor]="getPriorityColor(message.priority)">
|
<a class="cell-link" [routerLink]="['/messages', message.message_id]">
|
||||||
{{ getPriorityLabel(message.priority) }}
|
<nz-tag [nzColor]="getPriorityColor(message.priority)">
|
||||||
</nz-tag>
|
{{ getPriorityLabel(message.priority) }}
|
||||||
|
</nz-tag>
|
||||||
|
</a>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="timestamp-absolute">{{ message.timestamp | date:'yyyy-MM-dd HH:mm:ss' }}</div>
|
<a class="cell-link" [routerLink]="['/messages', message.message_id]">
|
||||||
<div class="timestamp-relative">{{ message.timestamp | relativeTime }}</div>
|
<div class="timestamp-absolute">{{ message.timestamp | date:'yyyy-MM-dd HH:mm:ss' }}</div>
|
||||||
|
<div class="timestamp-relative">{{ message.timestamp | relativeTime }}</div>
|
||||||
|
</a>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
} @empty {
|
} @empty {
|
||||||
|
|||||||
@@ -70,11 +70,13 @@
|
|||||||
.timestamp-absolute {
|
.timestamp-absolute {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: #333;
|
color: #333;
|
||||||
|
white-space: pre;
|
||||||
}
|
}
|
||||||
|
|
||||||
.timestamp-relative {
|
.timestamp-relative {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #999;
|
color: #999;
|
||||||
|
white-space: pre;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pagination-controls {
|
.pagination-controls {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Component, inject, signal, OnInit } from '@angular/core';
|
import { Component, inject, signal, OnInit } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { Router } from '@angular/router';
|
import { Router, RouterLink } from '@angular/router';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { NzTableModule, NzTableFilterList } from 'ng-zorro-antd/table';
|
import { NzTableModule, NzTableFilterList } from 'ng-zorro-antd/table';
|
||||||
import { NzButtonModule } from 'ng-zorro-antd/button';
|
import { NzButtonModule } from 'ng-zorro-antd/button';
|
||||||
@@ -24,6 +24,7 @@ import { RelativeTimePipe } from '../../../shared/pipes/relative-time.pipe';
|
|||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
FormsModule,
|
FormsModule,
|
||||||
|
RouterLink,
|
||||||
NzTableModule,
|
NzTableModule,
|
||||||
NzButtonModule,
|
NzButtonModule,
|
||||||
NzInputModule,
|
NzInputModule,
|
||||||
|
|||||||
@@ -16,9 +16,11 @@
|
|||||||
.timestamp-absolute {
|
.timestamp-absolute {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: #333;
|
color: #333;
|
||||||
|
white-space: pre;
|
||||||
}
|
}
|
||||||
|
|
||||||
.timestamp-relative {
|
.timestamp-relative {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #999;
|
color: #999;
|
||||||
|
white-space: pre;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,16 +27,29 @@
|
|||||||
Deactivate
|
Deactivate
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
<button
|
@if (expertMode() && isOutgoing() && subscription()!.confirmed && subscription()!.active) {
|
||||||
nz-button
|
<button
|
||||||
nzDanger
|
nz-button
|
||||||
nz-popconfirm
|
nz-popconfirm
|
||||||
nzPopconfirmTitle="Are you sure you want to delete this subscription?"
|
nzPopconfirmTitle="Set this subscription to inactive? You will stop receiving messages."
|
||||||
(nzOnConfirm)="deleteSubscription()"
|
(nzOnConfirm)="setInactive()"
|
||||||
>
|
>
|
||||||
<span nz-icon nzType="delete"></span>
|
<span nz-icon nzType="pause-circle"></span>
|
||||||
Delete
|
Set Inactive
|
||||||
</button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -58,9 +58,11 @@
|
|||||||
.timestamp-absolute {
|
.timestamp-absolute {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: #333;
|
color: #333;
|
||||||
|
white-space: pre;
|
||||||
}
|
}
|
||||||
|
|
||||||
.timestamp-relative {
|
.timestamp-relative {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #999;
|
color: #999;
|
||||||
|
white-space: pre;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { NzToolTipModule } from 'ng-zorro-antd/tooltip';
|
|||||||
import { ApiService } from '../../../core/services/api.service';
|
import { ApiService } from '../../../core/services/api.service';
|
||||||
import { AuthService } from '../../../core/services/auth.service';
|
import { AuthService } from '../../../core/services/auth.service';
|
||||||
import { NotificationService } from '../../../core/services/notification.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 { ChannelCacheService, ResolvedChannel } from '../../../core/services/channel-cache.service';
|
||||||
import { UserCacheService, ResolvedUser } from '../../../core/services/user-cache.service';
|
import { UserCacheService, ResolvedUser } from '../../../core/services/user-cache.service';
|
||||||
import { Subscription } from '../../../core/models';
|
import { Subscription } from '../../../core/models';
|
||||||
@@ -44,6 +45,7 @@ export class SubscriptionDetailComponent implements OnInit {
|
|||||||
private apiService = inject(ApiService);
|
private apiService = inject(ApiService);
|
||||||
private authService = inject(AuthService);
|
private authService = inject(AuthService);
|
||||||
private notification = inject(NotificationService);
|
private notification = inject(NotificationService);
|
||||||
|
private settingsService = inject(SettingsService);
|
||||||
private channelCacheService = inject(ChannelCacheService);
|
private channelCacheService = inject(ChannelCacheService);
|
||||||
private userCacheService = inject(UserCacheService);
|
private userCacheService = inject(UserCacheService);
|
||||||
|
|
||||||
@@ -52,6 +54,7 @@ export class SubscriptionDetailComponent implements OnInit {
|
|||||||
resolvedChannel = signal<ResolvedChannel | null>(null);
|
resolvedChannel = signal<ResolvedChannel | null>(null);
|
||||||
resolvedSubscriber = signal<ResolvedUser | null>(null);
|
resolvedSubscriber = signal<ResolvedUser | null>(null);
|
||||||
resolvedOwner = signal<ResolvedUser | null>(null);
|
resolvedOwner = signal<ResolvedUser | null>(null);
|
||||||
|
expertMode = this.settingsService.expertMode;
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
const subscriptionId = this.route.snapshot.paramMap.get('id');
|
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 {
|
deleteSubscription(): void {
|
||||||
const sub = this.subscription();
|
const sub = this.subscription();
|
||||||
const userId = this.authService.getUserId();
|
const userId = this.authService.getUserId();
|
||||||
|
|||||||
@@ -47,44 +47,66 @@
|
|||||||
<th>Channel</th>
|
<th>Channel</th>
|
||||||
<th>Subscriber</th>
|
<th>Subscriber</th>
|
||||||
<th>Owner</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">Created</th>
|
||||||
<th nzWidth="0">Actions</th>
|
<th nzWidth="0">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@for (sub of subscriptions(); track sub.subscription_id) {
|
@for (sub of subscriptions(); track sub.subscription_id) {
|
||||||
<tr class="clickable-row" (click)="viewSubscription(sub)">
|
<tr class="clickable-row">
|
||||||
<td>
|
<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>
|
||||||
<td>
|
<td>
|
||||||
<nz-tag [nzColor]="getTypeLabel(sub).color">
|
<a class="cell-link" [routerLink]="['/subscriptions', sub.subscription_id]">
|
||||||
{{ getTypeLabel(sub).label }}
|
<nz-tag [nzColor]="getTypeLabel(sub).color">
|
||||||
</nz-tag>
|
{{ getTypeLabel(sub).label }}
|
||||||
|
</nz-tag>
|
||||||
|
</a>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="cell-name">{{ sub.channel_internal_name }}</div>
|
<a class="cell-link" [routerLink]="['/subscriptions', sub.subscription_id]">
|
||||||
<div class="cell-id mono">{{ sub.channel_id }}</div>
|
<div class="cell-name">{{ sub.channel_internal_name }}</div>
|
||||||
|
<div class="cell-id mono">{{ sub.channel_id }}</div>
|
||||||
|
</a>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="cell-name">{{ getUserDisplayName(sub.subscriber_user_id) }}</div>
|
<a class="cell-link" [routerLink]="['/subscriptions', sub.subscription_id]">
|
||||||
<div class="cell-id mono">{{ sub.subscriber_user_id }}</div>
|
<div class="cell-name">{{ getUserDisplayName(sub.subscriber_user_id) }}</div>
|
||||||
|
<div class="cell-id mono">{{ sub.subscriber_user_id }}</div>
|
||||||
|
</a>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="cell-name">{{ getUserDisplayName(sub.channel_owner_user_id) }}</div>
|
<a class="cell-link" [routerLink]="['/subscriptions', sub.subscription_id]">
|
||||||
<div class="cell-id mono">{{ sub.channel_owner_user_id }}</div>
|
<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>
|
||||||
<td>
|
<td>
|
||||||
<nz-tag [nzColor]="getStatusInfo(sub).color">
|
<a class="cell-link" [routerLink]="['/subscriptions', sub.subscription_id]">
|
||||||
{{ getStatusInfo(sub).label }}
|
<nz-tag [nzColor]="getConfirmationInfo(sub).color">
|
||||||
</nz-tag>
|
{{ getConfirmationInfo(sub).label }}
|
||||||
|
</nz-tag>
|
||||||
|
</a>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="timestamp-absolute">{{ sub.timestamp_created | date:'yyyy-MM-dd HH:mm:ss' }}</div>
|
<a class="cell-link" [routerLink]="['/subscriptions', sub.subscription_id]">
|
||||||
<div class="timestamp-relative">{{ sub.timestamp_created | relativeTime }}</div>
|
<nz-tag [nzColor]="getActiveInfo(sub).color">
|
||||||
|
{{ getActiveInfo(sub).label }}
|
||||||
|
</nz-tag>
|
||||||
|
</a>
|
||||||
</td>
|
</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">
|
<div class="action-buttons">
|
||||||
@if (!sub.confirmed && isOwner(sub)) {
|
@if (!sub.confirmed && isOwner(sub)) {
|
||||||
<!-- Incoming unconfirmed: can accept or deny -->
|
<!-- Incoming unconfirmed: can accept or deny -->
|
||||||
@@ -130,7 +152,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
} @empty {
|
} @empty {
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="8">
|
<td colspan="9">
|
||||||
<nz-empty nzNotFoundContent="No subscriptions found"></nz-empty>
|
<nz-empty nzNotFoundContent="No subscriptions found"></nz-empty>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -64,9 +64,11 @@
|
|||||||
.timestamp-absolute {
|
.timestamp-absolute {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: #333;
|
color: #333;
|
||||||
|
white-space: pre;
|
||||||
}
|
}
|
||||||
|
|
||||||
.timestamp-relative {
|
.timestamp-relative {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #999;
|
color: #999;
|
||||||
|
white-space: pre;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Component, inject, signal, OnInit } from '@angular/core';
|
import { Component, inject, signal, OnInit } from '@angular/core';
|
||||||
import { CommonModule, DatePipe } from '@angular/common';
|
import { CommonModule, DatePipe } from '@angular/common';
|
||||||
import { Router } from '@angular/router';
|
import { Router, RouterLink } from '@angular/router';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { NzTableModule } from 'ng-zorro-antd/table';
|
import { NzTableModule } from 'ng-zorro-antd/table';
|
||||||
import { NzButtonModule } from 'ng-zorro-antd/button';
|
import { NzButtonModule } from 'ng-zorro-antd/button';
|
||||||
@@ -43,6 +43,7 @@ const TAB_CONFIGS: Record<SubscriptionTab, TabConfig> = {
|
|||||||
CommonModule,
|
CommonModule,
|
||||||
DatePipe,
|
DatePipe,
|
||||||
FormsModule,
|
FormsModule,
|
||||||
|
RouterLink,
|
||||||
NzTableModule,
|
NzTableModule,
|
||||||
NzButtonModule,
|
NzButtonModule,
|
||||||
NzIconModule,
|
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) {
|
if (sub.confirmed) {
|
||||||
return { label: 'Confirmed', color: 'green' };
|
return { label: 'Confirmed', color: 'green' };
|
||||||
}
|
}
|
||||||
return { label: 'Pending', color: 'orange' };
|
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 } {
|
getTypeLabel(sub: Subscription): { label: string; color: string } {
|
||||||
const userId = this.authService.getUserId();
|
const userId = this.authService.getUserId();
|
||||||
if (sub.subscriber_user_id === sub.channel_owner_user_id) {
|
if (sub.subscriber_user_id === sub.channel_owner_user_id) {
|
||||||
|
|||||||
@@ -56,7 +56,20 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<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()">
|
<button nz-button nzType="text" nzDanger (click)="logout()">
|
||||||
<span nz-icon nzType="logout"></span>
|
<span nz-icon nzType="logout"></span>
|
||||||
Logout
|
Logout
|
||||||
|
|||||||
@@ -51,6 +51,17 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.expert-mode-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
.expert-mode-label {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.header-trigger {
|
.header-trigger {
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -66,11 +77,23 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
line-height: 1.3;
|
||||||
|
|
||||||
.user-id {
|
.user-id {
|
||||||
color: #666;
|
color: #666;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.key-id {
|
||||||
|
color: #999;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.content-area {
|
.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 { CommonModule } from '@angular/common';
|
||||||
import { RouterOutlet, RouterLink, Router } from '@angular/router';
|
import { RouterOutlet, RouterLink, Router } from '@angular/router';
|
||||||
import { NzLayoutModule } from 'ng-zorro-antd/layout';
|
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 { NzIconModule } from 'ng-zorro-antd/icon';
|
||||||
import { NzButtonModule } from 'ng-zorro-antd/button';
|
import { NzButtonModule } from 'ng-zorro-antd/button';
|
||||||
import { NzDropDownModule } from 'ng-zorro-antd/dropdown';
|
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 { 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({
|
@Component({
|
||||||
selector: 'app-main-layout',
|
selector: 'app-main-layout',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
RouterOutlet,
|
RouterOutlet,
|
||||||
RouterLink,
|
RouterLink,
|
||||||
NzLayoutModule,
|
NzLayoutModule,
|
||||||
@@ -20,16 +26,34 @@ import { AuthService } from '../../core/services/auth.service';
|
|||||||
NzIconModule,
|
NzIconModule,
|
||||||
NzButtonModule,
|
NzButtonModule,
|
||||||
NzDropDownModule,
|
NzDropDownModule,
|
||||||
|
NzSwitchModule,
|
||||||
],
|
],
|
||||||
templateUrl: './main-layout.component.html',
|
templateUrl: './main-layout.component.html',
|
||||||
styleUrl: './main-layout.component.scss'
|
styleUrl: './main-layout.component.scss'
|
||||||
})
|
})
|
||||||
export class MainLayoutComponent {
|
export class MainLayoutComponent implements OnInit {
|
||||||
private authService = inject(AuthService);
|
private authService = inject(AuthService);
|
||||||
|
private apiService = inject(ApiService);
|
||||||
private router = inject(Router);
|
private router = inject(Router);
|
||||||
|
settingsService = inject(SettingsService);
|
||||||
|
|
||||||
isCollapsed = signal(false);
|
isCollapsed = signal(false);
|
||||||
userId = this.authService.getUserId();
|
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 {
|
toggleCollapsed(): void {
|
||||||
this.isCollapsed.update(v => !v);
|
this.isCollapsed.update(v => !v);
|
||||||
|
|||||||
@@ -67,15 +67,28 @@ body {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clickable row
|
// Clickable row with anchor link for proper middle-click support
|
||||||
.clickable-row {
|
.clickable-row {
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: #fafafa;
|
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 colors
|
||||||
.status-confirmed {
|
.status-confirmed {
|
||||||
color: #52c41a;
|
color: #52c41a;
|
||||||
@@ -150,3 +163,7 @@ nz-card {
|
|||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
white-space: pre;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user