diff --git a/web/projects/ui/src/app/app.config.ts b/web/projects/ui/src/app/app.config.ts
index 8b4436073..3bd32bd93 100644
--- a/web/projects/ui/src/app/app.config.ts
+++ b/web/projects/ui/src/app/app.config.ts
@@ -37,7 +37,7 @@ import {
VERSION,
WorkspaceConfig,
} from '@start9labs/shared'
-import { tuiObfuscateOptionsProvider } from '@taiga-ui/cdk'
+import { TUI_WINDOW_SIZE, tuiObfuscateOptionsProvider } from '@taiga-ui/cdk'
import {
provideTaiga,
TUI_DIALOGS_CLOSE,
@@ -67,11 +67,12 @@ import {
PATCH_CACHE,
PatchDbSource,
} from 'src/app/services/patch-db/patch-db-source'
+import { PluginsService } from 'src/app/services/plugins.service'
import { StateService } from 'src/app/services/state.service'
import { StorageService } from 'src/app/services/storage.service'
import {
- DateTransformer,
DatetimeTransformer,
+ DateTransformer,
} from 'src/app/utils/value-transformers'
import { environment } from 'src/environments/environment'
@@ -185,5 +186,9 @@ export const APP_CONFIG: ApplicationConfig = {
desktopLarge: Infinity,
},
},
+ {
+ provide: TUI_WINDOW_SIZE,
+ useExisting: PluginsService,
+ },
],
}
diff --git a/web/projects/ui/src/app/routes/portal/components/header/header.component.ts b/web/projects/ui/src/app/routes/portal/components/header/header.component.ts
index 28ef803f7..d24076f51 100644
--- a/web/projects/ui/src/app/routes/portal/components/header/header.component.ts
+++ b/web/projects/ui/src/app/routes/portal/components/header/header.component.ts
@@ -16,6 +16,7 @@ import { HeaderStatusComponent } from './status.component'
template: `
diff --git a/web/projects/ui/src/app/routes/portal/components/plugins.component.ts b/web/projects/ui/src/app/routes/portal/components/plugins.component.ts
new file mode 100644
index 000000000..c14e6e2ba
--- /dev/null
+++ b/web/projects/ui/src/app/routes/portal/components/plugins.component.ts
@@ -0,0 +1,100 @@
+import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
+import { FormsModule } from '@angular/forms'
+import { WA_IS_MOBILE } from '@ng-web-apis/platform'
+import { TuiAnimated } from '@taiga-ui/cdk'
+import { TuiButton, TuiPopup } from '@taiga-ui/core'
+import { ResizerComponent } from 'src/app/routes/portal/components/resizer.component'
+import { PluginsService } from 'src/app/services/plugins.service'
+
+@Component({
+ selector: 'app-plugins',
+ template: `
+
+
+
+ `,
+ styles: `
+ :host {
+ float: inline-end;
+ margin-inline-end: 0.5rem;
+ }
+
+ [tuiIconButton] {
+ background: transparent;
+ }
+
+ aside {
+ position: fixed;
+ inset: 0 0 0 calc(320px + (100% - 640px) * var(--plugins));
+ display: flex;
+ place-content: center;
+ place-items: center;
+ margin: var(--bumper) var(--bumper) var(--bumper) 0;
+ background: color-mix(in hsl, var(--start9-base-2) 75%, transparent);
+ background-image: linear-gradient(
+ transparent,
+ var(--tui-background-base)
+ );
+ backdrop-filter: blur(1rem);
+ border-radius: var(--bumper);
+
+ --tui-from: translateX(100%);
+
+ &._mobile {
+ inset-inline-start: 20%;
+ box-shadow:
+ inset 0 1px rgba(255, 255, 255, 0.25),
+ 0 0 0 100vh rgb(0 0 0 / 50%);
+
+ &::before {
+ content: '';
+ position: fixed;
+ inset: -100vh;
+ }
+
+ &.tui-enter,
+ &.tui-leave {
+ animation-name: tuiSlide, tuiFade;
+ }
+ }
+
+ &.tui-enter,
+ &.tui-leave {
+ animation-name: tuiSlide;
+ }
+ }
+ `,
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ imports: [FormsModule, TuiButton, ResizerComponent, TuiPopup, TuiAnimated],
+})
+export class PluginsComponent {
+ protected readonly mobile = inject(WA_IS_MOBILE)
+ protected readonly service = inject(PluginsService)
+
+ protected onClick(layerX: number) {
+ if (layerX < 0 && this.mobile) {
+ this.service.enabled.set(false)
+ }
+ }
+}
diff --git a/web/projects/ui/src/app/routes/portal/components/resizer.component.ts b/web/projects/ui/src/app/routes/portal/components/resizer.component.ts
new file mode 100644
index 000000000..e02e0f89d
--- /dev/null
+++ b/web/projects/ui/src/app/routes/portal/components/resizer.component.ts
@@ -0,0 +1,56 @@
+import { ChangeDetectionStrategy, Component } from '@angular/core'
+
+@Component({
+ selector: 'input[type="range"][appResizer]',
+ template: '',
+ styles: `
+ @use '@taiga-ui/styles/utils' as taiga;
+
+ :host {
+ @include taiga.transition(color);
+
+ position: fixed;
+ inset: 0 calc(320px - var(--bumper)) 0 calc(320px - 2 * var(--bumper));
+ appearance: none;
+ pointer-events: none;
+ background: none;
+ color: transparent;
+ outline: none;
+ cursor: ew-resize;
+
+ &:hover {
+ color: var(--tui-background-neutral-1-hover);
+ }
+
+ &::-webkit-slider-runnable-track {
+ block-size: 100%;
+ }
+
+ &::-webkit-slider-thumb {
+ block-size: 100%;
+ inline-size: calc(var(--bumper) * 3);
+ padding: var(--bumper);
+ appearance: none;
+ background: currentColor;
+ background-clip: content-box;
+ border: none;
+ pointer-events: auto;
+ border-radius: var(--tui-radius-l);
+ }
+
+ &::-moz-range-thumb {
+ block-size: 100%;
+ inline-size: calc(var(--bumper) * 3);
+ padding: var(--bumper);
+ appearance: none;
+ background: currentColor;
+ background-clip: content-box;
+ border: none;
+ pointer-events: auto;
+ border-radius: var(--tui-radius-l);
+ }
+ }
+ `,
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class ResizerComponent {}
diff --git a/web/projects/ui/src/app/routes/portal/portal.component.ts b/web/projects/ui/src/app/routes/portal/portal.component.ts
index 91b861124..073904924 100644
--- a/web/projects/ui/src/app/routes/portal/portal.component.ts
+++ b/web/projects/ui/src/app/routes/portal/portal.component.ts
@@ -6,6 +6,7 @@ import {
} from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop'
import { RouterOutlet } from '@angular/router'
+import { WA_IS_MOBILE } from '@ng-web-apis/platform'
import { ErrorService } from '@start9labs/shared'
import {
TuiButton,
@@ -21,15 +22,17 @@ import {
TuiProgress,
} from '@taiga-ui/kit'
import { PatchDB } from 'patch-db-client'
+import { PluginsComponent } from 'src/app/routes/portal/components/plugins.component'
import { TabsComponent } from 'src/app/routes/portal/components/tabs.component'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { OSService } from 'src/app/services/os.service'
import { DataModel } from 'src/app/services/patch-db/data-model'
+import { PluginsService } from 'src/app/services/plugins.service'
import { HeaderComponent } from './components/header/header.component'
@Component({
template: `
-
+
@@ -67,19 +70,47 @@ import { HeaderComponent } from './components/header/header.component'
styles: `
@use '@taiga-ui/styles/utils' as taiga;
+ @keyframes open {
+ from {
+ inline-size: 100%;
+ }
+
+ to {
+ inline-size: calc(320px + (100% - 640px) * var(--plugins));
+ }
+ }
+
:host {
- height: 100%;
+ @include taiga.transition(inline-size);
+
+ block-size: 100%;
+ inline-size: 100%;
display: flex;
flex-direction: column;
- // @TODO Theme
- background: url(/assets/img/background_dark.jpeg) fixed center/cover;
- &::before {
+ &._plugins {
+ inline-size: calc(320px + (100% - 640px) * var(--plugins));
+ animation: open var(--tui-duration) ease-in-out;
+ transition: none;
+
+ app-tabs {
+ inline-size: calc(100% - var(--bumper));
+ }
+ }
+
+ &::before,
+ &::after {
content: '';
position: fixed;
inset: 0;
backdrop-filter: blur(0.5rem);
}
+
+ &::after {
+ z-index: -1;
+ // @TODO Theme
+ background: url(/assets/img/background_dark.jpeg) fixed center/cover;
+ }
}
main {
@@ -101,6 +132,10 @@ import { HeaderComponent } from './components/header/header.component'
text-wrap: balance;
}
`,
+ host: {
+ '[class._plugins]': '!mobile && plugins.enabled()',
+ '[style.--plugins]': 'plugins.size() / 100',
+ },
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
RouterOutlet,
@@ -114,6 +149,7 @@ import { HeaderComponent } from './components/header/header.component'
TuiButton,
TuiPopup,
TuiCell,
+ PluginsComponent,
],
})
export class PortalComponent {
@@ -122,6 +158,8 @@ export class PortalComponent {
private readonly patch = inject>(PatchDB)
private readonly api = inject(ApiService)
+ readonly mobile = inject(WA_IS_MOBILE)
+ readonly plugins = inject(PluginsService)
readonly name = toSignal(this.patch.watch$('serverInfo', 'name'))
readonly update = toSignal(inject(OSService).updating$)
readonly bar = signal(true)
diff --git a/web/projects/ui/src/app/services/plugins.service.ts b/web/projects/ui/src/app/services/plugins.service.ts
new file mode 100644
index 000000000..e1a6f58ea
--- /dev/null
+++ b/web/projects/ui/src/app/services/plugins.service.ts
@@ -0,0 +1,37 @@
+import { inject, Injectable, INJECTOR, Injector, signal } from '@angular/core'
+import { toObservable } from '@angular/core/rxjs-interop'
+import { WA_IS_MOBILE } from '@ng-web-apis/platform'
+import { TUI_WINDOW_SIZE } from '@taiga-ui/cdk'
+import { TUI_MEDIA } from '@taiga-ui/core'
+import { combineLatest, map, Observable } from 'rxjs'
+
+@Injectable({ providedIn: 'root' })
+export class PluginsService extends Observable {
+ public readonly enabled = signal(false)
+ public readonly size = signal(100)
+
+ // @ts-expect-error triggering TUI_WINDOW_SIZE default factory
+ private readonly window = Injector.create({
+ parent: inject(INJECTOR),
+ providers: [{ provide: TUI_WINDOW_SIZE }],
+ }).get(TUI_WINDOW_SIZE)
+
+ private readonly media = inject(TUI_MEDIA)
+ private readonly stream = inject(WA_IS_MOBILE)
+ ? this.window
+ : combineLatest([
+ this.window,
+ toObservable(this.size),
+ toObservable(this.enabled),
+ ]).pipe(
+ map(([window, size, enabled]) =>
+ window.width < this.media.mobile || !enabled
+ ? window
+ : { ...window, width: 320 + (window.width - 640) * (size / 100) },
+ ),
+ )
+
+ constructor() {
+ super(subscriber => this.stream.subscribe(subscriber))
+ }
+}