diff --git a/web/projects/shared/src/i18n/dictionaries/de.ts b/web/projects/shared/src/i18n/dictionaries/de.ts index a1b8f7d4d..0dea86b80 100644 --- a/web/projects/shared/src/i18n/dictionaries/de.ts +++ b/web/projects/shared/src/i18n/dictionaries/de.ts @@ -549,7 +549,7 @@ export default { 584: 'Verbindungen können manchmal langsam oder unzuverlässig sein', 585: 'Öffentlich, wenn Sie die Adresse öffentlich teilen, andernfalls privat', 586: 'Erfordert ein Tor-fähiges Gerät oder einen Browser', - 587: 'In den meisten Fällen nicht empfohlen. Nur erforderlich für Apps, die HTTPS erzwingen', + 587: 'Sollte nur für Apps benötigt werden, die SSL erzwingen', 588: 'Ideal für anonyme, zensurresistente Bereitstellung und Fernzugriff', 589: 'Ideal für lokalen Zugriff', 590: 'Erfordert die Verbindung mit demselben lokalen Netzwerk (LAN) wie Ihr Server, entweder physisch oder über VPN', diff --git a/web/projects/shared/src/i18n/dictionaries/en.ts b/web/projects/shared/src/i18n/dictionaries/en.ts index 5e97cd43c..923558b0d 100644 --- a/web/projects/shared/src/i18n/dictionaries/en.ts +++ b/web/projects/shared/src/i18n/dictionaries/en.ts @@ -548,7 +548,7 @@ export const ENGLISH = { 'Connections can be slow or unreliable at times': 584, 'Public if you share the address publicly, otherwise private': 585, 'Requires using a Tor-enabled device or browser': 586, - 'Not recommended in most cases. Only needed for apps that enforce HTTPS': 587, + 'Should only needed for apps that enforce SSL': 587, 'Ideal for anonymous, censorship-resistant hosting and remote access': 588, 'Ideal for local access': 589, 'Requires being connected to the same Local Area Network (LAN) as your server, either physically or via VPN': 590, diff --git a/web/projects/shared/src/i18n/dictionaries/es.ts b/web/projects/shared/src/i18n/dictionaries/es.ts index e3897f23b..7791d6b41 100644 --- a/web/projects/shared/src/i18n/dictionaries/es.ts +++ b/web/projects/shared/src/i18n/dictionaries/es.ts @@ -549,7 +549,7 @@ export default { 584: 'Las conexiones pueden ser lentas o poco confiables a veces', 585: 'Público si compartes la dirección públicamente, de lo contrario privado', 586: 'Requiere un dispositivo o navegador habilitado para Tor', - 587: 'No recomendado en la mayoría de los casos. Solo necesario para aplicaciones que imponen HTTPS', + 587: 'Solo debería ser necesario para aplicaciones que imponen SSL', 588: 'Ideal para alojamiento y acceso remoto anónimo y resistente a la censura', 589: 'Ideal para acceso local', 590: 'Requiere estar conectado a la misma red de área local (LAN) que tu servidor, ya sea físicamente o mediante VPN', diff --git a/web/projects/shared/src/i18n/dictionaries/fr.ts b/web/projects/shared/src/i18n/dictionaries/fr.ts index 0c6ab45af..252519d63 100644 --- a/web/projects/shared/src/i18n/dictionaries/fr.ts +++ b/web/projects/shared/src/i18n/dictionaries/fr.ts @@ -549,7 +549,7 @@ export default { 584: 'Les connexions peuvent parfois être lentes ou peu fiables', 585: 'Public si vous partagez l’adresse publiquement, sinon privé', 586: 'Nécessite un appareil ou un navigateur compatible Tor', - 587: 'Non recommandé dans la plupart des cas. Nécessaire uniquement pour les applications qui imposent HTTPS', + 587: 'Ne devrait être nécessaire que pour les applications qui imposent SSL', 588: 'Idéal pour l’hébergement et l’accès à distance anonymes et résistants à la censure', 589: 'Idéal pour un accès local', 590: 'Nécessite d’être connecté au même réseau local (LAN) que votre serveur, soit physiquement, soit via VPN', diff --git a/web/projects/shared/src/i18n/dictionaries/pl.ts b/web/projects/shared/src/i18n/dictionaries/pl.ts index 01b1f619a..68202e0b3 100644 --- a/web/projects/shared/src/i18n/dictionaries/pl.ts +++ b/web/projects/shared/src/i18n/dictionaries/pl.ts @@ -549,7 +549,7 @@ export default { 584: 'Połączenia mogą być czasami wolne lub niestabilne', 585: 'Publiczne, jeśli udostępniasz adres publicznie, w przeciwnym razie prywatne', 586: 'Wymaga urządzenia lub przeglądarki obsługującej Tor', - 587: 'Niezalecane w większości przypadków. Wymagane tylko dla aplikacji wymuszających HTTPS', + 587: 'Powinno być wymagane tylko dla aplikacji wymuszających SSL', 588: 'Idealne do anonimowego, odpornego na cenzurę hostingu i zdalnego dostępu', 589: 'Idealne do dostępu lokalnego', 590: 'Wymaga połączenia z tą samą siecią lokalną (LAN) co serwer, fizycznie lub przez VPN', diff --git a/web/projects/ui/src/app/routes/portal/components/form/containers/control.component.ts b/web/projects/ui/src/app/routes/portal/components/form/containers/control.component.ts index ec99b45b4..2e97ec829 100644 --- a/web/projects/ui/src/app/routes/portal/components/form/containers/control.component.ts +++ b/web/projects/ui/src/app/routes/portal/components/form/containers/control.component.ts @@ -2,29 +2,19 @@ import { AsyncPipe } from '@angular/common' import { ChangeDetectionStrategy, Component, - DestroyRef, inject, Input, - TemplateRef, - ViewChild, } from '@angular/core' -import { takeUntilDestroyed } from '@angular/core/rxjs-interop' -import { i18nPipe } from '@start9labs/shared' +import { DialogService, i18nPipe } from '@start9labs/shared' import { IST } from '@start9labs/start-sdk' import { tuiAsControl, TuiControl } from '@taiga-ui/cdk' -import { - TuiAlertService, - TuiButton, - TuiDialogContext, - TuiError, -} from '@taiga-ui/core' +import { TuiError } from '@taiga-ui/core' import { TUI_FORMAT_ERROR, TUI_VALIDATION_ERRORS, TuiFieldErrorPipe, } from '@taiga-ui/kit' import { PolymorpheusOutlet } from '@taiga-ui/polymorpheus' -import { filter } from 'rxjs' import { ControlSpec } from '../controls/control' import { CONTROLS } from '../controls/controls' @@ -46,35 +36,6 @@ export const ERRORS = [ template: ` - @if (spec.warning || immutable) { - - {{ spec.warning }} - @if (immutable) { -

{{ 'This value cannot be changed once set' | i18n }}!

- } -
- - -
-
- } `, changeDetection: ChangeDetectionStrategy.OnPush, providers: [ @@ -92,21 +53,13 @@ export const ERRORS = [ }, ], hostDirectives: [ControlDirective], - imports: [ - AsyncPipe, - i18nPipe, - PolymorpheusOutlet, - TuiError, - TuiFieldErrorPipe, - TuiButton, - ], + imports: [AsyncPipe, PolymorpheusOutlet, TuiError, TuiFieldErrorPipe], }) export class FormControlComponent< T extends ControlSpec, V, > extends TuiControl { - private readonly destroyRef = inject(DestroyRef) - private readonly alerts = inject(TuiAlertService) + private readonly dialogs = inject(DialogService) private readonly i18n = inject(i18nPipe) protected readonly controls = CONTROLS @@ -114,30 +67,29 @@ export class FormControlComponent< @Input({ required: true }) spec!: T - @ViewChild('warning') - warning?: TemplateRef> - warned = false readonly order = ERRORS - get immutable(): boolean { - return 'immutable' in this.spec && this.spec.immutable - } - onInput(value: V | null) { const previous = this.value() + const immutable = + 'immutable' in this.spec && this.spec.immutable + ? `

${this.i18n.transform('This value cannot be changed once set')}

` + : '' + const warning = this.spec.warning + immutable - if (!this.warned && this.warning) { - this.alerts - .open(this.warning, { - label: this.i18n.transform('Warning'), - appearance: 'warning', + if (!this.warned && warning) { + this.dialogs + .openConfirm({ + label: 'Warning', + data: { content: warning as any, yes: 'Confirm', no: 'Cancel' }, closeable: false, - autoClose: 0, + dismissible: false, }) - .pipe(filter(Boolean), takeUntilDestroyed(this.destroyRef)) - .subscribe(() => { - this.onChange(previous) + .subscribe(confirm => { + if (!confirm) { + this.onChange(previous) + } }) } diff --git a/web/projects/ui/src/app/routes/portal/components/form/controls/number.component.ts b/web/projects/ui/src/app/routes/portal/components/form/controls/number.component.ts index bc7abf54b..4f4828e45 100644 --- a/web/projects/ui/src/app/routes/portal/components/form/controls/number.component.ts +++ b/web/projects/ui/src/app/routes/portal/components/form/controls/number.component.ts @@ -21,14 +21,14 @@ import { HintPipe } from '../pipes/hint.pipe' } @@ -51,4 +51,16 @@ export class FormNumberComponent extends Control { get precision(): number { return this.spec.integer ? 0 : Infinity } + + get postfix(): string { + return this.spec.units && (this.value !== null || !this.spec.placeholder) + ? ` ${this.spec.units}` + : '' + } + + get placeholder(): string { + const units = this.spec.units ? ` (${this.spec.units})` : '' + + return this.spec.placeholder ? this.spec.placeholder + units : '' + } } diff --git a/web/projects/ui/src/app/routes/portal/components/form/controls/select.component.ts b/web/projects/ui/src/app/routes/portal/components/form/controls/select.component.ts index 068c4d830..fcbd350f4 100644 --- a/web/projects/ui/src/app/routes/portal/components/form/controls/select.component.ts +++ b/web/projects/ui/src/app/routes/portal/components/form/controls/select.component.ts @@ -3,8 +3,14 @@ import { FormsModule } from '@angular/forms' import { invert } from '@start9labs/shared' import { IST } from '@start9labs/start-sdk' import { TUI_IS_MOBILE } from '@taiga-ui/cdk' -import { TuiIcon, TuiTextfield } from '@taiga-ui/core' -import { TuiDataListWrapper, TuiSelect, TuiTooltip } from '@taiga-ui/kit' +import { TuiDataList, TuiIcon, TuiTextfield } from '@taiga-ui/core' +import { + TuiDataListWrapper, + TuiFluidTypography, + tuiFluidTypographyOptionsProvider, + TuiSelect, + TuiTooltip, +} from '@taiga-ui/kit' import { Control } from './control' import { HintPipe } from '../pipes/hint.pipe' @@ -41,18 +47,32 @@ import { HintPipe } from '../pipes/hint.pipe' /> } @if (!mobile) { - + + @for (item of items; track $index) { + + } + } @if (spec | hint; as hint) { } `, + providers: [tuiFluidTypographyOptionsProvider({ max: 1 })], imports: [ FormsModule, TuiTextfield, TuiSelect, - TuiDataListWrapper, + TuiDataList, + TuiFluidTypography, TuiIcon, TuiTooltip, HintPipe, diff --git a/web/projects/ui/src/app/routes/portal/components/header/navigation.component.ts b/web/projects/ui/src/app/routes/portal/components/header/navigation.component.ts index eb980d2ab..ddcc1a548 100644 --- a/web/projects/ui/src/app/routes/portal/components/header/navigation.component.ts +++ b/web/projects/ui/src/app/routes/portal/components/header/navigation.component.ts @@ -22,7 +22,7 @@ import { getMenu } from 'src/app/utils/system-utilities' class="link" routerLinkActive="link_active" tuiHintDirection="bottom" - [tuiHintShowDelay]="250" + [tuiHintShowDelay]="128" [routerLink]="['/', item.routerLink]" [class.link_system]="item.routerLink === 'system'" [tuiHint]="rla.isActive ? '' : (item.name | i18n)" diff --git a/web/projects/ui/src/app/routes/portal/components/interfaces/interface.service.ts b/web/projects/ui/src/app/routes/portal/components/interfaces/interface.service.ts index 50f99d881..c95dccb45 100644 --- a/web/projects/ui/src/app/routes/portal/components/interfaces/interface.service.ts +++ b/web/projects/ui/src/app/routes/portal/components/interfaces/interface.service.ts @@ -7,9 +7,9 @@ import { i18nKey, i18nPipe } from '@start9labs/shared' type AddressWithInfo = { url: string - ssl: boolean info: T.HostnameInfo gateway?: GatewayPlus + showSsl: boolean } function cmpWithRankedPredicates( @@ -30,10 +30,7 @@ function filterTor(a: AddressWithInfo): a is TorAddress { return a.info.kind === 'onion' } function cmpTor(a: TorAddress, b: TorAddress): -1 | 0 | 1 { - for (let [x, y, sign] of [[a, b, 1] as const, [b, a, -1] as const]) { - if (y.url.startsWith('http:') && x.url.startsWith('https:')) return sign - } - return 0 + return cmpWithRankedPredicates(a, b, [x => !x.showSsl]) } type LanAddress = AddressWithInfo & { info: { kind: 'ip'; public: false } } @@ -146,10 +143,15 @@ export class InterfaceService { : undefined const res = [] if (url) { - res.push({ url, ssl: false, info, gateway }) + res.push({ url, info, gateway, showSsl: false }) } if (sslUrl) { - res.push({ url: sslUrl, ssl: true, info, gateway }) + res.push({ + url: sslUrl, + info, + gateway, + showSsl: !!url, + }) } return res }, @@ -326,7 +328,7 @@ export class InterfaceService { } private toDisplayAddress( - { info, ssl, url, gateway }: AddressWithInfo, + { info, url, gateway, showSsl }: AddressWithInfo, publicDomains: Record, ): DisplayAddress { let access: DisplayAddress['access'] @@ -351,15 +353,8 @@ export class InterfaceService { this.i18n.transform('Requires using a Tor-enabled device or browser'), ] // Tor (SSL) - if (ssl) { - type = `${type} (SSL)` - bullets = [ - this.i18n.transform( - 'Not recommended in most cases. Only needed for apps that enforce HTTPS', - ), - rootCaRequired, - ...bullets, - ] + if (showSsl) { + bullets = [rootCaRequired, ...bullets] // Tor (NON-SSL) } else { bullets.unshift( @@ -500,6 +495,14 @@ export class InterfaceService { } } + if (showSsl) { + type = `${type} (SSL)` + + bullets.unshift( + this.i18n.transform('Should only needed for apps that enforce SSL'), + ) + } + return { url, access, diff --git a/web/projects/ui/src/app/routes/portal/routes/notifications/item.component.ts b/web/projects/ui/src/app/routes/portal/routes/notifications/item.component.ts index 136d2a8cf..29d27b7f0 100644 --- a/web/projects/ui/src/app/routes/portal/routes/notifications/item.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/notifications/item.component.ts @@ -1,119 +1,131 @@ -import { TuiLineClamp } from '@taiga-ui/kit' import { CommonModule } from '@angular/common' import { ChangeDetectionStrategy, Component, + computed, inject, - Input, + input, } from '@angular/core' +import { toObservable, toSignal } from '@angular/core/rxjs-interop' import { RouterLink } from '@angular/router' -import { T } from '@start9labs/start-sdk' -import { tuiPure } from '@taiga-ui/cdk' +import { i18nPipe } from '@start9labs/shared' import { TuiIcon, TuiLink } from '@taiga-ui/core' +import { TuiAvatar, TuiLineClamp } from '@taiga-ui/kit' import { PatchDB } from 'patch-db-client' -import { first, Observable } from 'rxjs' +import { EMPTY, first, switchMap } from 'rxjs' import { ServerNotification } from 'src/app/services/api/api.types' import { NotificationService } from 'src/app/services/notification.service' import { DataModel } from 'src/app/services/patch-db/data-model' -import { toRouterLink } from 'src/app/utils/to-router-link' -import { i18nPipe } from '@start9labs/shared' @Component({ selector: '[notificationItem]', template: ` - - - {{ notificationItem.createdAt | date: 'medium' }} - - - - {{ notificationItem.title }} - - - @if (manifest$ | async; as manifest) { - - {{ manifest.title }} - - } @else if (notificationItem.packageId) { - {{ notificationItem.packageId }} - } @else { - - - } - - - - @if (overflow) { - - } - @if ([1, 2].includes(notificationItem.code)) { - - } - + @if (notificationItem(); as item) { + + + {{ item.createdAt | date: 'MMM d, y, h:mm a' }} + + + + {{ item.title }} + + + @if (pkg(); as pkg) { + @if (pkg.stateInfo.manifest; as manifest) { + + + + } @else { + {{ item.packageId || '-' }} + } + } @else { + - + } + + + + @if (overflow) { + + } + @if ([1, 2].includes(item.code)) { + + } + + } `, changeDetection: ChangeDetectionStrategy.OnPush, host: { - '[class._new]': '!notificationItem.seen', + '[class._new]': '!notificationItem()?.seen', }, styles: ` - @use '@taiga-ui/core/styles/taiga-ui-local' as taiga; - - :host { - grid-template-columns: 1fr; - - &._new td { - font-weight: bold; - color: var(--tui-text-primary); + :host._new td { + font-weight: bold; + color: var(--tui-text-primary); + } - &.checkbox { - box-shadow: inset 0.25rem 0 var(--tui-text-action); - } - } + .title { + width: 13rem; } - tui-icon { - vertical-align: text-top; - align-self: center; + .service { + width: 4.25rem; + text-align: center; + grid-column: 2; + grid-row: 1 / 3; + place-content: center; } - button { - position: relative; + tui-icon { + font-size: 1rem; + vertical-align: sub; } td { - padding: 0.25rem; - vertical-align: top; color: var(--tui-text-secondary); - } + grid-column: 1; - .checkbox { - padding-top: 0.4rem; + &:first-child { + width: 12rem; + padding-inline-start: 2.5rem; + white-space: nowrap; + } + + &:last-child { + grid-column: 1 / 3; + } } :host-context(tui-root._mobile) { - gap: 0.5rem; - padding: 0.75rem 1rem !important; - - .checkbox { - @include taiga.fullsize(); + :host { + grid-template-columns: 1fr 2rem; + user-select: none; + gap: 0.5rem; } - .date { - order: 1; + td:first-child { + padding: 0; + font: var(--tui-font-text-s); color: var(--tui-text-secondary); + margin-block-end: -0.25rem; } .title { @@ -121,59 +133,65 @@ import { i18nPipe } from '@start9labs/shared' font-size: 1.2em; display: flex; align-items: center; - gap: 0.5rem; + gap: 0.375rem; } - .service:not(:has(a)) { - display: none; + .service { + width: auto; + + &:not(:has(a)) { + display: none; + } } - } - :host-context(tui-root._mobile table:has(:checked)) tui-icon { - opacity: 0; + :host-context(table:has(:checked)) tui-icon { + opacity: 0; + } } `, - imports: [CommonModule, RouterLink, TuiLineClamp, TuiLink, TuiIcon, i18nPipe], + imports: [ + CommonModule, + RouterLink, + TuiLineClamp, + TuiLink, + TuiIcon, + i18nPipe, + TuiAvatar, + ], }) export class NotificationItemComponent { private readonly patch = inject>(PatchDB) readonly service = inject(NotificationService) - @Input({ required: true }) notificationItem!: ServerNotification - - overflow = false + readonly notificationItem = input>() - @tuiPure - get manifest$(): Observable { - return this.patch - .watch$( - 'packageData', - this.notificationItem.packageId || '', - 'stateInfo', - 'manifest', - ) - .pipe(first()) - } + readonly color = computed((item = this.notificationItem()) => + item ? this.service.getColor(item) : '', + ) - get color(): string { - return this.service.getColor(this.notificationItem) - } + readonly icon = computed((item = this.notificationItem()) => + item ? this.service.getIcon(item) : '', + ) - get icon(): string { - return this.service.getIcon(this.notificationItem) - } + readonly pkg = toSignal( + toObservable(this.notificationItem).pipe( + switchMap(item => + item + ? this.patch.watch$('packageData', item.packageId || '').pipe(first()) + : EMPTY, + ), + ), + ) - getLink(id: string) { - return toRouterLink(id) - } + overflow = false - onClick() { + onClick(item: ServerNotification) { if (this.overflow) { - this.service.viewModal(this.notificationItem, true) - this.notificationItem.seen = true - } else if ([1, 2].includes(this.notificationItem.code)) { - this.service.viewModal(this.notificationItem) - this.notificationItem.seen = true + this.service.viewModal(item, true) + item.seen = true + } else if ([1, 2].includes(item.code)) { + this.service.viewModal(item) + item.seen = true } } } diff --git a/web/projects/ui/src/app/routes/portal/routes/notifications/notifications.component.ts b/web/projects/ui/src/app/routes/portal/routes/notifications/notifications.component.ts index 973d6e2ef..d3f2ee98a 100644 --- a/web/projects/ui/src/app/routes/portal/routes/notifications/notifications.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/notifications/notifications.component.ts @@ -1,15 +1,24 @@ -import { NgTemplateOutlet } from '@angular/common' import { ChangeDetectionStrategy, Component, inject, OnInit, signal, + viewChild, } from '@angular/core' import { ActivatedRoute, Router } from '@angular/router' -import { ErrorService, i18nPipe, isEmptyObject } from '@start9labs/shared' -import { TuiButton, TuiDataList, TuiDropdown } from '@taiga-ui/core' -import { RR, ServerNotifications } from 'src/app/services/api/api.types' +import { + ErrorService, + i18nPipe, + isEmptyObject, + LoadingService, +} from '@start9labs/shared' +import { TuiButton } from '@taiga-ui/core' +import { + RR, + ServerNotification, + ServerNotifications, +} from 'src/app/services/api/api.types' import { ApiService } from 'src/app/services/api/embassy-api.service' import { NotificationService } from 'src/app/services/notification.service' import { TitleDirective } from 'src/app/services/title.service' @@ -17,55 +26,23 @@ import { NotificationsTableComponent } from './table.component' @Component({ template: ` - - {{ 'Notifications' | i18n }} - - + {{ 'Notifications' | i18n }}
{{ 'Notifications' | i18n }} - -
-
- - - - - + {{ 'Delete selected' | i18n }} - + +
`, styles: ` @@ -85,80 +62,59 @@ import { NotificationsTableComponent } from './table.component' `, host: { class: 'g-page' }, changeDetection: ChangeDetectionStrategy.OnPush, - imports: [ - TuiDropdown, - TuiButton, - TuiDataList, - NotificationsTableComponent, - TitleDirective, - i18nPipe, - NgTemplateOutlet, - ], + imports: [TuiButton, NotificationsTableComponent, TitleDirective, i18nPipe], }) export default class NotificationsComponent implements OnInit { private readonly router = inject(Router) private readonly route = inject(ActivatedRoute) + private readonly loader = inject(LoadingService) readonly service = inject(NotificationService) readonly api = inject(ApiService) readonly errorService = inject(ErrorService) - readonly notifications = signal(undefined) + readonly notifications = signal(null) + + protected tableNotifications = + viewChild>>('table') ngOnInit() { this.route.queryParams.subscribe(params => { this.router.navigate([], { relativeTo: this.route, queryParams: {} }) if (isEmptyObject(params)) { - this.getMore({}) + this.getMore({}).then(() => { + const latest = this.notifications()?.at(0) + if (latest) { + this.service.markSeenAll(latest.id) + } + }) } }) } async getMore(params: RR.GetNotificationsReq) { try { - this.notifications.set(undefined) + this.notifications.set(null) this.notifications.set(await this.api.getNotifications(params)) } catch (e: any) { this.errorService.handleError(e) } } - markSeen( - current: ServerNotifications = [], - toUpdate: ServerNotifications = [], - ) { - this.notifications.set( - current.map(c => ({ - ...c, - seen: toUpdate.some(n => n.id === c.id) || c.seen, - })), - ) - - this.service.markSeen(toUpdate) - } - - markUnseen( - current: ServerNotifications = [], - toUpdate: ServerNotifications = [], - ) { - this.notifications.set( - current.map(c => ({ - ...c, - seen: c.seen && !toUpdate.some(n => n.id === c.id), - })), - ) + async remove(all: ServerNotifications) { + const ids = + this.tableNotifications() + ?.selected() + .map(n => n.id) || [] + const loader = this.loader.open('Deleting').subscribe() - this.service.markUnseen(toUpdate) - } - - remove( - current: ServerNotifications = [], - toDelete: ServerNotifications = [], - ) { - this.notifications.set( - current.filter(c => !toDelete.some(n => n.id === c.id)), - ) - - this.service.remove(toDelete) + try { + await this.api.deleteNotifications({ ids }) + this.notifications.set(all.filter(n => !ids.includes(n.id))) + } catch (e: any) { + this.errorService.handleError(e) + } finally { + loader.unsubscribe() + } } } diff --git a/web/projects/ui/src/app/routes/portal/routes/notifications/table.component.ts b/web/projects/ui/src/app/routes/portal/routes/notifications/table.component.ts index e4e438fac..87fb1f4c5 100644 --- a/web/projects/ui/src/app/routes/portal/routes/notifications/table.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/notifications/table.component.ts @@ -1,89 +1,85 @@ -import { TUI_IS_MOBILE } from '@taiga-ui/cdk' import { TuiCheckbox, TuiSkeleton } from '@taiga-ui/kit' import { ChangeDetectionStrategy, Component, - inject, - Input, + computed, + input, OnChanges, signal, } from '@angular/core' import { FormsModule } from '@angular/forms' -import { - ServerNotification, - ServerNotifications, -} from 'src/app/services/api/api.types' +import { ServerNotification } from 'src/app/services/api/api.types' +import { TableComponent } from 'src/app/routes/portal/components/table.component' import { NotificationItemComponent } from './item.component' import { i18nPipe } from '@start9labs/shared' @Component({ - selector: 'table[notifications]', + selector: '[notifications]', template: ` - - - + + + @for (not of notifications(); track not) { + - - - - - - - - - @if (notifications) { - @for (notification of notifications; track $index) { - - - - } @empty { - - - - } - } @else { - @for (row of ['', '']; track $index) { + + } @empty { + @if (notifications()) { - + + } @else { + @for (i of ['', '']; track $index) { + + + + } } } - +
+ + {{ 'Date' | i18n }} +
{{ 'Date' | i18n }}{{ 'Title' | i18n }}{{ 'Service' | i18n }}{{ 'Message' | i18n }}
{{ 'No notifications' | i18n }}
-
{{ 'Loading' | i18n }}
-
{{ 'No notifications' | i18n }}
+
{{ 'Loading' | i18n }}
+
`, styles: ` - :host-context(tui-root._mobile) { - margin: 0 -1rem; + input { + position: absolute; + top: 50%; + left: 0.75rem; + transform: translateY(-50%); + } + td:only-child { + text-align: center; + } + + :host-context(tui-root._mobile) { input { position: absolute; - top: 0.875rem; - left: 1rem; + top: 2.875rem; + left: 0; z-index: 1; pointer-events: none; } @@ -100,40 +96,30 @@ import { i18nPipe } from '@start9labs/shared' NotificationItemComponent, TuiSkeleton, i18nPipe, + TableComponent, ], }) -export class NotificationsTableComponent implements OnChanges { - @Input() notifications?: ServerNotifications +export class NotificationsTableComponent> + implements OnChanges +{ + readonly notifications = input(null) - get all(): boolean | null { - if (!this.notifications?.length || !this.selected().length) { - return false - } - - if (this.notifications?.length === this.selected().length) { - return true - } - - return null - } - - readonly selected = signal([]) + readonly selected = signal([]) + readonly all = computed( + () => + !!this.selected()?.length && + (this.selected().length === this.notifications()?.length || null), + ) ngOnChanges() { this.selected.set([]) } - onAll(selected: boolean) { - this.selected.set((selected && this.notifications) || []) - } - - onToggle(notification: ServerNotification, event?: Event) { - event?.stopPropagation() - - if (this.selected().some(s => s.id === notification.id)) { - this.selected.update(value => value.filter(s => s.id !== notification.id)) + onToggle(notification: T) { + if (this.selected().includes(notification)) { + this.selected.update(selected => selected.filter(s => s !== notification)) } else { - this.selected.update(value => [...value, notification]) + this.selected.update(selected => [...selected, notification]) } } } diff --git a/web/projects/ui/src/app/routes/portal/routes/system/routes/sessions/table.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/sessions/table.component.ts index c4c6a0390..32f467eb9 100644 --- a/web/projects/ui/src/app/routes/portal/routes/system/routes/sessions/table.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/system/routes/sessions/table.component.ts @@ -85,8 +85,6 @@ import { i18nPipe } from '@start9labs/shared' `, styles: ` - @use '@taiga-ui/core/styles/taiga-ui-local' as taiga; - td { position: relative; width: 25%; diff --git a/web/projects/ui/src/app/routes/portal/routes/system/routes/ssh/table.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/ssh/table.component.ts index 6a50b7d91..b6723c245 100644 --- a/web/projects/ui/src/app/routes/portal/routes/system/routes/ssh/table.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/system/routes/ssh/table.component.ts @@ -69,8 +69,6 @@ import { SSHKey } from 'src/app/services/api/api.types' `, styles: ` - @use '@taiga-ui/core/styles/taiga-ui-local' as taiga; - td { position: relative; diff --git a/web/projects/ui/src/app/services/api/api.fixures.ts b/web/projects/ui/src/app/services/api/api.fixures.ts index 2020f31bc..70baeee2c 100644 --- a/web/projects/ui/src/app/services/api/api.fixures.ts +++ b/web/projects/ui/src/app/services/api/api.fixures.ts @@ -1598,6 +1598,8 @@ export namespace Mock { option1: 'option1', option2: 'option2', option3: 'option3', + option4: + 'https://qwertyuiopasdfghjklzxcvbnmqwertyuiopasdfghjklzxcvbnm12345.onion', }, disabled: ['option2'], })), @@ -1610,6 +1612,7 @@ export namespace Mock { default: 7, integer: false, units: 'BTC', + placeholder: 'Is it 237?', min: -100, max: 100, }), diff --git a/web/projects/ui/src/app/services/notification.service.ts b/web/projects/ui/src/app/services/notification.service.ts index edb109b6f..e6721e9b3 100644 --- a/web/projects/ui/src/app/services/notification.service.ts +++ b/web/projects/ui/src/app/services/notification.service.ts @@ -6,12 +6,9 @@ import { MARKDOWN, } from '@start9labs/shared' import { PatchDB } from 'patch-db-client' -import { firstValueFrom, merge, of, shareReplay, Subject } from 'rxjs' +import { merge, of, shareReplay, Subject } from 'rxjs' import { REPORT } from 'src/app/components/backup-report.component' -import { - ServerNotification, - ServerNotifications, -} from 'src/app/services/api/api.types' +import { ServerNotification } from 'src/app/services/api/api.types' import { ApiService } from 'src/app/services/api/embassy-api.service' import { DataModel } from 'src/app/services/patch-db/data-model' @@ -28,16 +25,6 @@ export class NotificationService { this.localUnreadCount$, ).pipe(shareReplay(1)) - async markSeen(notifications: ServerNotifications) { - const ids = notifications.filter(n => !n.seen).map(n => n.id) - - this.updateCount(-ids.length) - - this.api - .markSeenNotifications({ ids }) - .catch(e => this.errorService.handleError(e)) - } - async markSeenAll(latestId: number) { this.localUnreadCount$.next(0) @@ -46,24 +33,6 @@ export class NotificationService { .catch(e => this.errorService.handleError(e)) } - async markUnseen(notifications: ServerNotifications) { - const ids = notifications.filter(n => n.seen).map(n => n.id) - - this.updateCount(ids.length) - - this.api - .markUnseenNotifications({ ids }) - .catch(e => this.errorService.handleError(e)) - } - - async remove(notifications: ServerNotifications): Promise { - this.updateCount(-notifications.filter(n => !n.seen).length) - - this.api - .deleteNotifications({ ids: notifications.map(n => n.id) }) - .catch(e => this.errorService.handleError(e)) - } - getColor(notification: ServerNotification): string { switch (notification.level) { case 'info': @@ -95,7 +64,6 @@ export class NotificationService { viewModal(notification: ServerNotification, full = false) { const { data, createdAt, code, title, message } = notification - this.markSeen([notification]) if (code === 1) { // Backup Report @@ -116,10 +84,4 @@ export class NotificationService { .subscribe() } } - - private async updateCount(toAdjust: number) { - const currentCount = await firstValueFrom(this.unreadCount$) - - this.localUnreadCount$.next(Math.max(currentCount + toAdjust, 0)) - } }