Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,14 @@ This project uses Angular Material 21 with Material 3 theming. All UI must follo
| **Border** | `mat-border` (outline), `mat-border-subtle` (outline-variant) |
| **Shadow** | `mat-shadow-1` … `mat-shadow-5` |

### Icons

Single variable icon font: Material Symbols Outlined, set as the global default font set in `app.config.ts`. Write `<mat-icon>delete</mat-icon>` (no per-icon `fontSet`). Icons are **filled by default**; modifier classes (in `styles.css`): `.material-symbols-outline` (outline variation, e.g. inactive nav items) and `.material-symbols-light` (subtle indicators). Do NOT reintroduce the removed static `@fontsource/material-icons*` packages.

**Subsetting (important):** the font is a vendored subset at `frontend/public/fonts/material-symbols-outlined-subset.woff2` (a few dozen KB instead of ~3.8 MB), declared via a custom `@font-face` in `styles.css`. It is generated from the Google Fonts `icon_names` API by `pnpm generate:material-symbols`. Icon names are **discovered by scanning the source** (no hand-maintained list): `<mat-icon>…</mat-icon>` blocks in `.html` and inline `.ts` templates (both bare ligatures like `delete` and quoted names inside interpolations like `x ? 'visibility_off' : 'visibility'`), plus TS `icon: '…'` properties and `*_ICON* = '…'` constants. The API ignores unknown names, so occasional over-matches are harmless. If you reference an icon in a way none of these patterns catch, extend the regexes in `frontend/scripts/generate-material-symbols.mjs`. After adding any new icon, rerun the script and commit the regenerated `.woff2`.

