From b0b100effd66c1ace286f53c740c41c1573c84bd Mon Sep 17 00:00:00 2001 From: Philip Miglinci Date: Thu, 11 Jun 2026 17:05:07 +0200 Subject: [PATCH 01/16] feat: material navigation rail Signed-off-by: Philip Miglinci --- frontend/src/app/nav/nav.component.html | 62 ++++++---- frontend/src/app/nav/nav.component.scss | 155 ++++++++++++++++++++++++ frontend/src/app/nav/nav.component.ts | 46 ++++++- frontend/src/material-theme.scss | 6 - 4 files changed, 238 insertions(+), 31 deletions(-) create mode 100644 frontend/src/app/nav/nav.component.scss diff --git a/frontend/src/app/nav/nav.component.html b/frontend/src/app/nav/nav.component.html index 7754cc97..61006909 100644 --- a/frontend/src/app/nav/nav.component.html +++ b/frontend/src/app/nav/nav.component.html @@ -1,31 +1,51 @@ - + - Menu - - - folder - My files - - - link - Links - - - delete - Trash - - + {{ expanded() ? 'menu_open' : 'menu' }} + + } + diff --git a/frontend/src/app/nav/nav.component.scss b/frontend/src/app/nav/nav.component.scss new file mode 100644 index 00000000..c7c6bc1d --- /dev/null +++ b/frontend/src/app/nav/nav.component.scss @@ -0,0 +1,155 @@ +// M3 Expressive navigation rail: collapsed (96dp) <-> expanded (220dp). +// Angular Material has no rail component, so the rail anatomy is built on +// Material system variables. Spec: m3.material.io/components/navigation-rail +$rail-collapsed-width: 96px; +$rail-expanded-width: 220px; +$rail-motion: 250ms cubic-bezier(0.2, 0, 0, 1); + +.nav-rail { + width: $rail-collapsed-width; + background-color: var(--mat-sys-surface-container); + transition: width $rail-motion; + + // Edge-attached standard rail has no rounded corners (modal variant keeps them). + &.mat-drawer-side { + border-radius: 0; + } + + &.nav-rail-expanded { + width: $rail-expanded-width; + } +} + +.nav-rail-toggle { + margin: 44px 0 0 24px; +} + +.nav-rail-items { + display: flex; + flex-direction: column; + gap: 4px; + margin-top: 40px; +} + +.nav-rail-item { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + padding: 6px 0 4px; + color: var(--mat-sys-on-surface-variant); + text-decoration: none; + -webkit-tap-highlight-color: transparent; + + // Keep icon and label painted above the absolutely positioned indicator. + mat-icon, + .nav-rail-label { + position: relative; + } + + mat-icon { + margin: 4px 0; // centers the 24px icon in the 32px-high indicator + } + + &:hover .nav-rail-indicator { + background-color: color-mix(in srgb, var(--mat-sys-on-surface) 8%, transparent); + } + + &:focus-visible { + outline: none; + + .nav-rail-indicator { + outline: 2px solid var(--mat-sys-secondary); + outline-offset: 1px; + } + } + + &.active { + mat-icon { + color: var(--mat-sys-on-secondary-container); + } + + .nav-rail-label { + color: var(--mat-sys-secondary); + } + + .nav-rail-indicator { + background-color: var(--mat-sys-secondary-container); + } + + &:hover .nav-rail-indicator { + background-color: color-mix( + in srgb, + var(--mat-sys-on-secondary-container) 8%, + var(--mat-sys-secondary-container) + ); + } + } +} + +.nav-rail-indicator { + position: absolute; + top: 6px; + left: 50%; + width: 56px; + height: 32px; + transform: translateX(-50%); + border-radius: var(--mat-sys-corner-full); + transition: background-color 150ms linear; +} + +.nav-rail-label { + font: var(--mat-sys-label-medium); +} + +// Expanded rail: horizontal items with a full-width pill indicator. +.nav-rail-expanded { + .nav-rail-item { + flex-direction: row; + justify-content: flex-start; + gap: 8px; + height: 56px; + margin: 0 20px; + padding: 0 16px; + + mat-icon { + margin: 0; + } + + &.active .nav-rail-label { + color: var(--mat-sys-on-secondary-container); + } + } + + .nav-rail-indicator { + top: 0; + left: 0; + width: 100%; + height: 100%; + transform: none; + } + + .nav-rail-label { + font: var(--mat-sys-label-large); + } +} + +// Keep the page content margin in sync with the animated rail width on +// desktop (mode="side"). The sidenav's own measured margin is static, so it +// is overridden here to follow the collapse/expand transition. +.nav-shell:not(.nav-shell-handset) > .mat-drawer-content { + margin-left: $rail-collapsed-width !important; + transition: margin-left $rail-motion; +} + +.nav-shell:not(.nav-shell-handset).nav-shell-expanded > .mat-drawer-content { + margin-left: $rail-expanded-width !important; +} + +@media (prefers-reduced-motion: reduce) { + .nav-rail, + .nav-shell > .mat-drawer-content { + transition: none; + } +} diff --git a/frontend/src/app/nav/nav.component.ts b/frontend/src/app/nav/nav.component.ts index c7534779..e96d1317 100644 --- a/frontend/src/app/nav/nav.component.ts +++ b/frontend/src/app/nav/nav.component.ts @@ -1,28 +1,44 @@ import { BreakpointObserver, Breakpoints } from '@angular/cdk/layout'; -import { Component, computed, inject } from '@angular/core'; +import { + ChangeDetectionStrategy, + Component, + computed, + inject, + signal, + viewChild, +} from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { NgOptimizedImage } from '@angular/common'; import { MatButtonModule } from '@angular/material/button'; +import { MatRippleModule } from '@angular/material/core'; import { MatIconModule } from '@angular/material/icon'; -import { MatListModule } from '@angular/material/list'; import { MatMenuModule } from '@angular/material/menu'; -import { MatSidenavModule } from '@angular/material/sidenav'; +import { MatSidenav, MatSidenavModule } from '@angular/material/sidenav'; import { MatToolbarModule } from '@angular/material/toolbar'; import { Router, RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router'; import { AuthService } from '~/frontend/services/auth.service'; import { UserAvatarComponent } from '~/frontend/users/user-avatar.component'; +interface NavItem { + label: string; + icon: string; + route: string; + exact: boolean; +} + @Component({ selector: 'app-nav', templateUrl: './nav.component.html', + styleUrl: './nav.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, imports: [ NgOptimizedImage, MatToolbarModule, MatButtonModule, MatSidenavModule, - MatListModule, MatIconModule, MatMenuModule, + MatRippleModule, RouterOutlet, RouterLink, RouterLinkActive, @@ -34,11 +50,33 @@ export class NavComponent { private readonly auth = inject(AuthService); private readonly router = inject(Router); + private readonly drawer = viewChild.required(MatSidenav); + private readonly handsetObserver = toSignal(this.breakpointObserver.observe(Breakpoints.Handset)); protected readonly isHandset = computed(() => this.handsetObserver()?.matches ?? false); protected readonly userName = computed(() => this.auth.userName() ?? 'User'); protected readonly isAdmin = this.auth.isAdmin; + protected readonly navItems: readonly NavItem[] = [ + { label: 'My files', icon: 'folder', route: '/', exact: true }, + { label: 'Links', icon: 'link', route: '/links', exact: false }, + { label: 'Trash', icon: 'delete', route: '/trash', exact: false }, + ]; + + protected readonly expanded = signal(false); + // On handset the rail is shown as a modal expanded rail (replaces the drawer). + protected readonly railExpanded = computed(() => this.expanded() || this.isHandset()); + + protected toggleExpanded() { + this.expanded.update((value) => !value); + } + + protected onNavItemClick() { + if (this.isHandset()) { + this.drawer().close(); + } + } + logout() { this.auth.logout(); this.router.navigate(['/login']); diff --git a/frontend/src/material-theme.scss b/frontend/src/material-theme.scss index 0565136c..78c5f712 100644 --- a/frontend/src/material-theme.scss +++ b/frontend/src/material-theme.scss @@ -19,12 +19,6 @@ html { density: 0, ) ); - - @include mat.sidenav-overrides( - ( - container-width: calc(50 * var(--spacing, 0.25rem)), - ) - ); } body { From 1722e8fbbaa223a9c8072dd52bc0cd2f16b89388 Mon Sep 17 00:00:00 2001 From: Philip Miglinci Date: Tue, 16 Jun 2026 12:18:56 +0200 Subject: [PATCH 02/16] chore: add sidbar rails Signed-off-by: Philip Miglinci --- .../app/dashboard/dashboard.component.html | 2 +- .../links-list/links-list.component.html | 14 +- frontend/src/app/nav/nav.component.html | 58 ++++--- frontend/src/app/nav/nav.component.scss | 143 +++++++++++++++++- frontend/src/app/trash/trash.component.html | 24 +-- frontend/src/material-theme.scss | 4 +- frontend/src/styles.css | 13 +- package.json | 1 + pnpm-lock.yaml | 8 + 9 files changed, 217 insertions(+), 50 deletions(-) diff --git a/frontend/src/app/dashboard/dashboard.component.html b/frontend/src/app/dashboard/dashboard.component.html index 6cb57277..c3e646d9 100644 --- a/frontend/src/app/dashboard/dashboard.component.html +++ b/frontend/src/app/dashboard/dashboard.component.html @@ -1,5 +1,5 @@
- - +
+ -
- @if (!isHandset()) { - - } + - + @if (isHandset()) { } - Datei - Datei + - @@ -78,7 +97,8 @@ - - + diff --git a/frontend/src/app/nav/nav.component.scss b/frontend/src/app/nav/nav.component.scss index c7c6bc1d..1772a132 100644 --- a/frontend/src/app/nav/nav.component.scss +++ b/frontend/src/app/nav/nav.component.scss @@ -20,15 +20,43 @@ $rail-motion: 250ms cubic-bezier(0.2, 0, 0, 1); } } +// Rail header: brand logo above the collapse/expand toggle (M3 places the +// logo above the optional menu button and FAB). The logo, toggle and item +// icons are anchored to the same horizontal centre (48px) in both the +// collapsed and expanded rail, so expanding only reveals the labels and grows +// the width — the icons and logo never jump. +.nav-rail-header { + display: flex; + flex-direction: column; + gap: 12px; + padding-top: 20px; +} + +.nav-rail-logo { + display: flex; + align-items: center; + gap: 12px; + height: 48px; + padding-left: 32px; // 32px logo -> centred at 48px when collapsed +} + +.nav-rail-wordmark { + display: none; + font: var(--mat-sys-title-medium); + color: var(--mat-sys-on-surface); + white-space: nowrap; +} + .nav-rail-toggle { - margin: 44px 0 0 24px; + align-self: flex-start; + margin-left: 24px; // 48px icon button -> centred at 48px when collapsed } .nav-rail-items { display: flex; flex-direction: column; gap: 4px; - margin-top: 40px; + margin-top: 24px; } .nav-rail-item { @@ -49,7 +77,7 @@ $rail-motion: 250ms cubic-bezier(0.2, 0, 0, 1); } mat-icon { - margin: 4px 0; // centers the 24px icon in the 32px-high indicator + margin: 4px 0; // centres the 24px icon in the 32px-high indicator } &:hover .nav-rail-indicator { @@ -88,13 +116,14 @@ $rail-motion: 250ms cubic-bezier(0.2, 0, 0, 1); } } +// Collapsed: a 56x32 pill centred on the icon. Expanded: the indicator fills +// the row behind the icon and label. .nav-rail-indicator { position: absolute; top: 6px; - left: 50%; - width: 56px; + left: calc(50% - 28px); // centre the 56px pill without a transform, which + width: 56px; // would otherwise offset matRipple's centered ripple height: 32px; - transform: translateX(-50%); border-radius: var(--mat-sys-corner-full); transition: background-color 150ms linear; } @@ -103,8 +132,13 @@ $rail-motion: 250ms cubic-bezier(0.2, 0, 0, 1); font: var(--mat-sys-label-medium); } -// Expanded rail: horizontal items with a full-width pill indicator. +// Expanded rail: icons and labels sit in a row with a full-width pill +// indicator. The logo and toggle stay anchored, so only the items reflow. .nav-rail-expanded { + .nav-rail-wordmark { + display: inline; + } + .nav-rail-item { flex-direction: row; justify-content: flex-start; @@ -127,7 +161,6 @@ $rail-motion: 250ms cubic-bezier(0.2, 0, 0, 1); left: 0; width: 100%; height: 100%; - transform: none; } .nav-rail-label { @@ -135,6 +168,100 @@ $rail-motion: 250ms cubic-bezier(0.2, 0, 0, 1); } } +// Top app bar shares the rail's surface tone so the navigation chrome reads as +// one continuous surface wrapping the content (M3 top app bars are surface, not +// a saturated primary). +.nav-topbar { + gap: 8px; + background-color: var(--mat-sys-surface-container); + color: var(--mat-sys-on-surface); +} + +// The sidenav container sits behind the rail and content; give it the same +// grey so no light background shows through while the rail width animates. +.nav-shell { + background-color: var(--mat-sys-surface-container); +} + +// The content region behind the page is the same surface-container grey as the +// rail/top bar, so the inset white content card reads as if framed by a grey +// border with rounded corners. +.nav-shell .mat-drawer-content { + display: flex; + flex-direction: column; + background-color: var(--mat-sys-surface-container); + + // Thin, unobtrusive scrollbar instead of a reserved gutter (which showed as + // a grey margin on pages without a scrollbar and broke the flush right edge). + scrollbar-width: thin; + scrollbar-color: color-mix(in srgb, var(--mat-sys-on-surface) 35%, transparent) transparent; + + &::-webkit-scrollbar { + width: 8px; + height: 8px; + } + + &::-webkit-scrollbar-thumb { + background-color: color-mix(in srgb, var(--mat-sys-on-surface) 35%, transparent); + border-radius: var(--mat-sys-corner-full); + } + + &::-webkit-scrollbar-track { + background: transparent; + } +} + +.nav-content { + // Only the single top-left M3 corner needs custom CSS (no utility class + // covers one corner with an M3 token); grow/margin/background are applied as + // utility classes on the element in the template. + border-top-left-radius: var(--mat-sys-corner-large); +} + +// M3 search bar: full-corner pill on surface-container-high with a leading icon. +.search-bar { + display: flex; + flex: 1 1 auto; + align-items: center; + gap: 8px; + max-width: 480px; + height: 48px; + padding: 0 16px; + border-radius: var(--mat-sys-corner-full); + background-color: var(--mat-sys-surface-container-highest); + color: var(--mat-sys-on-surface-variant); + + &:focus-within { + outline: 2px solid var(--mat-sys-secondary); + outline-offset: 2px; + } +} + +.search-bar-leading { + flex: none; + color: var(--mat-sys-on-surface-variant); +} + +.search-bar-input { + flex: 1 1 auto; + min-width: 0; + border: none; + background: transparent; + outline: none; + color: var(--mat-sys-on-surface); + font: var(--mat-sys-body-large); + + &::placeholder { + color: var(--mat-sys-on-surface-variant); + } + + // Hide the native search clear control for a consistent M3 look. + &::-webkit-search-cancel-button { + -webkit-appearance: none; + appearance: none; + } +} + // Keep the page content margin in sync with the animated rail width on // desktop (mode="side"). The sidenav's own measured margin is static, so it // is overridden here to follow the collapse/expand transition. diff --git a/frontend/src/app/trash/trash.component.html b/frontend/src/app/trash/trash.component.html index 4468c43f..91caae0b 100644 --- a/frontend/src/app/trash/trash.component.html +++ b/frontend/src/app/trash/trash.component.html @@ -1,17 +1,17 @@ - + @for (item of pathResource.value(); track item.id; let last = $last) { + + + } + -
=14.0.0', npm: '>=6.0.0'} + '@fontsource/material-icons-outlined@5.2.6': + resolution: {integrity: sha512-99XKAkwnCg0s0/ywax+o3m01HSNM5gGzSBw+WnlWG2+WY3wOjcN+wXMfm4zP37Yme7Yze2DvKxF78tHWOrlwFw==} + '@fontsource/material-icons@5.2.7': resolution: {integrity: sha512-crPmK0L34lPGmS5GSGLasKpRGQzl95SxMsLM+QhBHPgR9uxSsyI5CUTb0cgoMpjtR+Bf1bC9QOe6pavoybbBwg==} @@ -4799,6 +4805,8 @@ snapshots: '@faker-js/faker@7.6.0': {} + '@fontsource/material-icons-outlined@5.2.6': {} + '@fontsource/material-icons@5.2.7': {} '@fontsource/roboto@5.2.10': {} From 5b31a26850ab4e39c9ba58ef25df7c6105d0110a Mon Sep 17 00:00:00 2001 From: Philip Miglinci Date: Tue, 16 Jun 2026 13:49:31 +0200 Subject: [PATCH 03/16] chore: refactor Signed-off-by: Philip Miglinci --- frontend/src/app/nav/nav.component.html | 18 +++----- frontend/src/app/nav/nav.component.scss | 57 ++++++++----------------- frontend/src/app/nav/nav.component.ts | 7 +++ 3 files changed, 29 insertions(+), 53 deletions(-) diff --git a/frontend/src/app/nav/nav.component.html b/frontend/src/app/nav/nav.component.html index a3916a54..90c3bad1 100644 --- a/frontend/src/app/nav/nav.component.html +++ b/frontend/src/app/nav/nav.component.html @@ -59,24 +59,16 @@ } - - + + @if (isHandset()) { } - + - @@ -98,7 +90,7 @@ diff --git a/frontend/src/app/nav/nav.component.scss b/frontend/src/app/nav/nav.component.scss index 1772a132..f96eb9a0 100644 --- a/frontend/src/app/nav/nav.component.scss +++ b/frontend/src/app/nav/nav.component.scss @@ -173,8 +173,21 @@ $rail-motion: 250ms cubic-bezier(0.2, 0, 0, 1); // a saturated primary). .nav-topbar { gap: 8px; + // Pad top and bottom so the bar's content lines up with the rail header (logo) + // and the inset content card, instead of sitting flush against the top. The + // padding shrinks once scrolled (see .nav-topbar-condensed) so the sticky bar + // becomes compact and frees up vertical space. + height: auto; + padding-top: 12px; + padding-bottom: 12px; background-color: var(--mat-sys-surface-container); color: var(--mat-sys-on-surface); + transition: padding $rail-motion; +} + +.nav-topbar-condensed { + padding-top: 4px; + padding-bottom: 4px; } // The sidenav container sits behind the rail and content; give it the same @@ -218,48 +231,11 @@ $rail-motion: 250ms cubic-bezier(0.2, 0, 0, 1); border-top-left-radius: var(--mat-sys-corner-large); } -// M3 search bar: full-corner pill on surface-container-high with a leading icon. -.search-bar { - display: flex; +// Reserves the top-bar area where a search bar may live, so adding one later +// doesn't shift the surrounding layout. +.search-slot { flex: 1 1 auto; - align-items: center; - gap: 8px; max-width: 480px; - height: 48px; - padding: 0 16px; - border-radius: var(--mat-sys-corner-full); - background-color: var(--mat-sys-surface-container-highest); - color: var(--mat-sys-on-surface-variant); - - &:focus-within { - outline: 2px solid var(--mat-sys-secondary); - outline-offset: 2px; - } -} - -.search-bar-leading { - flex: none; - color: var(--mat-sys-on-surface-variant); -} - -.search-bar-input { - flex: 1 1 auto; - min-width: 0; - border: none; - background: transparent; - outline: none; - color: var(--mat-sys-on-surface); - font: var(--mat-sys-body-large); - - &::placeholder { - color: var(--mat-sys-on-surface-variant); - } - - // Hide the native search clear control for a consistent M3 look. - &::-webkit-search-cancel-button { - -webkit-appearance: none; - appearance: none; - } } // Keep the page content margin in sync with the animated rail width on @@ -276,6 +252,7 @@ $rail-motion: 250ms cubic-bezier(0.2, 0, 0, 1); @media (prefers-reduced-motion: reduce) { .nav-rail, + .nav-topbar, .nav-shell > .mat-drawer-content { transition: none; } diff --git a/frontend/src/app/nav/nav.component.ts b/frontend/src/app/nav/nav.component.ts index e96d1317..0a45a916 100644 --- a/frontend/src/app/nav/nav.component.ts +++ b/frontend/src/app/nav/nav.component.ts @@ -67,10 +67,17 @@ export class NavComponent { // On handset the rail is shown as a modal expanded rail (replaces the drawer). protected readonly railExpanded = computed(() => this.expanded() || this.isHandset()); + // Condense the sticky top bar once the content is scrolled away from the top. + protected readonly scrolled = signal(false); + protected toggleExpanded() { this.expanded.update((value) => !value); } + protected onContentScroll(event: Event) { + this.scrolled.set((event.target as HTMLElement).scrollTop > 0); + } + protected onNavItemClick() { if (this.isHandset()) { this.drawer().close(); From 804bfa7152f99f196d07a7583a759b065939d4c2 Mon Sep 17 00:00:00 2001 From: Philip Miglinci Date: Tue, 16 Jun 2026 14:40:56 +0200 Subject: [PATCH 04/16] chore: refactor Signed-off-by: Philip Miglinci --- frontend/src/app/app.css | 4 ++++ frontend/src/app/nav/nav.component.html | 4 ++-- frontend/src/app/nav/nav.component.scss | 8 ++++++++ frontend/src/app/nav/nav.component.ts | 23 ++++++++++++++++++++--- 4 files changed, 34 insertions(+), 5 deletions(-) diff --git a/frontend/src/app/app.css b/frontend/src/app/app.css index e69de29b..478abc91 100644 --- a/frontend/src/app/app.css +++ b/frontend/src/app/app.css @@ -0,0 +1,4 @@ +:host { + display: block; + height: 100%; +} diff --git a/frontend/src/app/nav/nav.component.html b/frontend/src/app/nav/nav.component.html index 90c3bad1..2944ba79 100644 --- a/frontend/src/app/nav/nav.component.html +++ b/frontend/src/app/nav/nav.component.html @@ -26,7 +26,7 @@ [attr.aria-label]="expanded() ? 'Collapse navigation' : 'Expand navigation'" (click)="toggleExpanded()" > - {{ expanded() ? 'menu_open' : 'menu' }} + }
@@ -63,7 +63,7 @@ @if (isHandset()) { } diff --git a/frontend/src/app/nav/nav.component.scss b/frontend/src/app/nav/nav.component.scss index f96eb9a0..9cc808d3 100644 --- a/frontend/src/app/nav/nav.component.scss +++ b/frontend/src/app/nav/nav.component.scss @@ -5,6 +5,14 @@ $rail-collapsed-width: 96px; $rail-expanded-width: 220px; $rail-motion: 250ms cubic-bezier(0.2, 0, 0, 1); +// Establish a full-height chain so the sidenav container fills the viewport and +// page content scrolls inside mat-sidenav-content (whose (scroll) drives the +// condensed top bar) instead of on the document. +:host { + display: block; + height: 100%; +} + .nav-rail { width: $rail-collapsed-width; background-color: var(--mat-sys-surface-container); diff --git a/frontend/src/app/nav/nav.component.ts b/frontend/src/app/nav/nav.component.ts index 0a45a916..82bd667c 100644 --- a/frontend/src/app/nav/nav.component.ts +++ b/frontend/src/app/nav/nav.component.ts @@ -7,15 +7,16 @@ import { signal, viewChild, } from '@angular/core'; -import { toSignal } from '@angular/core/rxjs-interop'; +import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; import { NgOptimizedImage } from '@angular/common'; import { MatButtonModule } from '@angular/material/button'; import { MatRippleModule } from '@angular/material/core'; import { MatIconModule } from '@angular/material/icon'; import { MatMenuModule } from '@angular/material/menu'; -import { MatSidenav, MatSidenavModule } from '@angular/material/sidenav'; +import { MatSidenav, MatSidenavContent, MatSidenavModule } from '@angular/material/sidenav'; import { MatToolbarModule } from '@angular/material/toolbar'; -import { Router, RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router'; +import { NavigationEnd, Router, RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router'; +import { filter } from 'rxjs'; import { AuthService } from '~/frontend/services/auth.service'; import { UserAvatarComponent } from '~/frontend/users/user-avatar.component'; @@ -51,6 +52,7 @@ export class NavComponent { private readonly router = inject(Router); private readonly drawer = viewChild.required(MatSidenav); + private readonly content = viewChild.required(MatSidenavContent); private readonly handsetObserver = toSignal(this.breakpointObserver.observe(Breakpoints.Handset)); protected readonly isHandset = computed(() => this.handsetObserver()?.matches ?? false); @@ -70,6 +72,21 @@ export class NavComponent { // Condense the sticky top bar once the content is scrolled away from the top. protected readonly scrolled = signal(false); + constructor() { + // mat-sidenav-content stays mounted across route changes, so reset its + // scroll position (and the condensed top bar) when navigating, otherwise + // the next page loads mid-scroll. + this.router.events + .pipe( + filter((event) => event instanceof NavigationEnd), + takeUntilDestroyed(), + ) + .subscribe(() => { + this.content().getElementRef().nativeElement.scrollTop = 0; + this.scrolled.set(false); + }); + } + protected toggleExpanded() { this.expanded.update((value) => !value); } From d6bba1ad5688fc8a5fa13bb3df1b99f01297efc5 Mon Sep 17 00:00:00 2001 From: Philip Miglinci Date: Tue, 16 Jun 2026 14:47:29 +0200 Subject: [PATCH 05/16] chore: refactore to scss animations Signed-off-by: Philip Miglinci --- frontend/src/app/nav/nav.component.html | 4 ++-- frontend/src/app/nav/nav.component.scss | 28 ++++++++++++++++++++----- frontend/src/app/nav/nav.component.ts | 11 +--------- 3 files changed, 26 insertions(+), 17 deletions(-) diff --git a/frontend/src/app/nav/nav.component.html b/frontend/src/app/nav/nav.component.html index 2944ba79..fe294f75 100644 --- a/frontend/src/app/nav/nav.component.html +++ b/frontend/src/app/nav/nav.component.html @@ -59,8 +59,8 @@ } - - + + @if (isHandset()) { } - + - diff --git a/frontend/src/app/links/links-list/links-list.component.ts b/frontend/src/app/links/links-list/links-list.component.ts index 91175ac9..7784b32b 100644 --- a/frontend/src/app/links/links-list/links-list.component.ts +++ b/frontend/src/app/links/links-list/links-list.component.ts @@ -26,6 +26,7 @@ export type LinkStatusFilter = 'active' | 'expired' | 'revoked'; selector: 'app-links-list', templateUrl: './links-list.component.html', styleUrl: './links-list.component.css', + host: { class: 'flex flex-col grow min-h-0' }, imports: [ DatePipe, MatButtonModule, diff --git a/frontend/src/app/nav/nav.component.scss b/frontend/src/app/nav/nav.component.scss index b34596c5..782b0b27 100644 --- a/frontend/src/app/nav/nav.component.scss +++ b/frontend/src/app/nav/nav.component.scss @@ -148,7 +148,6 @@ $rail-motion: 250ms cubic-bezier(0.2, 0, 0, 1); padding-bottom: 12px; background-color: var(--mat-sys-surface-container); color: var(--mat-sys-on-surface); - transition: padding $rail-motion; } .nav-brand { @@ -162,51 +161,27 @@ $rail-motion: 250ms cubic-bezier(0.2, 0, 0, 1); color: var(--mat-sys-on-surface); } -@supports (animation-timeline: scroll()) { - .nav-topbar { - animation: nav-topbar-condense linear both; - animation-timeline: scroll(nearest); - animation-range: 0 64px; - } - - @keyframes nav-topbar-condense { - to { - padding-top: 4px; - padding-bottom: 4px; - } - } -} - .nav-shell { @apply h-full; background-color: var(--mat-sys-surface-container); } +// The shell itself never scrolls: it's a fixed-height column (top bar + content +// card). Scrolling lives inside each page so only its table body moves. .nav-shell .mat-drawer-content { display: flex; flex-direction: column; + overflow: hidden; background-color: var(--mat-sys-surface-container); - scrollbar-width: thin; - scrollbar-color: color-mix(in srgb, var(--mat-sys-on-surface) 35%, transparent) transparent; - - &::-webkit-scrollbar { - width: 8px; - height: 8px; - } - - &::-webkit-scrollbar-thumb { - background-color: color-mix(in srgb, var(--mat-sys-on-surface) 35%, transparent); - border-radius: var(--mat-sys-corner-full); - } - - &::-webkit-scrollbar-track { - background: transparent; - } } -// Only the single top-left corner needs CSS; no utility covers one M3 corner. +// Inset content card fills the remaining height and clips; the routed page owns +// its own scroll region. Only the single top-left corner needs CSS (no utility +// covers one M3 corner). .nav-content { - @apply grow; + @apply grow min-h-0 overflow-hidden; + display: flex; + flex-direction: column; background-color: var(--mat-sys-surface); border-top-left-radius: var(--mat-sys-corner-large); } @@ -223,12 +198,7 @@ $rail-motion: 250ms cubic-bezier(0.2, 0, 0, 1); @media (prefers-reduced-motion: reduce) { .nav-rail, - .nav-topbar, .nav-shell > .mat-drawer-content { transition: none; } - - .nav-topbar { - animation: none; - } } diff --git a/frontend/src/app/nav/nav.component.ts b/frontend/src/app/nav/nav.component.ts index 43379442..e96d1317 100644 --- a/frontend/src/app/nav/nav.component.ts +++ b/frontend/src/app/nav/nav.component.ts @@ -7,16 +7,15 @@ import { signal, viewChild, } from '@angular/core'; -import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; +import { toSignal } from '@angular/core/rxjs-interop'; import { NgOptimizedImage } from '@angular/common'; import { MatButtonModule } from '@angular/material/button'; import { MatRippleModule } from '@angular/material/core'; import { MatIconModule } from '@angular/material/icon'; import { MatMenuModule } from '@angular/material/menu'; -import { MatSidenav, MatSidenavContent, MatSidenavModule } from '@angular/material/sidenav'; +import { MatSidenav, MatSidenavModule } from '@angular/material/sidenav'; import { MatToolbarModule } from '@angular/material/toolbar'; -import { NavigationEnd, Router, RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router'; -import { filter } from 'rxjs'; +import { Router, RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router'; import { AuthService } from '~/frontend/services/auth.service'; import { UserAvatarComponent } from '~/frontend/users/user-avatar.component'; @@ -52,7 +51,6 @@ export class NavComponent { private readonly router = inject(Router); private readonly drawer = viewChild.required(MatSidenav); - private readonly content = viewChild.required(MatSidenavContent); private readonly handsetObserver = toSignal(this.breakpointObserver.observe(Breakpoints.Handset)); protected readonly isHandset = computed(() => this.handsetObserver()?.matches ?? false); @@ -69,19 +67,6 @@ export class NavComponent { // On handset the rail is shown as a modal expanded rail (replaces the drawer). protected readonly railExpanded = computed(() => this.expanded() || this.isHandset()); - constructor() { - // mat-sidenav-content stays mounted across route changes, so reset its - // scroll position when navigating, otherwise the next page loads mid-scroll. - this.router.events - .pipe( - filter((event) => event instanceof NavigationEnd), - takeUntilDestroyed(), - ) - .subscribe(() => { - this.content().getElementRef().nativeElement.scrollTop = 0; - }); - } - protected toggleExpanded() { this.expanded.update((value) => !value); } diff --git a/frontend/src/app/trash/trash.component.css b/frontend/src/app/trash/trash.component.css index a6f93453..1cf487a6 100644 --- a/frontend/src/app/trash/trash.component.css +++ b/frontend/src/app/trash/trash.component.css @@ -1,3 +1,8 @@ +/* Opaque background so rows don't show through the sticky header while scrolling. */ +.mat-mdc-header-cell { + background: var(--mat-sys-surface); +} + tr { &:hover { background: var(--mat-sys-surface-container-low); diff --git a/frontend/src/app/trash/trash.component.html b/frontend/src/app/trash/trash.component.html index 91caae0b..95680194 100644 --- a/frontend/src/app/trash/trash.component.html +++ b/frontend/src/app/trash/trash.component.html @@ -1,4 +1,8 @@ -
+
- - - - - +
+
- -
+ + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - + - + @if (!parentId()) { + + } - - -
+ + Name{{ element.name }}Name{{ element.name }}Trashed On{{ element.trashedAt | date }}Trashed On{{ element.trashedAt | date }}Restore Path - {{ formatOriginPath(element) }} - Restore Path + {{ formatOriginPath(element) }} + - - - @if (!parentId()) { + + - } - - - -
+ + + + + + + + + +
diff --git a/frontend/src/app/trash/trash.component.ts b/frontend/src/app/trash/trash.component.ts index 9305965b..f69f20cb 100644 --- a/frontend/src/app/trash/trash.component.ts +++ b/frontend/src/app/trash/trash.component.ts @@ -23,6 +23,7 @@ import { snackSuccessDuration } from '~/frontend/constants'; selector: 'app-trash', templateUrl: './trash.component.html', styleUrls: ['./trash.component.css'], + host: { class: 'flex flex-col grow min-h-0' }, imports: [ DatePipe, MatButtonModule, From cc422dc7b92c7bdbbe403ae55d8291bda8e53c9f Mon Sep 17 00:00:00 2001 From: Philip Miglinci Date: Wed, 17 Jun 2026 13:59:58 +0200 Subject: [PATCH 10/16] refactor: use tailwind classes Signed-off-by: Philip Miglinci --- frontend/src/app/app.css | 4 -- frontend/src/app/app.ts | 2 +- frontend/src/app/nav/nav.component.scss | 93 +++++++------------------ frontend/src/app/nav/nav.component.ts | 2 + 4 files changed, 30 insertions(+), 71 deletions(-) delete mode 100644 frontend/src/app/app.css diff --git a/frontend/src/app/app.css b/frontend/src/app/app.css deleted file mode 100644 index 478abc91..00000000 --- a/frontend/src/app/app.css +++ /dev/null @@ -1,4 +0,0 @@ -:host { - display: block; - height: 100%; -} diff --git a/frontend/src/app/app.ts b/frontend/src/app/app.ts index d8511c5e..50ff1a45 100644 --- a/frontend/src/app/app.ts +++ b/frontend/src/app/app.ts @@ -5,6 +5,6 @@ import { RouterOutlet } from '@angular/router'; selector: 'app-root', imports: [RouterOutlet], templateUrl: './app.html', - styleUrl: './app.css', + host: { class: 'block h-full' }, }) export class App {} diff --git a/frontend/src/app/nav/nav.component.scss b/frontend/src/app/nav/nav.component.scss index 782b0b27..17edf66f 100644 --- a/frontend/src/app/nav/nav.component.scss +++ b/frontend/src/app/nav/nav.component.scss @@ -1,62 +1,44 @@ @reference 'tailwindcss'; -// Custom M3 navigation rail (Angular Material has no rail component). -$rail-collapsed-width: 96px; -$rail-expanded-width: 220px; +// Custom M3 motion curve; no Tailwind/M3 token exists, and Tailwind cannot +// animate width/margin, so the rail transitions stay in plain CSS. $rail-motion: 250ms cubic-bezier(0.2, 0, 0, 1); -// Full-height chain so content scrolls inside mat-sidenav-content, not the document. -:host { - display: block; - height: 100%; -} - .nav-rail { - width: $rail-collapsed-width; + @apply w-24; background-color: var(--mat-sys-surface-container); transition: width $rail-motion; &.mat-drawer-side { - border-radius: 0; + @apply rounded-none; } &.nav-rail-expanded { - width: $rail-expanded-width; + @apply w-55; } } +// Inset the 48px burger so it sits on the same 48px axis as the item icons. .nav-rail-header { - display: flex; - padding-top: 20px; - // Inset the 48px burger so it sits on the same 48px axis as the item icons. - padding-inline-start: 24px; + @apply flex pt-5 ps-6; } .nav-rail-items { - display: flex; - flex-direction: column; - gap: 4px; - margin-top: 24px; + @apply flex flex-col gap-1 mt-6; } .nav-rail-item { - position: relative; - display: flex; - flex-direction: column; - align-items: center; - gap: 4px; - padding: 6px 0 4px; + @apply relative flex flex-col items-center gap-1 pt-1.5 pb-1 no-underline; color: var(--mat-sys-on-surface-variant); - text-decoration: none; -webkit-tap-highlight-color: transparent; mat-icon, .nav-rail-label { - position: relative; + @apply relative; } mat-icon { - margin: 4px 0; + @apply my-1; } &:hover .nav-rail-indicator { @@ -64,11 +46,11 @@ $rail-motion: 250ms cubic-bezier(0.2, 0, 0, 1); } &:focus-visible { - outline: none; + @apply outline-none; .nav-rail-indicator { + @apply outline-offset-1; outline: 2px solid var(--mat-sys-secondary); - outline-offset: 1px; } } @@ -95,15 +77,11 @@ $rail-motion: 250ms cubic-bezier(0.2, 0, 0, 1); } } +// Centre the pill with a negative margin instead of a transform, which would +// offset matRipple's ripple. .nav-rail-indicator { - position: absolute; - top: 6px; - // Centre the pill without a transform, which would offset matRipple's ripple. - left: calc(50% - 28px); - width: 56px; - height: 32px; + @apply absolute top-1.5 left-1/2 -ml-7 w-14 h-8 transition-colors duration-150 ease-linear; border-radius: var(--mat-sys-corner-full); - transition: background-color 150ms linear; } .nav-rail-label { @@ -112,15 +90,10 @@ $rail-motion: 250ms cubic-bezier(0.2, 0, 0, 1); .nav-rail-expanded { .nav-rail-item { - flex-direction: row; - justify-content: flex-start; - gap: 8px; - height: 56px; - margin: 0 20px; - padding: 0 16px; + @apply flex-row justify-start gap-2 h-14 mx-5 px-4; mat-icon { - margin: 0; + @apply m-0; } &.active .nav-rail-label { @@ -129,10 +102,7 @@ $rail-motion: 250ms cubic-bezier(0.2, 0, 0, 1); } .nav-rail-indicator { - top: 0; - left: 0; - width: 100%; - height: 100%; + @apply top-0 left-0 ml-0 w-full h-full; } .nav-rail-label { @@ -141,19 +111,13 @@ $rail-motion: 250ms cubic-bezier(0.2, 0, 0, 1); } .nav-topbar { - @apply sticky top-0 z-10; - gap: 8px; - height: auto; - padding-top: 12px; - padding-bottom: 12px; + @apply sticky top-0 z-10 gap-2 h-auto py-3; background-color: var(--mat-sys-surface-container); color: var(--mat-sys-on-surface); } .nav-brand { - display: flex; - align-items: center; - gap: 8px; + @apply flex items-center gap-2; } .nav-brand-name { @@ -169,9 +133,7 @@ $rail-motion: 250ms cubic-bezier(0.2, 0, 0, 1); // The shell itself never scrolls: it's a fixed-height column (top bar + content // card). Scrolling lives inside each page so only its table body moves. .nav-shell .mat-drawer-content { - display: flex; - flex-direction: column; - overflow: hidden; + @apply flex flex-col overflow-hidden; background-color: var(--mat-sys-surface-container); } @@ -179,21 +141,20 @@ $rail-motion: 250ms cubic-bezier(0.2, 0, 0, 1); // its own scroll region. Only the single top-left corner needs CSS (no utility // covers one M3 corner). .nav-content { - @apply grow min-h-0 overflow-hidden; - display: flex; - flex-direction: column; + @apply flex flex-col grow min-h-0 overflow-hidden; background-color: var(--mat-sys-surface); border-top-left-radius: var(--mat-sys-corner-large); } -// Material's measured drawer margin is static; override it to follow the rail. +// Material's measured drawer margin is static; override it (inline style → needs +// `!important`) so the content follows the rail width. .nav-shell:not(.nav-shell-handset) > .mat-drawer-content { - margin-left: $rail-collapsed-width !important; + @apply ml-24!; transition: margin-left $rail-motion; } .nav-shell:not(.nav-shell-handset).nav-shell-expanded > .mat-drawer-content { - margin-left: $rail-expanded-width !important; + @apply ml-55!; } @media (prefers-reduced-motion: reduce) { diff --git a/frontend/src/app/nav/nav.component.ts b/frontend/src/app/nav/nav.component.ts index e96d1317..bb875508 100644 --- a/frontend/src/app/nav/nav.component.ts +++ b/frontend/src/app/nav/nav.component.ts @@ -31,6 +31,8 @@ interface NavItem { templateUrl: './nav.component.html', styleUrl: './nav.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, + // Full-height chain so content scrolls inside mat-sidenav-content, not the document. + host: { class: 'block h-full' }, imports: [ NgOptimizedImage, MatToolbarModule, From 573b2dbc0798e93dbbd052a3011f5fd73287d470 Mon Sep 17 00:00:00 2001 From: Philip Miglinci Date: Wed, 17 Jun 2026 15:08:23 +0200 Subject: [PATCH 11/16] refactor: fix missing overflow Signed-off-by: Philip Miglinci --- frontend/src/app/nav/nav.component.html | 1 - frontend/src/app/nav/nav.component.scss | 10 ++++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/frontend/src/app/nav/nav.component.html b/frontend/src/app/nav/nav.component.html index ef98d630..07b768d1 100644 --- a/frontend/src/app/nav/nav.component.html +++ b/frontend/src/app/nav/nav.component.html @@ -8,7 +8,6 @@ class="nav-rail" [class.nav-rail-expanded]="railExpanded()" fixedInViewport - [attr.role]="isHandset() ? 'dialog' : null" [mode]="isHandset() ? 'over' : 'side'" [opened]="!isHandset()" > diff --git a/frontend/src/app/nav/nav.component.scss b/frontend/src/app/nav/nav.component.scss index 17edf66f..4b147081 100644 --- a/frontend/src/app/nav/nav.component.scss +++ b/frontend/src/app/nav/nav.component.scss @@ -137,11 +137,13 @@ $rail-motion: 250ms cubic-bezier(0.2, 0, 0, 1); background-color: var(--mat-sys-surface-container); } -// Inset content card fills the remaining height and clips; the routed page owns -// its own scroll region. Only the single top-left corner needs CSS (no utility -// covers one M3 corner). +// Inset content card fills the remaining height. File-list pages manage their +// own inner scroll region (so only their table body scrolls); pages without one +// (settings, admin) overflow and scroll within the card instead of being +// clipped. Only the single top-left corner needs CSS (no utility covers one M3 +// corner). .nav-content { - @apply flex flex-col grow min-h-0 overflow-hidden; + @apply flex flex-col grow min-h-0 overflow-auto; background-color: var(--mat-sys-surface); border-top-left-radius: var(--mat-sys-corner-large); } From ffc19439956268abad6fff1362f9e3a679a1d489 Mon Sep 17 00:00:00 2001 From: Philip Miglinci Date: Thu, 18 Jun 2026 11:32:51 +0200 Subject: [PATCH 12/16] refactor: move css to styles.css Signed-off-by: Philip Miglinci --- .../src/app/dashboard/dashboard.component.css | 30 ++++--------------- .../links/links-list/links-list.component.css | 12 -------- .../links/links-list/links-list.component.ts | 1 - frontend/src/app/nav/nav.component.scss | 5 ++-- frontend/src/app/trash/trash.component.css | 14 --------- frontend/src/app/trash/trash.component.ts | 1 - frontend/src/styles.css | 17 +++++++++++ 7 files changed, 26 insertions(+), 54 deletions(-) delete mode 100644 frontend/src/app/links/links-list/links-list.component.css delete mode 100644 frontend/src/app/trash/trash.component.css diff --git a/frontend/src/app/dashboard/dashboard.component.css b/frontend/src/app/dashboard/dashboard.component.css index 4b41ca1d..2b63148f 100644 --- a/frontend/src/app/dashboard/dashboard.component.css +++ b/frontend/src/app/dashboard/dashboard.component.css @@ -1,27 +1,9 @@ -/* Opaque background so rows don't show through the sticky header while scrolling. */ -.mat-mdc-header-cell { - background: var(--mat-sys-surface); -} - -tr { - /* - This is required because the material table set `background: inherit`, - which overrules TailwindCSS `background-color: ...` - */ - - &:hover { - background: var(--mat-sys-surface-container-low); - } - - &.selection-item-selected { - background: var(--mat-sys-secondary-container); - } - - &.drop-target-active { - background: var(--mat-sys-primary-container); - outline: 2px solid var(--mat-sys-primary); - outline-offset: -2px; - } +/* Rows highlighted as drop targets while dragging. The shared header/hover/ + selection backgrounds live in styles.css. */ +tr.drop-target-active { + background: var(--mat-sys-primary-container); + outline: 2px solid var(--mat-sys-primary); + outline-offset: -2px; } /* Breadcrumb buttons highlighted as drop targets while dragging */ diff --git a/frontend/src/app/links/links-list/links-list.component.css b/frontend/src/app/links/links-list/links-list.component.css deleted file mode 100644 index f9d8e8bf..00000000 --- a/frontend/src/app/links/links-list/links-list.component.css +++ /dev/null @@ -1,12 +0,0 @@ -/* Opaque background so rows don't show through the sticky header while scrolling. */ -.mat-mdc-header-cell { - background: var(--mat-sys-surface); -} - -tr { - /* Material sets `background: inherit` on rows; specify a concrete value - here so the hover state actually shows. */ - &:hover { - background: var(--mat-sys-surface-container-low); - } -} diff --git a/frontend/src/app/links/links-list/links-list.component.ts b/frontend/src/app/links/links-list/links-list.component.ts index 7784b32b..b54957e8 100644 --- a/frontend/src/app/links/links-list/links-list.component.ts +++ b/frontend/src/app/links/links-list/links-list.component.ts @@ -25,7 +25,6 @@ export type LinkStatusFilter = 'active' | 'expired' | 'revoked'; @Component({ selector: 'app-links-list', templateUrl: './links-list.component.html', - styleUrl: './links-list.component.css', host: { class: 'flex flex-col grow min-h-0' }, imports: [ DatePipe, diff --git a/frontend/src/app/nav/nav.component.scss b/frontend/src/app/nav/nav.component.scss index 4b147081..16146bbb 100644 --- a/frontend/src/app/nav/nav.component.scss +++ b/frontend/src/app/nav/nav.component.scss @@ -18,9 +18,10 @@ $rail-motion: 250ms cubic-bezier(0.2, 0, 0, 1); } } -// Inset the 48px burger so it sits on the same 48px axis as the item icons. +// Pin the header to the collapsed rail width so the centred burger stays on the +// item-icon axis and doesn't shift when the rail expands. .nav-rail-header { - @apply flex pt-5 ps-6; + @apply flex justify-center w-24 pt-3; } .nav-rail-items { diff --git a/frontend/src/app/trash/trash.component.css b/frontend/src/app/trash/trash.component.css deleted file mode 100644 index 1cf487a6..00000000 --- a/frontend/src/app/trash/trash.component.css +++ /dev/null @@ -1,14 +0,0 @@ -/* Opaque background so rows don't show through the sticky header while scrolling. */ -.mat-mdc-header-cell { - background: var(--mat-sys-surface); -} - -tr { - &:hover { - background: var(--mat-sys-surface-container-low); - } - - &.selection-item-selected { - background: var(--mat-sys-secondary-container); - } -} diff --git a/frontend/src/app/trash/trash.component.ts b/frontend/src/app/trash/trash.component.ts index f69f20cb..14dedff4 100644 --- a/frontend/src/app/trash/trash.component.ts +++ b/frontend/src/app/trash/trash.component.ts @@ -22,7 +22,6 @@ import { snackSuccessDuration } from '~/frontend/constants'; @Component({ selector: 'app-trash', templateUrl: './trash.component.html', - styleUrls: ['./trash.component.css'], host: { class: 'flex flex-col grow min-h-0' }, imports: [ DatePipe, diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 947c6576..92e4b6be 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -37,6 +37,23 @@ --mat-card-content-padding: 2rem 2rem 1.5rem; } +/* Material tables: opaque sticky header plus row hover/selection backgrounds. + Material sets `background: inherit` on rows, which overrides Tailwind bg + utilities, so concrete values are set here. */ +.mat-mdc-header-cell { + background: var(--mat-sys-surface); +} + +tr { + &:hover { + background: var(--mat-sys-surface-container-low); + } + + &.selection-item-selected { + background: var(--mat-sys-secondary-container); + } +} + /* Compact variant of matIconButton for inline-with-text contexts. The touch target stays at the M3 default (48px) via the overlay element. */ .mat-icon-button-compact { From aa36061970cfed2723e9bbc39db13f0491533cde Mon Sep 17 00:00:00 2001 From: Philip Miglinci Date: Thu, 18 Jun 2026 11:34:00 +0200 Subject: [PATCH 13/16] refactor: add white background for links Signed-off-by: Philip Miglinci --- .../public-link-viewer.component.html | 304 +++++++++--------- 1 file changed, 153 insertions(+), 151 deletions(-) diff --git a/frontend/src/app/public-links/public-link-viewer/public-link-viewer.component.html b/frontend/src/app/public-links/public-link-viewer/public-link-viewer.component.html index 468de688..0ee42768 100644 --- a/frontend/src/app/public-links/public-link-viewer/public-link-viewer.component.html +++ b/frontend/src/app/public-links/public-link-viewer/public-link-viewer.component.html @@ -1,174 +1,176 @@
-
-
- folder_shared -

