diff --git a/webapp/CLAUDE.md b/webapp/CLAUDE.md new file mode 100644 index 0000000..436d40d --- /dev/null +++ b/webapp/CLAUDE.md @@ -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`) diff --git a/webapp/src/app/core/models/delivery.model.ts b/webapp/src/app/core/models/delivery.model.ts new file mode 100644 index 0000000..3424748 --- /dev/null +++ b/webapp/src/app/core/models/delivery.model.ts @@ -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[]; +} diff --git a/webapp/src/app/core/models/index.ts b/webapp/src/app/core/models/index.ts index a891873..c7a4fad 100644 --- a/webapp/src/app/core/models/index.ts +++ b/webapp/src/app/core/models/index.ts @@ -5,4 +5,5 @@ export * from './subscription.model'; export * from './key-token.model'; export * from './client.model'; export * from './sender-name.model'; +export * from './delivery.model'; export * from './api-response.model'; diff --git a/webapp/src/app/core/models/subscription.model.ts b/webapp/src/app/core/models/subscription.model.ts index cbade36..e6f679c 100644 --- a/webapp/src/app/core/models/subscription.model.ts +++ b/webapp/src/app/core/models/subscription.model.ts @@ -26,7 +26,8 @@ export interface CreateSubscriptionRequest { } export interface ConfirmSubscriptionRequest { - confirmed: boolean; + confirmed?: boolean; + active?: boolean; } export interface SubscriptionListResponse { diff --git a/webapp/src/app/core/services/api.service.ts b/webapp/src/app/core/services/api.service.ts index 8c0c695..60d15b8 100644 --- a/webapp/src/app/core/services/api.service.ts +++ b/webapp/src/app/core/services/api.service.ts @@ -28,6 +28,7 @@ import { ClientListResponse, SenderNameStatistics, SenderNameListResponse, + DeliveryListResponse, } from '../models'; @Injectable({ @@ -167,6 +168,10 @@ export class ApiService { return this.http.delete(`${this.baseUrl}/messages/${messageId}`); } + getDeliveries(messageId: string): Observable { + return this.http.get(`${this.baseUrl}/messages/${messageId}/deliveries`); + } + // Subscription endpoints getSubscriptions(userId: string, filter?: SubscriptionFilter): Observable { let httpParams = new HttpParams(); diff --git a/webapp/src/app/core/services/settings.service.ts b/webapp/src/app/core/services/settings.service.ts new file mode 100644 index 0000000..ec53afd --- /dev/null +++ b/webapp/src/app/core/services/settings.service.ts @@ -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()); + } +} diff --git a/webapp/src/app/features/account/account-info/account-info.component.html b/webapp/src/app/features/account/account-info/account-info.component.html index 85edba8..0ccc946 100644 --- a/webapp/src/app/features/account/account-info/account-info.component.html +++ b/webapp/src/app/features/account/account-info/account-info.component.html @@ -13,36 +13,36 @@ } @else if (user()) { - - + + {{ user()!.user_id }} - - + + {{ user()!.username || '(Not set)' }} - - + + @if (user()!.is_pro) { Pro } @else { Free } - - + + {{ user()!.messages_sent }} - - + + {{ user()!.timestamp_created | relativeTime }} - - + + {{ user()!.timestamp_lastread | relativeTime }} - - + + {{ user()!.timestamp_lastsent | relativeTime }} - - + + @@ -62,22 +62,39 @@ - - + + {{ user()!.max_body_size | number }} bytes - - + + {{ user()!.max_title_length }} chars - - + + {{ user()!.default_channel }} - - + + {{ user()!.default_priority }} - - + + + @if (expertMode()) { + +

Deleting your account is permanent and cannot be undone. All your data will be lost.

+ +
+ } + } diff --git a/webapp/src/app/features/account/account-info/account-info.component.scss b/webapp/src/app/features/account/account-info/account-info.component.scss index e8d18fd..fe45382 100644 --- a/webapp/src/app/features/account/account-info/account-info.component.scss +++ b/webapp/src/app/features/account/account-info/account-info.component.scss @@ -43,3 +43,17 @@ margin-bottom: 16px; } } + +.danger-zone { + border-color: #ff4d4f !important; + + :host ::ng-deep .ant-card-head { + color: #ff4d4f; + border-bottom-color: #ff4d4f; + } + + .danger-warning { + color: #666; + margin-bottom: 16px; + } +} diff --git a/webapp/src/app/features/account/account-info/account-info.component.ts b/webapp/src/app/features/account/account-info/account-info.component.ts index d863c2a..c91776a 100644 --- a/webapp/src/app/features/account/account-info/account-info.component.ts +++ b/webapp/src/app/features/account/account-info/account-info.component.ts @@ -1,6 +1,7 @@ import { Component, inject, signal, OnInit } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; +import { Router } from '@angular/router'; import { NzCardModule } from 'ng-zorro-antd/card'; import { NzButtonModule } from 'ng-zorro-antd/button'; import { NzIconModule } from 'ng-zorro-antd/icon'; @@ -12,11 +13,14 @@ import { NzModalModule } from 'ng-zorro-antd/modal'; import { NzFormModule } from 'ng-zorro-antd/form'; import { NzInputModule } from 'ng-zorro-antd/input'; import { NzDividerModule } from 'ng-zorro-antd/divider'; +import { NzPopconfirmModule } from 'ng-zorro-antd/popconfirm'; import { ApiService } from '../../../core/services/api.service'; import { AuthService } from '../../../core/services/auth.service'; import { NotificationService } from '../../../core/services/notification.service'; +import { SettingsService } from '../../../core/services/settings.service'; import { UserWithExtra } from '../../../core/models'; import { RelativeTimePipe } from '../../../shared/pipes/relative-time.pipe'; +import { MetadataGridComponent, MetadataValueComponent } from '../../../shared/components/metadata-grid'; @Component({ selector: 'app-account-info', @@ -35,7 +39,10 @@ import { RelativeTimePipe } from '../../../shared/pipes/relative-time.pipe'; NzFormModule, NzInputModule, NzDividerModule, + NzPopconfirmModule, RelativeTimePipe, + MetadataGridComponent, + MetadataValueComponent, ], templateUrl: './account-info.component.html', styleUrl: './account-info.component.scss' @@ -44,9 +51,13 @@ export class AccountInfoComponent implements OnInit { private apiService = inject(ApiService); private authService = inject(AuthService); private notification = inject(NotificationService); + private settingsService = inject(SettingsService); + private router = inject(Router); user = signal(null); loading = signal(true); + deleting = signal(false); + expertMode = this.settingsService.expertMode; // Edit username modal showEditModal = signal(false); @@ -116,4 +127,21 @@ export class AccountInfoComponent implements OnInit { } }); } + + deleteAccount(): void { + const userId = this.authService.getUserId(); + if (!userId) return; + + this.deleting.set(true); + this.apiService.deleteUser(userId).subscribe({ + next: () => { + this.notification.success('Account deleted'); + this.authService.logout(); + this.router.navigate(['/login']); + }, + error: () => { + this.deleting.set(false); + } + }); + } } diff --git a/webapp/src/app/features/channels/channel-detail/channel-detail.component.html b/webapp/src/app/features/channels/channel-detail/channel-detail.component.html index b1dbc18..5197614 100644 --- a/webapp/src/app/features/channels/channel-detail/channel-detail.component.html +++ b/webapp/src/app/features/channels/channel-detail/channel-detail.component.html @@ -15,6 +15,19 @@ Edit + @if (expertMode()) { + + } } @@ -152,28 +165,76 @@ Subscriber Status Created + Actions @for (sub of subscriptions(); track sub.subscription_id) { - + -
{{ getUserDisplayName(sub.subscriber_user_id) }}
-
{{ sub.subscriber_user_id }}
+ +
{{ getUserDisplayName(sub.subscriber_user_id) }}
+
{{ sub.subscriber_user_id }}
+
- - {{ sub.confirmed ? 'Confirmed' : 'Pending' }} - + + + {{ sub.confirmed ? 'Confirmed' : 'Pending' }} + + -
{{ sub.timestamp_created | date:'yyyy-MM-dd HH:mm:ss' }}
-
{{ sub.timestamp_created | relativeTime }}
+ +
{{ sub.timestamp_created | date:'yyyy-MM-dd HH:mm:ss' }}
+
{{ sub.timestamp_created | relativeTime }}
+
+ + +
+ @if (!sub.confirmed) { + + + } @else { + + } +
} @empty { - + @@ -182,6 +243,84 @@ } + + + + + + + Title + Content + Sender + Priority + Time + + + + @for (message of messages(); track message.message_id) { + + + +
{{ message.title }}
+
{{ message.message_id }}
+
+ + + + @if (message.content) { +
{{ message.content | slice:0:128 }}{{ message.content.length > 128 ? '...' : '' }}
+ } @else { + + } +
+ + + + {{ message.sender_name || '-' }} + + + + + + {{ getPriorityLabel(message.priority) }} + + + + + +
{{ message.timestamp | date:'yyyy-MM-dd HH:mm:ss' }}
+
{{ message.timestamp | relativeTime }}
+
+ + + } @empty { + + + + + + } + +
+ @if (messagesTotalCount() > messagesPageSize) { +
+ +
+ } +
} @else {
diff --git a/webapp/src/app/features/channels/channel-detail/channel-detail.component.scss b/webapp/src/app/features/channels/channel-detail/channel-detail.component.scss index 258d3f0..f54f263 100644 --- a/webapp/src/app/features/channels/channel-detail/channel-detail.component.scss +++ b/webapp/src/app/features/channels/channel-detail/channel-detail.component.scss @@ -68,9 +68,51 @@ .timestamp-absolute { font-size: 13px; color: #333; + white-space: pre; } .timestamp-relative { font-size: 12px; color: #999; + white-space: pre; +} + +.clickable-row { + cursor: pointer; + + &:hover { + background-color: #fafafa; + } +} + +.action-buttons { + display: flex; + gap: 4px; +} + +.message-title { + font-weight: 500; + color: #333; +} + +.message-id { + font-size: 11px; + color: #999; + margin-top: 2px; +} + +.message-content { + font-size: 12px; + color: #666; +} + +.text-muted { + color: #999; +} + +.pagination-controls { + display: flex; + justify-content: center; + align-items: center; + padding: 16px 0; } diff --git a/webapp/src/app/features/channels/channel-detail/channel-detail.component.ts b/webapp/src/app/features/channels/channel-detail/channel-detail.component.ts index 5a96a6e..84f6e91 100644 --- a/webapp/src/app/features/channels/channel-detail/channel-detail.component.ts +++ b/webapp/src/app/features/channels/channel-detail/channel-detail.component.ts @@ -1,6 +1,6 @@ import { Component, inject, signal, computed, OnInit } from '@angular/core'; import { CommonModule, DatePipe } from '@angular/common'; -import { ActivatedRoute, Router } from '@angular/router'; +import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { FormsModule } from '@angular/forms'; import { NzCardModule } from 'ng-zorro-antd/card'; import { NzButtonModule } from 'ng-zorro-antd/button'; @@ -15,11 +15,13 @@ import { NzFormModule } from 'ng-zorro-antd/form'; import { NzTableModule } from 'ng-zorro-antd/table'; import { NzToolTipModule } from 'ng-zorro-antd/tooltip'; import { NzEmptyModule } from 'ng-zorro-antd/empty'; +import { NzPaginationModule } from 'ng-zorro-antd/pagination'; import { ApiService } from '../../../core/services/api.service'; import { AuthService } from '../../../core/services/auth.service'; import { NotificationService } from '../../../core/services/notification.service'; +import { SettingsService } from '../../../core/services/settings.service'; import { UserCacheService, ResolvedUser } from '../../../core/services/user-cache.service'; -import { ChannelWithSubscription, Subscription } from '../../../core/models'; +import { ChannelWithSubscription, Subscription, Message } from '../../../core/models'; import { RelativeTimePipe } from '../../../shared/pipes/relative-time.pipe'; import { CopyToClipboardDirective } from '../../../shared/directives/copy-to-clipboard.directive'; import { QrCodeDisplayComponent } from '../../../shared/components/qr-code-display/qr-code-display.component'; @@ -32,6 +34,7 @@ import { MetadataGridComponent, MetadataValueComponent } from '../../../shared/c CommonModule, DatePipe, FormsModule, + RouterLink, NzCardModule, NzButtonModule, NzIconModule, @@ -45,6 +48,7 @@ import { MetadataGridComponent, MetadataValueComponent } from '../../../shared/c NzTableModule, NzToolTipModule, NzEmptyModule, + NzPaginationModule, RelativeTimePipe, CopyToClipboardDirective, QrCodeDisplayComponent, @@ -60,13 +64,24 @@ export class ChannelDetailComponent implements OnInit { private apiService = inject(ApiService); private authService = inject(AuthService); private notification = inject(NotificationService); + private settingsService = inject(SettingsService); private userCacheService = inject(UserCacheService); channel = signal(null); subscriptions = signal([]); + messages = signal([]); userNames = signal>(new Map()); loading = signal(true); loadingSubscriptions = signal(false); + loadingMessages = signal(false); + deleting = signal(false); + expertMode = this.settingsService.expertMode; + + // Messages pagination + messagesPageSize = 16; + messagesNextPageToken = signal(null); + messagesTotalCount = signal(0); + messagesCurrentPage = signal(1); // Edit modal showEditModal = signal(false); editDisplayName = ''; @@ -107,6 +122,7 @@ export class ChannelDetailComponent implements OnInit { if (this.isOwner()) { this.loadSubscriptions(channelId); } + this.loadMessages(channelId); }, error: () => { this.loading.set(false); @@ -131,6 +147,69 @@ export class ChannelDetailComponent implements OnInit { }); } + loadMessages(channelId: string, nextPageToken?: string): void { + const userId = this.authService.getUserId(); + if (!userId) return; + + this.loadingMessages.set(true); + this.apiService.getChannelMessages(userId, channelId, { + page_size: this.messagesPageSize, + next_page_token: nextPageToken, + trimmed: true + }).subscribe({ + next: (response) => { + this.messages.set(response.messages); + this.messagesNextPageToken.set(response.next_page_token || null); + this.messagesTotalCount.set(response.total_count); + this.loadingMessages.set(false); + }, + error: () => { + this.loadingMessages.set(false); + } + }); + } + + messagesGoToPage(page: number): void { + const channel = this.channel(); + if (!channel) return; + + this.messagesCurrentPage.set(page); + // For pagination with tokens, we need to handle this differently + // The API uses next_page_token, so we'll reload from the beginning for now + // In a real implementation, you'd need to track tokens per page or use offset-based pagination + if (page === 1) { + this.loadMessages(channel.channel_id); + } else { + // For simplicity, use the next page token if going forward + const token = this.messagesNextPageToken(); + if (token) { + this.loadMessages(channel.channel_id, token); + } + } + } + + viewMessage(message: Message): void { + this.router.navigate(['/messages', message.message_id]); + } + + getPriorityColor(priority: number): string { + switch (priority) { + case 0: return 'default'; + case 1: return 'blue'; + case 2: return 'orange'; + default: return 'default'; + } + } + + getPriorityLabel(priority: number): string { + switch (priority) { + case 0: return 'Low'; + case 1: return 'Normal'; + case 2: return 'High'; + default: return 'Unknown'; + } + } + private resolveUserNames(subscriptions: Subscription[]): void { const userIds = new Set(); for (const sub of subscriptions) { @@ -245,4 +324,70 @@ export class ChannelDetailComponent implements OnInit { return { label: 'Not Subscribed', color: 'default' }; } + + deleteChannel(): void { + const channel = this.channel(); + const userId = this.authService.getUserId(); + if (!channel || !userId) return; + + this.deleting.set(true); + this.apiService.deleteChannel(userId, channel.channel_id).subscribe({ + next: () => { + this.notification.success('Channel deleted'); + this.router.navigate(['/channels']); + }, + error: () => { + this.deleting.set(false); + } + }); + } + + viewSubscription(sub: Subscription): void { + this.router.navigate(['/subscriptions', sub.subscription_id]); + } + + acceptSubscription(sub: Subscription): void { + const userId = this.authService.getUserId(); + if (!userId) return; + + this.apiService.confirmSubscription(userId, sub.subscription_id, { confirmed: true }).subscribe({ + next: () => { + this.notification.success('Subscription accepted'); + const channel = this.channel(); + if (channel) { + this.loadSubscriptions(channel.channel_id); + } + } + }); + } + + denySubscription(sub: Subscription): void { + const userId = this.authService.getUserId(); + if (!userId) return; + + this.apiService.deleteSubscription(userId, sub.subscription_id).subscribe({ + next: () => { + this.notification.success('Subscription denied'); + const channel = this.channel(); + if (channel) { + this.loadSubscriptions(channel.channel_id); + } + } + }); + } + + revokeSubscription(sub: Subscription): void { + const userId = this.authService.getUserId(); + if (!userId) return; + + this.apiService.deleteSubscription(userId, sub.subscription_id).subscribe({ + next: () => { + this.notification.success('Subscription revoked'); + const channel = this.channel(); + if (channel) { + this.loadSubscriptions(channel.channel_id); + } + } + }); + } } diff --git a/webapp/src/app/features/channels/channel-list/channel-list.component.html b/webapp/src/app/features/channels/channel-list/channel-list.component.html index 3141599..9e5d254 100644 --- a/webapp/src/app/features/channels/channel-list/channel-list.component.html +++ b/webapp/src/app/features/channels/channel-list/channel-list.component.html @@ -21,45 +21,95 @@ - Name - Internal Name - Owner - Status - Subscribers - Messages - Last Sent + Name + Internal Name + Owner + Status + Subscribers + Messages + Last Sent @for (channel of channels(); track channel.channel_id) { - + -
{{ channel.display_name }}
-
{{ channel.channel_id }}
- - - {{ channel.internal_name }} - - {{ getOwnerDisplayName(channel.owner_user_id) }} - - - {{ getSubscriptionStatus(channel).label }} - + @if (isOwned(channel)) { + +
{{ channel.display_name }}
+
{{ channel.channel_id }}
+
+ } @else { +
{{ channel.display_name }}
+
{{ channel.channel_id }}
+ } @if (isOwned(channel)) { - + + {{ channel.internal_name }} + + } @else { + {{ channel.internal_name }} + } + + + @if (isOwned(channel)) { + + {{ getOwnerDisplayName(channel.owner_user_id) }} + + } @else { + {{ getOwnerDisplayName(channel.owner_user_id) }} + } + + + @if (isOwned(channel)) { + + + {{ getSubscriptionStatus(channel).label }} + + + } @else { + + {{ getSubscriptionStatus(channel).label }} + + } + + + @if (isOwned(channel)) { + + + } @else { - } - {{ channel.messages_sent }} - @if (channel.timestamp_lastsent) { -
{{ channel.timestamp_lastsent | date:'yyyy-MM-dd HH:mm:ss' }}
-
{{ channel.timestamp_lastsent | relativeTime }}
+ @if (isOwned(channel)) { + + {{ channel.messages_sent }} + } @else { - Never + {{ channel.messages_sent }} + } + + + @if (isOwned(channel)) { + + @if (channel.timestamp_lastsent) { +
{{ channel.timestamp_lastsent | date:'yyyy-MM-dd HH:mm:ss' }}
+
{{ channel.timestamp_lastsent | relativeTime }}
+ } @else { + Never + } +
+ } @else { + @if (channel.timestamp_lastsent) { +
{{ channel.timestamp_lastsent | date:'yyyy-MM-dd HH:mm:ss' }}
+
{{ channel.timestamp_lastsent | relativeTime }}
+ } @else { + Never + } } diff --git a/webapp/src/app/features/channels/channel-list/channel-list.component.scss b/webapp/src/app/features/channels/channel-list/channel-list.component.scss index 6b2c2d4..9f00707 100644 --- a/webapp/src/app/features/channels/channel-list/channel-list.component.scss +++ b/webapp/src/app/features/channels/channel-list/channel-list.component.scss @@ -36,11 +36,13 @@ .timestamp-absolute { font-size: 13px; color: #333; + white-space: pre; } .timestamp-relative { font-size: 12px; color: #999; + white-space: pre; } .clickable-row { diff --git a/webapp/src/app/features/channels/channel-list/channel-list.component.ts b/webapp/src/app/features/channels/channel-list/channel-list.component.ts index 1df361e..a1093d9 100644 --- a/webapp/src/app/features/channels/channel-list/channel-list.component.ts +++ b/webapp/src/app/features/channels/channel-list/channel-list.component.ts @@ -1,6 +1,6 @@ import { Component, inject, signal, OnInit } from '@angular/core'; import { CommonModule, DatePipe } from '@angular/common'; -import { Router } from '@angular/router'; +import { Router, RouterLink } from '@angular/router'; import { NzTableModule } from 'ng-zorro-antd/table'; import { NzButtonModule } from 'ng-zorro-antd/button'; import { NzIconModule } from 'ng-zorro-antd/icon'; @@ -22,6 +22,7 @@ import { ChannelSubscribersComponent } from '../channel-subscribers/channel-subs imports: [ CommonModule, DatePipe, + RouterLink, NzTableModule, NzButtonModule, NzIconModule, diff --git a/webapp/src/app/features/clients/client-detail/client-detail.component.html b/webapp/src/app/features/clients/client-detail/client-detail.component.html index 9156c11..454816c 100644 --- a/webapp/src/app/features/clients/client-detail/client-detail.component.html +++ b/webapp/src/app/features/clients/client-detail/client-detail.component.html @@ -9,18 +9,20 @@ Back to Clients -
- -
+ @if (expertMode()) { +
+ +
+ }
diff --git a/webapp/src/app/features/clients/client-detail/client-detail.component.scss b/webapp/src/app/features/clients/client-detail/client-detail.component.scss index 2b61dc0..3ad059b 100644 --- a/webapp/src/app/features/clients/client-detail/client-detail.component.scss +++ b/webapp/src/app/features/clients/client-detail/client-detail.component.scss @@ -59,9 +59,11 @@ .timestamp-absolute { font-size: 13px; color: #333; + white-space: pre; } .timestamp-relative { font-size: 12px; color: #999; + white-space: pre; } diff --git a/webapp/src/app/features/clients/client-detail/client-detail.component.ts b/webapp/src/app/features/clients/client-detail/client-detail.component.ts index 800da64..fc55a2a 100644 --- a/webapp/src/app/features/clients/client-detail/client-detail.component.ts +++ b/webapp/src/app/features/clients/client-detail/client-detail.component.ts @@ -11,6 +11,7 @@ import { NzToolTipModule } from 'ng-zorro-antd/tooltip'; import { ApiService } from '../../../core/services/api.service'; import { AuthService } from '../../../core/services/auth.service'; import { NotificationService } from '../../../core/services/notification.service'; +import { SettingsService } from '../../../core/services/settings.service'; import { Client, ClientType, getClientTypeIcon } from '../../../core/models'; import { RelativeTimePipe } from '../../../shared/pipes/relative-time.pipe'; import { MetadataGridComponent, MetadataValueComponent } from '../../../shared/components/metadata-grid'; @@ -41,9 +42,11 @@ export class ClientDetailComponent implements OnInit { private apiService = inject(ApiService); private authService = inject(AuthService); private notification = inject(NotificationService); + private settingsService = inject(SettingsService); client = signal(null); loading = signal(true); + expertMode = this.settingsService.expertMode; ngOnInit(): void { const clientId = this.route.snapshot.paramMap.get('id'); diff --git a/webapp/src/app/features/clients/client-list/client-list.component.html b/webapp/src/app/features/clients/client-list/client-list.component.html index b61461d..ab82829 100644 --- a/webapp/src/app/features/clients/client-list/client-list.component.html +++ b/webapp/src/app/features/clients/client-list/client-list.component.html @@ -28,31 +28,41 @@ @for (client of clients(); track client.client_id) { - + - + + + -
{{ client.name || '-' }}
-
{{ client.client_id }}
+ +
{{ client.name || '-' }}
+
{{ client.client_id }}
+
- {{ getClientTypeLabel(client.type) }} + + {{ getClientTypeLabel(client.type) }} + -
- {{ client.agent_model }} - v{{ client.agent_version }} -
+ +
+ {{ client.agent_model }} + v{{ client.agent_version }} +
+
-
{{ client.timestamp_created | date:'yyyy-MM-dd HH:mm:ss' }}
-
{{ client.timestamp_created | relativeTime }}
+ +
{{ client.timestamp_created | date:'yyyy-MM-dd HH:mm:ss' }}
+
{{ client.timestamp_created | relativeTime }}
+
} @empty { diff --git a/webapp/src/app/features/clients/client-list/client-list.component.scss b/webapp/src/app/features/clients/client-list/client-list.component.scss index d07c2aa..b0c0398 100644 --- a/webapp/src/app/features/clients/client-list/client-list.component.scss +++ b/webapp/src/app/features/clients/client-list/client-list.component.scss @@ -46,9 +46,11 @@ .timestamp-absolute { font-size: 13px; color: #333; + white-space: pre; } .timestamp-relative { font-size: 12px; color: #999; + white-space: pre; } diff --git a/webapp/src/app/features/clients/client-list/client-list.component.ts b/webapp/src/app/features/clients/client-list/client-list.component.ts index dc5f7d7..4737775 100644 --- a/webapp/src/app/features/clients/client-list/client-list.component.ts +++ b/webapp/src/app/features/clients/client-list/client-list.component.ts @@ -1,6 +1,6 @@ import { Component, inject, signal, OnInit } from '@angular/core'; import { CommonModule, DatePipe } from '@angular/common'; -import { Router } from '@angular/router'; +import { Router, RouterLink } from '@angular/router'; import { NzTableModule } from 'ng-zorro-antd/table'; import { NzButtonModule } from 'ng-zorro-antd/button'; import { NzIconModule } from 'ng-zorro-antd/icon'; @@ -19,6 +19,7 @@ import { RelativeTimePipe } from '../../../shared/pipes/relative-time.pipe'; imports: [ CommonModule, DatePipe, + RouterLink, NzTableModule, NzButtonModule, NzIconModule, diff --git a/webapp/src/app/features/keys/key-detail/key-detail.component.html b/webapp/src/app/features/keys/key-detail/key-detail.component.html index 4a789cb..fd503e3 100644 --- a/webapp/src/app/features/keys/key-detail/key-detail.component.html +++ b/webapp/src/app/features/keys/key-detail/key-detail.component.html @@ -94,6 +94,91 @@
+ + + + + + + Title + Content + Channel + Sender + Priority + Time + + + + @for (message of messages(); track message.message_id) { + + + +
{{ message.title }}
+
{{ message.message_id }}
+
+ + + + @if (message.content) { +
{{ message.content | slice:0:128 }}{{ message.content.length > 128 ? '...' : '' }}
+ } @else { + + } +
+ + + +
{{ message.channel_internal_name }}
+
{{ message.channel_id }}
+
+ + + + {{ message.sender_name || '-' }} + + + + + + {{ getPriorityLabel(message.priority) }} + + + + + +
{{ message.timestamp | date:'yyyy-MM-dd HH:mm:ss' }}
+
{{ message.timestamp | relativeTime }}
+
+ + + } @empty { + + + + + + } + +
+ @if (messagesTotalCount() > messagesPageSize) { +
+ +
+ } +
} @else {
diff --git a/webapp/src/app/features/keys/key-detail/key-detail.component.scss b/webapp/src/app/features/keys/key-detail/key-detail.component.scss index 89f6976..c2ce331 100644 --- a/webapp/src/app/features/keys/key-detail/key-detail.component.scss +++ b/webapp/src/app/features/keys/key-detail/key-detail.component.scss @@ -90,9 +90,53 @@ .timestamp-absolute { font-size: 13px; color: #333; + white-space: pre; } .timestamp-relative { font-size: 12px; color: #999; + white-space: pre; +} + +.clickable-row { + cursor: pointer; + + &:hover { + background-color: #fafafa; + } +} + +.message-title { + font-weight: 500; + color: #333; +} + +.message-id { + font-size: 11px; + color: #999; + margin-top: 2px; +} + +.message-content { + font-size: 12px; + color: #666; +} + +.cell-name { + font-weight: 500; + color: #333; +} + +.cell-id { + font-size: 11px; + color: #999; + margin-top: 2px; +} + +.pagination-controls { + display: flex; + justify-content: center; + align-items: center; + padding: 16px 0; } diff --git a/webapp/src/app/features/keys/key-detail/key-detail.component.ts b/webapp/src/app/features/keys/key-detail/key-detail.component.ts index e4f154b..fdfa2cf 100644 --- a/webapp/src/app/features/keys/key-detail/key-detail.component.ts +++ b/webapp/src/app/features/keys/key-detail/key-detail.component.ts @@ -1,6 +1,6 @@ import { Component, inject, signal, OnInit } from '@angular/core'; import { CommonModule, DatePipe } from '@angular/common'; -import { ActivatedRoute, Router } from '@angular/router'; +import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { FormsModule } from '@angular/forms'; import { NzCardModule } from 'ng-zorro-antd/card'; import { NzButtonModule } from 'ng-zorro-antd/button'; @@ -14,12 +14,15 @@ import { NzInputModule } from 'ng-zorro-antd/input'; import { NzCheckboxModule } from 'ng-zorro-antd/checkbox'; import { NzSelectModule } from 'ng-zorro-antd/select'; import { NzToolTipModule } from 'ng-zorro-antd/tooltip'; +import { NzTableModule } from 'ng-zorro-antd/table'; +import { NzEmptyModule } from 'ng-zorro-antd/empty'; +import { NzPaginationModule } from 'ng-zorro-antd/pagination'; import { ApiService } from '../../../core/services/api.service'; import { AuthService } from '../../../core/services/auth.service'; import { NotificationService } from '../../../core/services/notification.service'; import { ChannelCacheService, ResolvedChannel } from '../../../core/services/channel-cache.service'; import { UserCacheService, ResolvedUser } from '../../../core/services/user-cache.service'; -import { KeyToken, parsePermissions, TokenPermission, ChannelWithSubscription } from '../../../core/models'; +import { KeyToken, parsePermissions, TokenPermission, ChannelWithSubscription, Message } from '../../../core/models'; import { RelativeTimePipe } from '../../../shared/pipes/relative-time.pipe'; import { MetadataGridComponent, MetadataValueComponent } from '../../../shared/components/metadata-grid'; @@ -36,6 +39,7 @@ interface PermissionOption { CommonModule, DatePipe, FormsModule, + RouterLink, NzCardModule, NzButtonModule, NzIconModule, @@ -48,6 +52,9 @@ interface PermissionOption { NzCheckboxModule, NzSelectModule, NzToolTipModule, + NzTableModule, + NzEmptyModule, + NzPaginationModule, RelativeTimePipe, MetadataGridComponent, MetadataValueComponent, @@ -71,6 +78,14 @@ export class KeyDetailComponent implements OnInit { availableChannels = signal([]); resolvedOwner = signal(null); + // Messages + messages = signal([]); + loadingMessages = signal(false); + messagesPageSize = 16; + messagesTotalCount = signal(0); + messagesCurrentPage = signal(1); + messagesNextPageToken = signal(null); + // Edit modal showEditModal = signal(false); editKeyName = ''; @@ -106,6 +121,7 @@ export class KeyDetailComponent implements OnInit { this.loading.set(false); this.resolveChannelNames(key); this.resolveOwner(key.owner_user_id); + this.loadMessages(keyId); }, error: () => { this.loading.set(false); @@ -113,6 +129,64 @@ export class KeyDetailComponent implements OnInit { }); } + loadMessages(keyId: string, nextPageToken?: string): void { + this.loadingMessages.set(true); + // Load more messages than page size to ensure we get enough after filtering + this.apiService.getMessages({ + page_size: 64, + next_page_token: nextPageToken, + trimmed: true + }).subscribe({ + next: (response) => { + // Filter messages by the key that was used to send them + const filtered = response.messages.filter(m => m.used_key_id === keyId); + this.messages.set(filtered.slice(0, this.messagesPageSize)); + this.messagesTotalCount.set(filtered.length); + this.messagesNextPageToken.set(response.next_page_token || null); + this.loadingMessages.set(false); + }, + error: () => { + this.loadingMessages.set(false); + } + }); + } + + messagesGoToPage(page: number): void { + const key = this.key(); + if (!key) return; + this.messagesCurrentPage.set(page); + if (page === 1) { + this.loadMessages(key.keytoken_id); + } else { + const token = this.messagesNextPageToken(); + if (token) { + this.loadMessages(key.keytoken_id, token); + } + } + } + + viewMessage(message: Message): void { + this.router.navigate(['/messages', message.message_id]); + } + + getPriorityColor(priority: number): string { + switch (priority) { + case 0: return 'default'; + case 1: return 'blue'; + case 2: return 'orange'; + default: return 'default'; + } + } + + getPriorityLabel(priority: number): string { + switch (priority) { + case 0: return 'Low'; + case 1: return 'Normal'; + case 2: return 'High'; + default: return 'Unknown'; + } + } + private resolveOwner(ownerId: string): void { this.userCacheService.resolveUser(ownerId).subscribe(resolved => { this.resolvedOwner.set(resolved); diff --git a/webapp/src/app/features/keys/key-list/key-list.component.html b/webapp/src/app/features/keys/key-list/key-list.component.html index adfe0a3..b331f65 100644 --- a/webapp/src/app/features/keys/key-list/key-list.component.html +++ b/webapp/src/app/features/keys/key-list/key-list.component.html @@ -34,50 +34,60 @@ @for (key of keys(); track key.keytoken_id) { - + -
- {{ key.name }} - @if (isCurrentKey(key)) { - Current - } -
-
{{ key.keytoken_id }}
+ +
+ {{ key.name }} + @if (isCurrentKey(key)) { + Current + } +
+
{{ key.keytoken_id }}
+
-
- @for (perm of getPermissions(key); track perm) { - - {{ perm }} - - } - @if (key.all_channels) { - - All Channels - - } @else if (key.channels && key.channels.length > 0) { - @for (channelId of key.channels; track channelId) { - - {{ getChannelDisplayName(channelId) }} + +
+ @for (perm of getPermissions(key); track perm) { + + {{ perm }} } - } -
+ @if (key.all_channels) { + + All Channels + + } @else if (key.channels && key.channels.length > 0) { + @for (channelId of key.channels; track channelId) { + + {{ getChannelDisplayName(channelId) }} + + } + } +
+ - {{ key.messages_sent }} - @if (key.timestamp_lastused) { -
{{ key.timestamp_lastused | date:'yyyy-MM-dd HH:mm:ss' }}
-
{{ key.timestamp_lastused | relativeTime }}
- } @else { - Never - } + + {{ key.messages_sent }} + - + + + @if (key.timestamp_lastused) { +
{{ key.timestamp_lastused | date:'yyyy-MM-dd HH:mm:ss' }}
+
{{ key.timestamp_lastused | relativeTime }}
+ } @else { + Never + } +
+ +
+ @if (expertMode()) { +
+ +
+ }
@@ -32,6 +47,10 @@
{{ message()!.channel_id }}
+ +
{{ resolvedChannelOwner()?.displayName || message()!.channel_owner_user_id }}
+
{{ message()!.channel_owner_user_id }}
+
{{ getPriorityLabel(message()!.priority) }} @@ -58,6 +77,48 @@
+ + @if (showDeliveries()) { + + + + + Client ID + Status + Retries + Created + Finalized + + + + @for (delivery of deliveriesTable.data; track delivery.delivery_id) { + + + + {{ delivery.receiver_client_id }} + + + + + {{ delivery.status }} + + + {{ delivery.retry_count }} + {{ delivery.timestamp_created | date:'yyyy-MM-dd HH:mm:ss' }} + {{ delivery.timestamp_finalized ? (delivery.timestamp_finalized | date:'yyyy-MM-dd HH:mm:ss') : '-' }} + + } + + + + } } @else {
diff --git a/webapp/src/app/features/messages/message-detail/message-detail.component.scss b/webapp/src/app/features/messages/message-detail/message-detail.component.scss index 29009c0..9f1e684 100644 --- a/webapp/src/app/features/messages/message-detail/message-detail.component.scss +++ b/webapp/src/app/features/messages/message-detail/message-detail.component.scss @@ -64,9 +64,11 @@ nz-card + nz-card { .timestamp-absolute { font-size: 13px; color: #333; + white-space: pre; } .timestamp-relative { font-size: 12px; color: #999; -} + white-space: pre; +} \ No newline at end of file diff --git a/webapp/src/app/features/messages/message-detail/message-detail.component.ts b/webapp/src/app/features/messages/message-detail/message-detail.component.ts index 8c79e05..14bf412 100644 --- a/webapp/src/app/features/messages/message-detail/message-detail.component.ts +++ b/webapp/src/app/features/messages/message-detail/message-detail.component.ts @@ -1,4 +1,4 @@ -import { Component, inject, signal, OnInit } from '@angular/core'; +import { Component, inject, signal, OnInit, computed } from '@angular/core'; import { CommonModule, DatePipe } from '@angular/common'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { NzCardModule } from 'ng-zorro-antd/card'; @@ -6,10 +6,15 @@ import { NzButtonModule } from 'ng-zorro-antd/button'; import { NzIconModule } from 'ng-zorro-antd/icon'; import { NzTagModule } from 'ng-zorro-antd/tag'; import { NzSpinModule } from 'ng-zorro-antd/spin'; +import { NzPopconfirmModule } from 'ng-zorro-antd/popconfirm'; +import { NzTableModule } from 'ng-zorro-antd/table'; import { ApiService } from '../../../core/services/api.service'; +import { AuthService } from '../../../core/services/auth.service'; import { NotificationService } from '../../../core/services/notification.service'; +import { SettingsService } from '../../../core/services/settings.service'; import { KeyCacheService, ResolvedKey } from '../../../core/services/key-cache.service'; -import { Message } from '../../../core/models'; +import { UserCacheService, ResolvedUser } from '../../../core/services/user-cache.service'; +import { Message, Delivery } from '../../../core/models'; import { RelativeTimePipe } from '../../../shared/pipes/relative-time.pipe'; import { MetadataGridComponent, MetadataValueComponent } from '../../../shared/components/metadata-grid'; @@ -24,6 +29,8 @@ import { MetadataGridComponent, MetadataValueComponent } from '../../../shared/c NzIconModule, NzTagModule, NzSpinModule, + NzPopconfirmModule, + NzTableModule, RouterLink, RelativeTimePipe, MetadataGridComponent, @@ -36,13 +43,28 @@ export class MessageDetailComponent implements OnInit { private route = inject(ActivatedRoute); private router = inject(Router); private apiService = inject(ApiService); + private authService = inject(AuthService); private notification = inject(NotificationService); + private settingsService = inject(SettingsService); private keyCacheService = inject(KeyCacheService); + private userCacheService = inject(UserCacheService); message = signal(null); resolvedKey = signal(null); + resolvedChannelOwner = signal(null); + deliveries = signal([]); loading = signal(true); deleting = signal(false); + loadingDeliveries = signal(false); + expertMode = this.settingsService.expertMode; + + isChannelOwner = computed(() => { + const msg = this.message(); + const userId = this.authService.getUserId(); + return msg !== null && userId !== null && msg.channel_owner_user_id === userId; + }); + + showDeliveries = computed(() => this.expertMode() && this.isChannelOwner()); ngOnInit(): void { const messageId = this.route.snapshot.paramMap.get('id'); @@ -58,6 +80,8 @@ export class MessageDetailComponent implements OnInit { this.message.set(message); this.loading.set(false); this.resolveKey(message.used_key_id); + this.resolveChannelOwner(message.channel_owner_user_id); + this.loadDeliveries(messageId); }, error: () => { this.loading.set(false); @@ -65,12 +89,34 @@ export class MessageDetailComponent implements OnInit { }); } + private loadDeliveries(messageId: string): void { + if (!this.showDeliveries()) { + return; + } + this.loadingDeliveries.set(true); + this.apiService.getDeliveries(messageId).subscribe({ + next: (response) => { + this.deliveries.set(response.deliveries); + this.loadingDeliveries.set(false); + }, + error: () => { + this.loadingDeliveries.set(false); + } + }); + } + private resolveKey(keyId: string): void { this.keyCacheService.resolveKey(keyId).subscribe({ next: (resolved) => this.resolvedKey.set(resolved) }); } + private resolveChannelOwner(userId: string): void { + this.userCacheService.resolveUser(userId).subscribe({ + next: (resolved) => this.resolvedChannelOwner.set(resolved) + }); + } + goBack(): void { this.router.navigate(['/messages']); } @@ -108,4 +154,13 @@ export class MessageDetailComponent implements OnInit { default: return 'default'; } } + + getStatusColor(status: string): string { + switch (status) { + case 'SUCCESS': return 'green'; + case 'FAILED': return 'red'; + case 'RETRY': return 'orange'; + default: return 'default'; + } + } } diff --git a/webapp/src/app/features/messages/message-list/message-list.component.html b/webapp/src/app/features/messages/message-list/message-list.component.html index 797c507..f187961 100644 --- a/webapp/src/app/features/messages/message-list/message-list.component.html +++ b/webapp/src/app/features/messages/message-list/message-list.component.html @@ -107,33 +107,45 @@ @for (message of messages(); track message.message_id) { - + -
{{ message.title }}
-
{{ message.message_id }}
+ +
{{ message.title }}
+
{{ message.message_id }}
+
- @if (message.content) { -
{{ message.content | slice:0:128 }}{{ message.content.length > 128 ? '...' : '' }}
- } @else { - - } + + @if (message.content) { +
{{ message.content | slice:0:128 }}{{ message.content.length > 128 ? '...' : '' }}
+ } @else { + + } +
-
{{ message.channel_internal_name }}
-
{{ message.channel_id }}
+ +
{{ message.channel_internal_name }}
+
{{ message.channel_id }}
+
- {{ message.sender_name || '-' }} + + {{ message.sender_name || '-' }} + - - {{ getPriorityLabel(message.priority) }} - + + + {{ getPriorityLabel(message.priority) }} + + -
{{ message.timestamp | date:'yyyy-MM-dd HH:mm:ss' }}
-
{{ message.timestamp | relativeTime }}
+ +
{{ message.timestamp | date:'yyyy-MM-dd HH:mm:ss' }}
+
{{ message.timestamp | relativeTime }}
+
} @empty { diff --git a/webapp/src/app/features/messages/message-list/message-list.component.scss b/webapp/src/app/features/messages/message-list/message-list.component.scss index 0d3f2f3..ea5b47a 100644 --- a/webapp/src/app/features/messages/message-list/message-list.component.scss +++ b/webapp/src/app/features/messages/message-list/message-list.component.scss @@ -70,11 +70,13 @@ .timestamp-absolute { font-size: 13px; color: #333; + white-space: pre; } .timestamp-relative { font-size: 12px; color: #999; + white-space: pre; } .pagination-controls { diff --git a/webapp/src/app/features/messages/message-list/message-list.component.ts b/webapp/src/app/features/messages/message-list/message-list.component.ts index 323fa5e..9886740 100644 --- a/webapp/src/app/features/messages/message-list/message-list.component.ts +++ b/webapp/src/app/features/messages/message-list/message-list.component.ts @@ -1,6 +1,6 @@ import { Component, inject, signal, OnInit } from '@angular/core'; import { CommonModule } from '@angular/common'; -import { Router } from '@angular/router'; +import { Router, RouterLink } from '@angular/router'; import { FormsModule } from '@angular/forms'; import { NzTableModule, NzTableFilterList } from 'ng-zorro-antd/table'; import { NzButtonModule } from 'ng-zorro-antd/button'; @@ -24,6 +24,7 @@ import { RelativeTimePipe } from '../../../shared/pipes/relative-time.pipe'; imports: [ CommonModule, FormsModule, + RouterLink, NzTableModule, NzButtonModule, NzInputModule, diff --git a/webapp/src/app/features/senders/sender-list/sender-list.component.scss b/webapp/src/app/features/senders/sender-list/sender-list.component.scss index 7ce5b76..288c814 100644 --- a/webapp/src/app/features/senders/sender-list/sender-list.component.scss +++ b/webapp/src/app/features/senders/sender-list/sender-list.component.scss @@ -16,9 +16,11 @@ .timestamp-absolute { font-size: 13px; color: #333; + white-space: pre; } .timestamp-relative { font-size: 12px; color: #999; + white-space: pre; } diff --git a/webapp/src/app/features/subscriptions/subscription-detail/subscription-detail.component.html b/webapp/src/app/features/subscriptions/subscription-detail/subscription-detail.component.html index a7e889d..368db00 100644 --- a/webapp/src/app/features/subscriptions/subscription-detail/subscription-detail.component.html +++ b/webapp/src/app/features/subscriptions/subscription-detail/subscription-detail.component.html @@ -27,16 +27,29 @@ Deactivate } - + @if (expertMode() && isOutgoing() && subscription()!.confirmed && subscription()!.active) { + + } + @if (expertMode() && isOutgoing()) { + + }
diff --git a/webapp/src/app/features/subscriptions/subscription-detail/subscription-detail.component.scss b/webapp/src/app/features/subscriptions/subscription-detail/subscription-detail.component.scss index 73acef1..0e7a19b 100644 --- a/webapp/src/app/features/subscriptions/subscription-detail/subscription-detail.component.scss +++ b/webapp/src/app/features/subscriptions/subscription-detail/subscription-detail.component.scss @@ -58,9 +58,11 @@ .timestamp-absolute { font-size: 13px; color: #333; + white-space: pre; } .timestamp-relative { font-size: 12px; color: #999; + white-space: pre; } diff --git a/webapp/src/app/features/subscriptions/subscription-detail/subscription-detail.component.ts b/webapp/src/app/features/subscriptions/subscription-detail/subscription-detail.component.ts index ab6d9b6..bd91b08 100644 --- a/webapp/src/app/features/subscriptions/subscription-detail/subscription-detail.component.ts +++ b/webapp/src/app/features/subscriptions/subscription-detail/subscription-detail.component.ts @@ -11,6 +11,7 @@ import { NzToolTipModule } from 'ng-zorro-antd/tooltip'; import { ApiService } from '../../../core/services/api.service'; import { AuthService } from '../../../core/services/auth.service'; import { NotificationService } from '../../../core/services/notification.service'; +import { SettingsService } from '../../../core/services/settings.service'; import { ChannelCacheService, ResolvedChannel } from '../../../core/services/channel-cache.service'; import { UserCacheService, ResolvedUser } from '../../../core/services/user-cache.service'; import { Subscription } from '../../../core/models'; @@ -44,6 +45,7 @@ export class SubscriptionDetailComponent implements OnInit { private apiService = inject(ApiService); private authService = inject(AuthService); private notification = inject(NotificationService); + private settingsService = inject(SettingsService); private channelCacheService = inject(ChannelCacheService); private userCacheService = inject(UserCacheService); @@ -52,6 +54,7 @@ export class SubscriptionDetailComponent implements OnInit { resolvedChannel = signal(null); resolvedSubscriber = signal(null); resolvedOwner = signal(null); + expertMode = this.settingsService.expertMode; ngOnInit(): void { const subscriptionId = this.route.snapshot.paramMap.get('id'); @@ -163,6 +166,19 @@ export class SubscriptionDetailComponent implements OnInit { }); } + setInactive(): void { + const sub = this.subscription(); + const userId = this.authService.getUserId(); + if (!sub || !userId) return; + + this.apiService.confirmSubscription(userId, sub.subscription_id, { active: false }).subscribe({ + next: (updated) => { + this.subscription.set(updated); + this.notification.success('Subscription set to inactive'); + } + }); + } + deleteSubscription(): void { const sub = this.subscription(); const userId = this.authService.getUserId(); diff --git a/webapp/src/app/features/subscriptions/subscription-list/subscription-list.component.html b/webapp/src/app/features/subscriptions/subscription-list/subscription-list.component.html index 192acca..0399ad1 100644 --- a/webapp/src/app/features/subscriptions/subscription-list/subscription-list.component.html +++ b/webapp/src/app/features/subscriptions/subscription-list/subscription-list.component.html @@ -47,44 +47,66 @@ Channel Subscriber Owner - Status + Confirmation + Active Created Actions @for (sub of subscriptions(); track sub.subscription_id) { - + - {{ sub.subscription_id }} + + {{ sub.subscription_id }} + - - {{ getTypeLabel(sub).label }} - + + + {{ getTypeLabel(sub).label }} + + -
{{ sub.channel_internal_name }}
-
{{ sub.channel_id }}
+ +
{{ sub.channel_internal_name }}
+
{{ sub.channel_id }}
+
-
{{ getUserDisplayName(sub.subscriber_user_id) }}
-
{{ sub.subscriber_user_id }}
+ +
{{ getUserDisplayName(sub.subscriber_user_id) }}
+
{{ sub.subscriber_user_id }}
+
-
{{ getUserDisplayName(sub.channel_owner_user_id) }}
-
{{ sub.channel_owner_user_id }}
+ +
{{ getUserDisplayName(sub.channel_owner_user_id) }}
+
{{ sub.channel_owner_user_id }}
+
- - {{ getStatusInfo(sub).label }} - + + + {{ getConfirmationInfo(sub).label }} + + -
{{ sub.timestamp_created | date:'yyyy-MM-dd HH:mm:ss' }}
-
{{ sub.timestamp_created | relativeTime }}
+ + + {{ getActiveInfo(sub).label }} + + - + + +
{{ sub.timestamp_created | date:'yyyy-MM-dd HH:mm:ss' }}
+
{{ sub.timestamp_created | relativeTime }}
+
+ +
@if (!sub.confirmed && isOwner(sub)) { @@ -130,7 +152,7 @@ } @empty { - + diff --git a/webapp/src/app/features/subscriptions/subscription-list/subscription-list.component.scss b/webapp/src/app/features/subscriptions/subscription-list/subscription-list.component.scss index f07d69a..8d66b6b 100644 --- a/webapp/src/app/features/subscriptions/subscription-list/subscription-list.component.scss +++ b/webapp/src/app/features/subscriptions/subscription-list/subscription-list.component.scss @@ -64,9 +64,11 @@ .timestamp-absolute { font-size: 13px; color: #333; + white-space: pre; } .timestamp-relative { font-size: 12px; color: #999; + white-space: pre; } diff --git a/webapp/src/app/features/subscriptions/subscription-list/subscription-list.component.ts b/webapp/src/app/features/subscriptions/subscription-list/subscription-list.component.ts index b35ba48..b5fe567 100644 --- a/webapp/src/app/features/subscriptions/subscription-list/subscription-list.component.ts +++ b/webapp/src/app/features/subscriptions/subscription-list/subscription-list.component.ts @@ -1,6 +1,6 @@ import { Component, inject, signal, OnInit } from '@angular/core'; import { CommonModule, DatePipe } from '@angular/common'; -import { Router } from '@angular/router'; +import { Router, RouterLink } from '@angular/router'; import { FormsModule } from '@angular/forms'; import { NzTableModule } from 'ng-zorro-antd/table'; import { NzButtonModule } from 'ng-zorro-antd/button'; @@ -43,6 +43,7 @@ const TAB_CONFIGS: Record = { CommonModule, DatePipe, FormsModule, + RouterLink, NzTableModule, NzButtonModule, NzIconModule, @@ -231,13 +232,20 @@ export class SubscriptionListComponent implements OnInit { }); } - getStatusInfo(sub: Subscription): { label: string; color: string } { + getConfirmationInfo(sub: Subscription): { label: string; color: string } { if (sub.confirmed) { return { label: 'Confirmed', color: 'green' }; } return { label: 'Pending', color: 'orange' }; } + getActiveInfo(sub: Subscription): { label: string; color: string } { + if (sub.active) { + return { label: 'Active', color: 'green' }; + } + return { label: 'Inactive', color: 'default' }; + } + getTypeLabel(sub: Subscription): { label: string; color: string } { const userId = this.authService.getUserId(); if (sub.subscriber_user_id === sub.channel_owner_user_id) { diff --git a/webapp/src/app/layout/main-layout/main-layout.component.html b/webapp/src/app/layout/main-layout/main-layout.component.html index 319ff13..89fef42 100644 --- a/webapp/src/app/layout/main-layout/main-layout.component.html +++ b/webapp/src/app/layout/main-layout/main-layout.component.html @@ -56,7 +56,20 @@
- {{ userId }} +
+ + Expert +
+