**File-type icons** use [lsicon](https://icon-sets.iconify.design/lsicon/) (Iconify) for known MIME types, rendered as `<mat-icon svgIcon="lsicon:...">` via `app-file-icon`. The used SVGs are pre-rendered into `frontend/src/util/lsicon-svgs.generated.ts` by `pnpm generate:lsicon` (keep the name list in that script in sync with `CATEGORY_ICON` in `frontend/src/util/file-icons.ts`); the `@iconify*` packages are devDependencies so the full dataset never enters the bundle.

### Angular Best Practices

- Always use standalone components over NgModules
Expand Down
Binary file not shown.
45 changes: 45 additions & 0 deletions frontend/scripts/generate-lsicon-svgs.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// Generates pre-rendered SVG strings for the handful of lsicon icons the file
// list uses, so the full @iconify-json/lsicon dataset (~212 KB) never ends up in
// the app bundle. Regenerate with: pnpm generate:lsicon
//
// The icon names below MUST stay in sync with CATEGORY_ICON in
// frontend/src/util/file-icons.ts.
import { writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { icons as lsicon } from '@iconify-json/lsicon';
import { getIconData, iconToHTML, iconToSVG, replaceIDs } from '@iconify/utils';

const NAMES = [
'picture-filled',
'music-filled',
'file-mp4-filled',
'file-pdf-filled',
'file-csv-filled',
'file-doc-filled',
'file-xls-filled',
'file-ppt-filled',
'file-zip-filled',
'file-rar-filled',
'file-txt-filled',
];

const svgs = {};
for (const name of NAMES.sort()) {
const data = getIconData(lsicon, name);
if (!data) throw new Error(`lsicon icon not found: ${name}`);
const rendered = iconToSVG(data);
svgs[name] = iconToHTML(replaceIDs(rendered.body), rendered.attributes);
}

const header = `// Code generated by frontend/scripts/generate-lsicon-svgs.mjs. DO NOT EDIT.
// Regenerate with: pnpm generate:lsicon
`;
const body = `export const LSICON_SVGS: Record<string, string> = ${JSON.stringify(svgs, null, 2)};\n`;

const target = resolve(
dirname(fileURLToPath(import.meta.url)),
'../src/util/lsicon-svgs.generated.ts',
);
writeFileSync(target, header + body);
console.log(`Wrote ${Object.keys(svgs).length} lsicon SVGs to ${target}`);
90 changes: 90 additions & 0 deletions frontend/scripts/generate-material-symbols.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
// Downloads a Material Symbols Outlined subset containing only the icons the app
// renders, via the Google Fonts `icon_names` API. The returned font is still a
// variable font (FILL 0..1, wght 300..400, opsz/GRAD pinned) so the filled/
// outline/light states keep working, but it ships only our glyphs — the full
// font is ~3.8 MB, the subset is a few dozen KB.
// Regenerate with: pnpm generate:material-symbols
//
// Icon names are discovered by scanning the source (templates + inline
// component templates + TS), so there is no list to keep in sync. The Google
// Fonts API ignores unknown names, so occasional over-matches are harmless.
import { mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';

const frontendRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const srcDir = join(frontendRoot, 'src');

function walkSource(dir) {
const files = [];
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
if (statSync(full).isDirectory()) files.push(...walkSource(full));
else if (entry.endsWith('.html') || entry.endsWith('.ts')) files.push(full);
}
return files;
}

// Material Symbols ligatures are lowercase snake_case (lsicon names use hyphens,
// so they never match these patterns).
const ICON_NAME = '[a-z][a-z0-9_]*';
// `<mat-icon ...>...</mat-icon>` blocks (covers .html and inline .ts templates).
// The closing tag tolerates whitespace before `>` (the formatter wraps long tags).
const MAT_ICON_BLOCK_RE = /<mat-icon\b[^>]*>([\s\S]*?)<\/mat-icon\s*>/g;
// A bare ligature, e.g. `<mat-icon>delete</mat-icon>`.
const LITERAL_RE = new RegExp(`^${ICON_NAME}$`);
// Quoted names inside interpolations, e.g. `{{ x ? 'visibility_off' : 'visibility' }}`.
const QUOTED_RE = new RegExp(`['"](${ICON_NAME})['"]`, 'g');
// TS: `icon: 'folder'` object properties.
const ICON_PROP_RE = new RegExp(`\\bicon\\s*:\\s*['"](${ICON_NAME})['"]`, 'g');
// TS: `*_ICON* = 'draft'` constants (e.g. FILE_ICON_FALLBACK, FOLDER_ICON).
const ICON_CONST_RE = new RegExp(`_ICON\\w*\\s*=\\s*['"](${ICON_NAME})['"]`, 'g');

const icons = new Set();
for (const file of walkSource(srcDir)) {
const source = readFileSync(file, 'utf8');
for (const [, inner] of source.matchAll(MAT_ICON_BLOCK_RE)) {
const trimmed = inner.trim();
if (LITERAL_RE.test(trimmed)) icons.add(trimmed);
else for (const [, name] of trimmed.matchAll(QUOTED_RE)) icons.add(name);
}
if (file.endsWith('.ts')) {
for (const [, name] of source.matchAll(ICON_PROP_RE)) icons.add(name);
for (const [, name] of source.matchAll(ICON_CONST_RE)) icons.add(name);
}
}
const names = [...icons].sort();
if (names.length === 0) throw new Error('No Material Symbols icons discovered in source.');

// Axis order must be lowercase-alpha then uppercase-alpha: opsz, wght, FILL, GRAD.
const family = 'Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,300..400,0..1,0';
const cssUrl =
`https://fonts.googleapis.com/css2?family=${family}` +
`&icon_names=${names.join(',')}&display=block`;

// A modern UA makes Google Fonts serve woff2 (with variations) rather than ttf.
const userAgent =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' +
'(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';

const cssResponse = await fetch(cssUrl, { headers: { 'User-Agent': userAgent } });
if (!cssResponse.ok) {
throw new Error(
`Google Fonts CSS request failed (${cssResponse.status}): ${await cssResponse.text()}`,
);
}
const css = await cssResponse.text();
const urlMatch = css.match(/url\((https:\/\/[^)]+)\)\s*format\('woff2'\)/);
if (!urlMatch) throw new Error(`No woff2 URL found in Google Fonts CSS:\n${css}`);

const fontResponse = await fetch(urlMatch[1]);
if (!fontResponse.ok) throw new Error(`Font download failed (${fontResponse.status})`);
const buffer = Buffer.from(await fontResponse.arrayBuffer());

const outDir = join(frontendRoot, 'public', 'fonts');
mkdirSync(outDir, { recursive: true });
const outPath = join(outDir, 'material-symbols-outlined-subset.woff2');
writeFileSync(outPath, buffer);

console.log(`Subset ${names.length} icons: ${names.join(', ')}`);
console.log(`Wrote ${(buffer.length / 1024).toFixed(1)} KB to ${outPath}`);
14 changes: 13 additions & 1 deletion frontend/src/app/app.config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import {
ApplicationConfig,
inject,
provideAppInitializer,
provideBrowserGlobalErrorListeners,
} from '@angular/core';
import { provideHttpClient, withInterceptors, withXhr } from '@angular/common/http';
import { provideRouter } from '@angular/router';
import { MAT_ICON_DEFAULT_OPTIONS, MatIconRegistry } from '@angular/material/icon';
import { DomSanitizer } from '@angular/platform-browser';
import { registerFileLsicons } from '~/util/file-icons';

import { routes } from '~/frontend/app.routes';
import { tokenInterceptor } from '~/frontend/services/auth.service';
Expand All @@ -18,5 +26,9 @@ export const appConfig: ApplicationConfig = {
withXhr(),
withInterceptors([tokenInterceptor, publicLinkTokenInterceptor, errorInterceptor]),
),
// Route every <mat-icon> through Material Symbols (variable) by default.
{ provide: MAT_ICON_DEFAULT_OPTIONS, useValue: { fontSet: 'material-symbols-outlined' } },
// Register lsicon file-type SVGs so <mat-icon svgIcon="lsicon:..."> works.
provideAppInitializer(() => registerFileLsicons(inject(MatIconRegistry), inject(DomSanitizer))),
],
};
2 changes: 1 addition & 1 deletion frontend/src/app/auth/forgot/forgot.component.html
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<div class="flex justify-center items-center min-h-screen p-4 mat-bg-surface-container-low">
<div class="flex justify-center items-center min-h-screen p-4 mat-bg-surface">
<mat-card class="auth-card w-full max-w-md">
<mat-card-content>
<div class="flex flex-col items-center mb-6 text-center">
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/app/auth/login/login.component.html
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<div class="flex justify-center items-center min-h-screen p-4 mat-bg-surface-container-low">
<div class="flex justify-center items-center min-h-screen p-4 mat-bg-surface">
<mat-card class="auth-card w-full max-w-md">
<mat-card-content>
<div class="flex flex-col items-center mb-6 text-center">
Expand Down Expand Up @@ -66,7 +66,7 @@ <h1 class="mat-font-title-lg">Sign in</h1>
</button>
</form>