Shared files

-
- @if (state().kind === 'ready') { -

- {{ ownerName() }} shared the public link - {{ linkName() }} - with you. - @if (isExpired()) { - Expired. - } @else if (expiresAt(); as date) { - Expires {{ date | relativeDate }}. - } -

- } -
- - @switch (state().kind) { - @case ('loading') { -

Loading…

- } - - @case ('codeRequired') { -
-

Enter code

+
+
+
+ folder_shared +

Shared files

+
+ @if (state().kind === 'ready') {

- The owner of this link has set a code. Ask them for it. + {{ ownerName() }} shared the public link + {{ linkName() }} + with you. + @if (isExpired()) { + Expired. + } @else if (expiresAt(); as date) { + Expires {{ date | relativeDate }}. + }

-
- - Code - - - @if (invalidCode()) { - Incorrect code. Please try again. - } - - - -
-
- } + } + - @case ('expired') { -
-

This link has expired

-

Ask the owner for a new one.

-
- } + @switch (state().kind) { + @case ('loading') { +

Loading…

+ } - @case ('unavailable') { -
- link_off -

This link is no longer available

-

- It may have been revoked or its contents changed. Ask the owner for a new one. -

-
- } + @case ('codeRequired') { +
+

Enter code

+

+ The owner of this link has set a code. Ask them for it. +

+
+ + Code + + + @if (invalidCode()) { + Incorrect code. Please try again. + } + + + +
+
+ } - @case ('notFound') { -
- link_off -

Link not found

-

- The link may have been revoked or never existed. -

-
- } + @case ('expired') { +
+

This link has expired

+

Ask the owner for a new one.

+
+ } - @case ('error') { -
-

Something went wrong

-

{{ stateMessage() }}

-
- } + @case ('unavailable') { +
+ link_off +

This link is no longer available

+

+ It may have been revoked or its contents changed. Ask the owner for a new one. +

+
+ } - @case ('ready') { - @if (singleFile(); as file) { -
-
- description -
- {{ file.name }} - - {{ file.size | bytes }} - + @case ('notFound') { +
+ link_off +

Link not found

+

+ The link may have been revoked or never existed. +

+
+ } + + @case ('error') { +
+

Something went wrong

+

{{ stateMessage() }}

+
+ } + + @case ('ready') { + @if (singleFile(); as file) { +
+
+ description +
+ {{ file.name }} + + {{ file.size | bytes }} + +
+ @if (previewUrl(); as url) { + + } +
- @if (previewUrl(); as url) { - - } - -
- } @else { - + - @if (dataSource.data.length === 0) { -

This folder is empty.

- } @else { - - - - - + @if (dataSource.data.length === 0) { +

This folder is empty.

+ } @else { +
- {{ row.isDirectory ? 'folder' : 'description' }} -
+ + + + - - - - + + + + - - - - + + + + - - - - + + + + - - -
+ {{ row.isDirectory ? 'folder' : 'description' }} + Name{{ row.name }}Name{{ row.name }}Size{{ row.size | bytes }}Size{{ row.size | bytes }} - - + +
+ + + + } } } } - } +