refactor: gateways page

This commit is contained in:
waterplea
2025-08-05 17:39:48 +07:00
parent 32999fc55f
commit 86dbf26253
6 changed files with 186 additions and 153 deletions

View File

@@ -1,8 +1,9 @@
<label tuiInputFiles [(ngModel)]="value">
<label tuiInputFiles>
<input
tuiInputFiles
[invalid]="invalid"
[accept]="spec.extensions.join(',')"
[(ngModel)]="value"
(blur)="onFocus(false)"
/>
<ng-template let-drop>
@@ -17,17 +18,25 @@
}
</div>
@if (value) {
<tui-tag
class="file"
size="l"
[value]="value.name"
[removable]="true"
(edited)="value = null"
/>
<tui-chip>
{{ value.name }}
<button
tuiIconButton
type="button"
appearance="icon"
size="xs"
iconStart="@tui.x"
(click.stop)="value = null"
>
{{ 'Delete' | i18n }}
</button>
</tui-chip>
} @else {
<small>{{ 'Click or drop file here' | i18n }}</small>
}
</div>
<div class="drop" [class.drop_hidden]="!drop">{{ 'Drop file here' | i18n }}</div>
<div class="drop" [class.drop_hidden]="!drop">
{{ 'Drop file here' | i18n }}
</div>
</ng-template>
</label>

View File