<div class="flex justify-center gap-2 mt-6">
<div class="flex justify-center gap-6 mt-6">
<a
routerLink="/forgot"
class="mat-font-label-lg mat-text-primary no-underline hover:underline"
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/app/auth/register/register.component.html
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<div class="flex justify-center items-center min-h-screen p-4 mat-bg-surface-container-low">
<div class="flex justify-center items-center min-h-screen p-4 mat-bg-surface">
<mat-card class="auth-card w-full max-w-md">
<mat-card-content>
<div class="flex flex-col items-center mb-6 text-center">
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/app/auth/reset/reset.component.html
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<div class="flex justify-center items-center min-h-screen p-4 mat-bg-surface-container-low">
<div class="flex justify-center items-center min-h-screen p-4 mat-bg-surface">
<mat-card class="auth-card w-full max-w-md">
<mat-card-content>
<div class="flex flex-col items-center mb-6 text-center">
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/app/auth/verify/verify.component.html
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<div class="flex justify-center items-center min-h-screen p-4 mat-bg-surface-container-low">
<div class="flex justify-center items-center min-h-screen p-4 mat-bg-surface">
<mat-card class="auth-card w-full max-w-md">
<mat-card-content>
<div class="flex flex-col items-center mb-6 text-center">
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/app/dashboard/dashboard.component.css
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
.mat-mdc-row {
height: var(--datei-table-row-height);
}

