diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 80fb4305..a9d0357f 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -121,12 +121,15 @@ Detailed documentation lives in `docs/`. Read the relevant file when working on |-------|------|----------------------| | Domain concepts (Space, Content, Schema, Translation, Asset) | [docs/concepts.md](../docs/concepts.md) | Any new feature, onboarding | | CDN caching, `cv` param, redirect logic, TTLs | [docs/cdn-caching.md](../docs/cdn-caching.md) | `functions/src/v1/cdn.ts`, public API | +| V1 API — all endpoints, routers, middleware, token permissions | [docs/v1-functions-api.md](../docs/v1-functions-api.md) | Any work in `functions/src/v1/` | | Publish flow & cache invalidation | [docs/publish-flow.md](../docs/publish-flow.md) | Content/translation publish, tasks | +| Webhooks — events, payload, HMAC signing, logging | [docs/webhooks.md](../docs/webhooks.md) | `functions/src/webhooks.ts`, `webhook-utils.ts`, webhook UI | | API token auth & permissions | [docs/auth-tokens.md](../docs/auth-tokens.md) | Middleware, token management, public API | | Firebase billing & cost optimization | [docs/billing.md](../docs/billing.md) | Functions, Storage, cost analysis | | Frontend architecture, routing, libs/ui | [docs/frontend-architecture.md](../docs/frontend-architecture.md) | Any Angular feature work | | NgRx Signal stores, state patterns | [docs/frontend-state.md](../docs/frontend-state.md) | Adding/editing stores or components | | User roles, route guards, UI permissions | [docs/frontend-permissions.md](../docs/frontend-permissions.md) | Auth, guards, user management | +| Spartan UI migration (checkbox, select, notifications) | [docs/spartan-ui-migration.md](../docs/spartan-ui-migration.md) | Migrating Material → Spartan, dialogs, forms | | **Feature modules — Admin** | | | | Admin overview (users, spaces, settings) | [docs/features/admin/overview.md](../docs/features/admin/overview.md) | Any admin feature | | Admin → Users | [docs/features/admin/admin-users.md](../docs/features/admin/admin-users.md) | `features/admin/users/` | diff --git a/.gitignore b/.gitignore index 9670ede3..2bc1f8af 100644 --- a/.gitignore +++ b/.gitignore @@ -53,3 +53,6 @@ Thumbs.db .runtimeconfig.json firebase-export firebase-export-* + +#Superpowers +/docs/superpowers diff --git a/CLAUDE.md b/CLAUDE.md index d93f19a3..c1768d89 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -117,12 +117,15 @@ Detailed documentation lives in `docs/`. Read the relevant file when working on |-------|------|----------------------| | Domain concepts (Space, Content, Schema, Translation, Asset) | [docs/concepts.md](docs/concepts.md) | Any new feature, onboarding | | CDN caching, `cv` param, redirect logic, TTLs | [docs/cdn-caching.md](docs/cdn-caching.md) | `functions/src/v1/cdn.ts`, public API | +| V1 API — all endpoints, routers, middleware, token permissions | [docs/v1-functions-api.md](docs/v1-functions-api.md) | Any work in `functions/src/v1/` | | Publish flow & cache invalidation | [docs/publish-flow.md](docs/publish-flow.md) | Content/translation publish, tasks | +| Webhooks — events, payload, HMAC signing, logging | [docs/webhooks.md](docs/webhooks.md) | `functions/src/webhooks.ts`, `webhook-utils.ts`, webhook UI | | API token auth & permissions | [docs/auth-tokens.md](docs/auth-tokens.md) | Middleware, token management, public API | | Firebase billing & cost optimization | [docs/billing.md](docs/billing.md) | Functions, Storage, cost analysis | | Frontend architecture, routing, libs/ui | [docs/frontend-architecture.md](docs/frontend-architecture.md) | Any Angular feature work | | NgRx Signal stores, state patterns | [docs/frontend-state.md](docs/frontend-state.md) | Adding/editing stores or components | | User roles, route guards, UI permissions | [docs/frontend-permissions.md](docs/frontend-permissions.md) | Auth, guards, user management | +| Spartan UI migration (checkbox, select, notifications) | [docs/spartan-ui-migration.md](docs/spartan-ui-migration.md) | Migrating Material → Spartan, dialogs, forms | | **Feature modules — Admin** | | | | Admin overview (users, spaces, settings) | [docs/features/admin/overview.md](docs/features/admin/overview.md) | Any admin feature | | Admin → Users | [docs/features/admin/admin-users.md](docs/features/admin/admin-users.md) | `features/admin/users/` | diff --git a/docs/concepts.md b/docs/concepts.md index 055b8353..a9740787 100644 --- a/docs/concepts.md +++ b/docs/concepts.md @@ -120,6 +120,8 @@ spaces/ {spaceId}/ contents/ {contentId} + history/ + {historyId} translations/ {translationId} schemas/ @@ -130,4 +132,9 @@ spaces/ {taskId} tokens/ {tokenId} + translations-history/ + {historyId} ``` + +> When a Space is deleted, `firestoreService.recursiveDelete()` removes the space document and all nested subcollections in one call. +> When a Content document is deleted, `firestoreService.recursiveDelete()` removes the content document and its `history` subcollection. Child folder contents (sibling documents referencing the folder via `parentSlug`) are cascade-deleted via the `onContentDelete` trigger. diff --git a/docs/features/spaces/assets.md b/docs/features/spaces/assets.md index 4a993fdd..0b80562e 100644 --- a/docs/features/spaces/assets.md +++ b/docs/features/spaces/assets.md @@ -44,6 +44,45 @@ File/folder browser driven by `SpaceStore.assetPath`. Supports two layout modes - `openImportDialog()` / `openExportDialog()` — creates Tasks for background processing - Unsplash integration (if `unsplash_ui_enable` Remote Config flag is `true`) — opens `UnsplashAssetsSelectDialogComponent` +## CDN Asset Endpoint + +``` +GET /api/v1/spaces/:spaceId/assets/:assetId +``` + +No auth required (public). Responses are cached for 365 days (`Cache-Control: public, max-age=31536000`). + +### Query Parameters + +| Param | Type | Description | +|-------|------|-------------| +| `w` | integer > 0 | Target width in pixels | +| `h` | integer > 0 | Target height in pixels | +| `q` | integer 1–100 | Output quality (default: `85`). Applies to JPEG, WebP, AVIF. Ignored for PNG. | +| `f` | string | Output format: `webp`, `jpeg`, `png`, or `avif`. Converts the image to this format. | +| `download` | (flag) | Changes `Content-Disposition` from `inline` to `form-data`, forcing a browser download. | +| `thumbnail` | (flag) | For animated WebP/GIF: extracts the first frame before resizing. For video: extracts a frame with FFmpeg, then resizes with Sharp. | + +### Resize Behaviour (`w` / `h`) + +Sharp is called as `resize(width ?? null, height ?? null)` with its default `cover` fit: + +| `w` | `h` | Behaviour | +|-----|-----|-----------| +| ✓ | — | Scale to width, height auto — aspect ratio preserved, no crop | +| — | ✓ | Scale to height, width auto — aspect ratio preserved, no crop | +| ✓ | ✓ | **`cover` crop** — resizes to fill the exact box, excess edges are cropped | +| — | — | No resize — only format/quality re-encoding if `f`/`q` provided | + +### Special Cases + +- **`image/svg+xml`** — always passed through; `w`/`h`/`f` are ignored. +- **Animated WebP or GIF without `thumbnail`** — passed through unchanged (Sharp cannot resize animated files). +- **Animated WebP or GIF with `thumbnail`** — first frame extracted, then `w`/`h`/`f` apply normally. +- **Video with `w` + `thumbnail`** — frame extracted via FFmpeg, then resized with Sharp; output defaults to `image/webp`. + +> See [V1 Functions API — Asset resize combinations](../../v1-functions-api.md) for the full implementation detail. + ## Image Preview Assets of type `image/*` render previews using `NgOptimizedImage` with the custom `IMAGE_LOADER`. The loader appends `?w=` to the CDN URL for responsive resizing and `&thumbnail=true` for animated files. diff --git a/docs/features/spaces/translations.md b/docs/features/spaces/translations.md index 6b751711..da0097d9 100644 --- a/docs/features/spaces/translations.md +++ b/docs/features/spaces/translations.md @@ -70,7 +70,7 @@ The main component is one of the most complex in the app. It renders a hierarchi | Service | Purpose | |---------|---------| -| `TranslationService` | CRUD + publish | +| `TranslationService` | CRUD + publish + publishDraft (called automatically after every write) | | `TranslationHistoryService` | Load and display edit history per key | | `LocaleService` | Load space locales | | `TaskService` | Create import/export tasks | @@ -78,3 +78,7 @@ The main component is one of the most complex in the app. It renders a hierarchi | `TranslateService` | AI translation (Google Translate / DeepL) | | `NotificationService` | Snackbar feedback | | `PlatformService` | Platform detection (keyboard shortcuts differ per OS) | + +## Draft Generation + +Every write operation in `TranslationService` (create, update, updateId, updateLocale, delete) automatically chains a call to `translation-publishdraft` onCall after the Firestore write succeeds. This keeps the draft Storage files (`draft/{locale}.json`) in sync without a Firestore trigger. diff --git a/docs/frontend-architecture.md b/docs/frontend-architecture.md index 1de94b28..dfe3caea 100644 --- a/docs/frontend-architecture.md +++ b/docs/frontend-architecture.md @@ -1,6 +1,6 @@ # Frontend Architecture -> Related: [State Management](frontend-state.md) · [User Roles & Permissions](frontend-permissions.md) · [Concepts](concepts.md) +> Related: [State Management](frontend-state.md) · [User Roles & Permissions](frontend-permissions.md) · [Concepts](concepts.md) · [Spartan UI Migration](spartan-ui-migration.md) ## Tech Stack @@ -8,7 +8,7 @@ |-------|-----------| | Framework | Angular 21 (standalone, zoneless, signals) | | State | NgRx Signals (`@ngrx/signals`) | -| UI Components | Angular Material + custom Spartan/Helm (`libs/ui/`) | +| UI Components | Angular Material (being migrated) + Spartan/Helm (`libs/ui/`) | | Styling | Tailwind CSS 4 + SCSS | | Backend SDK | AngularFire (Firestore, Auth, Storage, Functions, Remote Config) | | Rich Text | TipTap editor | diff --git a/docs/publish-flow.md b/docs/publish-flow.md index c090e7d6..e9a5e758 100644 --- a/docs/publish-flow.md +++ b/docs/publish-flow.md @@ -36,15 +36,50 @@ Draft files are separate from published files. Consumers must pass `?version=dra ``` 1. User clicks "Publish Translations" in the UI -2. Function reads all Translation documents from Firestore for the Space -3. Function groups by locale, writes flat key/value JSON to Storage: +2. Angular calls Firebase Function (translation-publish onCall) +3. Function reads all Translation documents from Firestore for the Space +4. Function groups by locale, writes flat key/value JSON to Storage: spaces/{spaceId}/translations/{locale}.json (one per locale) -4. Function updates the cache marker: +5. Function updates the cache marker: spaces/{spaceId}/translations/cache.json (new generation = new cv) ``` --- +## Translation Draft Flow + +Draft JSON files are kept in sync so consumers can preview unpublished changes via `?version=draft`. + +### Frontend saves (add / edit / rename / delete) + +``` +1. User saves a translation key in the UI +2. Angular TranslationService writes to Firestore +3. On success, TranslationService calls translation-publishdraft onCall +4. Function reads all translations and writes draft JSON to Storage: + spaces/{spaceId}/translations/draft/{locale}.json +``` + +### Import Task (TRANSLATION_IMPORT / TRANSLATION_IMPORT_FLAT) + +``` +1. Task Function processes all rows via BulkWriter +2. After bulk.close(), Function calls generateTranslationsDraft() once +3. Draft files written for all locales in a single pass +``` + +### CLI Manage API (POST /api/v1/spaces/:spaceId/translations/:locale) + +``` +1. Manage endpoint processes all rows via BulkWriter +2. After bulk.close(), endpoint calls generateTranslationsDraft() once +3. Draft files written for all locales in a single pass +``` + +> **Note:** There is no Firestore trigger watching translation writes. Draft generation is always triggered explicitly — either by the frontend calling `translation-publishdraft` or by the import/manage flow calling `generateTranslationsDraft()` directly at the end. + +--- + ## Cache Invalidation The `cache.json` file is a **cache pointer** — its content is mostly irrelevant, but its Firebase Storage **generation number** is used as the `cv` (cache version). @@ -96,6 +131,8 @@ Resolution is done at request time by reading additional Storage files. This add ## Implementation Files - `functions/src/contents.ts` — publish content Firebase Function -- `functions/src/translations.ts` — publish translations Firebase Function +- `functions/src/translations.ts` — `publish` onCall, `publishDraft` onCall, `onWriteToHistory` trigger - `functions/src/services/content.service.ts` — `contentLocaleCachePath`, `spaceContentCachePath` -- `functions/src/services/translation.service.ts` — `translationLocaleCachePath`, `spaceTranslationCachePath` +- `functions/src/services/translation.service.ts` — `saveTranslationFiles`, `generateTranslationsDraft`, `translationLocaleCachePath`, `spaceTranslationCachePath` +- `functions/src/tasks.ts` — `translationsImport`, `translationsImportJsonFlat` (call `generateTranslationsDraft` at end) +- `functions/src/v1/manage.ts` — CLI push endpoint (calls `generateTranslationsDraft` at end) diff --git a/docs/spartan-ui-migration.md b/docs/spartan-ui-migration.md new file mode 100644 index 00000000..ad776de3 --- /dev/null +++ b/docs/spartan-ui-migration.md @@ -0,0 +1,782 @@ +# Spartan UI Migration Guide + +> This document captures hard-won knowledge from migrating Angular Material components to the Spartan/Helm UI library (`libs/ui/`). Read this before touching any dialog, form, or notification code. + +--- + +## Migration Philosophy + +- **Dialog frame stays Material** — `MatDialogModule` (`mat-dialog-title`, `mat-dialog-content`, `mat-dialog-actions`, `[mat-dialog-close]`) is kept for the dialog container. Only form and interactive elements inside are replaced with Spartan. +- **Spartan components are headless primitives** — they render with `display: contents` or inject host classes. Layout is your responsibility. +- **All components are standalone** — import via `*Imports` barrel constants (e.g. `HlmButtonImports`, `HlmCheckboxImports`). + +--- + +## Component Replacement Table + +| Angular Material | Spartan Equivalent | Import | +|---|---|---| +| `MatButtonModule` | `hlmBtn` directive | `HlmButtonImports` | +| `MatFormFieldModule` | `hlmField` + `hlmFieldLabel` | `HlmFieldImports` | +| `MatInputModule` | `hlmInput` directive | `HlmInputImports` | +| `MatInputModule` (textarea) | `hlmTextarea` directive | `HlmTextareaImports` | +| `MatSelectModule` | `hlm-select` + related | `HlmSelectImports` | +| `MatAutocompleteModule` | `hlm-combobox` + related | `HlmComboboxImports` (see Combobox section) | +| `MatSlideToggleModule` | `hlm-switch` | `HlmSwitchImports` | +| `MatCheckboxModule` (boolean toggle) | `hlm-switch` with `formControlName` | `HlmSwitchImports` | +| `MatCheckboxModule` / `mat-selection-list` | `hlm-checkbox` | `HlmCheckboxImports` | +| `MatChipsModule` (`mat-chip-grid`) | `hlmInput` + `hlmBtn` badges | see Chips section | +| `MatDividerModule` | `hlm-separator` | `HlmSeparatorImports` | +| `MatTooltipModule` | `hlmTooltip` directive | `HlmTooltipImports` | +| `MatSnackBar` | `toast` from `ngx-sonner` | see Notifications section | + +--- + +## `mat-form-field` → `hlmField` — Standard Field Pattern + +Every `` maps to a `
` wrapper. The full anatomy: + +```html +
+ +
+ + {{ form.controls['name'].value?.length || 0 }}/30 +
+ Helper text here. + @if (form.controls['name'].errors; as errors) { + {{ fe.errors(errors) }} + } +
+``` + +Key rules: +- Always add `id` to the input and matching `for` on the label (ESLint requires it) +- **Character counter on a text input** → always use `hlmInputGroup` + `hlm-input-group-addon align="inline-end"` (places the counter inside the input at the trailing edge). Never use `` for counters on text inputs. +- **Character counter on a textarea** → use `hlmInputGroup` + `hlmInputGroupTextarea` + `hlm-input-group-addon align="block-end"` wrapping a `hlm-input-group-text`. This is the same group pattern as for text inputs, but with `align="block-end"` (below) instead of `align="inline-end"` (inside trailing edge). Do **not** use `hlmTextarea` + `hlm-field-description class="text-right"` for counters on textareas. +- When there is no counter, a plain `` (no group wrapper) is fine +- `mat-hint` (descriptive) → `` +- Multiple `hlm-field-description` elements are allowed in the same field +- `mat-error` → `` (component, not directive — see field error section) + +### Textarea + +Replace ` +{{ form.controls['description'].value?.length || 0 }}/250 + + + + + + {{ form.controls['description'].value?.length || 0 }}/250 + + + + + +``` + +Remove `TextFieldModule` and `HlmTextareaImports` from imports when all textareas in the component use `hlmInputGroupTextarea` — it is already included in `HlmInputGroupImports`. + +--- + +## `mat-chip-grid` → Spartan Tags Pattern + +There is no Spartan chip component. Replace `mat-chip-grid` / `mat-chip-row` with a plain `hlmInput` (for adding) and `hlmBtn` outline buttons (for display + removal): + +**Template:** +```html +
+ + +
+ @for (label of form.controls['labels'].value; track label) { + + } +
+
+``` + +**Component class:** +```typescript +addLabel(value: string): void { + if (value.trim()) { + const labels: string[] = this.form.controls['labels'].value ?? []; + this.form.controls['labels'].setValue([...labels, value.trim()]); + } +} + +removeLabel(label: string): void { + const labels: string[] = this.form.controls['labels'].value; + this.form.controls['labels'].setValue(labels.filter(l => l !== label)); +} +``` + +**Imports / providers required:** +```typescript +imports: [..., HlmInputImports, HlmButtonImports, HlmIconImports], +providers: [provideIcons({ lucideCircleX })], +``` + +> **Always use immutable updates** — call `setValue([...spread])` instead of mutating the array with `push` or `splice`. Mutating the array directly does not trigger Angular's change detection. + +--- + +## `mat-checkbox` (boolean toggle) → `hlm-switch` + +For a simple boolean form control (e.g. `autoTranslate`, `lock`), use `hlm-switch` with `formControlName` directly. Reserve `hlm-checkbox` for multi-select lists managed manually via `[checked]` + `(checkedChange)`. + +```html + +Translate other locales? + + +
+ + +
+``` + +Import: `HlmSwitchImports`, `HlmLabelImports`. + +--- + +## Dialog Actions Layout + +Always add `class="flex gap-2"` to `` and wrap the form in `class="flex flex-col gap-4 py-2"`: + +```html + + +
+ ... +
+
+ + + + + + +``` + +For **confirmation dialogs** (destructive actions), use `variant="destructive"` on the confirm button: + +```html + + + + +``` + +Remove the `
` spacers that were commonly added before/after `
` in Material dialogs — the `py-2` on the form and `gap-4` between fields handle spacing. + +--- + +## Table Filter Input — Spartan `hlmInputGroup` Pattern + +Replace `` + `(keyup)` event-based filtering with a reactive form + `hlmInputGroup`: + +**Template:** +```html + +
+
+
+ +
+ +
+
+
+
+ +``` + +**Component class:** +```typescript +filterForm = this.fb.group({ + search: this.fb.control('', []), +}); + +ngOnInit(): void { + this.filterForm.valueChanges.pipe(debounceTime(300)).subscribe({ + next: value => { + this.dataSource.filter = (value.search ?? '').toLowerCase(); + }, + }); +} +``` + +**Imports required:** +```typescript +imports: [..., ReactiveFormsModule, HlmFieldImports, HlmInputGroupImports, HlmIconImports], +providers: [provideIcons({ lucideSearch })], +``` + +Remove `MatFormFieldModule` and `MatInputModule`. The `(keyup)` handler and `applyFilter(event: KeyboardEvent)` method are no longer needed. + +--- + +## `hlm-select` — `itemToString` with Dynamic Data + +When select items come from injected dialog data (not a static enum), build the `itemToString` function by looking up from the data array: + +```typescript +// Static enum (known at compile time) +protected readonly roleItemToString = (value: string): string => { + const labels: Record = { custom: 'Custom', admin: 'Admin' }; + return labels[value] ?? value; +}; + +// Dynamic data (items from MAT_DIALOG_DATA) +protected readonly localeItemToString = (value: string): string => { + return this.data.locales.find(l => l.id === value)?.name ?? value; +}; +``` + +The rule is the same in both cases: **always provide `[itemToString]` when the select has a pre-selected value and uses `*hlmSelectPortal`**. + +### Prefix icon in the trigger + +To show an icon alongside the selected value text in the trigger, wrap both in ``. Without the wrapper the icon and value are block-level siblings and the icon stacks above the text. + +```html + + + + + + + + + + + + + +``` + +The icon binding can be driven by a getter or `computed()` that reads the current form control value, making it reactive to user selection: + +```typescript +// Getter reads the reactive form value +get type(): SchemaType { + return this.form.controls['type'].value; +} +``` + +```html + + + + +``` + +--- + + + +### Host renders as `display: contents` + +`HlmCheckbox` has `host: { class: 'contents peer' }`. The host element disappears from the layout tree; its inner `brn-checkbox` becomes the actual flex item. This is correct and intentional — do not add margin/padding to `hlm-checkbox` itself. + +### ❌ Wrong — description nested inside `