@@ -40,7 +40,8 @@ small {
margin-left: auto;
}
tui-tag {
tui-chip {
z-index: 1;
margin: -0.25rem -0.25rem -0.25rem auto;
pointer-events: auto;
}

View File

@@ -2,6 +2,7 @@ import { CommonModule } from '@angular/common'
import { NgModule } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { MaskitoDirective } from '@maskito/angular'
import { i18nPipe } from '@start9labs/shared'
import { TuiMapperPipe, TuiValueChanges } from '@taiga-ui/cdk'
import {
TuiAppearance,
@@ -14,6 +15,7 @@ import {
TuiNumberFormat,
} from '@taiga-ui/core'
import {
TuiChip,
TuiElasticContainer,
TuiFieldErrorPipe,
TuiFiles,
@@ -28,11 +30,11 @@ import {
TuiInputTimeModule,
TuiMultiSelectModule,
TuiSelectModule,
TuiTagModule,
TuiTextareaModule,
TuiTextfieldControllerModule,
} from '@taiga-ui/legacy'
import { ControlDirective } from './control.directive'
import { FilterHiddenPipe } from './filter-hidden.pipe'
import { FormArrayComponent } from './form-array/form-array.component'
import { FormColorComponent } from './form-color/form-color.component'
import { FormControlComponent } from './form-control/form-control.component'
@@ -49,8 +51,6 @@ import { FormToggleComponent } from './form-toggle/form-toggle.component'
import { FormUnionComponent } from './form-union/form-union.component'
import { HintPipe } from './hint.pipe'
import { MustachePipe } from './mustache.pipe'
import { FilterHiddenPipe } from './filter-hidden.pipe'
import { i18nPipe } from '@start9labs/shared'
@NgModule({
imports: [
@@ -66,7 +66,7 @@ import { i18nPipe } from '@start9labs/shared'
TuiSwitch,
TuiTooltip,
...TuiHint,
TuiTagModule,
TuiChip,
TuiButton,
...TuiExpand,
TuiTextfieldControllerModule,

View File

@@ -43,7 +43,7 @@ import { GatewayWithID } from './item.component'
appearance="action-grayscale"
iconEnd="@tui.external-link"
[pseudo]="true"
[textContent]="'view instructions'"
[textContent]="'View instructions' | i18n"
></a>
</p>
</hgroup>
@@ -62,7 +62,7 @@ import { GatewayWithID } from './item.component'
Add
</button>
</header>
<div #table [gateways]="gateways$ | async"></div>
<div [gateways]="gateways$ | async"></div>
</section>
`,
changeDetection: ChangeDetectionStrategy.OnPush,

View File

@@ -1,17 +1,28 @@
import {
ChangeDetectionStrategy,
Component,
inject,
input,
output,
} from '@angular/core'
import { i18nPipe } from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
import {
DialogService,
ErrorService,
i18nPipe,
LoadingService,
} from '@start9labs/shared'
import { ISB, T } from '@start9labs/start-sdk'
import {
TuiButton,
TuiDataList,
TuiDropdown,
TuiOptGroup,
TuiTextfield,
} from '@taiga-ui/core'
import { filter } from 'rxjs'
import { FormComponent } from 'src/app/routes/portal/components/form.component'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { FormDialogService } from 'src/app/services/form-dialog.service'
import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
export type GatewayWithID = T.NetworkInterfaceInfo & {
id: string
@@ -21,79 +32,164 @@ export type GatewayWithID = T.NetworkInterfaceInfo & {
@Component({
selector: 'tr[proxy]',
template: `
<td>{{ proxy().ipInfo.name }}</td>
<td>{{ proxy().ipInfo.deviceType || '-' }}</td>
<td>
<td [style.grid-column]="'span 2'">{{ proxy().ipInfo.name }}</td>
<td class="type">{{ proxy().ipInfo.deviceType || '-' }}</td>
<td [style.order]="-2">
{{ proxy().public ? ('Public' | i18n) : ('Private' | i18n) }}
</td>
<!-- // @TODO show both LAN IPs? -->
<td>{{ proxy().ipInfo.subnets[0] }}</td>
<td>{{ proxy().ipInfo.wanIp }}</td>
<td class="lan">{{ proxy().ipInfo.subnets[0] }}</td>
<td class="wan">{{ proxy().ipInfo.wanIp }}</td>
<td>
<button
tuiIconButton
iconStart="@tui.ellipsis"
appearance="icon"
[tuiDropdown]="content"
tuiDropdown
size="s"
appearance="flat-grayscale"
iconStart="@tui.ellipsis-vertical"
[tuiAppearanceState]="open ? 'hover' : null"
[(tuiDropdownOpen)]="open"
[tuiDropdownMaxHeight]="9999"
></button>
<ng-template #content>
<tui-data-list [style.width.rem]="13">
>
{{ 'More' | i18n }}
<tui-data-list size="s" *tuiTextfieldDropdown>
<tui-opt-group>
<button
tuiOption
iconStart="@tui.pencil"
(click)="onRename.emit(proxy())"
>
<button tuiOption new iconStart="@tui.pencil" (click)="rename()">
{{ 'Rename' | i18n }}
</button>
@if (proxy().ipInfo.deviceType === 'wireguard') {
</tui-opt-group>
@if (proxy().ipInfo.deviceType === 'wireguard') {
<tui-opt-group>
<button
tuiOption
appearance="negative"
iconStart="@tui.trash-2"
(click)="onRemove.emit(proxy())"
new
iconStart="@tui.trash"
class="g-negative"
(click)="remove()"
>
{{ 'Delete' | i18n }}
</button>
}
</tui-opt-group>
</tui-opt-group>
}
</tui-data-list>
</ng-template>
</button>
</td>
`,
styles: `
td:last-child {
grid-area: 3 / span 4;
white-space: nowrap;
grid-area: 1 / 3 / 5;
align-self: center;
text-align: right;
flex-direction: row-reverse;
justify-content: flex-end;
gap: 0.5rem;
}
:host-context(tui-root._mobile) {
display: grid;
grid-template-columns: repeat(3, min-content) 1fr;
align-items: center;
padding: 1rem 0.5rem;
gap: 0.5rem;
grid-template-columns: min-content 1fr min-content;
td {
display: flex;
padding: 0;
td:first-child {
font: var(--tui-font-text-m);
font-weight: bold;
}
.type {
order: -1;
&::before {
content: '\\00A0(';
}
&::after {
content: ')';
}
}
.lan,
.wan {
grid-column: span 2;
&::before {
content: 'LAN IPs: ';
color: var(--tui-text-primary);
}
}
.wan::before {
content: 'WAN IP: ';
}
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TuiButton, i18nPipe, TuiDropdown, TuiDataList, TuiOptGroup],
imports: [
TuiButton,
TuiDropdown,
TuiDataList,
TuiOptGroup,
TuiTextfield,
i18nPipe,
],
})
export class GatewaysItemComponent {
private readonly dialog = inject(DialogService)
private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService)
private readonly api = inject(ApiService)
private readonly formDialog = inject(FormDialogService)
readonly proxy = input.required<GatewayWithID>()
onRename = output<GatewayWithID>()
onRemove = output<GatewayWithID>()
open = false
remove() {
this.dialog
.openConfirm({ label: 'Are you sure?', size: 's' })
.pipe(filter(Boolean))
.subscribe(async () => {
const loader = this.loader.open('Deleting').subscribe()
try {
await this.api.removeTunnel({ id: this.proxy().id })
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
})
}
async rename() {
const { ipInfo, id } = this.proxy()
const renameSpec = ISB.InputSpec.of({
label: ISB.Value.text({
name: 'Label',
required: true,
default: ipInfo?.name || null,
}),
})
this.formDialog.open(FormComponent, {
label: 'Rename',
data: {
spec: await configBuilderToSpec(renameSpec),
buttons: [
{
text: 'Save',
handler: (value: typeof renameSpec._TYPE) =>
this.update(id, value.label),
},
],
},
})
}
private async update(id: string, name: string): Promise<boolean> {
const loader = this.loader.open('Saving').subscribe()
try {
await this.api.updateTunnel({ id, name })
return true
} catch (e: any) {
this.errorService.handleError(e)
return false
} finally {
loader.unsubscribe()
}
}
}

View File

@@ -1,25 +1,9 @@
import {
ChangeDetectionStrategy,
Component,
inject,
input,
} from '@angular/core'
import {
DialogService,
ErrorService,
i18nPipe,
LoadingService,
} from '@start9labs/shared'
import { ISB } from '@start9labs/start-sdk'
import { ChangeDetectionStrategy, Component, input } from '@angular/core'
import { i18nPipe } from '@start9labs/shared'
import { TuiSkeleton } from '@taiga-ui/kit'
import { filter } from 'rxjs'
import { FormComponent } from 'src/app/routes/portal/components/form.component'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { FormDialogService } from 'src/app/services/form-dialog.service'
import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
import { PlaceholderComponent } from 'src/app/routes/portal/components/placeholder.component'
import { TableComponent } from 'src/app/routes/portal/components/table.component'
import { GatewayWithID } from './item.component'
import { GatewaysItemComponent } from './item.component'
import { GatewaysItemComponent, GatewayWithID } from './item.component'
@Component({
selector: '[gateways]',
@@ -35,89 +19,32 @@ import { GatewaysItemComponent } from './item.component'
]"
>
@for (proxy of gateways(); track $index) {
<tr
[proxy]="proxy"
(onRename)="rename($event)"
(onRemove)="remove($event.id)"
></tr>
<tr [proxy]="proxy"></tr>
} @empty {
<tr>
<td colspan="5">
<div [tuiSkeleton]="true">{{ 'Loading' | i18n }}</div>
@if (gateways()) {
<app-placeholder icon="@tui.door-closed-locked">
<!-- @TODO Matt finalize text and add translations -->
No gateways
</app-placeholder>
} @else {
<div [tuiSkeleton]="true">{{ 'Loading' | i18n }}</div>
}
</td>
</tr>
}
</table>
`,
styles: `
:host {
grid-column: span 6;
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TuiSkeleton, i18nPipe, TableComponent, GatewaysItemComponent],
imports: [
TuiSkeleton,
i18nPipe,
TableComponent,
GatewaysItemComponent,
PlaceholderComponent,
],
})
export class GatewaysTableComponent<T extends GatewayWithID> {
readonly gateways = input<readonly T[] | null>(null)
private readonly dialog = inject(DialogService)
private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService)
private readonly api = inject(ApiService)
private readonly formDialog = inject(FormDialogService)
remove(id: string) {
this.dialog
.openConfirm({ label: 'Are you sure?', size: 's' })
.pipe(filter(Boolean))
.subscribe(async () => {
const loader = this.loader.open('Deleting').subscribe()
try {
await this.api.removeTunnel({ id })
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
})
}
async rename(gateway: GatewayWithID) {
const renameSpec = ISB.InputSpec.of({
label: ISB.Value.text({
name: 'Label',
required: true,
default: gateway.ipInfo?.name || null,
}),
})
this.formDialog.open(FormComponent, {
label: 'Rename',
data: {
spec: await configBuilderToSpec(renameSpec),
buttons: [
{
text: 'Save',
handler: (value: typeof renameSpec._TYPE) =>
this.update(gateway.id, value.label),
},
],
},
})
}
private async update(id: string, label: string): Promise<boolean> {
const loader = this.loader.open('Saving').subscribe()
try {
await this.api.updateTunnel({ id, name: label })
return true
} catch (e: any) {
this.errorService.handleError(e)
return false
} finally {
loader.unsubscribe()
}
}
}