/* Rows highlighted as drop targets while dragging. The shared header/hover/
selection backgrounds live in styles.css. */
tr.drop-target-active {
Expand Down
44 changes: 17 additions & 27 deletions frontend/src/app/dashboard/dashboard.component.html
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<div
class="flex flex-col grow min-h-0 mx-5 mt-3"
class="flex flex-col grow min-h-0 mx-5 mt-5"
appDragDrop
#drag="appDragDrop"
(dragStart)="onDrag($event)"
Expand All @@ -14,7 +14,6 @@
[appDropTarget]="null"
[dropTargetEnabled]="parentId() !== null"
>
<mat-icon>home</mat-icon>
My files
</button>
@for (item of pathResource.value(); track item.id; let last = $last) {
Expand All @@ -31,7 +30,7 @@
</nav>

<div
class="flex items-center gap-1 px-1 py-0.5 min-h-11 mat-corner-sm transition-all"
class="flex items-center gap-1 px-1 py-0.5 min-h-11 mat-corner-full transition-all"
[class.mat-bg-secondary-container]="selection.isAnySelected()"
>
@if (selection.isAnySelected()) {
Expand Down Expand Up @@ -72,39 +71,30 @@
</div>

<div class="grow min-h-0 overflow-auto">
<table mat-table [dataSource]="dataSource" class="mat-elevation-z8">
<ng-container matColumnDef="icon">
<th mat-header-cell *matHeaderCellDef></th>
<td mat-cell *matCellDef="let element" class="w-20">
<app-thumbnail-icon [file]="element" />
</td>
</ng-container>

<table mat-table [dataSource]="dataSource" matSort class="mat-elevation-z8">
<ng-container matColumnDef="name">
<th mat-header-cell *matHeaderCellDef>Name</th>
<th mat-header-cell *matHeaderCellDef mat-sort-header>Name</th>
<td mat-cell *matCellDef="let element">
{{ element.name }}
<div class="flex items-center gap-2">
<app-file-icon [file]="element" />
<span>{{ element.name }}</span>
</div>
</td>
</ng-container>

<ng-container matColumnDef="size">
<th mat-header-cell *matHeaderCellDef>Size</th>
<td mat-cell *matCellDef="let element">{{ element.size | bytes }}</td>
</ng-container>

<ng-container matColumnDef="createdAt">
<th mat-header-cell *matHeaderCellDef>Created At</th>
<td mat-cell *matCellDef="let element">{{ element.createdAt | date }}</td>
<ng-container matColumnDef="owner">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Owner</th>
<td mat-cell *matCellDef="let element"><app-owner-cell [file]="element" /></td>
</ng-container>

<ng-container matColumnDef="updatedAt">
<th mat-header-cell *matHeaderCellDef>Updated At</th>
<td mat-cell *matCellDef="let element">{{ element.updatedAt | date }}</td>
<th mat-header-cell *matHeaderCellDef mat-sort-header>Modified</th>
<td mat-cell *matCellDef="let element">{{ element.updatedAt | smartDate }}</td>
</ng-container>

<ng-container matColumnDef="mimeType">
<th mat-header-cell *matHeaderCellDef>Content Type</th>
<td mat-cell *matCellDef="let element">{{ element.mimeType }}</td>
<ng-container matColumnDef="size">
<th mat-header-cell *matHeaderCellDef mat-sort-header>Size</th>
<td mat-cell *matCellDef="let element">{{ element.size | bytes }}</td>
</ng-container>

<ng-container matColumnDef="actions">
Expand All @@ -116,7 +106,7 @@
(click)="$event.stopPropagation()"
aria-label="More actions"
>
<mat-icon>more_vert</mat-icon>
<mat-icon class="material-symbols-light">more_vert</mat-icon>
</button>
<mat-menu #rowMenu="matMenu">
<button mat-menu-item (click)="openRenameDialog(element)">
Expand Down
Loading