Compare commits
87 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5058c72375 | |||
| f5edcba7b2 | |||
| 64e1f71e18 | |||
| 7807d4ebe1 | |||
| 4410132409 | |||
| 00ff9e2702 | |||
| bb086e5869 | |||
| 674d772986 | |||
| ee296db7f6 | |||
| 8a4da2f0b9 | |||
| c28a2b1cf5 | |||
| 1427c92092 | |||
| 2fbb1c9b95 | |||
| 4c56eededc | |||
| f9d99b2c98 | |||
| 59608a23c5 | |||
| 2ddc57edb1 | |||
| 0bb656a512 | |||
| bb5a1fcad4 | |||
| 896b37792e | |||
| 2619fc67c8 | |||
| 4c14c67c33 | |||
| 494668bf24 | |||
| c4e22c706c | |||
| c747f3200f | |||
| 1dd1646cce | |||
| 6bbec2fc8e | |||
| 0c22ce8f09 | |||
| 67645cfd05 | |||
| 2591710f09 | |||
| 30999b038c | |||
| b5106d090f | |||
| a2ed334d0d | |||
| 9300c794b4 | |||
| 95dd48018a | |||
| c21b85afdf | |||
| 234a57d6b7 | |||
| 4bec507aab | |||
| a30d15f79d | |||
| b90604d311 | |||
| 77d0562b08 | |||
| aeda7e67a8 | |||
| bd9c67fc65 | |||
| 62fe27224c | |||
| 0708bb7352 | |||
| e6d5b9b77a | |||
| 04847391ad | |||
| 3d71b6836e | |||
| 833b5a921e | |||
| 3bf95538bd | |||
| eb7e977f3c | |||
| 0b8593950b | |||
| 51ac1a76de | |||
| 949bccfb8e | |||
| cfaf63468d | |||
| d6dcd82a53 | |||
| 3485acf3a8 | |||
| c04c2a9e98 | |||
| f1276faabc | |||
| 6029e226d5 | |||
| 135cc48301 | |||
| 54766fd5fc | |||
| fcc95b9195 | |||
| 042641d841 | |||
| 0358df82ac | |||
| 0f7088fe86 | |||
| 5408d0779c | |||
| abe94953b9 | |||
| 03fdcda054 | |||
| 5298cab9b1 | |||
| e05d93a67b | |||
| fd4fdd2624 | |||
| 639f4741e6 | |||
| d7071fdbc2 | |||
| 37cf19c405 | |||
| 37bbfb947f | |||
| 261b11436e | |||
| 280dbbcbc9 | |||
| ce17a685e0 | |||
| 64379c8901 | |||
| 1f8802363c | |||
| 58cdb4d9dc | |||
| 97cce691db | |||
| d0be26bb3e | |||
| 466084b5a3 | |||
| 558ff4b4c6 | |||
| bd85507308 |
+4
-2
@@ -317,5 +317,7 @@ Database/Vaults/*
|
||||
!DeepDrftPublic.Client/wwwroot/js/*.js
|
||||
# RCL compiled JS must be committed — MapStaticAssets serves from build-time manifest;
|
||||
# gitignored TS output is absent when manifest is generated, so absent from publish output.
|
||||
!DeepDrftShared.Client/wwwroot/js/parallax/
|
||||
!DeepDrftShared.Client/wwwroot/js/knob/
|
||||
# Re-include the whole RCL js/ tree so every compiled module (parallax, knob, theme, and
|
||||
# any added later) ships, rather than maintaining a per-module allowlist.
|
||||
!DeepDrftShared.Client/wwwroot/js/
|
||||
!DeepDrftShared.Client/wwwroot/js/**
|
||||
@@ -10,7 +10,7 @@ DeepDrftHome is a **net10.0** solution consisting of ten projects implementing a
|
||||
|
||||
- **DeepDrftPublic**: ASP.NET Core host. Blazor Web App with Server + WASM render modes. Owns browser-facing proxy controller for `api/track/*` (metadata listing and audio streaming), MudBlazor theme prerender, and TypeScript→JS audio interop. Public-facing site for listeners.
|
||||
- **DeepDrftPublic.Client**: Blazor WebAssembly assembly. All interactive UI (pages, player stack, dark-mode plumbing, HTTP clients for both backends). Pages include the public `/about` editorial page (`Pages/About.razor` — three-movement **"Liner Notes"** editorial treatment: numbered left-rail (oversized Bodoni numerals + vertical hairline spine + mono marginalia captions), asymmetric content column, pull-quotes breaking into the margin, hand-authored SVG waveform movement dividers (self-contained motif, not the live `WaveformVisualizer`), and stacked editorial definition list for CUTS/SESSIONS/MIXES; active-movement highlight via `about-rail.ts` IntersectionObserver interop; registered in `Layout/Pages.cs`). Home hero stat row (`NowPlayingStats.razor`) is live-data-backed via `IStatsDataService` / `StatsClient` (named `"DeepDrft.API"` client) with a `PersistentComponentState` prerender bridge; `RuntimeFormat` helper converts mix runtime seconds to `hh:mm`. Consumed by the public site.
|
||||
- **DeepDrftManager**: ASP.NET Core host. Blazor Web App with server-rendered `InteractiveServer` render mode. Hosts all CMS Razor components and pages under `Components/Pages/Cms/`, `Components/Pages/Tracks/`, `Components/Layout/CmsLayout.razor`, and `Components/Shared/` (all inlined from the former `DeepDrftCms` RCL). Public entry point: `Components/Pages/Home.razor` (`@page "/"`, no `[Authorize]`, uses lean `CmsHomeLayout`) — unauthenticated visitors see a DeepDrft-branded splash with a Login CTA; authenticated admins are redirected to `/catalogue` via `RedirectToCatalogue`. The catalogue dashboard (`Components/Pages/Index.razor`) lives at `@page "/catalogue"` and remains `[Authorize]`-gated with `CmsLayout`; its cards are **CUTS / SESSIONS / MIXES**, each deep-linking to `/releases?medium=<medium>` with the matching tab pre-selected. The consolidated browse surface is `Components/Pages/Tracks/Releases.razor` (`@page "/releases"`): bulk-action buttons (Generate All Profiles / Backfill High-res) → medium tab strip (ALL / CUTS / SESSIONS / MIXES) → the active tab's grid; waveform columns (Profile / High-res) — each showing a status icon when a datum is present and an always-visible generate/regenerate button — and per-track info tooltip live in `CmsAlbumBrowser`'s expanded child-row track table. Old list routes `/tracks`, `/tracks/albums`, `/tracks/archive` are kept as aliases on `Releases.razor` so bookmarks don't 404; operational sub-routes (`/tracks/upload`, edit routes, etc.) remain at `/tracks/*`. Gated by AuthBlocks login and hierarchical `Admin` role authorization. All track operations (upload, metadata read/write, delete, replace audio) are HTTP proxies via `ICmsTrackService` / `CmsTrackService` injected directly into Blazor components; no in-process data layer. The per-track "Replace audio" affordance in `BatchEdit` / `BatchTrackList` / `BatchTrackDetail` swaps the vault bytes, regenerates both waveform datums server-side, and re-derives `DurationSeconds` from the new audio; the track id, `EntryKey`, release membership, position, and all other metadata are preserved. The remove control on a persisted track is hidden when it is the release's sole remaining persisted track — a release can reach zero live tracks only via replace or release-level delete, not per-track removal. Two named HttpClients: `DeepDrft.Content.Cms` (bounded 100 s default, for all non-upload calls) and `DeepDrft.Content.Cms.Upload` (`InfiniteTimeSpan`, for large WAV uploads). Upload progress and idle/heartbeat timeout are driven by a single `ProgressStreamContent` wrapper (`Services/ProgressStreamContent.cs`); `CmsTrackService.UploadTrackAsync` adds a two-phase cancellation (idle window resets per progress tick; separate response-wait budget arms when the body completes).
|
||||
- **DeepDrftManager**: ASP.NET Core host. Blazor Web App with server-rendered `InteractiveServer` render mode. Hosts all CMS Razor components and pages under `Components/Pages/Cms/`, `Components/Pages/Tracks/`, `Components/Layout/CmsLayout.razor`, and `Components/Shared/` (all inlined from the former `DeepDrftCms` RCL). Public entry point: `Components/Pages/Home.razor` (`@page "/"`, no `[Authorize]`, uses lean `CmsHomeLayout`) — unauthenticated visitors see a DeepDrft-branded splash with a Login CTA; authenticated admins are redirected to `/catalogue` via `RedirectToCatalogue`. `Routes.razor` resolves `DefaultLayout` from the cascaded `Task<AuthenticationState>`: unauthenticated → `CmsHomeLayout`, authenticated → `CmsLayout`; this means the AuthBlocks `Login`/`Register` pages (which declare no `@layout`) render in the lean layout for unauthenticated visitors. `CmsLayout` carries a left `MudDrawer` (app-bar hamburger toggle) holding the CMS destinations (Catalogue `/catalogue`, Releases `/releases`, Upload `/tracks/upload`), the AuthBlocks `UserAdminMenu` fragment (self-gates to `UserAdmin`+, links Users/Registrations/Permissions), and a "Provision User" link to `/useradmin/users/new` wrapped in a `HierarchicalRoleAuthorizeView` (`UserAdmin`-gated) — making the AuthBlocks user-administration surface reachable from the CMS UI. The catalogue dashboard (`Components/Pages/Index.razor`) lives at `@page "/catalogue"` and remains `[Authorize]`-gated with `CmsLayout`; its cards are **CUTS / SESSIONS / MIXES**, each deep-linking to `/releases?medium=<medium>` with the matching tab pre-selected. The consolidated browse surface is `Components/Pages/Tracks/Releases.razor` (`@page "/releases"`): bulk-action buttons (Generate All Profiles / Backfill High-res) → medium tab strip (ALL / CUTS / SESSIONS / MIXES) → the active tab's grid; waveform columns (Profile / High-res) — each showing a status icon when a datum is present and an always-visible generate/regenerate button — and per-track info tooltip live in `CmsAlbumBrowser`'s expanded child-row track table. Old list routes `/tracks`, `/tracks/albums`, `/tracks/archive` are kept as aliases on `Releases.razor` so bookmarks don't 404; operational sub-routes (`/tracks/upload`, edit routes, etc.) remain at `/tracks/*`. Gated by AuthBlocks login and hierarchical `Admin` role authorization. All track operations (upload, metadata read/write, delete, replace audio) are HTTP proxies via `ICmsTrackService` / `CmsTrackService` injected directly into Blazor components; no in-process data layer. The per-track "Replace audio" affordance in `BatchEdit` / `BatchTrackList` / `BatchTrackDetail` swaps the vault bytes, regenerates both waveform datums server-side, and re-derives `DurationSeconds` from the new audio; the track id, `EntryKey`, release membership, position, and all other metadata are preserved. The remove control on a persisted track is hidden when it is the release's sole remaining persisted track — a release can reach zero live tracks only via replace or release-level delete, not per-track removal. Two named HttpClients: `DeepDrft.Content.Cms` (bounded 100 s default, for all non-upload calls) and `DeepDrft.Content.Cms.Upload` (`InfiniteTimeSpan`, for large WAV uploads). Upload progress and idle/heartbeat timeout are driven by a single `ProgressStreamContent` wrapper (`Services/ProgressStreamContent.cs`); `CmsTrackService.UploadTrackAsync` adds a two-phase cancellation (idle window resets per progress tick; separate response-wait budget arms when the body completes). The upload form is create-only: `BatchUpload.razor` calls `GET api/track/release/exists` as a pre-flight before transferring bytes and blocks the submit with a visible message if a (title, artist) match already exists; the server also rejects duplicates with 409. The authenticated user's id (`NameIdentifier` claim) is captured once into `_createdByUserId` at component initialization (`OnInitializedAsync`) — not re-read at submit — so a mid-session token expiry cannot discard a long-composed release; the page is `[Authorize]`-gated and runs `prerender: false`, so the auth state is fully available at init and only one init pass occurs. Within-batch multi-track Cuts still work by passing the release id from row 1 as `releaseId` on rows 2..N (the ATTACH path), while `BatchEdit.razor` uses the same ATTACH path for its legitimate adds-to-existing-release.
|
||||
- **DeepDrftShared.Client**: Razor Class Library. Shared Blazor components consumed by both `DeepDrftPublic` and `DeepDrftManager` for consistency across public and admin surfaces.
|
||||
- **DeepDrftData**: Class library. EF Core domain logic: `DeepDrftContext`, `TrackConfiguration`, `Migrations`, `TrackRepository`, `TrackService`, `TrackManager`. Consumed by `DeepDrftAPI` and tests.
|
||||
- **DeepDrftAPI**: ASP.NET Core host. Dual-database authority (SQL metadata + FileDatabase binary). AuthBlocks API host (owns registration, migration/seed, JWT endpoints). Track endpoints: streaming, vault write, upload+persist, delete+cleanup, paged list with filters, single metadata (ApiKey-gated operations), metadata update, waveform profiles (512-bucket seeker + per-track high-res visualizer datum in the `track-waveforms` vault), release-track join operations, `POST api/track/duration/backfill` (ApiKey-gated one-time backfill of `DurationSeconds` for existing rows from vault audio). Stats endpoints: `GET api/stats/home` (unauthenticated; returns `HomeStatsDto` with cut track count, per-`ReleaseType` cut release counts, mix release count, and total mix runtime seconds). Release endpoints: paged list with medium filter, single read, session hero-image upload (all unauthenticated reads; authenticated writes via ApiKey). Image endpoints: authenticated upload, unauthenticated streaming.
|
||||
@@ -76,17 +76,20 @@ Keep this seam clean — it is the most architecturally load-bearing part of the
|
||||
|
||||
### Theming and dark mode
|
||||
|
||||
- MudBlazor is the UI framework. Light and dark palettes (bespoke "Charleston in the Day" / "Lowcountry Summer Nights") defined inline in `MainLayout.razor`.
|
||||
- MudBlazor is the UI framework. Light and dark palettes (bespoke "Charleston in the Day" / "Lowcountry Summer Nights") defined in `DeepDrftShared.Client/Common/DeepDrftPalettes.cs`. `MainLayout.razor` mounts `<MudThemeProvider Theme="@DeepDrftPalettes.Default" IsDarkMode="_isDarkMode" />` — the palettes are not inline in the layout.
|
||||
- Dark mode toggles via cookie (`darkMode`, 365 days). Client-side via JS interop.
|
||||
- During server prerender, `DarkModeService` (in `DeepDrftPublic`) reads the cookie and seeds `DarkModeSettings.IsDarkMode`, which carries into WASM render via `PersistentComponentState`. Avoids "wrong theme flash" on initial paint.
|
||||
- `DarkModeSettings` lives in `DeepDrftPublic.Client.Common` (consumed by both server prerender and client components).
|
||||
- **Theme-aware token layer:** `DeepDrftShared.Client/wwwroot/styles/deepdrft-tokens.css` defines two kinds of CSS custom properties. *Source tokens* (`--deepdrft-navy`, `--deepdrft-white`, `--deepdrft-green-accent`, etc.) are brand constants — identical in `:root` and `.deepdrft-theme-dark`. *Theme-aware aliases* are defined in both blocks and flip when the theme wrapper class changes. Component and page CSS must bind the **alias**, not the source token, so neutral surfaces invert for free. Current alias families: `--deepdrft-page-surface`/`-text`/`-text-muted` (neutral page backgrounds and text), `--deepdrft-play-chip`/`-glyph`/`-chip-soft` (play-state icon chip and glyph), `--deepdrft-popover-surface` (default MudBlazor popover background — light: `color-mix(navy 4%, white)`, a near-page-background surface; dark: references source token `--deepdrft-popover-surface-dark`, a `color-mix(navy-mid 80%, green-accent 20%)` bluer navy defined once in `:root` and referenced by both the `.deepdrft-theme-dark` wrapper block and `body.deepdrft-theme-dark` so portaled popovers are reached). The bespoke glass panels (visualizer/queue/privacy) now bind their own theme-aware `--deepdrft-panel-surface`/`-text`/`-text-muted`/`-border`/`-row-hover` family: dark-glass charcoal (sourced from the `--deepdrft-panel-ground` constant) with light text in dark theme, and a light translucent glass with dark text in light theme. These tokens are re-declared in `body.deepdrft-theme-dark` because the panels are MudOverlay panels that portal to `<body>` (same portal scope as popovers); the `--deepdrft-panel-ground` source token is now consumed only via the dark `--deepdrft-panel-surface` value.
|
||||
- **Portaled-popover body-class bridge:** MudBlazor popovers portal to `<body>`, outside the `.deepdrft-theme-dark` wrapper `<div>`, so the dark popover token never reached them. Fix: `MainLayout.razor` stamps `deepdrft-theme-dark` on `<body>` via the `setBodyThemeClass(isDark)` helper in `DeepDrftShared.Client/Interop/theme/theme.ts` (lazy-imported as `_content/DeepDrftShared.Client/js/theme/theme.js`). The call fires only on first render or when `_isDarkMode` actually changes (gated by `_lastAppliedDarkMode` comparison) to avoid redundant JS calls on unrelated re-renders. The `body.deepdrft-theme-dark` selector in `deepdrft-tokens.css` resolves `--deepdrft-popover-surface` from `--deepdrft-popover-surface-dark` for these portaled elements.
|
||||
- **Interactive-accent icon treatment (`.dd-accent-icon` / `.dd-accent-fill`):** one reusable rule in `DeepDrftPublic/wwwroot/styles/deepdrft-styles.css` for green-accent interactive icon affordances (Play / Share / Add-to-Queue / lava-lamp trigger), replacing the former pile of per-site dark overrides. Wrap the affordance container in `.dd-accent-icon` to colour its glyphs green-accent in both themes; add `.dd-accent-fill` when the container also holds a `Color.Secondary` filled button that must go green-accent in dark. It is a CSS class (not a palette `Color`) because no MudBlazor `Color` enum is green in both themes, and it targets `.dd-accent-icon .mud-icon-button .mud-icon-root` (0,3,0) `!important` to beat MudBlazor's standalone `.mud-secondary-text` (0,1,0) `!important` on the glyph svg — specificity wins; source order is not load-bearing for the glyph clause. The Session/Mix release-detail hero Share/Play glyphs use this class too (already green-accent in light via `Color.Secondary`, so folding them in keeps light pixel-identical and fixes dark). The gas-lamp toggle (`GasLampLit`) is self-colored in its SVG (`fill="#2A5C4F"` on the frame) — no dark-only CSS rule is needed; `GasLamp` (unlit, light mode) continues to use `currentColor` and inherits nav text colour. New green-accent icons use this class, not a new override. (Convention detail in `DeepDrftPublic.Client/CLAUDE.md`.)
|
||||
- Typography: Google Fonts (Bodoni Moda, Cormorant, DM Sans). Hand-rolled gas-lamp icon (lit/unlit) lives in `DeepDrftShared.Client/Common/DDIcons.cs`.
|
||||
|
||||
### TypeScript interop, not raw JS
|
||||
|
||||
Audio interop authored in TypeScript under `DeepDrftPublic/Interop/audio/`, compiled to `wwwroot/js/audio/` via `Microsoft.TypeScript.MSBuild`. One module per responsibility (AudioContextManager, StreamDecoder, PlaybackScheduler, SpectrumAnalyzer, AudioPlayer), plus `index.ts` exposing `window.DeepDrftAudio`. `tsconfig.json` is **not** copied to output. In dev, raw `.ts` served from `/Interop/` for source-map debugging. A second interop module lives at `DeepDrftPublic/Interop/about/about-rail.ts` (IntersectionObserver for the About page active-movement rail highlight; compiled output gitignored).
|
||||
|
||||
**`DeepDrftShared.Client` also hosts TypeScript interop.** Its `tsconfig.json` maps `rootDir: "Interop"` → `outDir: "wwwroot/js"`, compiled by the same `Microsoft.TypeScript.MSBuild` package. Current modules: `Interop/parallax/parallax.ts` (parallax scroll for `ParallaxImage`) and `Interop/knob/knob.ts` (`capturePointer`/`releasePointer` for `RadialKnob`). Consumers lazy-import via the static-asset path `_content/DeepDrftShared.Client/js/<module>/<file>.js`.
|
||||
**`DeepDrftShared.Client` also hosts TypeScript interop.** Its `tsconfig.json` maps `rootDir: "Interop"` → `outDir: "wwwroot/js"`, compiled by the same `Microsoft.TypeScript.MSBuild` package. Current modules: `Interop/parallax/parallax.ts` (parallax scroll for `ParallaxImage`), `Interop/knob/knob.ts` (`capturePointer`/`releasePointer` for `RadialKnob`), and `Interop/theme/theme.ts` (`setBodyThemeClass(isDark)` — stamps/removes `deepdrft-theme-dark` on `<body>` so portaled MudBlazor elements inherit the dark popover token; consumed by `MainLayout.razor`). Consumers lazy-import via the static-asset path `_content/DeepDrftShared.Client/js/<module>/<file>.js`.
|
||||
|
||||
## Development Commands
|
||||
|
||||
@@ -126,7 +129,7 @@ All projects load secrets via `CredentialTools.ResolvePathOrThrow()` from gitign
|
||||
|
||||
- `DeepDrftPublic/appsettings.json`: Logging and URL config. Secrets loaded from `environment/api.json` (DeepDrftAPI base URL via `Api:ContentApiUrl`).
|
||||
- `DeepDrftManager/appsettings.json`: Logging and URL config. Secrets loaded from `environment/api.json` (DeepDrftAPI base URL via `Api:ContentApiUrl` and API key via `Api:ContentApiKey`). Non-secret upload tunables (in `appsettings.json` itself, not `environment/`): `Upload:IdleTimeoutSeconds` (default 90 — aborts a stalled body-streaming phase) and `Upload:ResponseTimeoutSeconds` (default 1200 — budget for server-side persist after the body is fully sent).
|
||||
- `DeepDrftAPI/appsettings.json`: Logging and hosting config. Secrets loaded from `environment/filedatabase.json` (FileDatabase vault path), `environment/apikey.json` (API key), `environment/connections.json` (SQL and Auth connection strings), `environment/authblocks.json` (AuthBlocks JWT/email/admin creds).
|
||||
- `DeepDrftAPI/appsettings.json`: Logging and hosting config. Non-secret upload tunable: `Upload:StagingPath` (default empty → a `staging` subdirectory under the FileDatabase vault path) — the data-disk directory where large audio bodies are staged during upload/replace-audio, kept off the system temp mount (`/tmp` is a small tmpfs on the Linux host); `Startup` also points the framework's multipart buffer here via `ASPNETCORE_TEMP`. Secrets loaded from `environment/filedatabase.json` (FileDatabase vault path), `environment/apikey.json` (API key), `environment/connections.json` (SQL and Auth connection strings), `environment/authblocks.json` (AuthBlocks JWT/email/admin creds).
|
||||
|
||||
## Folder-Level Guidance
|
||||
|
||||
|
||||
+2
-2
@@ -40,7 +40,7 @@ The CMS is now inlined as the primary content of `DeepDrftManager`, a dedicated
|
||||
- A `[HierarchicalRoleAuthorize("Admin")]` attribute (from `AuthBlocksWeb.HierarchicalAuthorize`) on every CMS page component, so `Admin` and any descendant role are admitted by the bundled hierarchical role handler.
|
||||
- Controllers and minimal-API endpoints for CMS operations (`POST api/cms/track`, `DELETE api/cms/track/{id}`, `PUT api/cms/track/{id}`). Controllers are host-owned per the existing convention. Protected by `[Authorize(Roles = "Admin")]` — the JWT bearer middleware AuthBlocks installs validates the access token on each request.
|
||||
- The `AddAuthBlocks(...)` call in `Program.cs` and the matching `await app.Services.UseAuthBlocksStartupAsync()` post-build hook. This installs JWT bearer middleware, the hierarchical role authorization handler, the `AuthDbContext`, the EF migrations, and seeds system roles plus the configured admin user on first boot.
|
||||
- The `app.MapAuthBlocks()` call that registers `/api/auth/*`, `/api/users/*`, `/api/roles/*`, `/api/user-roles/*`, and `/api/pending-registrations/*` minimal-API endpoints. The CMS UI uses `/api/auth/login`, `/api/auth/logout`, `/api/auth/refresh`, and `/api/auth/me`; the rest are available if Wave 3 account-management ever lands.
|
||||
- The `app.MapAuthBlocks()` call that registers `/api/auth/*`, `/api/users/*`, `/api/roles/*`, `/api/user-roles/*`, and `/api/pendingregistration/*` minimal-API endpoints. The CMS UI uses `/api/auth/login`, `/api/auth/logout`, `/api/auth/refresh`, and `/api/auth/me`; the rest are available if Wave 3 account-management ever lands.
|
||||
|
||||
**Render mode:** `InteractiveServer` for all CMS pages and routes. AuthBlocks's bundled UI (`AuthBlocksWeb` pages) is server-rendered MudBlazor with `JwtAuthenticationStateProvider` reading tokens from browser `localStorage` via JS interop. `InteractiveServer` is the right fit because: (a) it matches what the bundled login UI uses, (b) `InputFile` uploads are natively server-side, (c) CMS endpoints live in the `DeepDrftManager` process with direct access to services.
|
||||
|
||||
@@ -79,7 +79,7 @@ Concretely, from reading the library source:
|
||||
|
||||
- Real per-user accounts (`ApplicationUser` table). No shared password.
|
||||
- One seeded admin on first boot via `AdminUserSettings`. Username, email, password come from `DeepDrftManager/environment/authblocks.json` (gitignored, same pattern as `apikey.json`).
|
||||
- No public signup in Wave 1. The `/account/register` page that AuthBlocks bundles requires a registration code (generated by an admin via `/api/pending-registrations`). We do not surface `/account/register` in any nav until Wave 3 account management lands; the route exists but is uninteresting until then.
|
||||
- No public signup in Wave 1. The `/account/register` page that AuthBlocks bundles requires a registration code (generated by an admin via `/api/pendingregistration`). We do not surface `/account/register` in any nav until Wave 3 account management lands; the route exists but is uninteresting until then.
|
||||
- **Mutation attribution.** `TrackEntity` gains a nullable `CreatedByUserId : long?` column in the W1.2 migration. Populated on every CMS-originated mutation; null for historical CLI-added rows and for any pre-CMS data. Captures attribution from day one even though Wave 1 has exactly one user (`feedback_design_for_adaptability`).
|
||||
- **Role gate.** Every CMS page and every `api/cms/*` endpoint requires the `Admin` system role. We use `Admin` rather than introducing a new `CmsAdmin` role because the collective is small and the existing hierarchy already covers the case; if Wave 3 ever needs finer grain (e.g. a `ContentEditor` role that can edit but not delete), that is a `SystemRole.cs` edit upstream, not a redesign here.
|
||||
|
||||
|
||||
@@ -6,6 +6,73 @@ Newest entries at the top. Group by phase/wave header (mirroring `PLAN.md` / `CM
|
||||
|
||||
---
|
||||
|
||||
## Phase 18 — Theme / Dark-Mode Remediation (landed 2026-06-19)
|
||||
|
||||
**Landed:** 2026-06-19 on dev (Wave 1 + Wave 2 + Wave 3).
|
||||
|
||||
- **What:** A DRY token pass resolving six theming symptoms (five in dark mode, one in light) that all traced to three root causes: neutral page surfaces bound to constant brand tokens, the play chip bound to a constant light-grey, and no theme-aware popover-surface token. Resolved as one coherent pass via a shared token layer rather than per-component patches.
|
||||
|
||||
- **Why:** Symptom consolidation and root-cause analysis showed all six symptoms shared the same underlying structure — component CSS bypassing the theme-aware alias layer and binding constant source tokens directly. A single additive token pass in `deepdrft-tokens.css` plus targeted re-pointing of consumers fixes all six without scattering dark-mode rules.
|
||||
|
||||
- **Shape:**
|
||||
- **Token foundation (`deepdrft-tokens.css`):** Three new theme-aware token families added to `DeepDrftShared.Client/wwwroot/styles/deepdrft-tokens.css`, each defined in both `:root` (light) and `.deepdrft-theme-dark` (dark):
|
||||
- `--deepdrft-page-surface` / `--deepdrft-page-text` / `--deepdrft-page-text-muted` — neutral page surface family. Light: `--deepdrft-white` / `--deepdrft-navy` / `--deepdrft-muted`. Dark: `var(--mud-palette-background)` (#0D1B2A, the true page ground) / `--deepdrft-white` / `color-mix(muted 70%, white)` — neutral sections dissolve into the site background as one continuous dark field rather than reading as raised panels.
|
||||
- `--deepdrft-play-chip` / `--deepdrft-play-glyph` / `--deepdrft-play-chip-soft` — play-chip family. Light: soft-grey chip (matching prior `--deepdrft-soft`). Dark: `--deepdrft-green-accent` chip + `--deepdrft-navy` glyph (navy-on-green for solid chips); `--deepdrft-play-chip-soft` is `color-mix(green-accent 30%, transparent)` (the player-bar translucent override).
|
||||
- `--deepdrft-popover-surface` — popover surface. Light: `color-mix(navy 8%, white)` soft desaturated-navy wash. Dark: `#162437` (pixel-identical to `DeepDrftPalettes.Dark.Surface` — dark popovers unchanged, only light is retoned).
|
||||
- **Neutral-surface inversion (T2):** `Home.razor.css`, `About.razor.css`, `DeepDrftFooter.razor.css` re-pointed from constant `--deepdrft-white`/`--deepdrft-navy` to `--deepdrft-page-surface`/`--deepdrft-page-text`. Decorative navy/green sections (`.section-dark`, `.split-left`, `.cta-banner`, hero overlays) untouched — classification encoded in which token each section binds.
|
||||
- **Play-chip theming (T3):** `PlayStateIcon.razor.css` `.icon-container` re-pointed to `--deepdrft-play-chip`; glyph to `--deepdrft-play-glyph`. Player-bar context overrides chip to `--deepdrft-play-chip-soft` (translucent green wash). Light-mode parity and connect-option hover also corrected.
|
||||
- **Popover surface (T4):** `deepdrft-styles.css` binds `--deepdrft-popover-surface` to the MudBlazor default popover surface. Bespoke dark-glass panels (`--deepdrft-panel-ground`) untouched.
|
||||
- **Wave 2 refinements (on top of T1–T4):** App bar background moved to navy (`#112338`) from near-black (`#0D1B2A`). Neutral page surfaces re-pointed to `var(--mud-palette-background)` (`#0D1B2A`) as the true dark ground — sections dissolve into the body background rather than reading as navy-mid raised panels (resolves Wave 1's open question in favour of ground). Dark-mode hero legibility (superseded in Wave 3 — see below). Play-glyph settled on navy-on-green (solid chips) and green-on-green (player bar, via `--deepdrft-play-chip-soft`).
|
||||
- **Wave 3 — hero dark-mode legibility fix:** `DeepDrftHero.razor.css` hero text re-worked to bind theme-aware tokens directly in the base rules rather than via `:global(.deepdrft-theme-dark)` overrides (matching the About page's proven pattern). `.hero-title` and `.hero-desc` now bind `--deepdrft-page-text` directly; `.hero-subtitle` (previously bound to the constant `--deepdrft-muted`) now binds `--deepdrft-page-text-muted`, making it theme-aware for the first time. Only `.hero-title em` retains an explicit dark override (`:global(.deepdrft-theme-dark) .hero-title em` → `--deepdrft-green-accent`, lifting the low-contrast `--deepdrft-green` on the dark ground). Global hero-button dark treatment added to `deepdrft-styles.css`: `.deepdrft-theme-dark .btn-primary` → `--deepdrft-green-accent` fill + `--deepdrft-navy` text (hover: `--deepdrft-green-interactive`); `.deepdrft-theme-dark .btn-ghost` → `--deepdrft-page-text` color + `--deepdrft-border-light` border.
|
||||
- **Open questions resolved:** Dark neutral surface = ground (continuous field, `--mud-palette-background`) — not elevated navy-mid. Popover target: `color-mix(navy 8%, white)` in light; dark binds `#162437` (MudBlazor dark Surface) unchanged.
|
||||
|
||||
- **Design memo:** `product-notes/theme-dark-mode-remediation.md`.
|
||||
|
||||
### Phase 18 — Wave 4 — Popover-surface retune + portaled-popover body-class bridge (landed 2026-06-20)
|
||||
|
||||
**Landed:** 2026-06-20 on dev.
|
||||
|
||||
- **What:** Follow-on retune of `--deepdrft-popover-surface` values and a root-cause fix for portaled MudBlazor popovers that were never reaching the dark token.
|
||||
|
||||
- **Why:** Wave 1–3 shipped `--deepdrft-popover-surface` light at `color-mix(navy 8%, white)` (too saturated — read as a grey slab) and dark at flat `#162437`. More importantly, MudBlazor popovers portal to `<body>`, outside the `.deepdrft-theme-dark` wrapper `<div>`, so the dark token never applied to them at all. Both needed fixing as a pair.
|
||||
|
||||
- **Shape:**
|
||||
- **Token retune (`deepdrft-tokens.css`):** Light value changed from 8% → 4% navy mix (near-page-background, clearly light). Dark value changed from `#162437` to `color-mix(in srgb, var(--deepdrft-navy-mid) 80%, var(--deepdrft-green-accent) 20%)` — a bluer navy with a slight green accent. Dark value hoisted into a new source token `--deepdrft-popover-surface-dark` (defined once in `:root`), referenced by both the `.deepdrft-theme-dark` wrapper block and a new `body.deepdrft-theme-dark` block so portaled content is reached from either selector.
|
||||
- **Portaled-popover body-class bridge (`MainLayout.razor` + new TS module):** `MainLayout.razor` now stamps/removes `deepdrft-theme-dark` on `<body>` after each render via a new `DeepDrftShared.Client/Interop/theme/theme.ts` module exporting `setBodyThemeClass(isDark: boolean)`. Lazy-imported as `_content/DeepDrftShared.Client/js/theme/theme.js`. Call is gated to fire only on first render or when `_isDarkMode` changes (`_lastAppliedDarkMode` comparison) — no redundant JS calls on unrelated re-renders. `IJSObjectReference _themeModule` is disposed in `DisposeAsync` to clean up the module reference when the circuit tears down.
|
||||
|
||||
### Phase 18 — Wave 5 — Glass-panel theme-aware token family (landed 2026-06-20)
|
||||
|
||||
**Landed:** 2026-06-20 on dev.
|
||||
|
||||
- **What:** The three `MudOverlay`-based glass panels — the queue panel (`.deepdrft-queue-modal`), the waveform visualizer control deck, and the privacy modal — now render as a light translucent glass with legible dark text in light theme, while remaining the existing dark-glass charcoal in dark theme. Dark mode is visually unchanged; a latent white-on-light bug in the inline embed queue row was incidentally fixed by the token flip.
|
||||
|
||||
- **Why:** Prior to this wave, all three panels were bound to the constant `--deepdrft-panel-ground` token, exempting them from the theme-aware alias layer established in Waves 1–3. In light theme this produced white text on a near-white glass surface — unreadable. The panels needed their own theme-aware family (separate from `--deepdrft-popover-surface`, which targets MudBlazor default popovers) and the same `body.deepdrft-theme-dark` portal-scope treatment introduced for popovers in Wave 4.
|
||||
|
||||
- **Shape:**
|
||||
- **New token family (`deepdrft-tokens.css`):** `--deepdrft-panel-surface` / `--deepdrft-panel-text` / `--deepdrft-panel-text-muted` / `--deepdrft-panel-border` / `--deepdrft-panel-row-hover` — each defined in `:root` (light values: translucent glass with dark text), `.deepdrft-theme-dark` (dark-glass charcoal with light text, sourced from the existing `--deepdrft-panel-ground` constant), and `body.deepdrft-theme-dark` (same dark values re-declared so the tokens resolve correctly when the panels portal to `<body>` via `MudOverlay`).
|
||||
- **Consumer re-pointing:** The three panels and their descendants (queue rows, visualizer deck, privacy modal) previously bound `--deepdrft-panel-ground` directly; they are now re-pointed to the appropriate `--deepdrft-panel-surface`/`-text`/`-text-muted`/`-border`/`-row-hover` aliases.
|
||||
- **Exemption lifted:** This deliberately removes the previously-documented exemption of these panels from the theme-aware layer. `--deepdrft-panel-ground` is now consumed only as the dark-theme value of `--deepdrft-panel-surface`, not directly by any component CSS.
|
||||
|
||||
---
|
||||
|
||||
## Phase 17 — Player-Bar Queue View: Wave 17.3 — Fixed embed panel + iframe resize (landed 2026-06-19)
|
||||
|
||||
**Landed:** 2026-06-19 on dev.
|
||||
|
||||
- **What:** The Fixed (embed) mode queue panel and the OQ1 Option-A iframe resize handshake. Release embeds now render an always-shown, read-only queue panel below the player-bar controls; the Queue button collapses/expands that panel and posts the iframe's new height to the host page so the outer `<iframe>` element resizes to match. Single-track embeds (TrackEntryKey mode) have no queue, no panel, and no Queue button — unchanged compact behaviour. Phase 17 is now complete (all four waves landed).
|
||||
|
||||
- **Why:** Phase 11 wave 11.F armed release embeds with a queue (skip navigation, auto-advance), but the viewer had no way to see or jump within the queue. Wave 17.3 surfaces it in Fixed mode — read-only because a shared embed is not an editable playlist — and resolves OQ1 (Option A confirmed feasible: `postMessage` resize degrades gracefully if the host strips the script).
|
||||
|
||||
- **Shape:**
|
||||
- **Fixed embed queue panel** (`AudioPlayerBar.razor`): rendered conditionally on `ShowFixedPanel && _fixedPanelOpen` inside `.deepdrft-queue-embed-panel`; hosts `<QueueList Items="QueueItems" CurrentIndex="QueueCurrentIndex" Editable="false" OnJump="@OnQueueJump" />`. Read-only: no drag handles, no remove buttons. Row-jump (OQ2) calls `PlayRelease(Items, index)` — coherent from the armed-but-not-started state (`PlayRelease` already clears `IsArmed` and materializes a defensive copy).
|
||||
- **Queue button in Fixed mode** (`PlayerTransportZone`): toggles `_fixedPanelOpen`; triggers a height post after the panel renders. Gated on `ShowFixedPanel` so single-track embeds see no button.
|
||||
- **`EmbedSnippetBuilder.cs`** (`DeepDrftPublic.Client/Helpers/EmbedSnippetBuilder.cs`): `ForRelease` now mints a per-snippet random token (8 hex chars from `Guid.NewGuid().ToString("N")[..8]`). Token is used as the iframe id (`deepdrft-embed-{token}`) and threaded into the iframe src as `&EmbedId={token}`. Taller iframe height (release: 384 px vs. track: 196 px). Carries a host-side `<script>` listener that matches incoming `{type:"deepdrft-embed-resize", embedId}` messages against the snippet's own token and sets `iframe.style.height` — multiple release embeds on one host page resize independently (no cross-talk). Degrades to Option B if the host strips the script (panel still works inside the iframe at expanded height). `ForTrack` is unchanged (compact height 196 px, no script, no id token).
|
||||
- **`embed-frame.ts`** (`DeepDrftPublic/Interop/embed/embed-frame.ts`; compiled output gitignored): new TypeScript interop module. Reads `EmbedId` from `window.location.search` once at module load; exports `postHeight(element: HTMLElement)` — measures the player element's rendered height (`Math.ceil(getBoundingClientRect().height) + 2`), builds `{type:"deepdrft-embed-resize", height, embedId?}` payload (omits `embedId` when absent for backward-compatible degradation), and calls `window.parent.postMessage(payload, "*")`. No-ops when not framed (`window.parent === window`) or the element is unmeasurable.
|
||||
- **CSS** (`DeepDrftPublic/wwwroot/styles/deepdrft-styles.css`): new `deepdrft-queue-embed-panel` and related `deepdrft-` embed-panel classes for the fixed queue panel chrome.
|
||||
- **Tests** (`EmbedSnippetBuilderTests`): height divergence (ForRelease taller than ForTrack), ForTrack-unchanged (height 196, no script), id uniqueness (two ForRelease calls yield distinct ids), id/script-token consistency (iframe id matches token in script), EmbedId-in-src (token appears as `EmbedId=` in the iframe src).
|
||||
|
||||
---
|
||||
|
||||
## Phase 17 — Player-Bar Queue View: Wave 17.4 — Add-to-Queue affordance (landed 2026-06-19)
|
||||
|
||||
**Landed:** 2026-06-19 on dev.
|
||||
|
||||
+21
-7
@@ -16,7 +16,7 @@ Dual-database authority for tracks (SQL metadata + FileDatabase binary), release
|
||||
- `Controllers/TrackController.cs`: Track endpoints (see below).
|
||||
- `Controllers/ReleaseController.cs`: Release endpoints (see below).
|
||||
- `Middleware/ApiKeyAuthenticationMiddleware.cs`, `Middleware/ApiKeyAuthorizeAttribute.cs`: ApiKey validation logic (for track endpoints only).
|
||||
- `Models/`: Settings POCOs only (`ApiKeySettings`, `CorsSettings`, `FileDatabaseSettings`). No domain code.
|
||||
- `Models/`: Settings POCOs only (`ApiKeySettings`, `CorsSettings`, `FileDatabaseSettings`, `UploadSettings`, `UploadStagingDirectory`). No domain code.
|
||||
- `environment/filedatabase.json`: FileDatabase vault path config (loaded via CredentialTools, not in repo).
|
||||
- `environment/apikey.json`: API key for track endpoints (loaded via CredentialTools, not in repo, must be created locally or at deployment).
|
||||
- `environment/connections.json`: SQL and Auth connection strings (loaded via CredentialTools, not in repo, format: `{ "ConnectionStrings": { "DefaultConnection": "...", "Auth": "..." } }`).
|
||||
@@ -120,6 +120,17 @@ Admin backfill: for every track whose `DurationSeconds` SQL column is still null
|
||||
- **Response**: `{ updated: int, skipped: int }` — counts of rows written vs. already-populated rows bypassed.
|
||||
- Returns 200 on success. Returns 500 if the backfill operation fails.
|
||||
|
||||
### GET api/track/release/exists ([ApiKeyAuthorize])
|
||||
|
||||
Upload-form pre-flight: checks whether a release with the given (title, artist) already exists in the catalogue. Returns the matching `ReleaseDto` (so the caller can name it in a block message) or 404 when none exists. Uses the same `GetReleaseByTitleAndArtist` read the upload CREATE-path duplicate guard uses, so the pre-flight and the server backstop agree on the match by construction (exact ordinal comparison, soft-deleted rows excluded).
|
||||
|
||||
- **Header `ApiKey`**: required. Validated by `ApiKeyAuthenticationMiddleware`.
|
||||
- **Query parameters**:
|
||||
- `title` (string, required): the release title to check.
|
||||
- `artist` (string, required): the artist name to check.
|
||||
- Declared as a literal 2-segment route (`"release/exists"`) before the parameterized `{trackId}` route and distinct from `"release/{id:long}"` (different segment shape) — no routing ambiguity.
|
||||
- Returns 200 with `ReleaseDto` JSON if a match exists. Returns 400 if either query parameter is missing or whitespace. Returns 404 if no match. Returns 500 on query error.
|
||||
|
||||
### DELETE api/track/release/{id:long} ([ApiKeyAuthorize])
|
||||
|
||||
Soft-delete a release row. Used by the albums browser to remove an orphaned release (one with no live tracks).
|
||||
@@ -156,10 +167,11 @@ Soft-delete a release row. Used by the albums browser to remove an orphaned rele
|
||||
- `releaseType` (string, optional): enum `ReleaseType` (e.g., `Single`, `Album`, `EP`). Defaults to `Single` if null or unrecognized.
|
||||
- `medium` (string, optional): enum `ReleaseMedium` (e.g., `Cut`, `Mix`, `Session`). Defaults to `Cut` if null or unrecognized.
|
||||
- `trackNumber` (int?, optional): track position within the release (1-based). Defaults to 1 if ≤ 0 or null.
|
||||
- The upload stream is copied to a temp file under `Path.GetTempPath()` with the appropriate extension (`.wav`, `.mp3`, or `.flac`). The audio processor reads from disk and requires the correct extension for format detection. The temp file is always deleted in a `finally` block — success or failure.
|
||||
- `[RequestSizeLimit(~1.86 GB / 2_000_000_000)]` + `[RequestFormLimits(MultipartBodyLengthLimit = 2_000_000_000)]` lift the per-request ceiling above the framework default (~28 MB) so production-sized files are accepted. The body is streamed to the temp file, not buffered in memory.
|
||||
- Calls `UnifiedTrackService.UploadAsync`, which orchestrates: `TrackContentService.AddTrackAsync` (format-agnostic vault write via router) → `TrackManager` (SQL persist with `createdByUserId`).
|
||||
- Returns 200 with the **persisted** `TrackDto` JSON (Id populated) on success. Returns 400 for missing/invalid form fields or unsupported audio format. Returns 409 if the request violates domain cardinality rules (e.g., track number conflict). Returns 500 if processing fails.
|
||||
- `releaseId` (long?, optional): the SQL release ID to attach this track to. Omit (null) on the first row of a submit — this is the **CREATE path**, which mints a new release and blocks a pre-existing (title, artist) with 409. Set to the release id returned by row 1 for rows 2..N of a within-batch multi-track Cut — this is the **ATTACH path**, which skips the (title, artist) pre-existing check and attaches directly to the already-created release after validating the id matches the natural key. The upload form is create-only; appending to a pre-existing release must go through the edit tools.
|
||||
- The upload stream is copied to a staging file under the **upload staging directory** (resolved from `Upload:StagingPath`, defaulting to a `staging` subdirectory under the FileDatabase vault path — on the data disk, **never** `Path.GetTempPath()`) with the appropriate extension (`.wav`, `.mp3`, or `.flac`). The audio processor reads from disk and requires the correct extension for format detection. The staging file is always deleted in a `finally` block — success or failure. The framework's own multipart file-section buffer is relocated off the system temp mount too: `Startup.ConfigureDomainServices` sets the `ASPNETCORE_TEMP` env var to the same staging directory, so neither on-disk copy of a large body lands on `/tmp` (a small RAM-backed tmpfs on the Linux host).
|
||||
- `[RequestSizeLimit(~1.86 GB / 2_000_000_000)]` + `[RequestFormLimits(MultipartBodyLengthLimit = 2_000_000_000)]` lift the per-request ceiling above the framework default (~28 MB) so production-sized files are accepted. The body is streamed to the staging file, not buffered in memory.
|
||||
- `UnifiedTrackService.UploadAsync` orchestrates: release resolution (CREATE or ATTACH, see above) → `TrackContentService.AddTrackAsync` (format-agnostic vault write via router) → `TrackManager` (SQL persist with `createdByUserId`). Release resolution runs the cardinality guard on both paths and, on the CREATE path, calls `ITrackService.FindOrCreateRelease` (returns `(ReleaseDto Release, bool WasCreated)`); if `WasCreated` is false, a concurrent upload won the race and the request is rejected as a duplicate rather than silently attaching.
|
||||
- Returns 200 with the **persisted** `TrackDto` JSON (Id populated) on success. Returns 400 for missing/invalid form fields or unsupported audio format. Returns 409 for two distinct domain conditions: a pre-existing (title, artist) duplicate on the CREATE path (`DUPLICATE_RELEASE:` marker → 409 Conflict), or a track-number conflict within the release (`CARDINALITY_VIOLATION:` marker → 409 Conflict). Returns 500 if processing fails.
|
||||
|
||||
### DELETE api/track/{id:long} ([ApiKeyAuthorize])
|
||||
|
||||
@@ -177,7 +189,7 @@ Soft-delete a release row. Used by the albums browser to remove an orphaned rele
|
||||
- **Header `ApiKey`**: required. Validated by `ApiKeyAuthenticationMiddleware`.
|
||||
- **Route parameter `id`** (long): the SQL track ID.
|
||||
- **Form field `audioFile`** (`IFormFile`, required): the replacement audio bytes. File name must end in `.wav`, `.mp3`, or `.flac`.
|
||||
- `[RequestSizeLimit(~1.86 GB / 2_000_000_000)]` + `[RequestFormLimits(MultipartBodyLengthLimit = 2_000_000_000)]` mirror the upload ceiling. The body is streamed to a temp file (correct extension preserved for the audio processor), always deleted in a `finally` block.
|
||||
- `[RequestSizeLimit(~1.86 GB / 2_000_000_000)]` + `[RequestFormLimits(MultipartBodyLengthLimit = 2_000_000_000)]` mirror the upload ceiling. The body is streamed to a staging file under the upload staging directory (the same off-`/tmp` data-disk location as the upload path; correct extension preserved for the audio processor), always deleted in a `finally` block.
|
||||
- Calls `UnifiedTrackService.ReplaceAudioAsync`, which: looks up SQL row by id → calls `TrackContentService.ReplaceTrackAudioAsync(entryKey, tempFilePath)` (registers new audio under the existing `EntryKey`; removes the stale backing file only on a cross-format swap, after the new write succeeds) → regenerates both waveform datums (best-effort; a datum failure is logged and swallowed) → writes the new audio's duration to `DurationSeconds` via `ITrackService.SetDuration` (unconditional overwrite; a failure is surfaced, not swallowed, to prevent derived aggregates like `MixRuntimeSeconds` from silently going stale).
|
||||
- Returns 200 on success. Returns 400 if the file is missing or the format is unsupported. Returns 404 if the track id is not found. Returns 500 if vault processing fails.
|
||||
|
||||
@@ -367,6 +379,7 @@ Configured in `Startup.ConfigureDomainServices()`, applied to all endpoints via
|
||||
5. Ensure the `images` vault exists (type `MediaVaultType.Image`, created on first boot if missing) via `InitializeImageVault`.
|
||||
5a. Ensure the `track-waveforms` vault exists (type `MediaVaultType.Media`, created on first boot if missing) — holds per-track high-res visualizer datum keyed by `TrackEntity.EntryKey`.
|
||||
6. Register singletons: `AudioProcessor`, `ImageProcessor`, `TrackService` (the `DeepDrftContent` version for vault operations), `WaveformProfileService`.
|
||||
6a. **Upload staging directory** — resolve and create the on-disk staging directory (read `Upload:StagingPath`; if empty, default to a `staging` subdirectory under the FileDatabase vault path via `Startup.ResolveStagingPath`). Set the `ASPNETCORE_TEMP` env var to this directory before any request is served, relocating the framework's multipart file-section buffer (Layer 1) off the system temp mount. Register `UploadStagingDirectory` as a singleton so both `UploadTrack` and `ReplaceAudio` in `TrackController` stage to the same data-disk location (Layer 2) and never write to `/tmp` (a small RAM-backed tmpfs on the Linux host).
|
||||
|
||||
**In `Program.cs`** (SQL + AuthBlocks + wiring):
|
||||
|
||||
@@ -389,8 +402,9 @@ Mapped in `Development` only. Swagger UI at `/swagger` for testing endpoints loc
|
||||
|
||||
## Configuration files
|
||||
|
||||
- `appsettings.json`: Logging, hosting, CORS, and AuthBlocks config. **Does not contain secrets.**
|
||||
- `appsettings.json`: Logging, hosting, CORS, AuthBlocks, and non-secret upload config. **Does not contain secrets.**
|
||||
- `Logging`: standard ASP.NET structure.
|
||||
- `Upload:StagingPath`: non-secret string. Empty default → a `staging` subdirectory under the FileDatabase vault path (on the data disk). Override to an absolute path when the vault default is not suitable. Consumed by `Startup.ResolveStagingPath`.
|
||||
- `CorsSettings.AllowedOrigins`: array of origin URLs allowed to call the API (required; throws on startup if missing).
|
||||
- `AuthBlocks:Jwt:Issuer`, `AuthBlocks:Jwt:Audience`: JWT validation settings (loaded from `environment/authblocks.json`).
|
||||
- `environment/filedatabase.json` (required, loaded via CredentialTools, not in repo):
|
||||
|
||||
@@ -20,6 +20,7 @@ public class TrackController : ControllerBase
|
||||
private readonly UnifiedTrackService _unifiedService;
|
||||
private readonly ITrackService _sqlTrackService;
|
||||
private readonly WaveformProfileService _waveformProfileService;
|
||||
private readonly UploadStagingDirectory _stagingDirectory;
|
||||
private readonly ILogger<TrackController> _logger;
|
||||
|
||||
// FileDatabase is injected directly for PutTrack because that endpoint receives a pre-processed
|
||||
@@ -34,6 +35,7 @@ public class TrackController : ControllerBase
|
||||
UnifiedTrackService unifiedService,
|
||||
ITrackService sqlTrackService,
|
||||
WaveformProfileService waveformProfileService,
|
||||
UploadStagingDirectory stagingDirectory,
|
||||
ILogger<TrackController> logger)
|
||||
{
|
||||
_trackContentService = trackContentService;
|
||||
@@ -41,9 +43,48 @@ public class TrackController : ControllerBase
|
||||
_unifiedService = unifiedService;
|
||||
_sqlTrackService = sqlTrackService;
|
||||
_waveformProfileService = waveformProfileService;
|
||||
_stagingDirectory = stagingDirectory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
// Builds a unique staging file path on the data disk with the validated extension. The caller MUST
|
||||
// assign this to the local that its finally block guards BEFORE calling StageUploadAsync — that
|
||||
// way a mid-copy abort (OperationCanceledException, IO error) still triggers deletion of the
|
||||
// partially-written file. Staging lives under UploadStagingDirectory, never Path.GetTempPath() —
|
||||
// on the Linux host /tmp is a small tmpfs that cannot hold a large WAV.
|
||||
private string BuildStagingPath(string uploadExtension) =>
|
||||
Path.Combine(_stagingDirectory.Path, Guid.NewGuid().ToString("N") + uploadExtension);
|
||||
|
||||
// Streams an uploaded audio body to the pre-allocated staging path. The caller owns the path and
|
||||
// must delete it in a finally block; separating path generation from the copy ensures the finally
|
||||
// guard fires even when CopyToAsync throws before returning.
|
||||
private async Task StageUploadAsync(
|
||||
IFormFile audioFile, string stagingPath, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var stagingStream = new FileStream(
|
||||
stagingPath, FileMode.CreateNew, FileAccess.Write, FileShare.None,
|
||||
bufferSize: 81920, useAsync: true);
|
||||
await using var uploadStream = audioFile.OpenReadStream();
|
||||
await uploadStream.CopyToAsync(stagingStream, cancellationToken);
|
||||
}
|
||||
|
||||
// Best-effort removal of a staging file. Logs and swallows — a stranded staging file is a
|
||||
// disk-hygiene concern, not a request failure.
|
||||
private void DeleteStagingFile(string stagingPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (System.IO.File.Exists(stagingPath))
|
||||
{
|
||||
System.IO.File.Delete(stagingPath);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to delete staging file {StagingPath}", stagingPath);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Literal-segment routes first ---
|
||||
// These are declared before the parameterized "{trackId}" / "{id:long}" actions so route
|
||||
// resolution never treats "page", "upload", or "meta" as a trackId.
|
||||
@@ -96,6 +137,37 @@ public class TrackController : ControllerBase
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
// GET api/track/release/exists?title=...&artist=... ([ApiKeyAuthorize])
|
||||
// Upload-form pre-flight: does a release with this exact (title, artist) already exist? Returns the
|
||||
// matching ReleaseDto (so the caller can name it in the block message) or 404 when none exists. Uses
|
||||
// the same GetReleaseByTitleAndArtist read the upload create-path duplicate guard uses, so the
|
||||
// pre-flight and the server backstop agree on the match by construction (exact ordinal comparison,
|
||||
// soft-deleted rows excluded). "release/exists" is a literal 2-segment route declared before the
|
||||
// parameterized "{trackId}" route and distinct from "release/{id:long}" (different segment shape).
|
||||
[ApiKeyAuthorize]
|
||||
[HttpGet("release/exists")]
|
||||
public async Task<ActionResult> ReleaseExists(
|
||||
[FromQuery] string? title,
|
||||
[FromQuery] string? artist,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(title) || string.IsNullOrWhiteSpace(artist))
|
||||
return BadRequest("title and artist are both required");
|
||||
|
||||
var result = await _sqlTrackService.GetReleaseByTitleAndArtist(title, artist, ct);
|
||||
if (!result.Success)
|
||||
{
|
||||
var error = result.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
_logger.LogError("ReleaseExists failed for ({Title}, {Artist}): {Error}", title, artist, error);
|
||||
return StatusCode(500, "Failed to check release");
|
||||
}
|
||||
|
||||
if (result.Value is null)
|
||||
return NotFound();
|
||||
|
||||
return Ok(result.Value);
|
||||
}
|
||||
|
||||
// GET api/track/genres (unauthenticated)
|
||||
// Distinct non-null genres with track counts. Public browse data, same posture as GET
|
||||
// api/track/page. Literal segment, declared before the parameterized "{trackId}" route.
|
||||
@@ -220,6 +292,7 @@ public class TrackController : ControllerBase
|
||||
[FromForm] string? releaseType,
|
||||
[FromForm] string? medium,
|
||||
[FromForm] int? trackNumber,
|
||||
[FromForm] long? releaseId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("UploadTrack called: trackName={TrackName}, artist={Artist}, fileName={FileName}, size={Size}",
|
||||
@@ -287,23 +360,15 @@ public class TrackController : ControllerBase
|
||||
|
||||
var resolvedTrackNumber = trackNumber is > 0 ? trackNumber.Value : 1;
|
||||
|
||||
// The processor router selects by extension and reads from disk, so the temp file must carry
|
||||
// the upload's real extension. Path.GetTempFileName() yields .tmp, which the router rejects —
|
||||
// generate our own path preserving the validated .wav/.mp3/.flac extension.
|
||||
var tempPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + uploadExtension);
|
||||
|
||||
// Build the staging path before the copy so the finally block can delete the partial file
|
||||
// even if CopyToAsync throws mid-stream (client cancellation, disk-full, IO error).
|
||||
var stagingPath = BuildStagingPath(uploadExtension);
|
||||
try
|
||||
{
|
||||
await using (var tempStream = new FileStream(
|
||||
tempPath, FileMode.CreateNew, FileAccess.Write, FileShare.None,
|
||||
bufferSize: 81920, useAsync: true))
|
||||
await using (var uploadStream = audioFile.OpenReadStream())
|
||||
{
|
||||
await uploadStream.CopyToAsync(tempStream, cancellationToken);
|
||||
}
|
||||
await StageUploadAsync(audioFile, stagingPath, cancellationToken);
|
||||
|
||||
var result = await _unifiedService.UploadAsync(
|
||||
tempPath,
|
||||
stagingPath,
|
||||
trackName,
|
||||
artist,
|
||||
string.IsNullOrWhiteSpace(album) ? null : album,
|
||||
@@ -315,6 +380,7 @@ public class TrackController : ControllerBase
|
||||
parsedReleaseType,
|
||||
parsedMedium,
|
||||
resolvedTrackNumber,
|
||||
releaseId,
|
||||
cancellationToken);
|
||||
|
||||
if (!result.Success || result.Value is null)
|
||||
@@ -322,14 +388,19 @@ public class TrackController : ControllerBase
|
||||
var error = result.Messages.FirstOrDefault()?.Message ?? "Failed to process and store audio";
|
||||
_logger.LogWarning("UploadTrack: UnifiedTrackService failed for {TrackName}: {Error}", trackName, error);
|
||||
|
||||
// A cardinality rejection is a well-formed request that violates a domain rule, so it
|
||||
// is 409 Conflict — distinct from the 500 used for processing failure. The marker is
|
||||
// stripped so the client sees only the human-readable detail.
|
||||
// A cardinality or duplicate-release rejection is a well-formed request that violates a
|
||||
// domain rule, so it is 409 Conflict — distinct from the 500 used for processing failure.
|
||||
// The marker is stripped so the client sees only the human-readable detail.
|
||||
if (error.StartsWith(UnifiedTrackService.CardinalityViolationMarker, StringComparison.Ordinal))
|
||||
{
|
||||
return Conflict(error[UnifiedTrackService.CardinalityViolationMarker.Length..]);
|
||||
}
|
||||
|
||||
if (error.StartsWith(UnifiedTrackService.DuplicateReleaseMarker, StringComparison.Ordinal))
|
||||
{
|
||||
return Conflict(error[UnifiedTrackService.DuplicateReleaseMarker.Length..]);
|
||||
}
|
||||
|
||||
return StatusCode(500, error);
|
||||
}
|
||||
|
||||
@@ -343,17 +414,7 @@ public class TrackController : ControllerBase
|
||||
}
|
||||
finally
|
||||
{
|
||||
try
|
||||
{
|
||||
if (System.IO.File.Exists(tempPath))
|
||||
{
|
||||
System.IO.File.Delete(tempPath);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "UploadTrack: failed to delete temp file {TempPath}", tempPath);
|
||||
}
|
||||
DeleteStagingFile(stagingPath);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -529,21 +590,14 @@ public class TrackController : ControllerBase
|
||||
return BadRequest("Uploaded file must have a .wav, .mp3, or .flac extension");
|
||||
}
|
||||
|
||||
// The processor router selects by extension and reads from disk, so the temp file must carry
|
||||
// the upload's real extension. Mirrors UploadTrack — Path.GetTempFileName() yields .tmp.
|
||||
var tempPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + uploadExtension);
|
||||
|
||||
// Build the staging path before the copy so the finally block can delete the partial file
|
||||
// even if CopyToAsync throws mid-stream (client cancellation, disk-full, IO error).
|
||||
var stagingPath = BuildStagingPath(uploadExtension);
|
||||
try
|
||||
{
|
||||
await using (var tempStream = new FileStream(
|
||||
tempPath, FileMode.CreateNew, FileAccess.Write, FileShare.None,
|
||||
bufferSize: 81920, useAsync: true))
|
||||
await using (var uploadStream = audioFile.OpenReadStream())
|
||||
{
|
||||
await uploadStream.CopyToAsync(tempStream, cancellationToken);
|
||||
}
|
||||
await StageUploadAsync(audioFile, stagingPath, cancellationToken);
|
||||
|
||||
var result = await _unifiedService.ReplaceAudioAsync(id, tempPath, cancellationToken);
|
||||
var result = await _unifiedService.ReplaceAudioAsync(id, stagingPath, cancellationToken);
|
||||
if (result.Success)
|
||||
{
|
||||
_logger.LogInformation("ReplaceAudio succeeded: id={Id}", id);
|
||||
@@ -566,17 +620,7 @@ public class TrackController : ControllerBase
|
||||
}
|
||||
finally
|
||||
{
|
||||
try
|
||||
{
|
||||
if (System.IO.File.Exists(tempPath))
|
||||
{
|
||||
System.IO.File.Delete(tempPath);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "ReplaceAudio: failed to delete temp file {TempPath}", tempPath);
|
||||
}
|
||||
DeleteStagingFile(stagingPath);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,13 @@
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1" />
|
||||
<!-- AuthBlocks API host surface: AddAuthBlocks / MapAuthBlocks / UseAuthBlocksStartupAsync.
|
||||
The Manager keeps only Cerebellum.AuthBlocks.Web (web-side auth, no signing secret). -->
|
||||
<PackageReference Include="Cerebellum.AuthBlocks" Version="10.3.33" />
|
||||
<PackageReference Include="Cerebellum.AuthBlocks" Version="10.3.37" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Exposes the internal 409 markers (CardinalityViolationMarker / DuplicateReleaseMarker) to the
|
||||
test suite so UploadDuplicateDetectionTests can assert the orchestrator's rejection contract. -->
|
||||
<InternalsVisibleTo Include="DeepDrftTests" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace DeepDrftAPI.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// Non-secret upload tunables. <see cref="StagingPath"/> is the directory used to stage the raw
|
||||
/// audio body during upload/replace-audio. It must live on the data disk, never the system temp
|
||||
/// mount (on the Linux host <c>/tmp</c> is a small RAM-backed tmpfs that cannot hold a multi-hundred-MB
|
||||
/// WAV). When null/empty it defaults to a "staging" subdirectory under the FileDatabase vault path.
|
||||
/// </summary>
|
||||
public class UploadSettings
|
||||
{
|
||||
public string? StagingPath { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace DeepDrftAPI.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// The resolved, on-disk staging directory for upload/replace-audio bodies. Resolved once at
|
||||
/// startup from <see cref="UploadSettings"/> (or the vault path default) and guaranteed to exist.
|
||||
/// Injected into <c>TrackController</c> so the upload path never stages on the system temp mount.
|
||||
/// A typed wrapper rather than a bare string so DI resolves it unambiguously.
|
||||
/// </summary>
|
||||
public sealed record UploadStagingDirectory(string Path);
|
||||
}
|
||||
@@ -103,10 +103,13 @@ builder.Services.AddAuthBlocks(options =>
|
||||
options.JwtSettings.Audience = builder.Configuration["AuthBlocks:Jwt:Audience"]
|
||||
?? throw new InvalidOperationException("AuthBlocks:Jwt:Audience is required");
|
||||
|
||||
options.EmailConnection.Host = builder.Configuration["AuthBlocks:Email:Host"]
|
||||
options.EmailConnection.Host = builder.Configuration["AuthBlocks:Email:Host"]
|
||||
?? throw new InvalidOperationException("AuthBlocks:Email:Host is required");
|
||||
options.EmailConnection.Token = builder.Configuration["AuthBlocks:Email:Token"]
|
||||
options.EmailConnection.Token = builder.Configuration["AuthBlocks:Email:Token"]
|
||||
?? throw new InvalidOperationException("AuthBlocks:Email:Token is required");
|
||||
options.EmailConnection.FromAddress = builder.Configuration["AuthBlocks:Email:From"]
|
||||
?? throw new InvalidOperationException("AuthBlocks:Email:From is required");
|
||||
options.EmailConnection.TestInbox = builder.Configuration["AuthBlocks:Email:TestInbox"];
|
||||
|
||||
options.AdminUserSettings = new AdminUserSettings
|
||||
{
|
||||
|
||||
@@ -25,6 +25,16 @@ public class UnifiedTrackService
|
||||
/// follows the marker and is what the CMS surfaces to the admin.
|
||||
/// </summary>
|
||||
internal const string CardinalityViolationMarker = "CARDINALITY_VIOLATION: ";
|
||||
|
||||
/// <summary>
|
||||
/// Stable marker prefixed onto a duplicate-release rejection so the controller can map it to 409
|
||||
/// Conflict, the same way <see cref="CardinalityViolationMarker"/> is mapped. Fires when an upload
|
||||
/// with no explicit releaseId would create a release whose (title, artist) already exists in the
|
||||
/// catalogue — the upload form is a create-new tool, never an edit/append path. The human-readable
|
||||
/// detail follows the marker and is what the CMS surfaces to the admin.
|
||||
/// </summary>
|
||||
internal const string DuplicateReleaseMarker = "DUPLICATE_RELEASE: ";
|
||||
|
||||
private readonly TrackContentService _contentTrackContentService;
|
||||
private readonly ITrackService _sqlTrackService;
|
||||
private readonly FileDb _fileDatabase;
|
||||
@@ -64,33 +74,66 @@ public class UnifiedTrackService
|
||||
ReleaseType releaseType,
|
||||
ReleaseMedium medium,
|
||||
int trackNumber,
|
||||
long? releaseId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
// Cardinality pre-check — BEFORE the vault write so a rejected over-limit add never orphans
|
||||
// audio in the tracks vault. This is a READ-only peek (no release is created for an upload we
|
||||
// may reject); the real FindOrCreateRelease still runs below for the accepted path. Only the
|
||||
// find path can violate: a release that does not yet exist has zero tracks and admits its
|
||||
// first. The guard is the general form `(liveCount + 1) > Max`, not Session/Mix-hardcoded, so
|
||||
// a future bounded medium is covered by the same line.
|
||||
// Resolve which release this track lands on BEFORE the vault write, so a rejected upload never
|
||||
// orphans audio. Two paths:
|
||||
// - releaseId is null → CREATE path: this is the first row of a submit. (title, artist) must
|
||||
// NOT already exist — the upload form creates new releases only. A pre-existing match is a
|
||||
// duplicate and is blocked (409).
|
||||
// - releaseId is set → ATTACH path: rows 2..N of a within-batch multi-track Cut, attaching
|
||||
// to the release row 1 just created. No (title, artist) lookup — the release id is
|
||||
// authoritative — so the within-batch build is never mistaken for a pre-existing duplicate.
|
||||
// Both paths run the cardinality guard `(liveCount + 1) > Max` (not Session/Mix-hardcoded, so a
|
||||
// future bounded medium is covered by the same line).
|
||||
ResolvedRelease? resolved = null;
|
||||
if (!string.IsNullOrWhiteSpace(album))
|
||||
{
|
||||
var peek = await _sqlTrackService.GetReleaseByTitleAndArtist(album, artist, ct);
|
||||
if (!peek.Success)
|
||||
if (releaseId is { } attachId)
|
||||
{
|
||||
var error = peek.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
_logger.LogError("UploadAsync: release peek failed for ({Album}, {Artist}): {Error}", album, artist, error);
|
||||
return ResultContainer<TrackDto>.CreateFailResult($"Could not verify the release: {error}");
|
||||
}
|
||||
var attachPeek = await _sqlTrackService.GetReleaseByTitleAndArtist(album, artist, ct);
|
||||
if (!attachPeek.Success)
|
||||
{
|
||||
var error = attachPeek.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
_logger.LogError("UploadAsync: release peek failed for ({Album}, {Artist}): {Error}", album, artist, error);
|
||||
return ResultContainer<TrackDto>.CreateFailResult($"Could not verify the release: {error}");
|
||||
}
|
||||
|
||||
if (peek.Value is { } existing)
|
||||
{
|
||||
var cardinality = MediumRules.CardinalityOf(existing.Medium);
|
||||
if (existing.TrackCount + 1 > cardinality.Max)
|
||||
// The attach target must be the same release the natural key resolves to — a guard against
|
||||
// a stale/forged releaseId pointing at a different (title, artist) than this row carries.
|
||||
if (attachPeek.Value is not { } target || target.Id != attachId)
|
||||
{
|
||||
return ResultContainer<TrackDto>.CreateFailResult(
|
||||
$"{CardinalityViolationMarker}A {existing.Medium} release holds a single track; " +
|
||||
$"'{existing.Title}' already has one — edit the existing track or choose a different release.");
|
||||
$"{DuplicateReleaseMarker}The release this track should attach to could not be found. " +
|
||||
"Start the upload again.");
|
||||
}
|
||||
|
||||
var cardinalityCheck = CheckCardinality(target);
|
||||
if (cardinalityCheck is { } violation)
|
||||
return ResultContainer<TrackDto>.CreateFailResult(violation);
|
||||
|
||||
resolved = new ResolvedRelease(target.Id);
|
||||
}
|
||||
else
|
||||
{
|
||||
var peek = await _sqlTrackService.GetReleaseByTitleAndArtist(album, artist, ct);
|
||||
if (!peek.Success)
|
||||
{
|
||||
var error = peek.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
_logger.LogError("UploadAsync: release peek failed for ({Album}, {Artist}): {Error}", album, artist, error);
|
||||
return ResultContainer<TrackDto>.CreateFailResult($"Could not verify the release: {error}");
|
||||
}
|
||||
|
||||
// CREATE path: a pre-existing (title, artist) is a duplicate. Block it — the form never
|
||||
// edits or appends to an existing release.
|
||||
if (peek.Value is { } existing)
|
||||
{
|
||||
return ResultContainer<TrackDto>.CreateFailResult(
|
||||
$"{DuplicateReleaseMarker}A release titled '{existing.Title}' by {existing.Artist} already " +
|
||||
"exists. The upload form creates new releases only — use the edit tools to change an existing one.");
|
||||
}
|
||||
// resolved stays null → FindOrCreateRelease below creates the release.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,9 +152,12 @@ public class UnifiedTrackService
|
||||
// shared release (created on first sighting); an upload without one stays a loose track with
|
||||
// a null ReleaseId. Release-cardinal metadata (artist/genre/description/releaseDate/type/uploader)
|
||||
// rides on the release, not the track.
|
||||
long? releaseId = null;
|
||||
if (!string.IsNullOrWhiteSpace(album))
|
||||
long? resolvedReleaseId = resolved?.Id;
|
||||
if (!string.IsNullOrWhiteSpace(album) && resolvedReleaseId is null)
|
||||
{
|
||||
// CREATE path only: the duplicate guard above proved no (title, artist) match exists, so this
|
||||
// mints the release. (The attach path already resolved the id from the pre-check above and
|
||||
// skips FindOrCreateRelease entirely, so a within-batch row never re-runs the natural-key find.)
|
||||
var releaseData = new ReleaseDto
|
||||
{
|
||||
Title = album,
|
||||
@@ -124,13 +170,13 @@ public class UnifiedTrackService
|
||||
CreatedByUserId = createdByUserId,
|
||||
};
|
||||
|
||||
// Medium (like every other field in releaseData) applies only when this upload CREATES the
|
||||
// release. FindOrCreateRelease returns an existing (title, artist) row untouched — the first
|
||||
// upload's medium is authoritative. Do NOT "fix" this to overwrite the stored medium on a
|
||||
// subsequent track add: medium is a release-level property, changed only via the edit path
|
||||
// (PUT api/track/meta), never silently flipped by adding a track to an existing release.
|
||||
// FindOrCreateRelease either creates a fresh release (WasCreated = true) or returns the
|
||||
// row the concurrent winner just inserted (WasCreated = false). In the CREATE path the
|
||||
// duplicate peek above already verified no pre-existing row exists — so WasCreated = false
|
||||
// means we lost a concurrent-insert race. Treat that as the duplicate condition: reject
|
||||
// rather than silently attaching, keeping the DB unique index as the final safety net.
|
||||
var releaseResult = await _sqlTrackService.FindOrCreateRelease(album, artist, releaseData, ct);
|
||||
if (!releaseResult.Success || releaseResult.Value is null)
|
||||
if (!releaseResult.Success)
|
||||
{
|
||||
var error = releaseResult.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
_logger.LogError(
|
||||
@@ -139,11 +185,21 @@ public class UnifiedTrackService
|
||||
return ResultContainer<TrackDto>.CreateFailResult($"Track was uploaded but could not be saved: {error}");
|
||||
}
|
||||
|
||||
releaseId = releaseResult.Value.Id;
|
||||
var (resolvedRelease, wasCreated) = releaseResult.Value;
|
||||
if (!wasCreated)
|
||||
{
|
||||
// The winning concurrent upload created this release between our peek and our insert.
|
||||
// Reject with the same marker the pre-flight peek uses so the controller maps it to 409.
|
||||
return ResultContainer<TrackDto>.CreateFailResult(
|
||||
$"{DuplicateReleaseMarker}A release titled '{resolvedRelease.Title}' by {resolvedRelease.Artist} already " +
|
||||
"exists. The upload form creates new releases only — use the edit tools to change an existing one.");
|
||||
}
|
||||
|
||||
resolvedReleaseId = resolvedRelease.Id;
|
||||
}
|
||||
|
||||
var trackDto = TrackConverter.Convert(unpersisted);
|
||||
trackDto.ReleaseId = releaseId;
|
||||
trackDto.ReleaseId = resolvedReleaseId;
|
||||
trackDto.Release = null; // FK already resolved; Create must not re-resolve a detached graph.
|
||||
|
||||
var saveResult = await _sqlTrackService.Create(trackDto);
|
||||
@@ -166,6 +222,26 @@ public class UnifiedTrackService
|
||||
return saveResult;
|
||||
}
|
||||
|
||||
// The release a track resolved onto before the vault write. A null Id is the create path (mint
|
||||
// below); a non-null Id is the attach path (a within-batch multi-track Cut row 2..N).
|
||||
private readonly record struct ResolvedRelease(long Id);
|
||||
|
||||
// The cardinality guard shared by the attach path and (historically) the create path: a release
|
||||
// already at its medium's Max rejects a further track. Returns the marker-prefixed rejection
|
||||
// message, or null when the add is within limits. The create path never trips this (a brand-new
|
||||
// release has zero tracks and admits its first), so only the attach path calls it today.
|
||||
private static string? CheckCardinality(ReleaseDto release)
|
||||
{
|
||||
var cardinality = MediumRules.CardinalityOf(release.Medium);
|
||||
if (release.TrackCount + 1 > cardinality.Max)
|
||||
{
|
||||
return $"{CardinalityViolationMarker}A {release.Medium} release holds a single track; " +
|
||||
$"'{release.Title}' already has one — edit the existing track or choose a different release.";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replace an existing track's audio in place: look up the SQL row, swap only the vault bytes
|
||||
/// keyed by its EntryKey, regenerate both waveform datums from the new audio, then write the
|
||||
|
||||
@@ -47,9 +47,41 @@ namespace DeepDrftAPI
|
||||
return db;
|
||||
});
|
||||
|
||||
// Upload staging directory. Large audio bodies (multi-hundred-MB WAVs) must never stage on
|
||||
// the system temp mount — on the Linux host /tmp is a small RAM-backed tmpfs. We move BOTH
|
||||
// on-disk copies of an upload off /tmp onto the data disk:
|
||||
// Layer 1 — the framework's multipart file-section buffer (FileBufferingReadStream), which
|
||||
// reads its directory from the ASPNETCORE_TEMP env var (falling back to
|
||||
// Path.GetTempPath()). Setting the var here, before the host runs, relocates it.
|
||||
// Layer 2 — the controller's own staging file, via the injected UploadStagingDirectory.
|
||||
// Default location is a "staging" subdirectory beside the vaults; override with
|
||||
// Upload:StagingPath in appsettings.json.
|
||||
var uploadSettings = builder.Configuration.GetSection("Upload").Get<UploadSettings>();
|
||||
var stagingPath = ResolveStagingPath(uploadSettings?.StagingPath, vaultPath);
|
||||
Directory.CreateDirectory(stagingPath);
|
||||
|
||||
// AspNetCoreTempDirectory caches this value on first read and throws if the directory is
|
||||
// absent, so set it (and create the dir) before any request is served.
|
||||
Environment.SetEnvironmentVariable("ASPNETCORE_TEMP", stagingPath);
|
||||
builder.Services.AddSingleton(new UploadStagingDirectory(stagingPath));
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the absolute upload-staging directory. An explicit <paramref name="configuredPath"/>
|
||||
/// (from <c>Upload:StagingPath</c>) wins; otherwise it defaults to a <c>staging</c> subdirectory
|
||||
/// under <paramref name="vaultPath"/> — on the data disk, never the system temp mount. Pure so
|
||||
/// the "never <c>/tmp</c>" invariant is unit-testable without standing up the host.
|
||||
/// </summary>
|
||||
public static string ResolveStagingPath(string? configuredPath, string vaultPath)
|
||||
{
|
||||
var path = string.IsNullOrWhiteSpace(configuredPath)
|
||||
? Path.Combine(vaultPath, "staging")
|
||||
: configuredPath;
|
||||
return Path.GetFullPath(path);
|
||||
}
|
||||
|
||||
private static async Task InitializeTrackVault(FileDatabase fileDatabase)
|
||||
{
|
||||
if (!fileDatabase.HasVault(VaultConstants.Tracks))
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"Upload": {
|
||||
"StagingPath": ""
|
||||
},
|
||||
"CorsSettings": {
|
||||
"AllowedOrigins": [
|
||||
"https://localhost:12778",
|
||||
|
||||
@@ -8,7 +8,9 @@
|
||||
},
|
||||
"Email": {
|
||||
"Host": "smtp.your-provider.com",
|
||||
"Token": "your-email-token-here"
|
||||
"Token": "your-email-token-here",
|
||||
"From": "noreply@yourdomain.com",
|
||||
"TestInbox": "<sandbox-id>"
|
||||
},
|
||||
"Admin": {
|
||||
"UserName": "admin",
|
||||
|
||||
@@ -55,6 +55,7 @@ Notable repository / service methods beyond the standard CRUD:
|
||||
- `TrackRepository.GetHomeStatsAsync` / `ITrackService.GetHomeStats`: Returns `HomeStatsDto` — cut track count, per-`ReleaseType` cut release counts (zero-suppressed), mix release count, total mix runtime seconds (null durations counted as 0; tracks under a soft-deleted release excluded). Used by `StatsController`.
|
||||
- `TrackRepository.UpdateDurationAsync` / `ITrackService.UpdateDuration`: Null-guarded duration write — skips rows where `DurationSeconds` is already set. Used by the one-time backfill (`POST api/track/duration/backfill`).
|
||||
- `TrackRepository.SetDurationAsync` / `ITrackService.SetDuration`: Unconditional duration overwrite — no null guard, always stamps the new value. Used by the replace-audio path (`POST api/track/{id:long}/replace-audio`) where the existing non-null duration must be overwritten with the new audio's value. Returns a fail result when zero rows are affected (track removed between lookup and write).
|
||||
- `ITrackService.FindOrCreateRelease` / `TrackManager.FindOrCreateRelease`: Finds the release row matching (title, artist) or creates one if none exists. Returns `ResultContainer<(ReleaseDto Release, bool WasCreated)>` — the `WasCreated` flag lets the upload CREATE path distinguish a freshly minted release from one returned because a concurrent upload won the insert race (the latter is treated as a duplicate and rejected with 409, not silently attached). `ITrackService.GetReleaseByTitleAndArtist` is the read-only counterpart used for the upload pre-flight check and the ATTACH-path validation.
|
||||
|
||||
## Phase 16 — anonymous telemetry domain (EventRepository / EventManager)
|
||||
|
||||
|
||||
@@ -59,8 +59,12 @@ public interface ITrackService
|
||||
/// Resolve the release matching <paramref name="title"/> + <paramref name="artist"/>, creating
|
||||
/// one from <paramref name="releaseData"/> when none exists. Backs the upload flow's FK
|
||||
/// resolution so a track lands on a shared release rather than duplicating release-cardinal data.
|
||||
/// The <c>WasCreated</c> flag in the result is <see langword="true"/> when a new row was inserted
|
||||
/// and <see langword="false"/> when an existing row was found (including after a lost concurrent-insert
|
||||
/// race). The CREATE path in <c>UnifiedTrackService.UploadAsync</c> uses this to turn a
|
||||
/// "found existing" outcome into a duplicate rejection rather than a silent attach.
|
||||
/// </summary>
|
||||
Task<ResultContainer<ReleaseDto>> FindOrCreateRelease(
|
||||
Task<ResultContainer<(ReleaseDto Release, bool WasCreated)>> FindOrCreateRelease(
|
||||
string title, string artist, ReleaseDto releaseData, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -164,14 +164,14 @@ public class TrackManager
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ResultContainer<ReleaseDto>> FindOrCreateRelease(
|
||||
public async Task<ResultContainer<(ReleaseDto Release, bool WasCreated)>> FindOrCreateRelease(
|
||||
string title, string artist, ReleaseDto releaseData, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var existing = await Repository.GetReleaseByTitleAndArtistAsync(title, artist, cancellationToken);
|
||||
if (existing is not null)
|
||||
return ResultContainer<ReleaseDto>.CreatePassResult(TrackConverter.Convert(existing));
|
||||
return ResultContainer<(ReleaseDto, bool)>.CreatePassResult((TrackConverter.Convert(existing), false));
|
||||
|
||||
// The natural key (title + artist) is authoritative — override whatever the caller put
|
||||
// in releaseData so a typo upstream cannot create a release that won't be found again.
|
||||
@@ -186,21 +186,21 @@ public class TrackManager
|
||||
try
|
||||
{
|
||||
var added = await Repository.AddReleaseAsync(entity, cancellationToken);
|
||||
return ResultContainer<ReleaseDto>.CreatePassResult(TrackConverter.Convert(added));
|
||||
return ResultContainer<(ReleaseDto, bool)>.CreatePassResult((TrackConverter.Convert(added), true));
|
||||
}
|
||||
catch (ClassifiedDbException ex) when (ex.Error.Category == DbErrorCategory.UniqueViolation)
|
||||
{
|
||||
// Concurrent upload inserted the same (title, artist) between our read and write.
|
||||
// Re-query and return the winning row. Should not return null here since the
|
||||
// constraint just fired, but re-throw if it does so the caller sees an error.
|
||||
// Re-query and return the winning row as WasCreated=false so the caller (UploadAsync
|
||||
// CREATE path) treats the lost race as a duplicate rather than silently attaching.
|
||||
var race = await Repository.GetReleaseByTitleAndArtistAsync(title, artist, cancellationToken);
|
||||
if (race is null) throw;
|
||||
return ResultContainer<ReleaseDto>.CreatePassResult(TrackConverter.Convert(race));
|
||||
return ResultContainer<(ReleaseDto, bool)>.CreatePassResult((TrackConverter.Convert(race), false));
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
return ResultContainer<ReleaseDto>.CreateFailResult(e.Message);
|
||||
return ResultContainer<(ReleaseDto, bool)>.CreateFailResult(e.Message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -302,13 +302,13 @@ public class TrackManager
|
||||
if (newTrack.Release is { } release && !string.IsNullOrWhiteSpace(release.Title))
|
||||
{
|
||||
var resolved = await FindOrCreateRelease(release.Title, release.Artist, release);
|
||||
if (!resolved.Success || resolved.Value is null)
|
||||
if (!resolved.Success)
|
||||
{
|
||||
var error = resolved.Messages.FirstOrDefault()?.Message ?? "Failed to resolve release.";
|
||||
return ResultContainer<TrackDto>.CreateFailResult(error);
|
||||
}
|
||||
|
||||
newTrack.ReleaseId = resolved.Value.Id;
|
||||
newTrack.ReleaseId = resolved.Value.Release.Id;
|
||||
}
|
||||
|
||||
var added = await Repository.AddAsync(TrackConverter.Convert(newTrack));
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
@inherits LayoutComponentBase
|
||||
@using DeepDrftShared.Client.Common
|
||||
@using AuthBlocksWeb.Components.Layout
|
||||
|
||||
<MudThemeProvider IsDarkMode="false" Theme="@DeepDrftPalettes.Cms" />
|
||||
<MudPopoverProvider />
|
||||
@@ -8,6 +9,10 @@
|
||||
|
||||
<MudLayout>
|
||||
<MudAppBar Dense="true" Elevation="1" Color="Color.Primary">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Menu"
|
||||
Color="Color.Inherit"
|
||||
Edge="Edge.Start"
|
||||
OnClick="ToggleDrawer" />
|
||||
<MudText Typo="Typo.h6" Class="ml-3" Style="font-family: 'DM Sans', sans-serif; letter-spacing: 0.05em;">
|
||||
Deep Drft — Admin
|
||||
</MudText>
|
||||
@@ -18,6 +23,19 @@
|
||||
Color="Color.Inherit" />
|
||||
</MudTooltip>
|
||||
</MudAppBar>
|
||||
<MudDrawer @bind-Open="_drawerOpen" Elevation="2" Variant="DrawerVariant.Responsive" ClipMode="DrawerClipMode.Always">
|
||||
<MudNavMenu>
|
||||
<MudNavLink Href="/catalogue" Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.Dashboard">Catalogue</MudNavLink>
|
||||
<MudNavLink Href="/releases" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.LibraryMusic">Releases</MudNavLink>
|
||||
<MudNavLink Href="/tracks/upload" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.UploadFile">Upload</MudNavLink>
|
||||
<UserAdminMenu />
|
||||
<HierarchicalRoleAuthorizeView RolesList="@([SystemRoleConstants.UserAdmin])">
|
||||
<Authorized>
|
||||
<MudNavLink Href="/useradmin/users/new" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.PersonAdd">Provision User</MudNavLink>
|
||||
</Authorized>
|
||||
</HierarchicalRoleAuthorizeView>
|
||||
</MudNavMenu>
|
||||
</MudDrawer>
|
||||
<MudMainContent Class="pt-14 pb-8">
|
||||
<MudContainer MaxWidth="MaxWidth.False" Class="pa-4">
|
||||
@Body
|
||||
@@ -25,6 +43,12 @@
|
||||
</MudMainContent>
|
||||
</MudLayout>
|
||||
|
||||
@code {
|
||||
private bool _drawerOpen = true;
|
||||
|
||||
private void ToggleDrawer() => _drawerOpen = !_drawerOpen;
|
||||
}
|
||||
|
||||
<div id="blazor-error-ui" data-nosnippet>
|
||||
An unhandled error has occurred.
|
||||
<a href="." class="reload">Reload</a>
|
||||
|
||||
@@ -146,6 +146,9 @@
|
||||
private string _releaseDate = string.Empty;
|
||||
private ReleaseType _releaseType = ReleaseType.Single;
|
||||
private ReleaseMedium _medium = ReleaseMedium.Cut;
|
||||
// The id of the release being edited. New tracks added in this session attach to it via the upload
|
||||
// service's releaseId (ATTACH) path, so they are not rejected as a pre-existing-(title,artist) duplicate.
|
||||
private long? _releaseId;
|
||||
|
||||
// The medium selector drives ReleaseType visibility and is persisted on save: every UpdateAsync /
|
||||
// UploadTrackAsync call below passes _medium, and PUT api/track/meta resets ReleaseType to its
|
||||
@@ -214,6 +217,10 @@
|
||||
}
|
||||
|
||||
var release = tracks[0].Release;
|
||||
// The release being edited already exists, so any new track added here ATTACHES to it (the upload
|
||||
// service's releaseId path) rather than taking the CREATE path, which would reject it as a
|
||||
// duplicate (title, artist). Fall back to the track's own ReleaseId if the nav is not populated.
|
||||
_releaseId = release?.Id ?? tracks[0].ReleaseId;
|
||||
_albumName = albumName;
|
||||
_artist = release?.Artist ?? string.Empty;
|
||||
_genre = release?.Genre ?? string.Empty;
|
||||
@@ -592,6 +599,7 @@
|
||||
_releaseType,
|
||||
trackNumber,
|
||||
_medium,
|
||||
_releaseId,
|
||||
progress);
|
||||
|
||||
if (!uploadResult.Success || uploadResult.Value is null)
|
||||
|
||||
@@ -129,6 +129,11 @@
|
||||
// Set true once the admin has acknowledged the missing-hero warning, so a second submit proceeds.
|
||||
private bool _heroWarningAcknowledged;
|
||||
|
||||
// Captured once at component initialization on the live interactive circuit, while the token
|
||||
// is known-good, so a mid-session token expiry at submit time cannot discard a long-composed
|
||||
// release. Only assigned when the id parses successfully.
|
||||
private long? _createdByUserId;
|
||||
|
||||
private string _albumName = string.Empty;
|
||||
private string _artist = string.Empty;
|
||||
private string _genre = string.Empty;
|
||||
@@ -156,6 +161,19 @@
|
||||
}
|
||||
}
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
// Capture the user id once at load, while the token is known-good. The CMS host runs with
|
||||
// prerender: false (InteractiveServer), so this is the single init pass — auth state is
|
||||
// fully available. The page is [Authorize]-gated, so the parse should always succeed.
|
||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||
var userIdValue = authState.User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (long.TryParse(userIdValue, out var userId))
|
||||
{
|
||||
_createdByUserId = userId;
|
||||
}
|
||||
}
|
||||
|
||||
// Switching to a single-track medium collapses any multi-track selection to the first row so the
|
||||
// single-track invariant holds before submit. The predicate reads the same MediumRules cardinality
|
||||
// declaration the upload service enforces, so the form and the domain cannot drift.
|
||||
@@ -275,13 +293,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||
var userIdValue = authState.User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (!long.TryParse(userIdValue, out var createdByUserId))
|
||||
if (_createdByUserId is not long createdByUserId)
|
||||
{
|
||||
// The page is gated by [Authorize] under the Admin role, so a missing or
|
||||
// unparseable id here is a configuration bug, not normal client state.
|
||||
Logger.LogError("Authenticated user has no parseable NameIdentifier claim: {Value}", userIdValue);
|
||||
// _createdByUserId is set at component initialization from the authenticated principal.
|
||||
// A null here means the id was unavailable even at load — a genuine configuration bug,
|
||||
// since the page is [Authorize]-gated.
|
||||
Logger.LogError("User id was not captured at initialization — NameIdentifier claim missing or unparseable.");
|
||||
_errorMessage = "Your session is missing a valid identifier. Please sign in again.";
|
||||
return;
|
||||
}
|
||||
@@ -298,6 +315,29 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// Pre-flight duplicate guard (primary block): the upload form creates new releases only, so a
|
||||
// (title, artist) that already exists in the catalogue is refused BEFORE any bytes transfer —
|
||||
// the admin is not surprised at the end of a long upload. The server backstops this on the
|
||||
// create path, but checking here keeps the failure fast and visible. The values passed match
|
||||
// exactly what the upload sends (untrimmed _albumName/_artist) so the pre-flight and the server
|
||||
// agree on the match. A check failure (API unreachable) blocks rather than proceeding blind.
|
||||
var duplicateCheck = await CmsTrackService.GetExistingReleaseAsync(_albumName, _artist);
|
||||
if (!duplicateCheck.Success)
|
||||
{
|
||||
var checkError = duplicateCheck.Messages.FirstOrDefault()?.Message ?? "Unknown error";
|
||||
_errorMessage = $"Could not verify the release name: {checkError}";
|
||||
Snackbar.Add(_errorMessage, Severity.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (duplicateCheck.Value is { } existing)
|
||||
{
|
||||
_errorMessage = $"A release titled '{existing.Title}' by {existing.Artist} already exists. "
|
||||
+ "The upload form creates new releases only — use the edit tools to change an existing one.";
|
||||
Snackbar.Add(_errorMessage, Severity.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
// For single-track media (Session/Mix) the track name is derived from the Release Name —
|
||||
// no separate Track Name input is shown. Sync here so the stored name always matches.
|
||||
if (MediumRules.CardinalityOf(_medium).IsSingleTrack && _tracks.Count > 0)
|
||||
@@ -327,6 +367,11 @@
|
||||
}
|
||||
|
||||
int succeeded = 0, failed = 0;
|
||||
// Within-batch attach: row 1 creates the release (no releaseId → CREATE path); once it
|
||||
// succeeds we carry its ReleaseId into rows 2..N so they ATTACH to the just-created release
|
||||
// rather than tripping the server's pre-existing-duplicate block. Only a multi-track Cut
|
||||
// reaches row 2 (single-track media collapse to one row).
|
||||
long? batchReleaseId = null;
|
||||
for (int i = 0; i < _tracks.Count; i++)
|
||||
{
|
||||
var row = _tracks[i];
|
||||
@@ -375,6 +420,7 @@
|
||||
_releaseType,
|
||||
trackNumber,
|
||||
_medium,
|
||||
batchReleaseId,
|
||||
progress);
|
||||
|
||||
if (!result.Success || result.Value is null)
|
||||
@@ -387,6 +433,15 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
// Capture the release id created by the first successful row so subsequent rows
|
||||
// attach to it (the within-batch multi-track Cut path). Only set once — later
|
||||
// rows must not overwrite it. A null ReleaseId here (loose track) leaves it null,
|
||||
// which is correct: a release-less upload has no within-batch release to attach to.
|
||||
if (batchReleaseId is null && result.Value.ReleaseId is { } createdReleaseId)
|
||||
{
|
||||
batchReleaseId = createdReleaseId;
|
||||
}
|
||||
|
||||
// The upload endpoint does not accept an imagePath, so link the cover art with
|
||||
// a follow-up metadata update — same two-step pattern BatchEdit uses.
|
||||
if (_imagePath is { } imgPath)
|
||||
@@ -487,7 +542,13 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
Snackbar.Add($"Uploaded {succeeded} track(s); {failed} failed — review errors below.", Severity.Warning);
|
||||
// Surface the actual reason, not just counts — a server rejection (duplicate, cardinality)
|
||||
// relays a human-readable message via row.ErrorMessage. Show the first failure's reason so
|
||||
// the admin sees WHY without scanning the rows; the per-row errors remain as detail.
|
||||
var firstError = _tracks.FirstOrDefault(t => t.Status == BatchRowStatus.Failed)?.ErrorMessage;
|
||||
var reason = string.IsNullOrWhiteSpace(firstError) ? "review errors below" : firstError;
|
||||
_errorMessage = succeeded == 0 ? reason : $"{succeeded} uploaded; {failed} failed: {reason}";
|
||||
Snackbar.Add($"Uploaded {succeeded} track(s); {failed} failed — {reason}", Severity.Warning);
|
||||
// Stay on page so the admin can see the failed rows.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
AdditionalAssemblies="new[] { typeof(AuthBlocksWeb._Imports).Assembly }"
|
||||
NotFoundPage="typeof(NotFound)">
|
||||
<Found Context="routeData">
|
||||
<AuthorizeRouteView RouteData="routeData"
|
||||
DefaultLayout="typeof(Layout.CmsLayout)">
|
||||
<AuthorizeRouteView RouteData="routeData"
|
||||
DefaultLayout="@_currentLayout">
|
||||
<NotAuthorized Context="authState">
|
||||
@if (authState.User.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
@@ -18,3 +18,20 @@
|
||||
<FocusOnNavigate RouteData="routeData" Selector="h1" />
|
||||
</Found>
|
||||
</Router>
|
||||
|
||||
@code {
|
||||
[CascadingParameter] private Task<AuthenticationState>? AuthenticationState { get; set; }
|
||||
|
||||
private Type _currentLayout = typeof(Layout.CmsHomeLayout);
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
if (AuthenticationState is not null)
|
||||
{
|
||||
var authState = await AuthenticationState;
|
||||
_currentLayout = authState.User.Identity?.IsAuthenticated == true
|
||||
? typeof(Layout.CmsLayout)
|
||||
: typeof(Layout.CmsHomeLayout);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MudBlazor" Version="8.15.0" />
|
||||
<PackageReference Include="Cerebellum.AuthBlocks.Web" Version="10.3.33" />
|
||||
<PackageReference Include="Cerebellum.AuthBlocks.Web" Version="10.3.37" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -68,6 +68,7 @@ public class CmsTrackService : ICmsTrackService
|
||||
ReleaseType releaseType,
|
||||
int trackNumber,
|
||||
ReleaseMedium medium = ReleaseMedium.Cut,
|
||||
long? releaseId = null,
|
||||
IProgress<long>? progress = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
@@ -91,6 +92,9 @@ public class CmsTrackService : ICmsTrackService
|
||||
// The upload endpoint binds "medium" to the created release's ReleaseMedium (defaulting to Cut
|
||||
// for an unrecognised value). Authoritative only when this upload creates the release.
|
||||
multipart.Add(new StringContent(medium.ToString()), "medium");
|
||||
// releaseId present → ATTACH (rows 2..N of a within-batch Cut); absent → CREATE (server rejects a
|
||||
// pre-existing (title, artist) as a duplicate). Only sent when set so the form omits it on row 1.
|
||||
if (releaseId is { } rid) multipart.Add(new StringContent(rid.ToString()), "releaseId");
|
||||
|
||||
var send = await phase.SendAsync(UploadPath, multipart, $"upload of {trackName}");
|
||||
if (send.Response is not { } response)
|
||||
@@ -474,6 +478,53 @@ public class CmsTrackService : ICmsTrackService
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ResultContainer<ReleaseDto?>> GetExistingReleaseAsync(
|
||||
string title, string artist, CancellationToken ct = default)
|
||||
{
|
||||
var client = _httpClientFactory.CreateClient(ContentCmsClientName);
|
||||
var query = $"api/track/release/exists?title={Uri.EscapeDataString(title)}&artist={Uri.EscapeDataString(artist)}";
|
||||
|
||||
HttpResponseMessage response;
|
||||
try
|
||||
{
|
||||
response = await client.GetAsync(query, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Content API call failed for release existence check ({Title}, {Artist})", title, artist);
|
||||
return ResultContainer<ReleaseDto?>.CreateFailResult("Content API is unreachable.");
|
||||
}
|
||||
|
||||
using (response)
|
||||
{
|
||||
// 404 is the not-found (null) case, not a failure — no release matches this (title, artist).
|
||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
return ResultContainer<ReleaseDto?>.CreatePassResult(null);
|
||||
}
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogError("Content API release existence check failed for ({Title}, {Artist}): {Status}",
|
||||
title, artist, (int)response.StatusCode);
|
||||
return ResultContainer<ReleaseDto?>.CreateFailResult("Failed to check for an existing release.");
|
||||
}
|
||||
|
||||
ReleaseDto? release;
|
||||
try
|
||||
{
|
||||
release = await response.Content.ReadFromJsonAsync<ReleaseDto>(ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to deserialize ReleaseDto from release existence check");
|
||||
return ResultContainer<ReleaseDto?>.CreateFailResult("Content API returned an unexpected response.");
|
||||
}
|
||||
|
||||
return ResultContainer<ReleaseDto?>.CreatePassResult(release);
|
||||
}
|
||||
}
|
||||
|
||||
private static readonly HashSet<string> KnownImageMimeTypes = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"image/jpeg", "image/png", "image/gif", "image/webp", "image/svg+xml", "image/bmp"
|
||||
|
||||
@@ -25,6 +25,10 @@ public interface ICmsTrackService
|
||||
/// sets Content-Length and is the denominator for <paramref name="progress"/>, which reports cumulative
|
||||
/// bytes pushed to the wire. Each progress tick also resets the idle/heartbeat upload timeout, so a
|
||||
/// stalled connection aborts without a fixed total-duration cap.
|
||||
/// <paramref name="releaseId"/> distinguishes the two rows of a within-batch multi-track Cut: null on
|
||||
/// the first row (CREATE — the server rejects a pre-existing (title, artist) as a duplicate) and the
|
||||
/// id returned by that first row on rows 2..N (ATTACH — the server skips the duplicate check and adds
|
||||
/// the track to the release the batch just created).
|
||||
/// </summary>
|
||||
Task<ResultContainer<TrackDto>> UploadTrackAsync(
|
||||
Stream wavStream,
|
||||
@@ -42,9 +46,20 @@ public interface ICmsTrackService
|
||||
ReleaseType releaseType,
|
||||
int trackNumber,
|
||||
ReleaseMedium medium = ReleaseMedium.Cut,
|
||||
long? releaseId = null,
|
||||
IProgress<long>? progress = null,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Upload-form pre-flight: returns the existing release whose exact (title, artist) matches, or null
|
||||
/// when none exists. Backs the duplicate block the form runs BEFORE transferring bytes, so the admin
|
||||
/// is not surprised at the end of a long upload. A 404 from the API is the not-found (null) case, not
|
||||
/// a failure. The match semantics are the API's <c>GetReleaseByTitleAndArtist</c> — the same read the
|
||||
/// server backstop uses — so the pre-flight and the backstop agree.
|
||||
/// </summary>
|
||||
Task<ResultContainer<ReleaseDto?>> GetExistingReleaseAsync(
|
||||
string title, string artist, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Delete a track via the Content API, which removes the SQL row then the vault entry.
|
||||
/// Maps a 404 to a "Track not found." failure.
|
||||
|
||||
@@ -11,14 +11,14 @@ All interactive UI for the site. Blazor WebAssembly. Pages, controls, the stream
|
||||
## Actual structure
|
||||
|
||||
- `Pages/`: Routable components. `Home.razor` (hero/about), `SessionDetail.razor` (session detail — hero-dominant overlay composition rendered via `<ReleaseHeroOverlay>`: large background hero image with darkening gradient shim, cover thumbnail + title + play button overlaid near the hero's bottom, genre/date/share overlaid at the top; uses `MudContainer MaxWidth="Large"`; **does not compose `ReleaseDetailScaffold`** — `PlayTrack` is wired directly in its own `@code` block; mounts `<WaveformVisualizer>` ambient engine + `<WaveformVisualizerControlPopover>` directly; renders `<ReleaseDescription>` below the hero for the release's description blurb), `MixDetail.razor` (mix detail — composes `ReleaseDetailScaffold` with `TopRightAction` lava-lamp `<WaveformVisualizerControlPopover>`; hero+meta rendered via `<ReleaseHeroOverlay Class="mix-hero">` in the scaffold's `Hero` slot with `ShowHeader="false"` suppressing the duplicate masthead; square ~600px cover-as-background with metadata overlaid; full-bleed `<WaveformVisualizer>` is the mode-A centerpiece mounted by the page directly; renders `<ReleaseDescription>` below the hero for the release's description blurb), `CutDetail.razor` (album detail — composes `ReleaseDetailScaffold` with the `Ambient` slot carrying `<WaveformVisualizer>` + `<WaveformVisualizerControlPopover>` for mode-B ambient layer; renders `<ReleaseDescription>` below the hero for the release's description blurb; each track row carries a per-track `<SharePopover EntryKey="@track.EntryKey" />` aligned far-right as the last flex child of `.cut-detail-track-row`), `FramePlayer.razor` (embeddable iframe player at `/FramePlayer`, uses `EmbedLayout`; two mutually-exclusive modes via query params: `TrackEntryKey` stages a single track as before; `ReleaseEntryKey` resolves the release's ordered tracks via `FramePlayerViewModel`, stages track 0 via `PlayerService.StageTrack`, and arms the queue via `Queue.Arm` — no JS interop in either path, so both run safely during prerender; the first play gesture in `AudioPlayerBar` routes through `Queue.Start()` which streams the current track and clears the armed state; release embeds expose queue skip-prev/next navigation in the player bar while single-track embeds show none; track-title links open in a new tab so the iframe keeps playing). **No demo pages** (`Counter.razor`, `Weather.razor` do not exist).
|
||||
- `Layout/`: `MainLayout.razor` (root layout, wraps in `AudioPlayerProvider`, hosts theme switcher), `DeepDrftMenu.razor` (branded menu bar), `NavMenu.razor` (nav list), `Pages.cs` (centralised nav index — `MenuPages` for header, `AllPages` for exhaustive list).
|
||||
- `Layout/`: `MainLayout.razor` (root layout, wraps in `AudioPlayerProvider`, hosts theme switcher), `DeepDrftMenu.razor` (branded menu bar), `NavMenu.razor` (nav list), `Pages.cs` (centralised nav index — `MenuPages` for header, `AllPages` for exhaustive list), `DeepDrftFooter.razor` (site footer — logo, nav links, copyright; contains a "Privacy" button that opens a screen-centered tinted modal via `MudOverlay` (`DarkBackground="true"`, `Modal="true"`) carrying the anonymous-listener privacy note; trigger-button styling in the co-located `DeepDrftFooter.razor.css`, overlay chrome in the global `deepdrft-styles.css`; follows the `QueueOverlay`/`WaveformVisualizerControlPopover` `MudOverlay` idiom — scrim-click closes, panel stops propagation).
|
||||
- `Controls/`: Reusable components.
|
||||
- `TrackCard.razor`: Individual track display (image, name, artist, album, genre, release date). Play/pause icon controlled via `IsPaused` parameter.
|
||||
- `TracksGallery.razor`: Responsive grid of `TrackCard` items (MudBlazor `MudGrid` with breakpoints). Fully controlled by parent; derives active-track state from cascaded player service.
|
||||
- `AppNavLink.razor`: Nav link with active-page highlight.
|
||||
- `AudioPlayerProvider.razor`: Cascading host for `IStreamingPlayerService`. Everything inside it gets the player via `[CascadingParameter]`.
|
||||
- `StreamNowButton.razor`: Reusable streaming-trigger button. Fetches a random track, warms the AudioContext (Safari gesture requirement), and starts streaming via `IStreamingPlayerService`. Accepts `ButtonClass` and `ButtonLabel` for distinct visual presentations; `OnStreamStarted` EventCallback for post-stream side effects (e.g., mobile menu close).
|
||||
- `AudioPlayerBar.razor`: Dock UI at the bottom (play/pause/seek/volume).
|
||||
- `AudioPlayerBar.razor`: Dock UI at the bottom (play/pause/seek/volume). In Fixed (embed) mode, renders an always-shown read-only queue panel below the controls when `ShowFixedPanel && _fixedPanelOpen` (release embeds only; single-track embeds stay panel-free). The Queue button in Fixed mode toggles `_fixedPanelOpen` and triggers a `postHeight` call via `embed-frame.ts` so the host page can resize the outer iframe. TypeScript counterpart for the resize handshake: `DeepDrftPublic/Interop/embed/embed-frame.ts` — reads `EmbedId` from `window.location.search`, exports `postHeight(element)` which measures the player element and posts `{type:"deepdrft-embed-resize", height, embedId?}` to `window.parent`; no-ops when not framed (compiled output gitignored).
|
||||
- `AudioPlayerBar/PlayerControls.razor`: Play/pause/stop buttons in the transport zone. Renders via `<PlayStateIcon>`. In embedded (`Fixed`) mode, skip-previous and skip-next render when `!Fixed || HasPrevious || HasNext` — so a release embed (which has a queue) shows forward/back navigation while a single-track embed (no queue) hides them; the Stop button is hidden in all embed contexts (`!Fixed` only).
|
||||
- `AudioPlayerBar/TrackMetaLabel.razor`: Now-playing track-title + artist row. Takes `[Parameter] bool Fixed` (passed from `AudioPlayerBar.razor`). When `Fixed` (embedded iframe), the track-title anchor renders with `target="_blank" rel="noopener noreferrer"` so clicking it opens the release detail page in a new tab; the docked (non-embedded) player keeps same-tab nav. When no release is attached the title renders unlinked in both modes.
|
||||
- `AudioPlayerBar/PlayStateIcon.razor`: Icon button encapsulating service subscription + transport-state icon selection. Injects `IPlayerService`, subscribes to `StateChanged`, calls `PlaybackIcons.Resolve()` to determine icon and active state.
|
||||
@@ -41,7 +41,7 @@ All interactive UI for the site. Blazor WebAssembly. Pages, controls, the stream
|
||||
- `Helpers/`: Utilities and mapper functions.
|
||||
- `PlaybackIcons.cs`: Static `Resolve(isPlaying, isPaused, trackId, currentTrackId)` method — the sole glyph-mapping source for transport icons across all surfaces. Returns `(Icon, IsActive, IsPaused)` tuple.
|
||||
- `RuntimeFormat.cs`: Static `ToHoursMinutes(double totalSeconds)` helper. Formats a seconds value as `h:mm` (hours not zero-padded, minutes always two digits). Negative / non-finite inputs return `"0:00"`. Used by `NowPlayingStats` for the mix runtime figure.
|
||||
- `EmbedSnippetBuilder.cs`: Static helper that builds the iframe embed snippet the share popover copies. `ForTrack(baseUri, trackEntryKey)` → `<iframe src="…FramePlayer?TrackEntryKey=…">` and `ForRelease(baseUri, releaseEntryKey)` → `<iframe src="…FramePlayer?ReleaseEntryKey=…">`. iframe chrome (dimensions, border-radius, autoplay permission) is identical across both targets and defined once here.
|
||||
- `EmbedSnippetBuilder.cs`: Static helper that builds the iframe embed snippet the share popover copies. Two targets diverge in height and content (Phase 17 wave 17.3): `ForTrack(baseUri, trackEntryKey)` → compact `<iframe>` at 196 px (no queue panel, no script, unchanged from before 17.3). `ForRelease(baseUri, releaseEntryKey)` → taller `<iframe>` at 384 px plus a host-side `<script>` resize listener; mints a fresh random token per call (8 hex chars from `Guid.NewGuid().ToString("N")[..8]`) used as the iframe id (`deepdrft-embed-{token}`) and threaded into the iframe src as `&EmbedId={token}` — the in-iframe `embed-frame.ts` reads this token and includes it in `postMessage` payloads so the host listener can route resize messages to the correct iframe when multiple release embeds share a host page. The script matches on `embedId` and applies `iframe.style.height`; degrades safely (panel still works inside the iframe) if the host strips the script. Pure string composition — unit-testable without rendering. TypeScript counterpart: `DeepDrftPublic/Interop/embed/embed-frame.ts` (compiled output gitignored).
|
||||
- `Services/`: Audio player + dark-mode services.
|
||||
- `IPlayerService` / `IStreamingPlayerService`: Contracts exposed to UI.
|
||||
- `AudioPlayerService`: Abstract base (lifecycle, initialise, select track, play/pause/stop/seek/volume).
|
||||
@@ -140,6 +140,16 @@ Component state lives in ViewModels (registered scoped in DI). Components render
|
||||
- CSS classes prefixed `deepdrft-` live in `DeepDrftPublic/wwwroot/styles/deepdrft-styles.css` (shared across server and client).
|
||||
- Custom SVG icons: `DeepDrftShared.Client/Common/DDIcons.cs` (hand-rolled gas-lamp, lava-lamp, etc. — shared across public and CMS surfaces).
|
||||
|
||||
### Interactive-accent icons (`.dd-accent-icon` / `.dd-accent-fill`)
|
||||
|
||||
Green-accent interactive icon affordances (Play / Share / Add-to-Queue / lava-lamp trigger, etc.) use a **single reusable treatment** in `deepdrft-styles.css`, not per-site dark overrides. Wrap the affordance(s) in a container carrying `.dd-accent-icon`; the rule colours the inner `.mud-icon-root` glyph green-accent (`--deepdrft-green-accent`, the brand constant — same value in both palettes) in **both** themes. Add `.dd-accent-fill` to the same container when it also holds a filled `Color.Secondary` `MudButton` whose fill must go green-accent in **dark** (dark-only — light already renders green fill + white text).
|
||||
|
||||
Two reasons this is needed and why it's a class, not a palette colour: (1) no MudBlazor `Color` enum is green in both themes (`Dark.Secondary` is off-white), so palette-only solutions can't express "green in both"; (2) MudBlazor stamps the standalone rule `.mud-secondary-text { color: …secondary !important }` (0,1,0) on the glyph `<svg>`, so wrapper-level overrides never reach it — the reusable rule targets `.dd-accent-icon .mud-icon-button .mud-icon-root` (0,3,0) `!important`, which beats it on specificity alone; source order is not load-bearing for the glyph clause. The Session/Mix release-detail hero Share/Play glyphs use this class too: they were already green-accent in light (via `Color.Secondary` → `Light.Secondary`), so folding them in keeps light pixel-identical while fixing the dark over-image glyphs — they are not actually theme-divergent. **Add new green-accent icon affordances by applying this class, not by spawning a new dark override.**
|
||||
|
||||
**Self-themed components are authoritative over `.dd-accent-icon`.** `PlayStateIcon` owns its glyph colour inside `.icon-container` and must beat a surrounding `.dd-accent-icon` in dark — its scoped CSS rule targets `.mud-icon-root` at (0,5,0) `!important` (after Blazor's scope attribute is applied), which outranks the consolidation rule's (0,3,0) `!important`. Do not wrap a `PlayStateIcon` in `.dd-accent-icon` expecting to recolor its play-chip glyph — the play chip always shows navy (`--deepdrft-play-glyph`) against the moss-green chip in dark.
|
||||
|
||||
**Gas-lamp toggle is self-colored in its SVG.** `DDIcons.GasLampLit` (dark-mode icon) carries `fill="#2A5C4F"` directly on its frame path — no CSS colour override is needed. The former dark nav rule (`.deepdrft-theme-dark .dd-nav-actions .mud-icon-button`) has been removed as dead. `DDIcons.GasLamp` (light-mode icon) continues to use `currentColor` and inherits nav text colour in light (the unlit toggle is theme-divergent by design).
|
||||
|
||||
## Development commands
|
||||
|
||||
```bash
|
||||
|
||||
@@ -26,7 +26,7 @@ else
|
||||
SkipNext="@SkipNext"
|
||||
SkipPrevious="@SkipPrevious"
|
||||
ShowQueueButton="ShowQueueButton"
|
||||
QueueOpen="_queueOpen"
|
||||
QueueOpen="QueueButtonOpen"
|
||||
QueueToggle="@ToggleQueue"
|
||||
Class="transport-zone"/>
|
||||
|
||||
@@ -42,6 +42,23 @@ else
|
||||
Class="seek-zone"/>
|
||||
</div>
|
||||
|
||||
@* Fixed (embed) queue panel (§4 / AC5). A release embed shows the up-next inline below the
|
||||
controls as a read-only list (Editable=false → no drag handles, no remove buttons; C3).
|
||||
Jump-to-track is still allowed (OQ2) — routed through the same OnQueueJump as the docked
|
||||
overlay, which calls PlayRelease (clearing IsArmed if the embed was armed-but-not-started).
|
||||
Gated on ShowFixedPanel so a single-track embed (empty queue) stays panel-free (UC6). The
|
||||
Queue button collapses/expands this panel (OQ1 Option A); collapse hides it and posts the
|
||||
shrunken height to the host iframe. *@
|
||||
@if (ShowFixedPanel && _fixedPanelOpen)
|
||||
{
|
||||
<div class="deepdrft-queue-embed-panel">
|
||||
<QueueList Items="QueueItems"
|
||||
CurrentIndex="QueueCurrentIndex"
|
||||
Editable="false"
|
||||
OnJump="@OnQueueJump"/>
|
||||
</div>
|
||||
}
|
||||
|
||||
@* Minimize / close — positioned absolutely top-right *@
|
||||
@if (!Fixed)
|
||||
{
|
||||
@@ -62,8 +79,8 @@ else
|
||||
|
||||
@* Docked queue overlay (Phase 17 §3.2). MudOverlay portals to the body, so its position here in
|
||||
the dock subtree does not affect its screen-centered rendering. Only mounted in docked mode —
|
||||
the Fixed embed gets its own inline panel in a later wave. *@
|
||||
@if (ShowQueueButton)
|
||||
the Fixed embed renders its own inline panel inside the surface above. *@
|
||||
@if (ShowDockedOverlay)
|
||||
{
|
||||
<QueueOverlay Visible="_queueOpen"
|
||||
Items="QueueItems"
|
||||
|
||||
@@ -40,6 +40,13 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
|
||||
private IJSObjectReference? _spacerModule;
|
||||
private bool _spacerObserved;
|
||||
|
||||
// Fixed-embed → host resize handshake (OQ1 Option A). When the inline panel collapses/expands we
|
||||
// measure the player's live height and post it to the host so the iframe resizes to match. The
|
||||
// dirty flag defers the post to OnAfterRenderAsync so the DOM reflects the new panel state first.
|
||||
private IJSObjectReference? _embedModule;
|
||||
private bool _embedHeightDirty;
|
||||
private bool _embedHeightPosted;
|
||||
|
||||
private bool IsLoaded => PlayerService?.IsLoaded ?? false;
|
||||
private bool IsLoading => PlayerService?.IsLoading ?? false;
|
||||
|
||||
@@ -64,13 +71,31 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
|
||||
private bool HasNext => QueueService?.HasNext ?? false;
|
||||
private bool HasPrevious => QueueService?.HasPrevious ?? false;
|
||||
|
||||
// Queue overlay state. The button (and overlay) appear only in docked mode with a non-empty queue,
|
||||
// mirroring the skip-affordance gating (AC1): with no queue the bar is byte-for-byte its pre-queue
|
||||
// self. The Fixed embed gets an inline panel in a later wave, so the docked overlay is !Fixed-only.
|
||||
private bool ShowQueueButton => !Fixed && (QueueService?.Items.Count ?? 0) > 0;
|
||||
// Queue button gating. The button appears in BOTH modes when a queue is loaded, mirroring the
|
||||
// skip-affordance gating (AC1): with no queue the bar is byte-for-byte its pre-queue self, so a
|
||||
// single-track embed (empty queue) shows no button and no panel (UC6). In docked mode it toggles
|
||||
// the overlay; in Fixed mode it collapses/expands the inline panel (OQ1 Option A).
|
||||
private bool HasQueue => (QueueService?.Items.Count ?? 0) > 0;
|
||||
private bool ShowQueueButton => HasQueue;
|
||||
|
||||
// The docked overlay mounts only in docked mode; the Fixed embed renders its inline panel instead.
|
||||
private bool ShowDockedOverlay => !Fixed && HasQueue;
|
||||
|
||||
// The Fixed-mode inline panel: always shown (read-only, C3) when a release embed has a queue.
|
||||
// Gated on Fixed + non-empty so single-track embeds keep their compact, panel-free bar (UC6).
|
||||
private bool ShowFixedPanel => Fixed && HasQueue;
|
||||
|
||||
private IReadOnlyList<TrackDto> QueueItems => QueueService?.Items ?? [];
|
||||
private int QueueCurrentIndex => QueueService?.CurrentIndex ?? -1;
|
||||
|
||||
// Fixed-mode panel collapse state (OQ1 Option A). Default expanded so a release embed shows the
|
||||
// up-next out of the box; the Queue button collapses it to let the viewer reclaim iframe space.
|
||||
private bool _fixedPanelOpen = true;
|
||||
|
||||
// The Queue button's "open" state differs by mode: docked tracks the overlay, Fixed tracks the
|
||||
// inline panel's expanded state. One button, mode-appropriate meaning.
|
||||
private bool QueueButtonOpen => Fixed ? _fixedPanelOpen : _queueOpen;
|
||||
|
||||
/// <summary>
|
||||
/// Display time - shows seek position while dragging, otherwise current playback time.
|
||||
/// </summary>
|
||||
@@ -137,7 +162,21 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
|
||||
await QueueService.Previous();
|
||||
}
|
||||
|
||||
private void ToggleQueue() => _queueOpen = !_queueOpen;
|
||||
// Docked: toggle the overlay. Fixed: collapse/expand the inline panel and flag a height re-post so
|
||||
// the host iframe resizes to match the new panel state (OQ1 Option A). The post happens in
|
||||
// OnAfterRenderAsync (below) once the DOM reflects the new state, then degrades safely — the host
|
||||
// listener may simply not be present (Option B's behaviour).
|
||||
private void ToggleQueue()
|
||||
{
|
||||
if (Fixed)
|
||||
{
|
||||
_fixedPanelOpen = !_fixedPanelOpen;
|
||||
_embedHeightDirty = true;
|
||||
return;
|
||||
}
|
||||
|
||||
_queueOpen = !_queueOpen;
|
||||
}
|
||||
|
||||
private void CloseQueue() => _queueOpen = false;
|
||||
|
||||
@@ -160,7 +199,21 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
// The Fixed embed is already in normal flow — no spacer/clip needed.
|
||||
// Fixed embed: post the live player height to the host so the iframe sizes to the panel. We
|
||||
// post on the first render (so the host snaps to the expanded panel rather than the snippet's
|
||||
// initial guess) and whenever the panel is collapsed/expanded (_embedHeightDirty). No spacer/
|
||||
// clip here — the embed is in normal flow.
|
||||
if (Fixed)
|
||||
{
|
||||
if (ShowFixedPanel && (!_embedHeightPosted || _embedHeightDirty))
|
||||
{
|
||||
_embedHeightDirty = false;
|
||||
_embedHeightPosted = true;
|
||||
await PostEmbedHeight();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// For the docked player: we observe in BOTH expanded and minimized states
|
||||
// so --player-height always reflects the live height of whichever element
|
||||
// is visible. This keeps the WaveformVisualizer clipped to the top of
|
||||
@@ -169,7 +222,6 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
|
||||
// minimized → observe _miniDock (floating FAB container, ~56–60px)
|
||||
// The player-spacer's .minimized class uses a hardcoded height and ignores
|
||||
// the var, so publishing the FAB height here does not regress the spacer.
|
||||
if (Fixed) return;
|
||||
|
||||
var elementToObserve = _isMinimized ? _miniDock : _playerRoot;
|
||||
var alreadyOnThisElement = _spacerObserved && elementToObserve.Id == _lastObservedElement.Id;
|
||||
@@ -198,6 +250,37 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
|
||||
}
|
||||
}
|
||||
|
||||
// Measure the player root's live height and post it to the host page (OQ1 Option A). Best-effort:
|
||||
// a missing module or a host that ignores the message just means no outer resize (Option B value).
|
||||
private async Task PostEmbedHeight()
|
||||
{
|
||||
var module = await GetEmbedModuleAsync();
|
||||
if (module is null) return;
|
||||
|
||||
try
|
||||
{
|
||||
await module.InvokeVoidAsync("postHeight", _playerRoot);
|
||||
}
|
||||
catch (JSException)
|
||||
{
|
||||
// Runtime gone or element detached mid-teardown — nothing actionable.
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<IJSObjectReference?> GetEmbedModuleAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
return _embedModule ??= await JsRuntime.InvokeAsync<IJSObjectReference>(
|
||||
"import", "./js/embed/embed-frame.js");
|
||||
}
|
||||
catch (JSException)
|
||||
{
|
||||
// Module failed to load — the panel still renders and toggles; only the outer resize is lost.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task Expand() => await SetMinimized(false);
|
||||
|
||||
/// <summary>
|
||||
@@ -318,5 +401,18 @@ public partial class AudioPlayerBar : ComponentBase, IAsyncDisposable
|
||||
}
|
||||
_spacerModule = null;
|
||||
}
|
||||
|
||||
if (_embedModule is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _embedModule.DisposeAsync();
|
||||
}
|
||||
catch (JSException)
|
||||
{
|
||||
// Runtime already gone (navigation/teardown) — nothing to clean up.
|
||||
}
|
||||
_embedModule = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,20 @@
|
||||
right: 0.5rem;
|
||||
}
|
||||
|
||||
/* PLAYER-BAR play-chip override (Phase 18, T3). PlayStateIcon's chip defaults to the solid
|
||||
--deepdrft-play-chip (moss-green in dark) used on release heroes and Cut track rows. On the
|
||||
player dock that solid green reads too hot, so here — and only here — swap to the
|
||||
translucent --deepdrft-play-chip-soft (same green, much less opaque).
|
||||
The glyph stays --mud-palette-primary (green on the soft translucent wash), giving the
|
||||
preferred green-on-green look on the player bar in dark mode. */
|
||||
::deep .player-surface .icon-container {
|
||||
background-color: var(--deepdrft-play-chip-soft);
|
||||
}
|
||||
|
||||
::deep .player-surface .icon-container .mud-icon-button {
|
||||
color: var(--mud-palette-primary);
|
||||
}
|
||||
|
||||
/* Minimized floating dock — positioning + hover only; colour from MudFab */
|
||||
.minimized-dock {
|
||||
position: fixed;
|
||||
|
||||
@@ -14,14 +14,6 @@
|
||||
Color="Color.Primary"
|
||||
Disabled="!CanPlay"
|
||||
OnToggle="@TogglePlayPause"/>
|
||||
@if (!Fixed || HasPrevious || HasNext)
|
||||
{
|
||||
<MudIconButton Icon="@Icons.Material.Filled.SkipNext"
|
||||
Color="Color.Primary"
|
||||
Size="Size.Large"
|
||||
OnClick="@SkipNext"
|
||||
Disabled="!HasNext"/>
|
||||
}
|
||||
@if (!Fixed)
|
||||
{
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Stop"
|
||||
@@ -30,4 +22,12 @@
|
||||
OnClick="@Stop"
|
||||
Disabled="!IsLoaded"/>
|
||||
}
|
||||
@if (!Fixed || HasPrevious || HasNext)
|
||||
{
|
||||
<MudIconButton Icon="@Icons.Material.Filled.SkipNext"
|
||||
Color="Color.Primary"
|
||||
Size="Size.Large"
|
||||
OnClick="@SkipNext"
|
||||
Disabled="!HasNext"/>
|
||||
}
|
||||
</MudStack>
|
||||
|
||||
@@ -23,17 +23,19 @@
|
||||
@* Queue toggle: a second row between the transport controls and the timestamp (§3.1 placement —
|
||||
"below the control buttons, to the left of the timestamps"). Shown only when a queue is loaded,
|
||||
mirroring the skip-affordance gating, so an empty/single-track player is byte-for-byte unchanged. *@
|
||||
@if (ShowQueueButton)
|
||||
{
|
||||
<MudTooltip Text="Queue">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.QueueMusic"
|
||||
Color="Color.Primary"
|
||||
Size="Size.Medium"
|
||||
OnClick="QueueToggle"
|
||||
aria-label="Queue"
|
||||
aria-expanded="@QueueOpen"
|
||||
Class="@($"deepdrft-queue-toggle{(QueueOpen ? " deepdrft-queue-toggle-active" : "")}")"/>
|
||||
</MudTooltip>
|
||||
}
|
||||
<TimestampLabel CurrentTime="DisplayTime" Duration="@Duration"/>
|
||||
<MudStack Row AlignItems="AlignItems.Center">
|
||||
<TimestampLabel CurrentTime="DisplayTime" Duration="@Duration"/>
|
||||
@if (ShowQueueButton)
|
||||
{
|
||||
<MudTooltip Text="Queue">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.QueueMusic"
|
||||
Color="Color.Primary"
|
||||
Size="Size.Medium"
|
||||
OnClick="QueueToggle"
|
||||
aria-label="Queue"
|
||||
aria-expanded="@QueueOpen"
|
||||
Class="@($"deepdrft-queue-toggle{(QueueOpen ? " deepdrft-queue-toggle-active" : "")}")"/>
|
||||
</MudTooltip>
|
||||
}
|
||||
</MudStack>
|
||||
</MudStack>
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
font-family: var(--deepdrft-font-mono);
|
||||
font-size: 0.65rem;
|
||||
letter-spacing: 0.28em;
|
||||
color: var(--deepdrft-green-accent);
|
||||
color: var(--deepdrft-green);
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 1.8rem;
|
||||
display: flex;
|
||||
@@ -27,7 +27,7 @@
|
||||
display: block;
|
||||
width: 2.5rem;
|
||||
height: 1px;
|
||||
background: var(--deepdrft-green-accent);
|
||||
background: var(--deepdrft-green);
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
@@ -36,14 +36,14 @@
|
||||
font-weight: 300;
|
||||
line-height: 0.92;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--deepdrft-navy);
|
||||
color: var(--deepdrft-page-text);
|
||||
margin-bottom: 0.5rem;
|
||||
animation-delay: 0.22s;
|
||||
}
|
||||
|
||||
.hero-title em {
|
||||
font-style: italic;
|
||||
color: var(--deepdrft-green);
|
||||
color: var(--deepdrft-green-accent);
|
||||
}
|
||||
|
||||
.hero-subtitle {
|
||||
@@ -51,7 +51,7 @@
|
||||
font-size: clamp(1rem, 2vw, 1.35rem);
|
||||
font-weight: 300;
|
||||
font-style: italic;
|
||||
color: var(--deepdrft-muted);
|
||||
color: var(--deepdrft-page-text-muted);
|
||||
margin-bottom: 3rem;
|
||||
letter-spacing: 0.04em;
|
||||
animation-delay: 0.34s;
|
||||
@@ -61,7 +61,7 @@
|
||||
font-family: var(--deepdrft-font-body);
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.75;
|
||||
color: var(--deepdrft-navy);
|
||||
color: var(--deepdrft-page-text);
|
||||
opacity: 0.7;
|
||||
max-width: 36ch;
|
||||
margin-bottom: 3rem;
|
||||
@@ -81,3 +81,11 @@
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark-mode accent override (Phase 18, Wave 3).
|
||||
.hero-title and .hero-desc bind --deepdrft-page-text directly above (theme-aware).
|
||||
The em italic is the only element needing an explicit dark lift:
|
||||
--deepdrft-green (#1A3C34) is low-contrast on the navy ground; lift to green-accent. */
|
||||
:global(.deepdrft-theme-dark) .hero-title em {
|
||||
color: var(--deepdrft-green-accent);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-content: center;
|
||||
background-color: var(--deepdrft-soft);
|
||||
background-color: var(--deepdrft-play-chip);
|
||||
border-radius: 50%;
|
||||
height: 60px;
|
||||
width: 60px;
|
||||
@@ -10,5 +10,27 @@
|
||||
}
|
||||
|
||||
.icon-container:hover {
|
||||
background-color: color-mix(var(--deepdrft-soft), var(--deepdrft-navy-mid) 25%);
|
||||
background-color: color-mix(in srgb, var(--deepdrft-play-chip), var(--deepdrft-navy-mid) 25%);
|
||||
}
|
||||
|
||||
/* In dark mode the chip is moss-green and MudIconButton's Color.Primary/Secondary green
|
||||
glyph would vanish against it, so pin the glyph to --deepdrft-play-glyph (navy) in dark
|
||||
only. In light mode the token also resolves to navy, but applying it there overrides
|
||||
Color.Secondary (green-accent) on hero/row mounts — a visible regression. Scoping to
|
||||
.deepdrft-theme-dark preserves the MudBlazor Color prop in light and fixes only dark.
|
||||
::deep reaches the portaled-in-scope MudIconButton icon, which doesn't carry this
|
||||
component's scope attribute. */
|
||||
.deepdrft-theme-dark .icon-container ::deep .mud-icon-button {
|
||||
color: var(--deepdrft-play-glyph);
|
||||
}
|
||||
|
||||
/* PlayStateIcon is authoritative over its own glyph colour — a surrounding .dd-accent-icon
|
||||
must NOT recolor the play-chip glyph in dark. The consolidation rule is:
|
||||
.dd-accent-icon .mud-icon-button .mud-icon-root (0,3,0) !important
|
||||
After Blazor scoped-CSS compilation this rule becomes:
|
||||
.deepdrft-theme-dark .icon-container[b-xxx] .mud-icon-button .mud-icon-root (0,5,0) !important
|
||||
(0,5,0) beats (0,3,0) — wins on specificity; !important parity is irrelevant.
|
||||
Dark only: light already renders the navy glyph via the MudBlazor Color prop. */
|
||||
.deepdrft-theme-dark .icon-container ::deep .mud-icon-button .mud-icon-root {
|
||||
color: var(--deepdrft-play-glyph) !important;
|
||||
}
|
||||
@@ -52,7 +52,7 @@
|
||||
</MudStack>
|
||||
@if (ShareContent is not null)
|
||||
{
|
||||
<div class="release-hero-share">
|
||||
<div class="release-hero-share dd-accent-icon">
|
||||
@ShareContent
|
||||
</div>
|
||||
}
|
||||
@@ -74,7 +74,7 @@
|
||||
</div>
|
||||
@if (PlayContent is not null)
|
||||
{
|
||||
<div class="release-hero-play">
|
||||
<div class="release-hero-play dd-accent-icon">
|
||||
@PlayContent
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -151,14 +151,13 @@
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
/* The play affordance and share button sit over a dark image — force their icon glyphs to the
|
||||
light theme color regardless of MudBlazor's Secondary palette. Both PlayStateIcon and
|
||||
SharePopover render MudIconButton / MudProgressCircular internals, so ::deep is required. */
|
||||
::deep .release-hero-play .mud-icon-button,
|
||||
::deep .release-hero-play .mud-progress-circular,
|
||||
::deep .release-hero-share .mud-icon-button {
|
||||
color: var(--deepdrft-white);
|
||||
}
|
||||
/* The play/share glyphs are coloured by the shared .dd-accent-icon treatment (green-accent in
|
||||
both themes) applied on .release-hero-play / .release-hero-share in ReleaseHeroOverlay.razor —
|
||||
see deepdrft-styles.css. No co-located colour rule here: the former white override was removed
|
||||
because its glyph clauses (.mud-icon-button .mud-icon-root) could not reach the
|
||||
.mud-secondary-text !important glyph at wrapper specificity, and its spinner clause
|
||||
(.mud-progress-circular) was live but is now correctly covered by .dd-accent-icon —
|
||||
making the spinner green-accent (was white) in light mode, the one intentional light delta. */
|
||||
|
||||
@media (max-width: 599.98px) {
|
||||
.release-hero {
|
||||
|
||||
@@ -29,15 +29,17 @@
|
||||
the shared WaveformVisualizerControlState and raises Changed; the visualizer bridge subscribes. This
|
||||
host only toggles open/closed and centers the panel — it stays purely presentational. *@
|
||||
|
||||
<MudTooltip Text="Visualizer settings">
|
||||
<MudIconButton Icon="@(_open ? DDIcons.LavaLampFilled : DDIcons.LavaLamp)"
|
||||
Size="@IconSize"
|
||||
Color="Color.Secondary"
|
||||
Disabled="@(!RendererInfo.IsInteractive)"
|
||||
OnClick="@Toggle"
|
||||
aria-label="Visualizer settings"
|
||||
aria-expanded="@_open" />
|
||||
</MudTooltip>
|
||||
<div class="dd-accent-icon">
|
||||
<MudTooltip Text="Visualizer settings">
|
||||
<MudIconButton Icon="@(_open ? DDIcons.LavaLampFilled : DDIcons.LavaLamp)"
|
||||
Size="@IconSize"
|
||||
Color="Color.Secondary"
|
||||
Disabled="@(!RendererInfo.IsInteractive)"
|
||||
OnClick="@Toggle"
|
||||
aria-label="Visualizer settings"
|
||||
aria-expanded="@_open" />
|
||||
</MudTooltip>
|
||||
</div>
|
||||
|
||||
@* The tinted modal scrim that also HOLDS the panel. DarkBackground = the mild tint; OnClick on the scrim
|
||||
dismisses (knob-drag-safe, see header). The panel is the overlay's centered child; it stops click
|
||||
|
||||
@@ -34,147 +34,164 @@
|
||||
|
||||
@if (Visible)
|
||||
{
|
||||
@* ── Row 1 — MODE (always visible). Toggles + collisions group left; color pinned right. ── *@
|
||||
<MudStack Row AlignItems="AlignItems.Center" Justify="Justify.Center" Class="mx-auto" Spacing="4">
|
||||
<MudGrid>
|
||||
@* ── Row 1 — MODE (always visible). ── *@
|
||||
<MudItem xs="3" Class="d-flex align-center">
|
||||
<MudStack Row AlignItems="AlignItems.Center" Justify="Justify.FlexStart">
|
||||
<span class="wvc-section-label">MODE:</span>
|
||||
</MudStack>
|
||||
</MudItem>
|
||||
|
||||
<MudTooltip Text="Show the sound, or hide the ribbon.">
|
||||
<div class="wvc-toggle @(ControlState.WaveformEnabled ? "wvc-toggle-on" : "wvc-toggle-off")" role="group" aria-label="Waveform ribbon on or off">
|
||||
<MudIconButton Icon="@(ControlState.WaveformEnabled ? DDIcons.WaveformFilled : DDIcons.Waveform)"
|
||||
Color="Color.Primary"
|
||||
OnClick="@ToggleWaveform"
|
||||
aria-label="Waveform ribbon on or off"
|
||||
aria-pressed="@ControlState.WaveformEnabled"/>
|
||||
</div>
|
||||
</MudTooltip>
|
||||
<MudItem xs="9">
|
||||
<MudStack Row AlignItems="AlignItems.Center" Justify="Justify.Center" Class="mx-auto" Spacing="4">
|
||||
<MudTooltip Text="Show the sound, or hide the ribbon.">
|
||||
<div class="wvc-toggle @(ControlState.WaveformEnabled ? "wvc-toggle-on" : "wvc-toggle-off")" role="group" aria-label="Waveform ribbon on or off">
|
||||
<MudIconButton Icon="@(ControlState.WaveformEnabled ? DDIcons.WaveformFilled : DDIcons.Waveform)"
|
||||
Color="Color.Primary"
|
||||
OnClick="@ToggleWaveform"
|
||||
aria-label="Waveform ribbon on or off"
|
||||
aria-pressed="@ControlState.WaveformEnabled"/>
|
||||
</div>
|
||||
</MudTooltip>
|
||||
|
||||
@* Collisions are the interaction BETWEEN the two subsystems — meaningless with only one
|
||||
@* Collisions are the interaction BETWEEN the two subsystems — meaningless with only one
|
||||
present, so visible only when BOTH are on (§3 truth table). *@
|
||||
@if (ControlState.LavaEnabled && ControlState.WaveformEnabled)
|
||||
{
|
||||
<MudTooltip Text="How hard the blobs body-check the beat.">
|
||||
<div class="waveform-visualizer-control mix-visualizer-control" role="group" aria-label="Collision strength">
|
||||
<RadialKnob Value="@ControlState.CollisionStrength"
|
||||
ValueChanged="@OnCollisionStrengthChanged"
|
||||
Min="0" Max="1" Step="0.001"
|
||||
Size="64"
|
||||
Color="Color.Primary"/>
|
||||
<MudIcon Icon="@Icons.Material.Filled.Compress" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon"/>
|
||||
</div>
|
||||
</MudTooltip>
|
||||
}
|
||||
@* </div> *@
|
||||
@if (ControlState.LavaEnabled && ControlState.WaveformEnabled)
|
||||
{
|
||||
<MudTooltip Text="How hard the blobs body-check the beat.">
|
||||
<div class="waveform-visualizer-control mix-visualizer-control" role="group" aria-label="Collision strength">
|
||||
<RadialKnob Value="@ControlState.CollisionStrength"
|
||||
ValueChanged="@OnCollisionStrengthChanged"
|
||||
Min="0" Max="1" Step="0.001"
|
||||
Size="64"
|
||||
Color="Color.Primary"/>
|
||||
<MudIcon Icon="@Icons.Material.Filled.Compress"
|
||||
Size="Size.Small"
|
||||
Color="Color.Primary"
|
||||
Class="waveform-visualizer-control-icon mix-visualizer-control-icon"/>
|
||||
</div>
|
||||
</MudTooltip>
|
||||
}
|
||||
|
||||
@* Color applies to the whole field regardless of which subsystems are on, so it is pinned
|
||||
far-right of row 1 and never reflows when collisions hides (§3). *@
|
||||
<MudTooltip Text="How fast the lamp drifts through its colors.">
|
||||
<div class="waveform-visualizer-control mix-visualizer-control" role="group" aria-label="Color gradient rotation speed">
|
||||
<RadialKnob Value="@ControlState.GradientRotationSpeed"
|
||||
ValueChanged="@OnGradientRotationSpeedChanged"
|
||||
Min="0" Max="1" Step="0.001"
|
||||
Size="64"
|
||||
Color="Color.Primary"/>
|
||||
<MudIcon Icon="@Icons.Material.Filled.Palette" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon"/>
|
||||
</div>
|
||||
</MudTooltip>
|
||||
@if (ControlState.LavaEnabled && ControlState.WaveformEnabled)
|
||||
{
|
||||
<MudTooltip Text="How fast the lamp drifts through its colors.">
|
||||
<div class="waveform-visualizer-control mix-visualizer-control" role="group" aria-label="Color gradient rotation speed">
|
||||
<RadialKnob Value="@ControlState.GradientRotationSpeed"
|
||||
ValueChanged="@OnGradientRotationSpeedChanged"
|
||||
Min="0" Max="1" Step="0.001"
|
||||
Size="64"
|
||||
Color="Color.Primary"/>
|
||||
<MudIcon Icon="@Icons.Material.Filled.Palette" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon"/>
|
||||
</div>
|
||||
</MudTooltip>
|
||||
}
|
||||
|
||||
<MudTooltip Text="Light the lamp — or let it go cold.">
|
||||
<div class="wvc-toggle @(ControlState.LavaEnabled ? "wvc-toggle-on" : "wvc-toggle-off")" role="group" aria-label="Lava field on or off">
|
||||
<MudIconButton Icon="@(ControlState.LavaEnabled ? DDIcons.LavaLampFilled : DDIcons.LavaLamp)"
|
||||
Color="Color.Primary"
|
||||
OnClick="@ToggleLava"
|
||||
aria-label="Lava field on or off"
|
||||
aria-pressed="@ControlState.LavaEnabled"/>
|
||||
</div>
|
||||
</MudTooltip>
|
||||
|
||||
</MudStack>
|
||||
|
||||
@* ── Row 2 — WAVE section (only when waveform on). Both controls are RadialKnobs (scroll reverted
|
||||
from MudSlider per Phase 15 polish); width pinned far-right via wvc-row-wave space-between. ── *@
|
||||
@if (ControlState.WaveformEnabled)
|
||||
{
|
||||
<div class="wvc-row wvc-row-section wvc-row-wave">
|
||||
<span class="wvc-section-label">WAVE:</span>
|
||||
|
||||
<MudStack Row AlignItems="AlignItems.Center" Justify="Justify.Center" Class="mx-auto" Spacing="4">
|
||||
<MudTooltip Text="How fast the sound rolls by.">
|
||||
<div class="waveform-visualizer-control mix-visualizer-control" role="group" aria-label="Waveform scroll speed">
|
||||
<RadialKnob Value="@ControlState.ScrollSpeed"
|
||||
ValueChanged="@OnScrollSpeedChanged"
|
||||
Min="0" Max="1" Step="0.001"
|
||||
Size="64"
|
||||
Color="Color.Primary" />
|
||||
<MudIcon Icon="@Icons.Material.Filled.Speed" Size="Size.Small" Color="Color.Surface" Class="waveform-visualizer-control-icon mix-visualizer-control-icon" />
|
||||
</div>
|
||||
</MudTooltip>
|
||||
|
||||
<MudTooltip Text="How wide the ribbon spreads across the lamp.">
|
||||
<div class="waveform-visualizer-control mix-visualizer-control" role="group" aria-label="Waveform width">
|
||||
<RadialKnob Value="@ControlState.WaveformWidth"
|
||||
ValueChanged="@OnWaveformWidthChanged"
|
||||
Min="0" Max="1" Step="0.001"
|
||||
Size="64"
|
||||
Color="Color.Primary" />
|
||||
<MudIcon Icon="@Icons.Material.Filled.SettingsEthernet" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon" />
|
||||
<MudTooltip Text="Light the lamp — or let it go cold.">
|
||||
<div class="wvc-toggle @(ControlState.LavaEnabled ? "wvc-toggle-on" : "wvc-toggle-off")" role="group" aria-label="Lava field on or off">
|
||||
<MudIconButton Icon="@(ControlState.LavaEnabled ? DDIcons.LavaLampFilled : DDIcons.LavaLamp)"
|
||||
Color="Color.Primary"
|
||||
OnClick="@ToggleLava"
|
||||
aria-label="Lava field on or off"
|
||||
aria-pressed="@ControlState.LavaEnabled"/>
|
||||
</div>
|
||||
</MudTooltip>
|
||||
</MudStack>
|
||||
</div>
|
||||
}
|
||||
|
||||
@* ── Row 3 — LAVA section (only when lava on). ── *@
|
||||
@if (ControlState.LavaEnabled)
|
||||
{
|
||||
<div class="wvc-row wvc-row-section">
|
||||
<span class="wvc-section-label">LAVA:</span>
|
||||
</MudItem>
|
||||
|
||||
@* ── Row 2 — WAVE section (only when waveform on). ── *@
|
||||
@if (ControlState.WaveformEnabled)
|
||||
{
|
||||
<MudItem xs="3" Class="d-flex align-center">
|
||||
<MudStack Row AlignItems="AlignItems.Center" Justify="Justify.FlexStart">
|
||||
<span class="wvc-section-label">WAVE:</span>
|
||||
</MudStack>
|
||||
</MudItem>
|
||||
|
||||
<MudStack Row AlignItems="AlignItems.Center" Justify="Justify.Center" Class="mx-auto" Spacing="4">
|
||||
<MudTooltip Text="How heavy the wax feels — float, or sink.">
|
||||
<div class="waveform-visualizer-control mix-visualizer-control" role="group" aria-label="Lava gravity">
|
||||
<RadialKnob Value="@ControlState.LavaGravity"
|
||||
ValueChanged="@OnLavaGravityChanged"
|
||||
Min="0" Max="1" Step="0.001"
|
||||
Size="64"
|
||||
Color="Color.Primary" />
|
||||
<MudIcon Icon="@Icons.Material.Filled.ArrowDownward" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon" />
|
||||
</div>
|
||||
</MudTooltip>
|
||||
<MudItem xs="9">
|
||||
<MudStack Row AlignItems="AlignItems.Center" Justify="Justify.Center" Class="mx-auto" Spacing="4">
|
||||
<MudTooltip Text="How fast the sound rolls by.">
|
||||
<div class="waveform-visualizer-control mix-visualizer-control" role="group" aria-label="Waveform scroll speed">
|
||||
<RadialKnob Value="@ControlState.ScrollSpeed"
|
||||
ValueChanged="@OnScrollSpeedChanged"
|
||||
Min="0" Max="1" Step="0.001"
|
||||
Size="64"
|
||||
Color="Color.Primary"/>
|
||||
<MudIcon Icon="@Icons.Material.Filled.Speed" Size="Size.Small" Color="Color.Surface" Class="waveform-visualizer-control-icon mix-visualizer-control-icon"/>
|
||||
</div>
|
||||
</MudTooltip>
|
||||
|
||||
<MudTooltip Text="Crank the burner. More heat, more rolling boil.">
|
||||
<div class="waveform-visualizer-control mix-visualizer-control" role="group" aria-label="Lava heat">
|
||||
<RadialKnob Value="@ControlState.LavaHeat"
|
||||
ValueChanged="@OnLavaHeatChanged"
|
||||
Min="0" Max="1" Step="0.001"
|
||||
Size="64"
|
||||
Color="Color.Primary" />
|
||||
<MudIcon Icon="@Icons.Material.Filled.LocalFireDepartment" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon" />
|
||||
</div>
|
||||
</MudTooltip>
|
||||
<MudTooltip Text="How wide the ribbon spreads across the lamp.">
|
||||
<div class="waveform-visualizer-control mix-visualizer-control" role="group" aria-label="Waveform width">
|
||||
<RadialKnob Value="@ControlState.WaveformWidth"
|
||||
ValueChanged="@OnWaveformWidthChanged"
|
||||
Min="0" Max="1" Step="0.001"
|
||||
Size="64"
|
||||
Color="Color.Primary"/>
|
||||
<MudIcon Icon="@Icons.Material.Filled.SettingsEthernet" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon"/>
|
||||
</div>
|
||||
</MudTooltip>
|
||||
</MudStack>
|
||||
</MudItem>
|
||||
}
|
||||
|
||||
@if (ControlState.LavaEnabled)
|
||||
{
|
||||
<MudItem xs="3" Class="d-flex align-center">
|
||||
<MudStack Row AlignItems="AlignItems.Center" Justify="Justify.FlexStart">
|
||||
<span class="wvc-section-label">LAVA:</span>
|
||||
</MudStack>
|
||||
</MudItem>
|
||||
|
||||
<MudTooltip Text="How much goo is in the lamp.">
|
||||
<div class="waveform-visualizer-control mix-visualizer-control" role="group" aria-label="Fluid amount">
|
||||
<RadialKnob Value="@ControlState.FluidAmount"
|
||||
ValueChanged="@OnFluidAmountChanged"
|
||||
Min="0" Max="1" Step="0.001"
|
||||
Size="64"
|
||||
Color="Color.Primary" />
|
||||
<MudIcon Icon="@Icons.Material.Filled.BubbleChart" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon" />
|
||||
</div>
|
||||
</MudTooltip>
|
||||
<MudItem xs="9" Class="d-flex align-center">
|
||||
<MudStack Row AlignItems="AlignItems.Center" Justify="Justify.Center" Class="mx-auto" Spacing="4">
|
||||
<MudTooltip Text="How heavy the wax feels — float, or sink.">
|
||||
<div class="waveform-visualizer-control mix-visualizer-control" role="group" aria-label="Lava gravity">
|
||||
<RadialKnob Value="@ControlState.LavaGravity"
|
||||
ValueChanged="@OnLavaGravityChanged"
|
||||
Min="0" Max="1" Step="0.001"
|
||||
Size="64"
|
||||
Color="Color.Primary"/>
|
||||
<MudIcon Icon="@Icons.Material.Filled.ArrowDownward" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon"/>
|
||||
</div>
|
||||
</MudTooltip>
|
||||
|
||||
<MudTooltip Text="Runny and gooey, or tight little globes.">
|
||||
<div class="waveform-visualizer-control mix-visualizer-control" role="group" aria-label="Fluid viscosity">
|
||||
<RadialKnob Value="@ControlState.FluidViscosity"
|
||||
ValueChanged="@OnFluidViscosityChanged"
|
||||
Min="0" Max="1" Step="0.001"
|
||||
Size="64"
|
||||
Color="Color.Primary" />
|
||||
<MudIcon Icon="@Icons.Material.Filled.Opacity" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon" />
|
||||
</div>
|
||||
</MudTooltip>
|
||||
</MudStack>
|
||||
</div>
|
||||
}
|
||||
<MudTooltip Text="Crank the burner. More heat, more rolling boil.">
|
||||
<div class="waveform-visualizer-control mix-visualizer-control" role="group" aria-label="Lava heat">
|
||||
<RadialKnob Value="@ControlState.LavaHeat"
|
||||
ValueChanged="@OnLavaHeatChanged"
|
||||
Min="0" Max="1" Step="0.001"
|
||||
Size="64"
|
||||
Color="Color.Primary"/>
|
||||
<MudIcon Icon="@Icons.Material.Filled.LocalFireDepartment" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon"/>
|
||||
</div>
|
||||
</MudTooltip>
|
||||
|
||||
<MudTooltip Text="How much goo is in the lamp.">
|
||||
<div class="waveform-visualizer-control mix-visualizer-control" role="group" aria-label="Fluid amount">
|
||||
<RadialKnob Value="@ControlState.FluidAmount"
|
||||
ValueChanged="@OnFluidAmountChanged"
|
||||
Min="0" Max="1" Step="0.001"
|
||||
Size="64"
|
||||
Color="Color.Primary"/>
|
||||
<MudIcon Icon="@Icons.Material.Filled.BubbleChart" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon"/>
|
||||
</div>
|
||||
</MudTooltip>
|
||||
|
||||
<MudTooltip Text="Runny and gooey, or tight little globes.">
|
||||
<div class="waveform-visualizer-control mix-visualizer-control" role="group" aria-label="Fluid viscosity">
|
||||
<RadialKnob Value="@ControlState.FluidViscosity"
|
||||
ValueChanged="@OnFluidViscosityChanged"
|
||||
Min="0" Max="1" Step="0.001"
|
||||
Size="64"
|
||||
Color="Color.Primary"/>
|
||||
<MudIcon Icon="@Icons.Material.Filled.Opacity" Size="Size.Small" Class="waveform-visualizer-control-icon mix-visualizer-control-icon"/>
|
||||
</div>
|
||||
</MudTooltip>
|
||||
</MudStack>
|
||||
</MudItem>
|
||||
}
|
||||
</MudGrid>
|
||||
}
|
||||
|
||||
</div>
|
||||
|
||||
@@ -38,3 +38,8 @@
|
||||
color: var(--mud-palette-primary);
|
||||
opacity: 0.78;
|
||||
}
|
||||
|
||||
.wvc-row {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
}
|
||||
@@ -3,19 +3,74 @@ namespace DeepDrftPublic.Client.Helpers;
|
||||
/// <summary>
|
||||
/// Builds the iframe embed snippet the share popover copies. Two targets: a single track
|
||||
/// (<see cref="ForTrack"/> → <c>FramePlayer?TrackEntryKey=...</c>) and a whole release
|
||||
/// (<see cref="ForRelease"/> → <c>FramePlayer?ReleaseEntryKey=...</c>). The iframe chrome
|
||||
/// (dimensions, border radius, autoplay permission) is identical across both, defined once here.
|
||||
/// (<see cref="ForRelease"/> → <c>FramePlayer?ReleaseEntryKey=...</c>).
|
||||
///
|
||||
/// <para>
|
||||
/// The two snippets diverge in height by design (Phase 17 §4.1, OQ6): a single-track embed has no
|
||||
/// queue, so <see cref="ForTrack"/> stays at the compact player height; a release embed renders the
|
||||
/// always-shown queue panel below the controls, so <see cref="ForRelease"/> is taller to show it
|
||||
/// without clipping. Other iframe chrome (width, border radius, autoplay permission) is identical and
|
||||
/// defined once in <see cref="Frame"/>.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// OQ1 Option A — collapse/expand resize handshake. The release snippet carries a tiny host-side
|
||||
/// listener: the embedded player posts its desired height when the viewer collapses/expands the
|
||||
/// queue panel, and the listener sizes this specific iframe to match. It is scoped to the snippet's
|
||||
/// own iframe (matched by id) and ignores any message whose shape does not match, so it cannot be
|
||||
/// driven by foreign frames. It degrades to Option B's behaviour if the host strips the script: the
|
||||
/// panel still renders and toggles inside the iframe at its default (expanded) height — only the
|
||||
/// outer resize is lost. The track snippet needs no script (no panel, no toggle).
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Multi-embed isolation: each <see cref="ForRelease"/> call mints a fresh random token (8 hex
|
||||
/// chars). The token is used as the iframe id (<c>deepdrft-embed-{token}</c>) and threaded into
|
||||
/// the iframe src as <c>&EmbedId={token}</c> so the iframe can learn its own id. The host-side
|
||||
/// resize script matches incoming messages on <c>embedId</c> and resizes only the iframe whose id
|
||||
/// matches the token — two releases on one host page resize independently without cross-talk. Two
|
||||
/// calls for the same release still get distinct tokens, ensuring uniqueness even when the same
|
||||
/// release is pasted twice. Older snippets that lack <c>embedId</c> in their postMessage payload are
|
||||
/// silently ignored by the script (backward-compatible degradation).
|
||||
/// </para>
|
||||
///
|
||||
/// Pure string composition so the snippet shape is unit-testable without rendering the component.
|
||||
/// </summary>
|
||||
public static class EmbedSnippetBuilder
|
||||
{
|
||||
// Compact single-track height (the historical embed height — must not change: UC6/AC6).
|
||||
private const int TrackHeight = 196;
|
||||
|
||||
// Release height: the compact player plus the queue panel (fixed, internally scrollable past N
|
||||
// rows per OQ6). The panel collapses to the track height via the resize handshake below.
|
||||
private const int ReleaseHeight = 384;
|
||||
|
||||
// baseUri carries a trailing slash (NavigationManager.BaseUri), so "FramePlayer" appends cleanly.
|
||||
public static string ForTrack(string baseUri, string trackEntryKey)
|
||||
=> Frame($"{baseUri}FramePlayer?TrackEntryKey={trackEntryKey}");
|
||||
=> Frame($"{baseUri}FramePlayer?TrackEntryKey={trackEntryKey}", TrackHeight);
|
||||
|
||||
public static string ForRelease(string baseUri, string releaseEntryKey)
|
||||
=> Frame($"{baseUri}FramePlayer?ReleaseEntryKey={releaseEntryKey}");
|
||||
{
|
||||
// Mint a fresh random token per call so two embeds on the same host page never share an id,
|
||||
// even when they point at the same release.
|
||||
var token = Guid.NewGuid().ToString("N")[..8];
|
||||
var iframeId = $"deepdrft-embed-{token}";
|
||||
var src = $"{baseUri}FramePlayer?ReleaseEntryKey={releaseEntryKey}&EmbedId={token}";
|
||||
return Frame(src, ReleaseHeight, iframeId, ResizeScript(iframeId, token));
|
||||
}
|
||||
|
||||
private static string Frame(string src)
|
||||
=> $"""<iframe src="{src}" width="656" height="196" frameborder="0" style="border-radius:8px;" allow="autoplay"></iframe>""";
|
||||
private static string Frame(string src, int height, string iframeId = "deepdrft-embed", string trailingScript = "")
|
||||
=> $"""<iframe id="{iframeId}" src="{src}" width="656" height="{height}" frameborder="0" style="border-radius:8px;" allow="autoplay"></iframe>{trailingScript}""";
|
||||
|
||||
// Host-side listener: resize the matching iframe when the embedded player posts its panel height.
|
||||
// The embedId field in the payload is matched against the snippet's own token so only this
|
||||
// iframe is driven — foreign frames or other release embeds on the same page cannot interfere.
|
||||
// The height is clamped to a sane floor so a bad payload can't collapse the player away.
|
||||
// Messages without embedId (older snippets) are silently ignored.
|
||||
private static string ResizeScript(string iframeId, string token) =>
|
||||
"<script>(function(){var id=\"" + iframeId + "\",tok=\"" + token + "\";" +
|
||||
"window.addEventListener(\"message\",function(e){var d=e.data;" +
|
||||
"if(!d||d.type!==\"deepdrft-embed-resize\"||d.embedId!==tok)return;" +
|
||||
"var f=document.getElementById(id);var h=Number(d.height);" +
|
||||
"if(f&&h>=150)f.style.height=h+\"px\";});})();</script>";
|
||||
}
|
||||
|
||||
@@ -4,8 +4,34 @@
|
||||
<ul class="deepdrft-footer-links">
|
||||
<li><a href="/about">About</a></li>
|
||||
<li><a href="#">Contact</a></li>
|
||||
<li><button class="deepdrft-footer-privacy-btn" @onclick="@OpenPrivacy" type="button" aria-haspopup="dialog">Privacy</button></li>
|
||||
</ul>
|
||||
<div class="deepdrft-footer-copy">© 2026 Deep DRFT</div>
|
||||
</div>
|
||||
<p class="deepdrft-footer-privacy">We keep a random tag in your browser so we can count how many people a track reaches — not who they are. No account, no name, nothing personal, nothing shared with anyone else. Clear your browser data and the tag’s gone.</p>
|
||||
</footer>
|
||||
</footer>
|
||||
|
||||
<MudOverlay Visible="@_privacyOpen"
|
||||
DarkBackground="true"
|
||||
Modal="true"
|
||||
OnClick="@ClosePrivacy"
|
||||
Class="deepdrft-privacy-overlay">
|
||||
<div class="deepdrft-privacy-modal" @onclick:stopPropagation="true">
|
||||
<div class="deepdrft-privacy-modal-header">
|
||||
<span class="deepdrft-privacy-modal-title">Privacy</span>
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Close"
|
||||
Size="Size.Small"
|
||||
Color="Color.Default"
|
||||
OnClick="@ClosePrivacy"
|
||||
aria-label="Close privacy note"
|
||||
Class="deepdrft-privacy-modal-close" />
|
||||
</div>
|
||||
<p class="deepdrft-privacy-modal-body">We keep a random tag in your browser so we can count how many people a track reaches — not who they are. No account, no name, nothing personal, nothing shared with anyone else.</p>
|
||||
</div>
|
||||
</MudOverlay>
|
||||
|
||||
@code {
|
||||
private bool _privacyOpen;
|
||||
|
||||
private void OpenPrivacy() => _privacyOpen = true;
|
||||
private void ClosePrivacy() => _privacyOpen = false;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
WaveformVisualizer backdrop (z-index:0), keeping footer text fully legible. */
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
background: var(--deepdrft-white);
|
||||
background: var(--deepdrft-page-surface);
|
||||
border-top: 1px solid var(--deepdrft-border);
|
||||
padding: 3rem;
|
||||
display: flex;
|
||||
@@ -22,7 +22,7 @@
|
||||
font-family: var(--deepdrft-font-display);
|
||||
font-size: 1.5rem;
|
||||
font-weight: 400;
|
||||
color: var(--deepdrft-navy);
|
||||
color: var(--deepdrft-page-text);
|
||||
}
|
||||
|
||||
.deepdrft-footer-logo span {
|
||||
@@ -38,33 +38,37 @@
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.deepdrft-footer-links a {
|
||||
.deepdrft-footer-links a,
|
||||
.deepdrft-footer-links button {
|
||||
font-family: var(--deepdrft-font-mono);
|
||||
font-size: 0.62rem;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--deepdrft-muted);
|
||||
color: var(--deepdrft-page-text-muted);
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.deepdrft-footer-links a:hover { color: var(--deepdrft-navy); }
|
||||
.deepdrft-footer-links a:hover,
|
||||
.deepdrft-footer-links button:hover { color: var(--deepdrft-page-text); }
|
||||
|
||||
.deepdrft-footer-copy {
|
||||
font-family: var(--deepdrft-font-mono);
|
||||
font-size: 0.58rem;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--deepdrft-muted);
|
||||
color: var(--deepdrft-page-text-muted);
|
||||
}
|
||||
|
||||
.deepdrft-footer-privacy {
|
||||
font-family: var(--deepdrft-font-mono);
|
||||
font-size: 0.55rem;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--deepdrft-muted);
|
||||
opacity: 0.7;
|
||||
/* PRIVACY trigger — reset button chrome so it reads as a link, not a button element.
|
||||
Typography/colour shared with footer <a> links via the grouped selector above. */
|
||||
.deepdrft-footer-privacy-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
line-height: 1.6;
|
||||
cursor: pointer;
|
||||
line-height: inherit;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
@media (max-width: 440px) {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
@using DeepDrftPublic.Client.Services
|
||||
|
||||
@* Desktop Menu *@
|
||||
<div class="d-none d-sm-flex">
|
||||
<div class="d-none d-md-flex">
|
||||
<nav class="@NavClass">
|
||||
<MudStack Row AlignItems="AlignItems.Center">
|
||||
<a class="dd-nav-brand" href="/">
|
||||
@@ -42,14 +42,29 @@
|
||||
|
||||
<div class="dd-nav-actions">
|
||||
<StreamNowButton ButtonClass="dd-nav-cta" ButtonLabel="Stream Now ▶"/>
|
||||
<MudIconButton Icon="@(DarkLightModeButtonIcon)" Color="Color.Inherit" OnClick="@DarkModeToggle"/>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
@* Mobile Menu *@
|
||||
<div class="d-flex d-sm-none">
|
||||
<div class="d-flex d-md-none">
|
||||
<nav class="@NavClass">
|
||||
<a class="dd-nav-brand" href="/">Deep DRFT</a>
|
||||
<MudStack Row AlignItems="AlignItems.Center">
|
||||
<a class="dd-nav-brand" href="/">
|
||||
<MudImage Src="img/deepdrft-logo-l.webp"
|
||||
Alt="Deep Drft Ornamental Logo Left"
|
||||
Width="24"
|
||||
Height="24 "/>
|
||||
|
||||
<span class="mx-2">Deep DRFT</span>
|
||||
|
||||
<MudImage Src="img/deepdrft-logo-r.webp"
|
||||
Alt="Deep Drft Ornamental Logo Right"
|
||||
Width="24"
|
||||
Height="24 "/>
|
||||
</a>
|
||||
</MudStack>
|
||||
|
||||
<div class="dd-nav-actions">
|
||||
<button type="button"
|
||||
@@ -59,6 +74,8 @@
|
||||
@onclick="ToggleMobileMenu">
|
||||
<span></span><span></span><span></span>
|
||||
</button>
|
||||
|
||||
<MudIconButton Icon="@(DarkLightModeButtonIcon)" Color="Color.Inherit" OnClick="@DarkModeToggle"/>
|
||||
</div>
|
||||
|
||||
@if (_mobileMenuOpen)
|
||||
@@ -117,6 +134,12 @@
|
||||
|
||||
private string DarkLightModeIconSvg => IsDarkMode ? DDIcons.GasLampLit : DDIcons.GasLamp;
|
||||
|
||||
private string DarkLightModeButtonIcon => IsDarkMode switch
|
||||
{
|
||||
true => DDIcons.GasLampLit,
|
||||
false => DDIcons.GasLamp,
|
||||
};
|
||||
|
||||
private async Task DarkModeToggle()
|
||||
{
|
||||
IsDarkMode = !IsDarkMode;
|
||||
|
||||
@@ -16,7 +16,12 @@
|
||||
justify-content: space-between;
|
||||
gap: 2rem;
|
||||
|
||||
padding: 1.5rem 3rem;
|
||||
/* Height is pinned to the shared --deepdrft-nav-height token so the main-content
|
||||
clearance (.dd-main-content) always matches the bar exactly. Contents stay
|
||||
vertically centred via align-items; horizontal padding only here. */
|
||||
height: var(--deepdrft-nav-height);
|
||||
box-sizing: border-box;
|
||||
padding: 0 3rem;
|
||||
|
||||
border-bottom: 1px solid var(--deepdrft-border);
|
||||
box-shadow: none;
|
||||
@@ -50,6 +55,10 @@
|
||||
color: var(--deepdrft-white);
|
||||
}
|
||||
|
||||
.dd-nav-dark .dd-nav-brand > ::deep img {
|
||||
filter: invert(1);
|
||||
}
|
||||
|
||||
/* Centred link list */
|
||||
.dd-nav-links {
|
||||
display: flex;
|
||||
@@ -222,6 +231,6 @@
|
||||
/* Mobile padding — give the nav room to breathe without crowding */
|
||||
@media (max-width: 599px) {
|
||||
.dd-nav {
|
||||
padding: 1rem 1.25rem;
|
||||
padding: 0 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
@using Microsoft.AspNetCore.Components
|
||||
@inherits LayoutComponentBase
|
||||
@implements IDisposable
|
||||
@implements IAsyncDisposable
|
||||
|
||||
<MudThemeProvider Theme="@DeepDrftPalettes.Default" IsDarkMode="_isDarkMode" />
|
||||
<MudPopoverProvider />
|
||||
@@ -15,7 +16,7 @@
|
||||
<MudLayout Style="display: flex; flex-direction: column; min-height: 100vh">
|
||||
<AudioPlayerProvider>
|
||||
<DeepDrftMenu Elevation="4" @bind-IsDarkMode="_isDarkMode" />
|
||||
<MudMainContent Class="flex-grow-1 pt-16 pb-8">
|
||||
<MudMainContent Class="flex-grow-1 pb-8 dd-main-content">
|
||||
<MudContainer MaxWidth="MaxWidth.False" Class="pa-4">
|
||||
@Body
|
||||
</MudContainer>
|
||||
@@ -42,10 +43,13 @@
|
||||
private string _audioPlayerClass = "minimized";
|
||||
private const string DarkModeKey = "darkMode";
|
||||
private bool _isDarkMode = false;
|
||||
private bool? _lastAppliedDarkMode = null;
|
||||
private PersistingComponentStateSubscription _persistingSubscription;
|
||||
private IJSObjectReference? _themeModule;
|
||||
|
||||
[Inject] public required PersistentComponentState PersistentState { get; set; }
|
||||
[Inject] public required DarkModeSettings DarkModeSettings { get; set; }
|
||||
[Inject] public required IJSRuntime JS { get; set; }
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
@@ -66,6 +70,24 @@
|
||||
_persistingSubscription = PersistentState.RegisterOnPersisting(PersistDarkMode);
|
||||
}
|
||||
|
||||
// Sync dark mode class on <body> so portaled MudBlazor elements (popovers, menus, selects)
|
||||
// inherit --deepdrft-popover-surface from body.deepdrft-theme-dark rather than from :root only.
|
||||
// Popovers portal outside the ThemeWrapperClass div, so only a body-level class can reach them.
|
||||
// Gated: only fires on first render or when _isDarkMode actually changes, to avoid redundant
|
||||
// JS calls on unrelated re-renders (e.g. audio player minimize/expand).
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
await base.OnAfterRenderAsync(firstRender);
|
||||
|
||||
if (firstRender || _isDarkMode != _lastAppliedDarkMode)
|
||||
{
|
||||
_lastAppliedDarkMode = _isDarkMode;
|
||||
_themeModule ??= await JS.InvokeAsync<IJSObjectReference>(
|
||||
"import", "./_content/DeepDrftShared.Client/js/theme/theme.js");
|
||||
await _themeModule.InvokeVoidAsync("setBodyThemeClass", _isDarkMode);
|
||||
}
|
||||
}
|
||||
|
||||
// Theme wrapper class for CSS targeting
|
||||
private string ThemeWrapperClass => _isDarkMode ? "deepdrft-theme-dark" : "deepdrft-theme-light";
|
||||
|
||||
@@ -80,6 +102,15 @@
|
||||
_persistingSubscription.Dispose();
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_themeModule != null)
|
||||
{
|
||||
try { await _themeModule.DisposeAsync(); }
|
||||
catch (JSDisconnectedException) { /* circuit torn down */ }
|
||||
}
|
||||
}
|
||||
|
||||
private void ToggleAudioPlayerMinimized(bool isMinimized)
|
||||
{
|
||||
_audioPlayerClass = isMinimized ? "minimized" : "expanded";
|
||||
|
||||
@@ -23,8 +23,8 @@
|
||||
|
||||
@* ── HERO — the page opener. Reuses the .hero-* type scale with About's own words.
|
||||
NOT DeepDrftHero (that hard-codes the Deep/DRFT masthead + streaming CTA). ── *@
|
||||
<section class="hero">
|
||||
<MudGrid Spacing="0" Style="height: 100%;">
|
||||
<section class="hero pb-20">
|
||||
<MudGrid Spacing="0">
|
||||
<MudItem xs="12" md="6">
|
||||
<div class="hero-left">
|
||||
<div class="hero-eyebrow @AnimClass">Charleston, South Carolina</div>
|
||||
|
||||
@@ -25,7 +25,6 @@
|
||||
|
||||
/* ── HERO — the page opener (type scale from Home's .hero-*) ── */
|
||||
.hero {
|
||||
min-height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -35,7 +34,7 @@
|
||||
justify-content: center;
|
||||
padding: 6rem 3rem;
|
||||
position: relative;
|
||||
background: var(--deepdrft-white);
|
||||
background: var(--deepdrft-page-surface);
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@@ -43,7 +42,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
background: var(--deepdrft-white);
|
||||
background: var(--deepdrft-page-surface);
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@@ -56,7 +55,7 @@
|
||||
font-family: var(--deepdrft-font-mono);
|
||||
font-size: 0.65rem;
|
||||
letter-spacing: 0.28em;
|
||||
color: var(--deepdrft-green-accent);
|
||||
color: var(--deepdrft-green);
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 1.8rem;
|
||||
display: flex;
|
||||
@@ -70,7 +69,7 @@
|
||||
display: block;
|
||||
width: 2.5rem;
|
||||
height: 1px;
|
||||
background: var(--deepdrft-green-accent);
|
||||
background: var(--deepdrft-green);
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
@@ -79,21 +78,21 @@
|
||||
font-weight: 300;
|
||||
line-height: 0.92;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--deepdrft-navy);
|
||||
color: var(--deepdrft-page-text);
|
||||
margin-bottom: 0.5rem;
|
||||
animation-delay: 0.22s;
|
||||
}
|
||||
|
||||
.hero-title em {
|
||||
font-style: italic;
|
||||
color: var(--deepdrft-green);
|
||||
color: var(--deepdrft-green-accent);
|
||||
}
|
||||
|
||||
.hero-desc {
|
||||
font-family: var(--deepdrft-font-body);
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.75;
|
||||
color: var(--deepdrft-navy);
|
||||
color: var(--deepdrft-page-text);
|
||||
opacity: 0.7;
|
||||
max-width: 36ch;
|
||||
margin-bottom: 3rem;
|
||||
@@ -109,7 +108,7 @@
|
||||
.movement {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(140px, 14%) minmax(0, 1fr);
|
||||
background: var(--deepdrft-white);
|
||||
background: var(--deepdrft-page-surface);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
@@ -141,14 +140,14 @@
|
||||
font-weight: 300;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.04em;
|
||||
color: var(--deepdrft-navy);
|
||||
color: var(--deepdrft-page-text);
|
||||
opacity: 0.14;
|
||||
padding-left: 1.4rem;
|
||||
transition: color 0.5s ease, opacity 0.5s ease;
|
||||
}
|
||||
|
||||
.movement.is-active .rail-numeral {
|
||||
color: var(--deepdrft-green-accent);
|
||||
color: var(--deepdrft-green);
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
@@ -163,7 +162,7 @@
|
||||
font-size: 0.58rem;
|
||||
letter-spacing: 0.24em;
|
||||
text-transform: uppercase;
|
||||
color: var(--deepdrft-muted);
|
||||
color: var(--deepdrft-page-text-muted);
|
||||
writing-mode: vertical-rl;
|
||||
transform: rotate(180deg);
|
||||
transform-origin: center;
|
||||
@@ -195,7 +194,7 @@
|
||||
|
||||
.wave-stroke path {
|
||||
fill: none;
|
||||
stroke: var(--deepdrft-green-accent);
|
||||
stroke: var(--deepdrft-green);
|
||||
stroke-width: 1.4;
|
||||
opacity: 0.7;
|
||||
vector-effect: non-scaling-stroke;
|
||||
@@ -207,7 +206,7 @@
|
||||
font-size: 0.62rem;
|
||||
letter-spacing: 0.28em;
|
||||
text-transform: uppercase;
|
||||
color: var(--deepdrft-navy);
|
||||
color: var(--deepdrft-page-text);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -221,7 +220,7 @@
|
||||
font-family: var(--deepdrft-font-mono);
|
||||
font-size: 0.62rem;
|
||||
letter-spacing: 0.28em;
|
||||
color: var(--deepdrft-green-accent);
|
||||
color: var(--deepdrft-green);
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 1.4rem;
|
||||
}
|
||||
@@ -231,20 +230,20 @@
|
||||
font-size: clamp(2.6rem, 5vw, 4.2rem);
|
||||
font-weight: 300;
|
||||
line-height: 1.02;
|
||||
color: var(--deepdrft-navy);
|
||||
color: var(--deepdrft-page-text);
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.movement-title em {
|
||||
font-style: italic;
|
||||
color: var(--deepdrft-green);
|
||||
color: var(--deepdrft-green-accent);
|
||||
}
|
||||
|
||||
.movement-prose {
|
||||
font-family: var(--deepdrft-font-body);
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.85;
|
||||
color: var(--deepdrft-navy);
|
||||
color: var(--deepdrft-page-text);
|
||||
opacity: 0.72;
|
||||
max-width: 56ch;
|
||||
}
|
||||
@@ -279,14 +278,15 @@
|
||||
}
|
||||
|
||||
/* Graceful-degrade slot shown until a portrait file lands. A flat tonal panel in
|
||||
the navy family, matching the circular portrait frame. */
|
||||
the navy family, matching the circular portrait frame. Mixes a touch of navy into
|
||||
--deepdrft-page-surface so the gradient inverts with the section in dark mode. */
|
||||
.bio-portrait-placeholder {
|
||||
width: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
background:
|
||||
linear-gradient(160deg,
|
||||
color-mix(in srgb, var(--deepdrft-navy) 8%, var(--deepdrft-white)) 0%,
|
||||
color-mix(in srgb, var(--deepdrft-navy) 16%, var(--deepdrft-white)) 100%);
|
||||
color-mix(in srgb, var(--deepdrft-navy) 8%, var(--deepdrft-page-surface)) 0%,
|
||||
color-mix(in srgb, var(--deepdrft-navy) 16%, var(--deepdrft-page-surface)) 100%);
|
||||
}
|
||||
|
||||
/* The marginalia caption — mono, sits directly under the framed portrait. */
|
||||
@@ -295,7 +295,7 @@
|
||||
font-size: 0.56rem;
|
||||
letter-spacing: 0.2em;
|
||||
text-transform: uppercase;
|
||||
color: var(--deepdrft-muted);
|
||||
color: var(--deepdrft-page-text-muted);
|
||||
margin-top: 0.9rem;
|
||||
padding-left: 0.1rem;
|
||||
}
|
||||
@@ -309,7 +309,7 @@
|
||||
font-size: 2rem;
|
||||
font-weight: 300;
|
||||
line-height: 1.1;
|
||||
color: var(--deepdrft-navy);
|
||||
color: var(--deepdrft-page-text);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
@@ -317,7 +317,7 @@
|
||||
font-family: var(--deepdrft-font-body);
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.8;
|
||||
color: var(--deepdrft-navy);
|
||||
color: var(--deepdrft-page-text);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
@@ -339,7 +339,7 @@
|
||||
font-size: 0.56rem;
|
||||
letter-spacing: 0.2em;
|
||||
text-transform: uppercase;
|
||||
color: var(--deepdrft-muted);
|
||||
color: var(--deepdrft-page-text-muted);
|
||||
margin-top: 0.9rem;
|
||||
}
|
||||
|
||||
@@ -354,7 +354,7 @@
|
||||
font-family: var(--deepdrft-font-mono);
|
||||
font-size: 0.62rem;
|
||||
letter-spacing: 0.28em;
|
||||
color: var(--deepdrft-green-accent);
|
||||
color: var(--deepdrft-green);
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 1.2rem;
|
||||
}
|
||||
@@ -467,7 +467,7 @@
|
||||
font-family: var(--deepdrft-font-display);
|
||||
font-size: 1.5rem;
|
||||
font-weight: 400;
|
||||
color: var(--deepdrft-navy);
|
||||
color: var(--deepdrft-page-text);
|
||||
min-width: 7rem;
|
||||
}
|
||||
|
||||
@@ -477,7 +477,7 @@
|
||||
font-family: var(--deepdrft-font-body);
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.6;
|
||||
color: var(--deepdrft-navy);
|
||||
color: var(--deepdrft-page-text);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
@@ -502,7 +502,7 @@
|
||||
font-size: clamp(1.8rem, 3.4vw, 2.9rem);
|
||||
font-weight: 300;
|
||||
line-height: 1.15;
|
||||
color: var(--deepdrft-navy);
|
||||
color: var(--deepdrft-page-text);
|
||||
}
|
||||
|
||||
/* ══════════════════ CLOSING CTA (reused vocabulary) ══════════════════ */
|
||||
@@ -606,6 +606,14 @@
|
||||
|
||||
.btn-outline-white:hover { border-color: var(--deepdrft-white); }
|
||||
|
||||
/* ── DARK-MODE OVERRIDES ── */
|
||||
/* In dark mode, decorative em accents that use --deepdrft-green (#1A3C34) become
|
||||
near-invisible on the navy ground. Switch to --deepdrft-green-accent (#3D7A68). */
|
||||
:global(.deepdrft-theme-dark) .hero-title em,
|
||||
:global(.deepdrft-theme-dark) .movement-title em {
|
||||
color: var(--deepdrft-green-accent);
|
||||
}
|
||||
|
||||
/* ══════════════════ RESPONSIVE COLLAPSE ══════════════════
|
||||
|
||||
Below 960px the rail collapses: the spine + vertical numeral can't survive a
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
padding: 36px 0 20px 0;
|
||||
padding: 12px 0 20px 0;
|
||||
}
|
||||
|
||||
.archive-controls-search {
|
||||
|
||||
@@ -83,7 +83,7 @@ else
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="cut-detail-actions">
|
||||
<div class="cut-detail-actions dd-accent-icon dd-accent-fill">
|
||||
@* Header Play loads the full album into the queue at index 0 (§3.4 seam,
|
||||
closed P11 W1). Disabled until at least one streamable track is resolved. *@
|
||||
<MudButton Variant="Variant.Filled"
|
||||
@@ -133,7 +133,7 @@ else
|
||||
{
|
||||
var track = ViewModel.Tracks[i];
|
||||
var index = i;
|
||||
<div class="cut-detail-track-row">
|
||||
<div class="cut-detail-track-row dd-accent-icon">
|
||||
<span class="cut-detail-track-number">@track.TrackNumber</span>
|
||||
<div class="cut-detail-track-play">
|
||||
<PlayStateIcon Track="@track"
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
justify-content: center;
|
||||
padding: 6rem 3rem;
|
||||
position: relative;
|
||||
background: var(--deepdrft-white);
|
||||
background: var(--deepdrft-page-surface);
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
align-items: center;
|
||||
gap: 2rem;
|
||||
padding: 2rem 3rem;
|
||||
background: var(--deepdrft-white);
|
||||
background: var(--deepdrft-page-surface);
|
||||
}
|
||||
|
||||
.divider-line {
|
||||
@@ -43,7 +43,7 @@
|
||||
font-family: var(--deepdrft-font-mono);
|
||||
font-size: 0.6rem;
|
||||
letter-spacing: 0.25em;
|
||||
color: var(--deepdrft-muted);
|
||||
color: var(--deepdrft-page-text-muted);
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -51,7 +51,7 @@
|
||||
/* ── SECTION (sound) ── */
|
||||
.section {
|
||||
padding: 7rem 3rem;
|
||||
background: var(--deepdrft-white);
|
||||
background: var(--deepdrft-page-surface);
|
||||
}
|
||||
|
||||
@media (min-width: 960px) {
|
||||
@@ -64,7 +64,7 @@
|
||||
font-family: var(--deepdrft-font-mono);
|
||||
font-size: 0.62rem;
|
||||
letter-spacing: 0.28em;
|
||||
color: var(--deepdrft-green-accent);
|
||||
color: var(--deepdrft-green);
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 1.2rem;
|
||||
}
|
||||
@@ -74,12 +74,12 @@
|
||||
font-size: clamp(2.8rem, 5vw, 4.5rem);
|
||||
font-weight: 300;
|
||||
line-height: 1;
|
||||
color: var(--deepdrft-navy);
|
||||
color: var(--deepdrft-page-text);
|
||||
}
|
||||
|
||||
.section-title em {
|
||||
font-style: italic;
|
||||
color: var(--deepdrft-green);
|
||||
color: var(--deepdrft-green-accent);
|
||||
}
|
||||
|
||||
/* The body column is already full height; make it a flex container that
|
||||
@@ -106,7 +106,7 @@
|
||||
font-family: var(--deepdrft-font-body);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.8;
|
||||
color: var(--deepdrft-navy);
|
||||
color: var(--deepdrft-page-text);
|
||||
opacity: 0.65;
|
||||
max-width: 52ch;
|
||||
}
|
||||
@@ -130,7 +130,7 @@
|
||||
}
|
||||
|
||||
.medium-card {
|
||||
background: var(--deepdrft-white);
|
||||
background: var(--deepdrft-page-surface);
|
||||
border: 1px solid var(--deepdrft-border);
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
@@ -189,7 +189,7 @@
|
||||
font-family: var(--deepdrft-font-mono);
|
||||
font-size: 0.58rem;
|
||||
letter-spacing: 0.2em;
|
||||
color: var(--deepdrft-muted);
|
||||
color: var(--deepdrft-page-text-muted);
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
@@ -198,7 +198,7 @@
|
||||
font-family: var(--deepdrft-font-display);
|
||||
font-size: 1.6rem;
|
||||
font-weight: 400;
|
||||
color: var(--deepdrft-navy);
|
||||
color: var(--deepdrft-page-text);
|
||||
margin-bottom: 0.75rem;
|
||||
line-height: 1.1;
|
||||
}
|
||||
@@ -207,7 +207,7 @@
|
||||
font-family: var(--deepdrft-font-body);
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.65;
|
||||
color: var(--deepdrft-navy);
|
||||
color: var(--deepdrft-page-text);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
@@ -333,7 +333,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
background: var(--deepdrft-white);
|
||||
background: var(--deepdrft-page-surface);
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@@ -387,7 +387,7 @@
|
||||
font-family: var(--deepdrft-font-display);
|
||||
font-size: clamp(2rem, 3.5vw, 3rem);
|
||||
font-weight: 300;
|
||||
color: var(--deepdrft-navy);
|
||||
color: var(--deepdrft-page-text);
|
||||
line-height: 1.05;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
@@ -417,7 +417,7 @@
|
||||
|
||||
.connect-option:hover {
|
||||
border-color: var(--deepdrft-green-accent);
|
||||
background: #f3f6f4;
|
||||
background: color-mix(in srgb, var(--deepdrft-page-surface), var(--deepdrft-green-accent) 8%);
|
||||
}
|
||||
|
||||
.option-icon {
|
||||
@@ -426,14 +426,16 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--deepdrft-navy);
|
||||
/* Inversion pair with the glyph below: a contrast chip against the page surface
|
||||
(navy chip / white glyph in light; white chip / navy glyph on the dark ground). */
|
||||
background: var(--deepdrft-page-text);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.option-icon svg {
|
||||
width: 0.9rem;
|
||||
height: 0.9rem;
|
||||
stroke: var(--deepdrft-white);
|
||||
stroke: var(--deepdrft-page-surface);
|
||||
fill: none;
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
@@ -442,14 +444,14 @@
|
||||
font-family: var(--deepdrft-font-mono);
|
||||
font-size: 0.65rem;
|
||||
letter-spacing: 0.15em;
|
||||
color: var(--deepdrft-navy);
|
||||
color: var(--deepdrft-page-text);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.option-text-sub {
|
||||
font-family: var(--deepdrft-font-body);
|
||||
font-size: 0.75rem;
|
||||
color: var(--deepdrft-muted);
|
||||
color: var(--deepdrft-page-text-muted);
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
|
||||
@@ -558,6 +560,14 @@
|
||||
|
||||
.btn-outline-white:hover { border-color: var(--deepdrft-white); }
|
||||
|
||||
/* ── DARK-MODE OVERRIDES ── */
|
||||
/* In dark mode, decorative em accents that use --deepdrft-green (#1A3C34) become
|
||||
near-invisible on the navy ground. Switch to --deepdrft-green-accent (#3D7A68). */
|
||||
:global(.deepdrft-theme-dark) .section-title em,
|
||||
:global(.deepdrft-theme-dark) .connect-title em {
|
||||
color: var(--deepdrft-green-accent);
|
||||
}
|
||||
|
||||
@media (max-width: 599px) {
|
||||
.cta-banner {
|
||||
flex-direction: column;
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
// Embed (iframe) → host resize handshake (Phase 17 wave 17.3, OQ1 Option A).
|
||||
//
|
||||
// The Fixed-mode player renders an always-shown queue panel below the controls. A collapse/expand
|
||||
// toggle lets the embedder's viewer reclaim the panel's vertical space — but collapsing inside the
|
||||
// iframe only reclaims space if the *outer* iframe element also shrinks. The iframe cannot resize
|
||||
// itself, so it posts its desired pixel height to the host page; the embed snippet (minted by
|
||||
// EmbedSnippetBuilder.ForRelease) carries a tiny listener that sets iframe.style.height.
|
||||
//
|
||||
// Degrades safely: if the host page ignores or strips the snippet's listener (Option B's value), the
|
||||
// panel still renders and toggles inside the iframe — only the outer resize is lost. We post nothing
|
||||
// when not framed (window === parent), so the docked player is unaffected.
|
||||
//
|
||||
// Multi-embed isolation: EmbedSnippetBuilder.ForRelease mints a per-snippet random token and passes
|
||||
// it as ?EmbedId=<token> in the iframe src. We read it here from window.location.search and include
|
||||
// it in the postMessage payload as `embedId`. The host-side resize script matches on this token so
|
||||
// only the correct iframe is resized when multiple embeds share a host page. If EmbedId is absent
|
||||
// (older already-pasted snippets), embedId is omitted from the payload — those snippets' scripts
|
||||
// ignore messages without a matching embedId, so there is no cross-talk either way.
|
||||
|
||||
const MESSAGE_TYPE = "deepdrft-embed-resize";
|
||||
|
||||
/** Read the EmbedId query param from the iframe's own URL, if present. */
|
||||
function readEmbedId(): string | null {
|
||||
try {
|
||||
return new URLSearchParams(window.location.search).get("EmbedId");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Resolved once at module load — the URL does not change while the iframe is alive.
|
||||
const embedId: string | null = readEmbedId();
|
||||
|
||||
/**
|
||||
* Measure the live rendered height of the player element and post it to the host page so it can size
|
||||
* the iframe to match. No-op when not embedded in a frame, or when the element is unmeasurable.
|
||||
*
|
||||
* targetOrigin is "*" deliberately: the embedder's origin is unknown (any blog can embed us) and the
|
||||
* payload carries no secrets — just a height the host is free to ignore.
|
||||
*
|
||||
* The payload includes `embedId` when the iframe src carried an EmbedId query param. The host-side
|
||||
* resize script matches on this field to isolate multiple embeds on the same page.
|
||||
*/
|
||||
export function postHeight(element: HTMLElement): void {
|
||||
if (window.parent === window) return; // Not framed — nothing to resize.
|
||||
if (!element) return;
|
||||
|
||||
// ceil + a hairline guard against sub-pixel rounding that would otherwise clip the bottom edge.
|
||||
const height = Math.ceil(element.getBoundingClientRect().height) + 2;
|
||||
if (!Number.isFinite(height) || height <= 0) return;
|
||||
|
||||
const payload: Record<string, unknown> = { type: MESSAGE_TYPE, height };
|
||||
if (embedId !== null) payload.embedId = embedId;
|
||||
window.parent.postMessage(payload, "*");
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 478 KiB After Width: | Height: | Size: 525 KiB |
@@ -18,6 +18,15 @@ html, body {
|
||||
color: var(--mud-palette-text-primary);
|
||||
}
|
||||
|
||||
/* Main-content clearance for the fixed frosted-glass nav (.dd-nav). The nav is
|
||||
position:fixed (so content scrolls under its backdrop blur) and thus out of flow;
|
||||
in MainLayout's flex column the content would otherwise start at the top and slide
|
||||
under the bar. Pad the top by the shared --deepdrft-nav-height token so the clearance
|
||||
tracks the bar exactly across breakpoints. Replaces the old hardcoded MudBlazor pt-16. */
|
||||
.dd-main-content {
|
||||
padding-top: var(--deepdrft-nav-height, 88px);
|
||||
}
|
||||
|
||||
/* Ensure the theme wrapper fills the full viewport so no background gap shows. */
|
||||
.deepdrft-theme-dark,
|
||||
.deepdrft-theme-light {
|
||||
@@ -358,6 +367,22 @@ h2, h3, h4, h5, h6,
|
||||
font-family: var(--deepdrft-font-mono) !important;
|
||||
}
|
||||
|
||||
/* Default MudBlazor popover surface (Phase 18, T4 — symptom #1). Selects, menus, and the
|
||||
share-popover body render inside .mud-popover. (Tooltips are NOT covered here — MudBlazor
|
||||
tooltips paint from --mud-palette-text, not the popover surface.) Their visible surface is the
|
||||
inner .mud-paper, which paints background-color: var(--mud-palette-surface). Inspection settled
|
||||
the root cause: the "too dark" is NOT --deepdrft-panel-ground leakage (the bespoke dark-glass
|
||||
panels are MudOverlay .mud-overlay-content surfaces and never match .mud-popover) — it is simply
|
||||
that the popover surface tracks --mud-palette-surface with no desaturated-navy treatment. So
|
||||
re-point --mud-palette-surface to the theme-aware --deepdrft-popover-surface *within the popover
|
||||
scope only*: a soft desaturated-navy wash in light, the existing panel-ground charcoal in dark.
|
||||
Scoping the variable (not a flat background) means any inner .mud-paper, .mud-list, or menu picks
|
||||
it up for free, while the global surface used elsewhere on the page is unaffected. */
|
||||
.mud-popover {
|
||||
--mud-palette-surface: var(--deepdrft-popover-surface);
|
||||
background-color: var(--deepdrft-popover-surface);
|
||||
}
|
||||
|
||||
.deepdrft-share-popover-body {
|
||||
padding: 0.75rem 1rem;
|
||||
min-width: 280px;
|
||||
@@ -399,11 +424,11 @@ h2, h3, h4, h5, h6,
|
||||
section labels are LIGHT (static). The slider track/thumb and the lamp toggles are green.
|
||||
============================================================================= */
|
||||
.waveform-visualizer-control-panel.mix-visualizer-controls-bar {
|
||||
/* Greyed panel ground — desaturated charcoal so the blue slider reads against it (defect #1).
|
||||
Token is tunable in deepdrft-tokens.css without touching this rule. */
|
||||
background: var(--deepdrft-panel-ground);
|
||||
/* Square corners + thin light border — NowPlayingCard chrome (§5). */
|
||||
border: 1px solid var(--deepdrft-border-light);
|
||||
/* Theme-aware glass ground — dark charcoal in dark theme, light translucent glass in light
|
||||
(so the deck reads against the light page). Tunable in deepdrft-tokens.css. */
|
||||
background: var(--deepdrft-panel-surface);
|
||||
/* Square corners + thin theme-aware border — NowPlayingCard chrome (§5). */
|
||||
border: 1px solid var(--deepdrft-panel-border);
|
||||
border-radius: 0;
|
||||
/* Optional backdrop blur — cheap on a small modal panel, nice over the visualizer (§5). */
|
||||
backdrop-filter: blur(8px);
|
||||
@@ -420,7 +445,7 @@ h2, h3, h4, h5, h6,
|
||||
--mud-palette-primary: var(--deepdrft-green-accent); /* knob arc/pointer + slider track/thumb (interactive) */
|
||||
--mud-palette-surface: var(--deepdrft-navy); /* knob center fill — darkest navy reads against the panel */
|
||||
--mud-palette-surface-variant: var(--deepdrft-muted); /* knob background track — muted-navy filler */
|
||||
--mud-palette-text-primary: var(--deepdrft-white); /* knob value label — light */
|
||||
--mud-palette-text-primary: var(--deepdrft-panel-text); /* knob value label — flips dark on light glass */
|
||||
}
|
||||
|
||||
/* ── Row layout (§3). Each row is a horizontal band. Row 1 (MODE) and row 3 (WAVE) use
|
||||
@@ -461,13 +486,13 @@ h2, h3, h4, h5, h6,
|
||||
}
|
||||
|
||||
/* ── Section label "LAVA:" / "WAVE:" (§3, §5). NowPlayingCard .np-label TYPOGRAPHY (mono, uppercase,
|
||||
tracked), recoloured LIGHT — labels are static, so light by the colour principle (§5, §10.3). ── */
|
||||
tracked), coloured via --deepdrft-panel-text — theme-aware (navy in light, off-white in dark). ── */
|
||||
.waveform-visualizer-control-panel .wvc-section-label {
|
||||
font-family: var(--deepdrft-font-mono);
|
||||
font-size: 0.6rem;
|
||||
letter-spacing: 0.25em;
|
||||
text-transform: uppercase;
|
||||
color: var(--deepdrft-white);
|
||||
color: var(--deepdrft-panel-text);
|
||||
align-self: center;
|
||||
flex: 0 0 auto;
|
||||
opacity: 0.85;
|
||||
@@ -495,10 +520,11 @@ h2, h3, h4, h5, h6,
|
||||
opacity: 0.38;
|
||||
}
|
||||
|
||||
/* Caption icons render LIGHT (§5/§9: static/decorative = light). !important beats the scoped
|
||||
.mix-visualizer-control ::deep .mix-visualizer-control-icon rule (which sets green for the legacy
|
||||
inline mount) when the icon also carries mix-visualizer-control-icon. Lamp toggles are MudIconButton
|
||||
not MudIcon so they are unaffected — they stay green (interactive, Color.Primary). (defect #3) */
|
||||
/* Caption icons inherit the portaled panel's body text — theme-aware (dark text on light glass,
|
||||
off-white on dark glass). !important beats the scoped .mix-visualizer-control ::deep
|
||||
.mix-visualizer-control-icon rule (which sets green for the legacy inline mount) when the icon also
|
||||
carries mix-visualizer-control-icon. Lamp toggles are MudIconButton not MudIcon so they are
|
||||
unaffected — they stay green (interactive, Color.Primary). (defect #3) */
|
||||
.waveform-visualizer-control-panel .waveform-visualizer-control-icon {
|
||||
opacity: 0.85;
|
||||
translate: 0 -1rem;
|
||||
@@ -597,6 +623,28 @@ body:has(.waveform-visualizer-control-overlay) {
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark-mode button overrides (Phase 18, Wave 3).
|
||||
In dark, --deepdrft-navy fill/text blends into the #0D1B2A page ground.
|
||||
Primary: green-accent fill + navy text reads as a clear CTA (matches play-chip language).
|
||||
Ghost: white text + light border stands off the dark ground. */
|
||||
.deepdrft-theme-dark .btn-primary {
|
||||
background: var(--deepdrft-green-accent);
|
||||
color: var(--deepdrft-navy);
|
||||
}
|
||||
|
||||
.deepdrft-theme-dark .btn-primary:hover {
|
||||
background: var(--deepdrft-green-interactive);
|
||||
}
|
||||
|
||||
.deepdrft-theme-dark .btn-ghost {
|
||||
color: var(--deepdrft-page-text);
|
||||
border-color: var(--deepdrft-border-light);
|
||||
}
|
||||
|
||||
.deepdrft-theme-dark .btn-ghost:hover {
|
||||
border-color: var(--deepdrft-page-text);
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
CUT ALBUM DETAIL (/cuts/{id})
|
||||
Header splits left-meta / right-cover; the cover carries an explicit theme
|
||||
@@ -703,6 +751,69 @@ body:has(.waveform-visualizer-control-overlay) {
|
||||
}
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
INTERACTIVE-ACCENT ICON TREATMENT (.dd-accent-icon / .dd-accent-fill)
|
||||
----------------------------------------------------------------------------
|
||||
The single, reusable green-accent treatment for interactive icon affordances —
|
||||
replaces the per-site dark-mode overrides that previously had to fight the palette.
|
||||
|
||||
WHY a class and not a palette colour: no MudBlazor Color enum is green in BOTH
|
||||
themes (Dark.Secondary is off-white, Dark.Primary is green; Light.Secondary is
|
||||
green, Light.Primary is navy), so every "green in both" affordance had to be
|
||||
patched per-site. --deepdrft-green-accent (#3D7A68) is the brand constant — the
|
||||
SAME value in both palettes — so a non-theme-scoped rule is correct: light already
|
||||
renders these glyphs green-accent (via Color.Secondary → Light.Secondary), so this
|
||||
keeps light pixel-identical while fixing dark.
|
||||
|
||||
WHY it reaches the glyph: MudBlazor colours a Color.Secondary icon by stamping
|
||||
.mud-secondary-text on the inner .mud-icon-root <svg>, and that rule is `!important`
|
||||
(color: var(--mud-palette-secondary) !important). Targeting only the .mud-icon-button
|
||||
wrapper therefore never wins — the svg keeps its own !important colour. The documented
|
||||
override bug. The glyph clause .dd-accent-icon .mud-icon-button .mud-icon-root is
|
||||
specificity (0,3,0) + !important, which beats MudBlazor's standalone .mud-secondary-text
|
||||
(0,1,0) + !important on specificity alone — source order is not load-bearing for the
|
||||
glyph clause. The .mud-icon-button selector carries the
|
||||
Color.Inherit affordances (lava-lamp glyph inherits the wrapper colour, no
|
||||
.mud-secondary-text to fight); the spinner covers the PlayStateIcon loading state.
|
||||
|
||||
Apply .dd-accent-icon to a CONTAINER of the affordance(s); add .dd-accent-fill
|
||||
alongside it when the container ALSO holds a filled MudButton whose Color.Secondary
|
||||
fill must go green-accent in dark (a filled button is a background fill, not a glyph —
|
||||
light already renders green-accent fill + white text, so .dd-accent-fill is DARK-ONLY
|
||||
to keep light pixel-identical). The Session/Mix hero Share/Play glyphs use this class
|
||||
too (they were already green-accent in light via Color.Secondary, so folding them in
|
||||
keeps light pixel-identical and fixes dark — the over-image glyphs are not actually
|
||||
theme-divergent). The one genuinely theme-divergent affordance (gas-lamp = inherited
|
||||
nav text in light) does NOT use this class — it keeps a dark-only rule below.
|
||||
|
||||
The glyph rule targets glyphs inside an ICON button (.mud-icon-button .mud-icon-root)
|
||||
only — the filled Play button is a .mud-button-filled (not .mud-icon-button), so its
|
||||
StartIcon is naturally excluded and keeps its own contrast colour (white in light,
|
||||
navy in dark). The bare .mud-icon-button selector carries the Color.Inherit case
|
||||
(lava-lamp glyph inherits the wrapper colour); the spinner covers the loading state. */
|
||||
.dd-accent-icon .mud-icon-button .mud-icon-root,
|
||||
.dd-accent-icon .mud-icon-button,
|
||||
.dd-accent-icon .mud-progress-circular {
|
||||
color: var(--deepdrft-green-accent) !important;
|
||||
}
|
||||
|
||||
/* Filled-button variant (DARK-ONLY): green-accent fill + navy glyph/label, matching the
|
||||
play-chip language. In dark, Color.Secondary fill resolves to off-white (unreadable);
|
||||
here it becomes a clear green CTA. Light is untouched (already green fill + white text). */
|
||||
.deepdrft-theme-dark .dd-accent-fill .mud-button-filled {
|
||||
background-color: var(--deepdrft-green-accent);
|
||||
color: var(--deepdrft-navy);
|
||||
}
|
||||
|
||||
.deepdrft-theme-dark .dd-accent-fill .mud-button-filled .mud-icon-root {
|
||||
color: var(--deepdrft-navy) !important;
|
||||
}
|
||||
|
||||
/* Gas-lamp dark-mode toggle: the frame now carries an explicit #2A5C4F fill in its SVG
|
||||
(DDIcons.GasLampLit), so no CSS colour override is needed here in dark. The nav rule
|
||||
that previously set green-accent on the MudIconButton has been removed — it was the
|
||||
only .mud-icon-button in .dd-nav-actions and is now dead. */
|
||||
|
||||
/* =============================================================================
|
||||
RELEASE DESCRIPTION BLURB
|
||||
Shared block rendered just below the hero/header on every release detail page
|
||||
@@ -791,8 +902,8 @@ body:has(.deepdrft-queue-overlay) {
|
||||
width: min(90vw, 520px);
|
||||
height: min(90vw, 520px);
|
||||
max-height: 90vh;
|
||||
background: var(--deepdrft-panel-ground);
|
||||
border: 1px solid var(--deepdrft-border-light);
|
||||
background: var(--deepdrft-panel-surface);
|
||||
border: 1px solid var(--deepdrft-panel-border);
|
||||
border-radius: 0;
|
||||
backdrop-filter: blur(8px);
|
||||
overflow: hidden;
|
||||
@@ -803,16 +914,16 @@ body:has(.deepdrft-queue-overlay) {
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.85rem 1rem;
|
||||
border-bottom: 1px solid var(--deepdrft-border-light);
|
||||
border-bottom: 1px solid var(--deepdrft-panel-border);
|
||||
}
|
||||
|
||||
/* Mono uppercase eyebrow — the NowPlayingCard .np-label typography, recoloured light (static). */
|
||||
/* Mono uppercase eyebrow — the NowPlayingCard .np-label typography, theme-aware (static). */
|
||||
.deepdrft-queue-modal-title {
|
||||
font-family: var(--deepdrft-font-mono);
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.2em;
|
||||
text-transform: uppercase;
|
||||
color: var(--deepdrft-white);
|
||||
color: var(--deepdrft-panel-text);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
@@ -840,12 +951,12 @@ body:has(.deepdrft-queue-overlay) {
|
||||
gap: 0.6rem;
|
||||
padding: 0.45rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
color: var(--deepdrft-white);
|
||||
color: var(--deepdrft-panel-text);
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.deepdrft-queue-row:hover {
|
||||
background: color-mix(in srgb, var(--deepdrft-white) 6%, transparent);
|
||||
background: var(--deepdrft-panel-row-hover);
|
||||
}
|
||||
|
||||
/* Current track: a subtle green wash + left accent, matching the green = active principle. */
|
||||
@@ -863,7 +974,7 @@ body:has(.deepdrft-queue-overlay) {
|
||||
.deepdrft-queue-position {
|
||||
font-family: var(--deepdrft-font-mono);
|
||||
font-size: 0.72rem;
|
||||
opacity: 0.6;
|
||||
color: var(--deepdrft-panel-text-muted);
|
||||
min-width: 1.4rem;
|
||||
text-align: right;
|
||||
flex: 0 0 auto;
|
||||
@@ -888,7 +999,7 @@ body:has(.deepdrft-queue-overlay) {
|
||||
|
||||
.deepdrft-queue-artist {
|
||||
font-size: 0.74rem;
|
||||
opacity: 0.6;
|
||||
color: var(--deepdrft-panel-text-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -905,3 +1016,95 @@ body:has(.deepdrft-queue-overlay) {
|
||||
background: color-mix(in srgb, var(--deepdrft-green-accent) 22%, transparent);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
/* ── Fixed (embed) inline queue panel (Phase 17 §4, OQ6). ──
|
||||
Rendered below the player controls inside the embed surface. A fixed sensible height with internal
|
||||
scroll past N rows (NOT grow-to-cap): ~4.5 rows are visible, the rest scroll. A top hairline
|
||||
separates it from the controls. The list rows reuse the shared .deepdrft-queue-* styles above. */
|
||||
.deepdrft-queue-embed-panel {
|
||||
margin-top: 0.5rem;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px solid var(--deepdrft-border-light);
|
||||
max-height: 184px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
PRIVACY OVERLAY
|
||||
Screen-centered modal following the same MudOverlay idiom as the visualizer
|
||||
controls and queue overlays. MudOverlay portals to body — CSS isolation cannot
|
||||
reach portaled content, so chrome lives here in the global sheet.
|
||||
============================================================================= */
|
||||
|
||||
/* Raise above the sticky header (100), player dock (1200), and minimized FAB (1300). */
|
||||
.deepdrft-privacy-overlay {
|
||||
z-index: 1400 !important;
|
||||
}
|
||||
|
||||
/* Mild tint: doubled selector (0,2,0) outranks MudBlazor's .mud-overlay-dark (0,1,0). */
|
||||
.deepdrft-privacy-overlay .mud-overlay-scrim.mud-overlay-dark {
|
||||
background-color: rgba(var(--deepdrft-scrim-rgb), var(--deepdrft-modal-scrim-alpha));
|
||||
}
|
||||
|
||||
.deepdrft-privacy-overlay .mud-overlay-content {
|
||||
max-height: 90vh;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* Lock body scroll while the overlay is open. */
|
||||
body:has(.deepdrft-privacy-overlay) {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Panel: compact width, navy-panel ground, thin light border — matches queue/visualizer chrome. */
|
||||
.deepdrft-privacy-modal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: min(90vw, 480px);
|
||||
background: var(--deepdrft-panel-surface);
|
||||
border: 1px solid var(--deepdrft-panel-border);
|
||||
border-radius: 0;
|
||||
backdrop-filter: blur(8px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.deepdrft-privacy-modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.85rem 0.85rem 0.85rem 1rem;
|
||||
border-bottom: 1px solid var(--deepdrft-panel-border);
|
||||
}
|
||||
|
||||
/* Mono uppercase eyebrow — matches queue modal title. */
|
||||
.deepdrft-privacy-modal-title {
|
||||
font-family: var(--deepdrft-font-mono);
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.2em;
|
||||
text-transform: uppercase;
|
||||
color: var(--deepdrft-panel-text);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* Tuck the close icon flush with the panel edge; keep it subtle. */
|
||||
.deepdrft-privacy-modal-close {
|
||||
opacity: 0.6;
|
||||
color: var(--deepdrft-panel-text) !important;
|
||||
}
|
||||
|
||||
.deepdrft-privacy-modal-close:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Privacy copy: same mono treatment as the former inline paragraph; theme-aware text colour
|
||||
so it stays legible on both the dark-glass (dark) and light-glass (light) panel surfaces. */
|
||||
.deepdrft-privacy-modal-body {
|
||||
font-family: var(--deepdrft-font-mono);
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.06em;
|
||||
line-height: 1.7;
|
||||
color: var(--deepdrft-panel-text);
|
||||
opacity: 0.8;
|
||||
margin: 0;
|
||||
padding: 1rem 1rem 1.25rem;
|
||||
}
|
||||
|
||||
@@ -12,11 +12,16 @@ public static class DDIcons
|
||||
""";
|
||||
|
||||
/// <summary>
|
||||
/// Charleston gas lamp with lit flame - for dark mode
|
||||
/// Charleston gas lamp with lit flame - for dark mode.
|
||||
/// Frame/body path uses an explicit darker-green fill (#2A5C4F — palette PrimaryDarken /
|
||||
/// --deepdrft-green-light) instead of currentColor so it is deterministic in the nav
|
||||
/// regardless of inherited colour. The flame ellipses keep their literal orange/yellow/cream
|
||||
/// fills. Only rendered in dark mode (DarkLightModeButtonIcon in DeepDrftMenu.razor).
|
||||
/// If the palette's PrimaryDarken changes, update #2A5C4F to match.
|
||||
/// </summary>
|
||||
public const string GasLampLit = """
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M11 0h2v2h-2zM5 6l7-4 7 4v2H5zM6 8h12l-1.5 10h-9zM7.7 9l1.2 8h6.2l1.2-8zM9 19h6v1H9zM10 21h4v2h-4z"/>
|
||||
<path fill="#2A5C4F" d="M11 0h2v2h-2zM5 6l7-4 7 4v2H5zM6 8h12l-1.5 10h-9zM7.7 9l1.2 8h6.2l1.2-8zM9 19h6v1H9zM10 21h4v2h-4z"/>
|
||||
<ellipse cx="12" cy="13" rx="2.5" ry="3.5" fill="#FF9800"/>
|
||||
<ellipse cx="12" cy="12.5" rx="1.5" ry="2.5" fill="#FFCA28"/>
|
||||
<ellipse cx="12" cy="12" rx=".7" ry="1.5" fill="#FFF8E1"/>
|
||||
|
||||
@@ -114,7 +114,7 @@ public static class DeepDrftPalettes
|
||||
Tertiary = "#1A3C34", // Deep green - tertiary accent
|
||||
Background = "#0D1B2A", // Navy - the light palette's primary as the dark ground
|
||||
Surface = "#162437", // Navy-mid - elevated cards/panels
|
||||
AppbarBackground = "rgba(13,27,42,0.92)", // Semi-opaque navy
|
||||
AppbarBackground = "rgba(17,35,56,0.92)", // Semi-opaque #112338 navy — distinct appbar bar, lighter than the #0D1B2A page ground
|
||||
AppbarText = "#FAFAF8",
|
||||
DrawerBackground = "#162437", // Navy-mid
|
||||
DrawerText = "#FAFAF8",
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* theme - body-class helpers for dark-mode theme toggling.
|
||||
*
|
||||
* Single Responsibility: apply or remove the deepdrft-theme-dark class on
|
||||
* document.body so that portaled MudBlazor elements (popovers, menus, selects)
|
||||
* inherit --deepdrft-popover-surface from body.deepdrft-theme-dark rather than
|
||||
* from :root only. Popovers portal outside the ThemeWrapperClass div, so only
|
||||
* a body-level class can reach them.
|
||||
*/
|
||||
|
||||
/** Toggle the deepdrft-theme-dark class on document.body.
|
||||
* @param isDark true to add the class, false to remove it. */
|
||||
export function setBodyThemeClass(isDark: boolean): void {
|
||||
document.body.classList.toggle('deepdrft-theme-dark', isDark);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* theme - body-class helpers for dark-mode theme toggling.
|
||||
*
|
||||
* Single Responsibility: apply or remove the deepdrft-theme-dark class on
|
||||
* document.body so that portaled MudBlazor elements (popovers, menus, selects)
|
||||
* inherit --deepdrft-popover-surface from body.deepdrft-theme-dark rather than
|
||||
* from :root only. Popovers portal outside the ThemeWrapperClass div, so only
|
||||
* a body-level class can reach them.
|
||||
*/
|
||||
/** Toggle the deepdrft-theme-dark class on document.body.
|
||||
* @param isDark true to add the class, false to remove it. */
|
||||
export function setBodyThemeClass(isDark) {
|
||||
document.body.classList.toggle('deepdrft-theme-dark', isDark);
|
||||
}
|
||||
//# sourceMappingURL=/js/theme/theme.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"theme.js","sourceRoot":"/Interop/","sources":["theme/theme.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH;gEACgE;AAChE,MAAM,UAAU,iBAAiB,CAAC,MAAe;IAC7C,QAAQ,CAAC,IAAI,CAAC,SAAS,CAAC,MAAM,CAAC,qBAAqB,EAAE,MAAM,CAAC,CAAC;AAClE,CAAC"}
|
||||
@@ -28,9 +28,24 @@
|
||||
(Phase 15 §4/§10.5). Mild so the panel reads as modal without a blackout. Change here once. */
|
||||
--deepdrft-modal-scrim-alpha: 0.15;
|
||||
/* Panel ground — muted, desaturated charcoal beneath the controls panel.
|
||||
Tunable: increase blue channel (e.g. #1e2235) to recover warmth, lower (e.g. #191b20) to go darker. */
|
||||
Tunable: increase blue channel (e.g. #1e2235) to recover warmth, lower (e.g. #191b20) to go darker.
|
||||
Source token; consumed by the theme-aware --deepdrft-panel-surface dark value below. */
|
||||
--deepdrft-panel-ground: #1a1c22;
|
||||
|
||||
/* Glass-panel family — the bespoke overlay panels (queue / visualizer control deck / privacy).
|
||||
Light values here make these panels a light translucent glass with dark text so they read
|
||||
coherently against the light page; the .deepdrft-theme-dark block below reproduces today's
|
||||
dark-glass charcoal exactly so dark mode is visually unchanged. Surface keeps the glassmorphic
|
||||
translucency (paired with backdrop-blur in the consuming rules).
|
||||
Light surface: near-page-surface white at 82% so the backdrop blur still shows through;
|
||||
text/border are navy-based for legibility on the light glass. */
|
||||
--deepdrft-panel-surface: rgba(250, 250, 248, 0.82);
|
||||
--deepdrft-panel-text: var(--deepdrft-navy);
|
||||
--deepdrft-panel-text-muted: var(--deepdrft-muted);
|
||||
--deepdrft-panel-border: var(--deepdrft-border);
|
||||
/* Row/hover wash on the panel surface — a navy tint on light, a white tint on dark (below). */
|
||||
--deepdrft-panel-row-hover: color-mix(in srgb, var(--deepdrft-navy) 8%, transparent);
|
||||
|
||||
/* Wireframe font stack */
|
||||
--deepdrft-font-display: "Cormorant Garamond", Georgia, serif;
|
||||
--deepdrft-font-mono: "Geist Mono", monospace;
|
||||
@@ -68,11 +83,54 @@
|
||||
--gradient-warm: var(--deepdrft-green);
|
||||
--gradient-light: var(--deepdrft-green-accent);
|
||||
|
||||
/* Theme-aware page-surface family (Phase 18). The "neutral page surface" concept:
|
||||
sections that were hardcoded to --deepdrft-white because the site was light-only.
|
||||
Light values reproduce today's look exactly; the .deepdrft-theme-dark block below
|
||||
inverts them onto the navy ground so neutral sections dissolve into one dark field. */
|
||||
--deepdrft-page-surface: var(--deepdrft-white);
|
||||
--deepdrft-page-text: var(--deepdrft-navy);
|
||||
--deepdrft-page-text-muted: var(--deepdrft-muted);
|
||||
|
||||
/* Play-chip family (Phase 18). PlayStateIcon's chip is shared across release heroes,
|
||||
Cut track rows, and the player bar. Light keeps the current soft-grey chip + glyph;
|
||||
dark turns the chip moss-green with a navy glyph. The -soft variant is the player-bar
|
||||
override (same green, much less opaque). */
|
||||
--deepdrft-play-chip: var(--deepdrft-soft);
|
||||
--deepdrft-play-glyph: var(--deepdrft-navy);
|
||||
--deepdrft-play-chip-soft: var(--deepdrft-soft);
|
||||
|
||||
/* Popover surface (Phase 18). Default MudBlazor popovers (selects/menus/tooltips/share
|
||||
body) bind this. Light uses a very subtle navy wash (4%) — near the page background but
|
||||
just perceptibly off-white so the popover reads as an elevated surface. Dark uses a
|
||||
bluer navy (colour-mix of navy-mid + green-accent at 20%), defined once in
|
||||
--deepdrft-popover-surface-dark below and referenced by both the .deepdrft-theme-dark
|
||||
wrapper block and the body.deepdrft-theme-dark block so portaled popover content (which
|
||||
portals to <body>, outside the wrapper div) is also reached. The bespoke glass panels
|
||||
(visualizer/queue/privacy) do NOT bind this — they have their own theme-aware
|
||||
--deepdrft-panel-* family (dark glass in dark theme, light glass in light). */
|
||||
--deepdrft-popover-surface-dark: color-mix(in srgb, var(--deepdrft-navy-mid) 80%, var(--deepdrft-green-accent) 20%);
|
||||
--deepdrft-popover-surface: color-mix(in srgb, var(--deepdrft-navy) 4%, var(--deepdrft-white));
|
||||
|
||||
/* Fixed-nav height — single source of truth shared by the frosted-glass nav
|
||||
(DeepDrftMenu.razor.css pins .dd-nav to this) and the main-content clearance
|
||||
(.dd-main-content padding-top in deepdrft-styles.css). The nav is position:fixed
|
||||
so content scrolls under its backdrop blur; this keeps the clearance in lockstep
|
||||
with the bar so content never overlaps. Mobile (<600px) override below. */
|
||||
--deepdrft-nav-height: 88px;
|
||||
|
||||
/* Legacy font aliases retired in Phase 0.1 — all consumers now use --deepdrft-font-*.
|
||||
Palette aliases (--deepdrft-primary, --theme-*, etc.) remain; they still have
|
||||
consumers and are scheduled for retirement in Phase 0.3/0.4. */
|
||||
}
|
||||
|
||||
/* Mobile fixed-nav height — matches the <600px breakpoint in DeepDrftMenu.razor.css
|
||||
(tighter horizontal padding + smaller bar). Cascades to .dd-nav and .dd-main-content. */
|
||||
@media (max-width: 599px) {
|
||||
:root {
|
||||
--deepdrft-nav-height: 72px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark theme - wireframe palette (navy ground / green-accent / off-white).
|
||||
Mirrors the light palette's vocabulary on a dark ground. Same alias structure
|
||||
as :root so utility classes (.deepdrft-chip-*, .deepdrft-border-*, .deepdrft-text-*)
|
||||
@@ -108,4 +166,55 @@
|
||||
--gradient-accent: var(--deepdrft-green-accent);
|
||||
--gradient-warm: var(--deepdrft-green);
|
||||
--gradient-light: var(--deepdrft-green-light);
|
||||
|
||||
/* Theme-aware page-surface family (Phase 18) — inverted onto the true page ground.
|
||||
Binds --mud-palette-background (#0D1B2A) so neutral sections (Home hero-left,
|
||||
medium grid, footer, About light sections) dissolve into the site background as
|
||||
one continuous dark field rather than reading as raised panels (#112338 navy
|
||||
is card-elevation, not the page ground). */
|
||||
--deepdrft-page-surface: var(--mud-palette-background);
|
||||
--deepdrft-page-text: var(--deepdrft-white);
|
||||
/* Lift muted text toward white so eyebrows/sub-text stay legible on the dark ground. */
|
||||
--deepdrft-page-text-muted: color-mix(in srgb, var(--deepdrft-muted) 70%, var(--deepdrft-white));
|
||||
|
||||
/* Play-chip family (Phase 18) — moss-green chip, navy glyph (green-on-green on the
|
||||
player bar; navy-on-green on solid chips). The -soft variant is the player-bar
|
||||
override: same green, much less opaque (translucent wash over the navy dock). */
|
||||
--deepdrft-play-chip: var(--deepdrft-green-accent);
|
||||
--deepdrft-play-glyph: var(--deepdrft-navy);
|
||||
--deepdrft-play-chip-soft: color-mix(in srgb, var(--deepdrft-green-accent) 30%, transparent);
|
||||
|
||||
/* Popover surface (Phase 18) — within .deepdrft-theme-dark wrapper this value applies to
|
||||
non-portaled elements only (drawers, inline menus). Portaled MudBlazor popovers live at
|
||||
<body> level; the body.deepdrft-theme-dark block below uses the same source token. */
|
||||
--deepdrft-popover-surface: var(--deepdrft-popover-surface-dark);
|
||||
|
||||
/* Glass-panel family (dark) — reproduces today's dark-glass chrome EXACTLY. Surface is the
|
||||
opaque charcoal ground the panels used directly before tokenisation; text is off-white;
|
||||
border is the thin light-on-dark hairline (NowPlayingCard spirit); row hover is the prior
|
||||
white 6% wash. Dark mode must look unchanged. */
|
||||
--deepdrft-panel-surface: var(--deepdrft-panel-ground);
|
||||
--deepdrft-panel-text: var(--deepdrft-white);
|
||||
--deepdrft-panel-text-muted: color-mix(in srgb, var(--deepdrft-white) 60%, transparent);
|
||||
--deepdrft-panel-border: var(--deepdrft-border-light);
|
||||
--deepdrft-panel-row-hover: color-mix(in srgb, var(--deepdrft-white) 6%, transparent);
|
||||
}
|
||||
|
||||
/* Portal-scope dark popover surface. MudBlazor popovers (selects, menus, share body) portal
|
||||
to <body>, placing them outside the .deepdrft-theme-dark wrapper div. MainLayout.razor syncs
|
||||
deepdrft-theme-dark onto <body> via JS after each render, so this selector reaches portaled
|
||||
content. Resolved from --deepdrft-popover-surface-dark (defined in :root above) — bluer navy
|
||||
(navy-mid + 20% green-accent tint) rather than the pure charcoal #162437. */
|
||||
body.deepdrft-theme-dark {
|
||||
--deepdrft-popover-surface: var(--deepdrft-popover-surface-dark);
|
||||
|
||||
/* The bespoke glass panels (queue / visualizer / privacy) are MudOverlay panels that portal to
|
||||
<body>, outside the .deepdrft-theme-dark wrapper div — same portal scope as popovers. Re-declare
|
||||
the dark glass-panel family here so the panels resolve the dark (charcoal) values; without this
|
||||
they would fall through to the light :root values while the page is in dark mode. */
|
||||
--deepdrft-panel-surface: var(--deepdrft-panel-ground);
|
||||
--deepdrft-panel-text: var(--deepdrft-white);
|
||||
--deepdrft-panel-text-muted: color-mix(in srgb, var(--deepdrft-white) 60%, transparent);
|
||||
--deepdrft-panel-border: var(--deepdrft-border-light);
|
||||
--deepdrft-panel-row-hover: color-mix(in srgb, var(--deepdrft-white) 6%, transparent);
|
||||
}
|
||||
|
||||
@@ -28,6 +28,9 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Referenced for UnifiedTrackService — the dual-database upload orchestrator whose create-path
|
||||
duplicate guard and within-batch attach path are exercised in UploadDuplicateDetectionTests. -->
|
||||
<ProjectReference Include="..\DeepDrftAPI\DeepDrftAPI.csproj" />
|
||||
<ProjectReference Include="..\DeepDrftContent\DeepDrftContent.csproj" />
|
||||
<ProjectReference Include="..\DeepDrftData\DeepDrftData.csproj" />
|
||||
<!-- Referenced for ProgressStreamContent (the upload progress/heartbeat HttpContent). It is plain
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using DeepDrftPublic.Client.Helpers;
|
||||
|
||||
namespace DeepDrftTests;
|
||||
@@ -5,8 +6,9 @@ namespace DeepDrftTests;
|
||||
/// <summary>
|
||||
/// Unit tests for the share-popover embed snippet (<see cref="EmbedSnippetBuilder"/>). The builder is
|
||||
/// the mode-aware half of SharePopover: track mode targets FramePlayer's TrackEntryKey param, release
|
||||
/// mode targets its ReleaseEntryKey param. The iframe chrome (dimensions, autoplay) must be identical
|
||||
/// across both. Pure string composition, tested directly without rendering the component.
|
||||
/// mode targets its ReleaseEntryKey param. The two snippets share width/border/autoplay chrome but
|
||||
/// diverge in height by design (Phase 17 §4.1, OQ6): the release embed is taller to show its queue
|
||||
/// panel; the track embed stays compact. Pure string composition, tested without rendering.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class EmbedSnippetBuilderTests
|
||||
@@ -27,12 +29,13 @@ public class EmbedSnippetBuilderTests
|
||||
{
|
||||
var snippet = EmbedSnippetBuilder.ForRelease(BaseUri, "rel-xyz");
|
||||
|
||||
Assert.That(snippet, Does.Contain(@"src=""https://deepdrft.example/FramePlayer?ReleaseEntryKey=rel-xyz"""));
|
||||
// src contains ReleaseEntryKey; may also carry additional query params (e.g. EmbedId).
|
||||
Assert.That(snippet, Does.Contain("ReleaseEntryKey=rel-xyz"));
|
||||
Assert.That(snippet, Does.Not.Contain("TrackEntryKey"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void BothModes_ShareIdenticalIframeChrome()
|
||||
public void BothModes_ShareIdenticalNonHeightChrome()
|
||||
{
|
||||
var track = EmbedSnippetBuilder.ForTrack(BaseUri, "k");
|
||||
var release = EmbedSnippetBuilder.ForRelease(BaseUri, "k");
|
||||
@@ -43,12 +46,115 @@ public class EmbedSnippetBuilderTests
|
||||
{
|
||||
Assert.That(snippet, Does.StartWith("<iframe "));
|
||||
Assert.That(snippet, Does.Contain(@"width=""656"""));
|
||||
Assert.That(snippet, Does.Contain(@"height=""196"""));
|
||||
Assert.That(snippet, Does.Contain(@"frameborder=""0"""));
|
||||
Assert.That(snippet, Does.Contain(@"style=""border-radius:8px;"""));
|
||||
Assert.That(snippet, Does.Contain(@"allow=""autoplay"""));
|
||||
Assert.That(snippet, Does.EndWith("></iframe>"));
|
||||
Assert.That(snippet, Does.Contain("</iframe>"));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// T14 (Phase 17 §9): the release embed is taller than the track embed (it shows a queue panel),
|
||||
// and the track embed's height is unchanged from its historical value (UC6/AC6).
|
||||
[Test]
|
||||
public void ForTrack_KeepsHistoricalCompactHeight()
|
||||
{
|
||||
var track = EmbedSnippetBuilder.ForTrack(BaseUri, "k");
|
||||
|
||||
Assert.That(track, Does.Contain(@"height=""196"""));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ForRelease_IsTallerThanForTrack_ToShowQueuePanel()
|
||||
{
|
||||
var trackHeight = HeightOf(EmbedSnippetBuilder.ForTrack(BaseUri, "k"));
|
||||
var releaseHeight = HeightOf(EmbedSnippetBuilder.ForRelease(BaseUri, "k"));
|
||||
|
||||
Assert.That(releaseHeight, Is.GreaterThan(trackHeight));
|
||||
}
|
||||
|
||||
// The release snippet carries the host-side resize listener (OQ1 Option A); the track snippet,
|
||||
// having no panel to collapse, does not.
|
||||
[Test]
|
||||
public void ForRelease_IncludesResizeListenerScript()
|
||||
{
|
||||
var release = EmbedSnippetBuilder.ForRelease(BaseUri, "k");
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(release, Does.Contain("<script>"));
|
||||
Assert.That(release, Does.Contain("deepdrft-embed-resize"));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ForTrack_HasNoResizeListenerScript()
|
||||
{
|
||||
var track = EmbedSnippetBuilder.ForTrack(BaseUri, "k");
|
||||
|
||||
Assert.That(track, Does.Not.Contain("<script>"));
|
||||
}
|
||||
|
||||
// --- Multi-embed isolation (Phase 17 major remediation) ---
|
||||
|
||||
// Two ForRelease calls must produce snippets with different iframe ids so both can coexist on one
|
||||
// host page without the host-side resize script resolving only the first via getElementById.
|
||||
[Test]
|
||||
public void ForRelease_TwoCalls_ProduceDifferentIframeIds()
|
||||
{
|
||||
var a = EmbedSnippetBuilder.ForRelease(BaseUri, "rel-xyz");
|
||||
var b = EmbedSnippetBuilder.ForRelease(BaseUri, "rel-xyz"); // same release, different call
|
||||
|
||||
var idA = IframeId(a);
|
||||
var idB = IframeId(b);
|
||||
|
||||
Assert.That(idA, Is.Not.EqualTo(idB),
|
||||
"each ForRelease call must mint a distinct iframe id to prevent multi-embed cross-talk");
|
||||
}
|
||||
|
||||
// The iframe id and the token embedded in the host-side resize script must be consistent within
|
||||
// a single snippet — the script assigns the id string to a JS variable and calls getElementById
|
||||
// with it, so the id literal must appear in the script's var initializer.
|
||||
[Test]
|
||||
public void ForRelease_IframeIdAndScriptToken_AreConsistentWithinOneSnippet()
|
||||
{
|
||||
var snippet = EmbedSnippetBuilder.ForRelease(BaseUri, "rel-abc");
|
||||
|
||||
var id = IframeId(snippet);
|
||||
Assert.That(id, Does.StartWith("deepdrft-embed-"), "id must carry the expected prefix");
|
||||
|
||||
// The iframe element must declare the minted id.
|
||||
Assert.That(snippet, Does.Contain($@"id=""{id}"""),
|
||||
"iframe element must carry the minted id");
|
||||
|
||||
// The script stores the id in a JS var and calls getElementById(id) — confirm the id literal
|
||||
// appears in the script's var initializer so the right iframe is targeted.
|
||||
Assert.That(snippet, Does.Contain($@"var id=""{id}"""),
|
||||
"resize script must initialise its id variable with the same minted id");
|
||||
}
|
||||
|
||||
// The iframe src must carry EmbedId so the iframe content (embed-frame.ts) can read its own
|
||||
// token and include it in postMessage payloads for the host-side script to match on.
|
||||
[Test]
|
||||
public void ForRelease_SrcCarriesEmbedIdParam()
|
||||
{
|
||||
var snippet = EmbedSnippetBuilder.ForRelease(BaseUri, "rel-def");
|
||||
|
||||
Assert.That(snippet, Does.Contain("EmbedId="),
|
||||
"iframe src must include EmbedId query param so embed-frame.ts can read its own token");
|
||||
}
|
||||
|
||||
private static int HeightOf(string snippet)
|
||||
{
|
||||
var match = Regex.Match(snippet, @"height=""(\d+)""");
|
||||
Assert.That(match.Success, Is.True, "snippet must declare an iframe height");
|
||||
return int.Parse(match.Groups[1].Value);
|
||||
}
|
||||
|
||||
private static string IframeId(string snippet)
|
||||
{
|
||||
var match = Regex.Match(snippet, @"id=""([^""]+)""");
|
||||
Assert.That(match.Success, Is.True, "snippet must declare an iframe id");
|
||||
return match.Groups[1].Value;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,9 +60,9 @@ public class MediumWritePathTests
|
||||
"Live at the Vault", "Artist A", ReleaseData("Live at the Vault", "Artist A", ReleaseMedium.Session));
|
||||
|
||||
Assert.That(result.Success, Is.True);
|
||||
Assert.That(result.Value!.Medium, Is.EqualTo(ReleaseMedium.Session));
|
||||
Assert.That(result.Value.Release.Medium, Is.EqualTo(ReleaseMedium.Session));
|
||||
|
||||
var stored = await CreateRepository().GetReleaseByIdAsync(result.Value.Id);
|
||||
var stored = await CreateRepository().GetReleaseByIdAsync(result.Value.Release.Id);
|
||||
Assert.That(stored!.Medium, Is.EqualTo(ReleaseMedium.Session));
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@ public class MediumWritePathTests
|
||||
var result = await manager.FindOrCreateRelease(
|
||||
"Sunset Set", "DJ B", ReleaseData("Sunset Set", "DJ B", ReleaseMedium.Mix));
|
||||
|
||||
Assert.That(result.Value!.Medium, Is.EqualTo(ReleaseMedium.Mix));
|
||||
Assert.That(result.Value.Release.Medium, Is.EqualTo(ReleaseMedium.Mix));
|
||||
}
|
||||
|
||||
// 9.5.A — a Cut upload (the default) creates a release carrying Medium == Cut.
|
||||
@@ -87,7 +87,7 @@ public class MediumWritePathTests
|
||||
var result = await manager.FindOrCreateRelease(
|
||||
"Studio Album", "Artist C", ReleaseData("Studio Album", "Artist C", ReleaseMedium.Cut));
|
||||
|
||||
Assert.That(result.Value!.Medium, Is.EqualTo(ReleaseMedium.Cut));
|
||||
Assert.That(result.Value.Release.Medium, Is.EqualTo(ReleaseMedium.Cut));
|
||||
}
|
||||
|
||||
// 9.5.A — a second upload to an existing release does NOT mutate the stored medium. The first
|
||||
@@ -105,10 +105,10 @@ public class MediumWritePathTests
|
||||
var found = await manager.FindOrCreateRelease(
|
||||
"Live at the Vault", "Artist A", ReleaseData("Live at the Vault", "Artist A", ReleaseMedium.Cut));
|
||||
|
||||
Assert.That(found.Value!.Id, Is.EqualTo(created.Value!.Id), "same release row is returned");
|
||||
Assert.That(found.Value.Medium, Is.EqualTo(ReleaseMedium.Session), "medium stays as first set");
|
||||
Assert.That(found.Value.Release.Id, Is.EqualTo(created.Value.Release.Id), "same release row is returned");
|
||||
Assert.That(found.Value.Release.Medium, Is.EqualTo(ReleaseMedium.Session), "medium stays as first set");
|
||||
|
||||
var stored = await CreateRepository().GetReleaseByIdAsync(created.Value.Id);
|
||||
var stored = await CreateRepository().GetReleaseByIdAsync(created.Value.Release.Id);
|
||||
Assert.That(stored!.Medium, Is.EqualTo(ReleaseMedium.Session), "DB row unchanged");
|
||||
}
|
||||
|
||||
@@ -207,9 +207,9 @@ public class MediumWritePathTests
|
||||
var result = await manager.FindOrCreateRelease("Studio Album", "Artist C", data);
|
||||
|
||||
Assert.That(result.Success, Is.True);
|
||||
Assert.That(result.Value!.Description, Is.EqualTo(prose));
|
||||
Assert.That(result.Value.Release.Description, Is.EqualTo(prose));
|
||||
|
||||
var stored = await CreateRepository().GetReleaseByIdAsync(result.Value.Id);
|
||||
var stored = await CreateRepository().GetReleaseByIdAsync(result.Value.Release.Id);
|
||||
Assert.That(stored!.Description, Is.EqualTo(prose));
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
using System.Text;
|
||||
using Data.Data.Repositories;
|
||||
using Data.Managers;
|
||||
using DeepDrftAPI.Services;
|
||||
using DeepDrftContent;
|
||||
using DeepDrftContent.Processors;
|
||||
using DeepDrftData;
|
||||
using DeepDrftData.Data;
|
||||
using DeepDrftData.Repositories;
|
||||
using DeepDrftModels.DTOs;
|
||||
using DeepDrftModels.Entities;
|
||||
using DeepDrftModels.Enums;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using NetBlocks.Models;
|
||||
using FileDb = DeepDrftContent.FileDatabase.Services.FileDatabase;
|
||||
|
||||
namespace DeepDrftTests;
|
||||
|
||||
/// <summary>
|
||||
/// Server-backstop coverage for upload duplicate detection. Drives the full
|
||||
/// <see cref="UnifiedTrackService.UploadAsync"/> dual-database write over a real temp-isolated
|
||||
/// <see cref="FileDb"/> vault and an EF in-memory <see cref="DeepDrftContext"/>, so the create-path
|
||||
/// duplicate block, the within-batch attach path, and the existing single-track cardinality rule are
|
||||
/// all asserted against the same orchestrator the controller calls.
|
||||
///
|
||||
/// The rule under test: a (title, artist) that pre-existed the submit is blocked on the CREATE path
|
||||
/// (no releaseId), but the within-batch multi-track Cut still succeeds because rows 2..N pass the
|
||||
/// release id row 1 created (ATTACH path) and so skip the duplicate lookup entirely.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class UploadDuplicateDetectionTests
|
||||
{
|
||||
private string _testDir = string.Empty;
|
||||
private DeepDrftContext _context = null!;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
_testDir = Path.Combine(Path.GetTempPath(), "UploadDuplicateDetectionTests", Guid.NewGuid().ToString());
|
||||
Directory.CreateDirectory(_testDir);
|
||||
|
||||
var options = new DbContextOptionsBuilder<DeepDrftContext>()
|
||||
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
_context = new DeepDrftContext(options);
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void TearDown()
|
||||
{
|
||||
_context.Dispose();
|
||||
try { Directory.Delete(_testDir, recursive: true); }
|
||||
catch { /* Best-effort cleanup — ignore failures */ }
|
||||
}
|
||||
|
||||
private TrackManager CreateManager()
|
||||
{
|
||||
var repository = new TrackRepository(
|
||||
_context, NullLogger<Repository<DeepDrftContext, TrackEntity>>.Instance);
|
||||
return new TrackManager(
|
||||
repository, NullLogger<Manager<TrackEntity, TrackDto, TrackRepository, TrackConverter>>.Instance);
|
||||
}
|
||||
|
||||
private async Task<UnifiedTrackService> CreateUnifiedServiceAsync(ITrackService sqlTrackService)
|
||||
{
|
||||
var fileDatabase = await FileDb.FromAsync(_testDir);
|
||||
Assert.That(fileDatabase, Is.Not.Null);
|
||||
|
||||
var content = new TrackContentService(
|
||||
fileDatabase!, new AudioProcessorRouter(
|
||||
new AudioProcessor(), new Mp3AudioProcessor(), new FlacAudioProcessor()));
|
||||
var waveforms = new WaveformProfileService(
|
||||
fileDatabase!, new AudioProcessor(), new RmsLoudnessAlgorithm(),
|
||||
Options.Create(new WaveformProfileOptions()), NullLogger<WaveformProfileService>.Instance);
|
||||
|
||||
return new UnifiedTrackService(
|
||||
content, sqlTrackService, fileDatabase!, waveforms,
|
||||
NullLogger<UnifiedTrackService>.Instance);
|
||||
}
|
||||
|
||||
private async Task<string> WriteWavAsync(double durationSeconds)
|
||||
{
|
||||
var path = Path.Combine(_testDir, Guid.NewGuid().ToString("N") + ".wav");
|
||||
await File.WriteAllBytesAsync(path, BuildMinimalPcmWav(durationSeconds));
|
||||
return path;
|
||||
}
|
||||
|
||||
private Task<ResultContainer<TrackDto>> UploadAsync(
|
||||
UnifiedTrackService service, string tempPath, string trackName, string artist,
|
||||
string? album, ReleaseMedium medium, long? releaseId)
|
||||
=> service.UploadAsync(
|
||||
tempPath, trackName, artist, album,
|
||||
genre: null, description: null, releaseDate: null, createdByUserId: 1,
|
||||
originalFileName: null, releaseType: ReleaseType.Single, medium: medium,
|
||||
trackNumber: 1, releaseId: releaseId, ct: default);
|
||||
|
||||
// CREATE path: a brand-new single-track Mix succeeds (no pre-existing (title, artist)).
|
||||
[Test]
|
||||
public async Task UploadAsync_NewSingleTrackRelease_Succeeds()
|
||||
{
|
||||
var service = await CreateUnifiedServiceAsync(CreateManager());
|
||||
|
||||
var result = await UploadAsync(
|
||||
service, await WriteWavAsync(2.0), "Sunset Set", "DJ B", "Sunset Set", ReleaseMedium.Mix, releaseId: null);
|
||||
|
||||
Assert.That(result.Success, Is.True, result.Messages.FirstOrDefault()?.Message);
|
||||
Assert.That(result.Value!.ReleaseId, Is.Not.Null);
|
||||
}
|
||||
|
||||
// CREATE path: uploading a (title, artist) that already exists is blocked with the duplicate marker
|
||||
// (which the controller maps to 409), for ANY medium — here a Cut.
|
||||
[Test]
|
||||
public async Task UploadAsync_DuplicateTitleArtist_IsBlockedWithDuplicateMarker()
|
||||
{
|
||||
var service = await CreateUnifiedServiceAsync(CreateManager());
|
||||
|
||||
var first = await UploadAsync(
|
||||
service, await WriteWavAsync(2.0), "Studio Album", "Artist C", "Studio Album", ReleaseMedium.Cut, releaseId: null);
|
||||
Assert.That(first.Success, Is.True, "the first create must succeed");
|
||||
|
||||
// Second submit, same (title, artist), no releaseId → CREATE path → duplicate block.
|
||||
var duplicate = await UploadAsync(
|
||||
service, await WriteWavAsync(2.0), "Studio Album", "Artist C", "Studio Album", ReleaseMedium.Cut, releaseId: null);
|
||||
|
||||
Assert.That(duplicate.Success, Is.False);
|
||||
var message = duplicate.Messages.FirstOrDefault()?.Message ?? string.Empty;
|
||||
Assert.That(message, Does.StartWith(UnifiedTrackService.DuplicateReleaseMarker));
|
||||
Assert.That(message, Does.Contain("Studio Album"), "the block message names the existing release");
|
||||
}
|
||||
|
||||
// The crux regression guard: a within-batch multi-track Cut. Row 1 CREATEs the release; row 2 passes
|
||||
// row 1's ReleaseId (ATTACH path) and must succeed — the within-batch release is NOT a pre-existing
|
||||
// duplicate. Both tracks end up on the same release.
|
||||
[Test]
|
||||
public async Task UploadAsync_WithinBatchMultiTrackCut_AttachesAndSucceeds()
|
||||
{
|
||||
var manager = CreateManager();
|
||||
var service = await CreateUnifiedServiceAsync(manager);
|
||||
|
||||
var row1 = await UploadAsync(
|
||||
service, await WriteWavAsync(2.0), "Track One", "Artist A", "Live at the Vault", ReleaseMedium.Cut, releaseId: null);
|
||||
Assert.That(row1.Success, Is.True, "row 1 creates the release");
|
||||
var releaseId = row1.Value!.ReleaseId;
|
||||
Assert.That(releaseId, Is.Not.Null);
|
||||
|
||||
// Row 2 attaches to the just-created release — same (title, artist), but with the explicit id.
|
||||
var row2 = await UploadAsync(
|
||||
service, await WriteWavAsync(2.0), "Track Two", "Artist A", "Live at the Vault", ReleaseMedium.Cut, releaseId);
|
||||
|
||||
Assert.That(row2.Success, Is.True, row2.Messages.FirstOrDefault()?.Message);
|
||||
Assert.That(row2.Value!.ReleaseId, Is.EqualTo(releaseId), "row 2 lands on the same release row 1 created");
|
||||
|
||||
var peek = (await ((ITrackService)manager).GetReleaseByTitleAndArtist("Live at the Vault", "Artist A")).Value!;
|
||||
Assert.That(peek.TrackCount, Is.EqualTo(2), "both within-batch tracks are on the one release");
|
||||
}
|
||||
|
||||
// The existing single-track cardinality rule still fires on the attach path: a Session already
|
||||
// holding its one track rejects a second add with the cardinality marker (controller → 409). This
|
||||
// is reachable here only via an explicit releaseId, since a no-id second submit is the duplicate path.
|
||||
[Test]
|
||||
public async Task UploadAsync_SecondTrackOnSingleTrackRelease_IsBlockedWithCardinalityMarker()
|
||||
{
|
||||
var manager = CreateManager();
|
||||
var service = await CreateUnifiedServiceAsync(manager);
|
||||
|
||||
var first = await UploadAsync(
|
||||
service, await WriteWavAsync(2.0), "Live Set", "DJ A", "Live Set", ReleaseMedium.Session, releaseId: null);
|
||||
Assert.That(first.Success, Is.True);
|
||||
var releaseId = first.Value!.ReleaseId;
|
||||
|
||||
// A second track aimed at the same single-track Session via its id → cardinality rejection.
|
||||
var second = await UploadAsync(
|
||||
service, await WriteWavAsync(2.0), "Second Take", "DJ A", "Live Set", ReleaseMedium.Session, releaseId);
|
||||
|
||||
Assert.That(second.Success, Is.False);
|
||||
var message = second.Messages.FirstOrDefault()?.Message ?? string.Empty;
|
||||
Assert.That(message, Does.StartWith(UnifiedTrackService.CardinalityViolationMarker));
|
||||
}
|
||||
|
||||
// ATTACH anti-forgery guard: when the caller supplies a releaseId that does NOT match the release
|
||||
// the natural key (title, artist) resolves to, the upload is rejected. Guards against a stale or
|
||||
// forged releaseId pointing at a different (title, artist) than this row carries.
|
||||
[Test]
|
||||
public async Task UploadAsync_AttachWithMismatchedReleaseId_IsRejectedWithDuplicateMarker()
|
||||
{
|
||||
var manager = CreateManager();
|
||||
var service = await CreateUnifiedServiceAsync(manager);
|
||||
|
||||
// Create two separate releases so we have two distinct ids.
|
||||
var releaseA = await UploadAsync(
|
||||
service, await WriteWavAsync(2.0), "Track One", "Artist A", "Release A", ReleaseMedium.Cut, releaseId: null);
|
||||
Assert.That(releaseA.Success, Is.True, "release A must be created");
|
||||
var idA = releaseA.Value!.ReleaseId!.Value;
|
||||
|
||||
var releaseB = await UploadAsync(
|
||||
service, await WriteWavAsync(2.0), "Track One", "Artist B", "Release B", ReleaseMedium.Cut, releaseId: null);
|
||||
Assert.That(releaseB.Success, Is.True, "release B must be created");
|
||||
|
||||
// Try to ATTACH to release A while carrying release B's (title, artist). The natural-key lookup
|
||||
// resolves to B — id A ≠ B.Id → anti-forgery guard fires.
|
||||
var forged = await UploadAsync(
|
||||
service, await WriteWavAsync(2.0), "Track Two", "Artist B", "Release B", ReleaseMedium.Cut, releaseId: idA);
|
||||
|
||||
Assert.That(forged.Success, Is.False);
|
||||
var message = forged.Messages.FirstOrDefault()?.Message ?? string.Empty;
|
||||
Assert.That(message, Does.StartWith(UnifiedTrackService.DuplicateReleaseMarker));
|
||||
}
|
||||
|
||||
// Loose-track success: an upload with null/whitespace album stays release-less (ReleaseId null).
|
||||
// Confirms the duplicate guard is correctly bypassed for tracks that carry no album.
|
||||
[Test]
|
||||
public async Task UploadAsync_NullAlbum_SucceedsAsLooseTrack()
|
||||
{
|
||||
var service = await CreateUnifiedServiceAsync(CreateManager());
|
||||
|
||||
var result = await UploadAsync(
|
||||
service, await WriteWavAsync(2.0), "Standalone Cut", "DJ Solo", album: null, ReleaseMedium.Cut, releaseId: null);
|
||||
|
||||
Assert.That(result.Success, Is.True, result.Messages.FirstOrDefault()?.Message);
|
||||
Assert.That(result.Value!.ReleaseId, Is.Null, "a null-album track must stay a loose track with no release");
|
||||
}
|
||||
|
||||
// Case-sensitivity caveat: the assertion below verifies ordinal == equality as implemented by the
|
||||
// EF in-memory provider (which evaluates LINQ predicates in-process). The deployed PostgreSQL
|
||||
// instance may use a different column collation (e.g. case-insensitive) — production case-sensitivity
|
||||
// depends on the collation of the `release` table's `title` and `artist` columns, not on this test.
|
||||
// Matching semantics: GetReleaseByTitleAndArtist (the read both the pre-flight and the create-path
|
||||
// duplicate guard use) is exact — a case difference is NOT a match, so it does not trip the block.
|
||||
// This asserts the pre-flight and the create path agree by using the one shared read.
|
||||
[Test]
|
||||
public async Task UploadAsync_CaseDifferentTitle_IsNotADuplicateOnInMemoryProvider()
|
||||
{
|
||||
var service = await CreateUnifiedServiceAsync(CreateManager());
|
||||
|
||||
var first = await UploadAsync(
|
||||
service, await WriteWavAsync(2.0), "Studio Album", "Artist C", "Studio Album", ReleaseMedium.Cut, releaseId: null);
|
||||
Assert.That(first.Success, Is.True);
|
||||
|
||||
// Different case → not the same natural key under the in-memory provider's ordinal == →
|
||||
// admitted as a new release. Production outcome depends on PostgreSQL column collation.
|
||||
var differentCase = await UploadAsync(
|
||||
service, await WriteWavAsync(2.0), "Studio Album", "Artist C", "STUDIO ALBUM", ReleaseMedium.Cut, releaseId: null);
|
||||
|
||||
Assert.That(differentCase.Success, Is.True, differentCase.Messages.FirstOrDefault()?.Message);
|
||||
}
|
||||
|
||||
// Builds a standard-PCM mono 16-bit 44.1 kHz WAV of the requested duration with a full-scale square
|
||||
// wave (non-silent so the loudness algorithm yields a real envelope). Same layout as
|
||||
// TrackReplaceAudioTests / WaveformProfileServiceTests.
|
||||
private static byte[] BuildMinimalPcmWav(double durationSeconds)
|
||||
{
|
||||
const int sampleRate = 44100;
|
||||
const ushort channels = 1;
|
||||
const ushort bitsPerSample = 16;
|
||||
const ushort blockAlign = channels * (bitsPerSample / 8);
|
||||
const uint byteRate = sampleRate * blockAlign;
|
||||
|
||||
var frames = (int)(sampleRate * durationSeconds);
|
||||
var data = new byte[frames * blockAlign];
|
||||
for (var i = 0; i < frames; i++)
|
||||
{
|
||||
var sample = (i % 2 == 0) ? short.MaxValue : short.MinValue;
|
||||
data[i * 2] = (byte)(sample & 0xFF);
|
||||
data[i * 2 + 1] = (byte)((sample >> 8) & 0xFF);
|
||||
}
|
||||
|
||||
using var ms = new MemoryStream();
|
||||
using var w = new BinaryWriter(ms, Encoding.ASCII, leaveOpen: true);
|
||||
|
||||
w.Write(Encoding.ASCII.GetBytes("RIFF"));
|
||||
w.Write((uint)(36 + data.Length));
|
||||
w.Write(Encoding.ASCII.GetBytes("WAVE"));
|
||||
|
||||
w.Write(Encoding.ASCII.GetBytes("fmt "));
|
||||
w.Write(16u);
|
||||
w.Write((ushort)1); // PCM
|
||||
w.Write(channels);
|
||||
w.Write((uint)sampleRate);
|
||||
w.Write(byteRate);
|
||||
w.Write(blockAlign);
|
||||
w.Write(bitsPerSample);
|
||||
|
||||
w.Write(Encoding.ASCII.GetBytes("data"));
|
||||
w.Write((uint)data.Length);
|
||||
w.Write(data);
|
||||
|
||||
w.Flush();
|
||||
return ms.ToArray();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using DeepDrftAPI;
|
||||
|
||||
namespace DeepDrftTests;
|
||||
|
||||
/// <summary>
|
||||
/// Guards the upload-staging directory resolution (<see cref="Startup.ResolveStagingPath"/>). The
|
||||
/// load-bearing invariant: large audio bodies must stage on the data disk, never the system temp
|
||||
/// mount — on the Linux host /tmp is a small RAM-backed tmpfs that cannot hold a multi-hundred-MB WAV.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class UploadStagingPathTests
|
||||
{
|
||||
[Test]
|
||||
public void ResolveStagingPath_DefaultsToStagingUnderVault_WhenUnconfigured()
|
||||
{
|
||||
var vaultPath = Path.Combine(Path.GetTempPath(), "DeepDrftTests", Guid.NewGuid().ToString());
|
||||
|
||||
foreach (var configured in new[] { null, "", " " })
|
||||
{
|
||||
var resolved = Startup.ResolveStagingPath(configured, vaultPath);
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(resolved, Is.EqualTo(Path.GetFullPath(Path.Combine(vaultPath, "staging"))),
|
||||
"An unset/blank StagingPath must default to a 'staging' subdirectory under the vault path");
|
||||
Assert.That(Path.IsPathFullyQualified(resolved), Is.True,
|
||||
"The resolved staging path must be absolute");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ResolveStagingPath_HonoursExplicitOverride()
|
||||
{
|
||||
var vaultPath = Path.Combine("data", "vaults");
|
||||
var configured = Path.Combine(Path.GetTempPath(), "DeepDrftTests", "custom-staging", Guid.NewGuid().ToString());
|
||||
|
||||
var resolved = Startup.ResolveStagingPath(configured, vaultPath);
|
||||
|
||||
Assert.That(resolved, Is.EqualTo(Path.GetFullPath(configured)),
|
||||
"An explicit Upload:StagingPath must win over the vault-path default");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ResolveStagingPath_NeverResolvesIntoSystemTempDirectory_ForDataDiskVault()
|
||||
{
|
||||
// A production-shaped vault path on the data disk (the real config is a relative "../Database/Vaults").
|
||||
// The resolved staging dir must sit under that vault, not under Path.GetTempPath() (= /tmp on Linux).
|
||||
var vaultPath = Path.Combine("..", "Database", "Vaults");
|
||||
|
||||
var resolved = Startup.ResolveStagingPath(configuredPath: null, vaultPath);
|
||||
|
||||
var systemTemp = Path.GetFullPath(Path.GetTempPath());
|
||||
// Note: because vaultPath is relative, Path.GetFullPath resolves it against the CWD, which is
|
||||
// never the system temp directory. The StartsWith guard therefore catches the case where
|
||||
// ResolveStagingPath mistakenly uses Path.GetTempPath() directly, rather than proving the
|
||||
// absolute production path never overlaps with /tmp on any machine. The EndsWith assertion
|
||||
// is the load-bearing check: it verifies the output is rooted under the vault tree, not
|
||||
// under a hard-coded temp location.
|
||||
Assert.That(resolved.StartsWith(systemTemp, StringComparison.Ordinal), Is.False,
|
||||
"The default staging directory must never live under the system temp mount");
|
||||
Assert.That(resolved, Does.EndWith(Path.Combine("Database", "Vaults", "staging")),
|
||||
"The default staging directory must hang off the vault path on the data disk");
|
||||
}
|
||||
}
|
||||
@@ -304,18 +304,21 @@ it can begin immediately. **Landed:** 2026-06-19 on dev. 17.2 (docked overlay, e
|
||||
`MudDropContainer` reorder) and 17.3 (Fixed embed panel + snippet resize — **the OQ1
|
||||
Option-A-vs-B feasibility call is made here**) hang off it and are largely parallel. Add-to-Queue
|
||||
split to a standalone 17.4 (needs only the existing `Enqueue`/`EnqueueRange`, not 17.1's new
|
||||
members). **Landed (17.2):** 2026-06-19 on dev. **Landed (17.4):** 2026-06-19 on dev. 17.3 remains
|
||||
pending.
|
||||
members). **Landed (17.2):** 2026-06-19 on dev. **Landed (17.4):** 2026-06-19 on dev. **Landed
|
||||
(17.3):** 2026-06-19 on dev.
|
||||
|
||||
**Phase 17 is complete.** All four waves (17.1 engine additions + shared `QueueList`, 17.2 docked
|
||||
overlay, 17.3 Fixed embed panel + iframe resize handshake, 17.4 Add-to-Queue affordance) landed on
|
||||
dev 2026-06-19. See `COMPLETED.md §17` for the full completion records.
|
||||
|
||||
Full design — goal, constraints, use cases, acceptance criteria, test cases, wave decomposition, and
|
||||
the open-question set: `product-notes/phase-17-player-queue-view.md`.
|
||||
|
||||
**Open questions — all 11 resolved (Daniel, 2026-06-19; spec §10).**
|
||||
|
||||
- **OQ1** → **Option A, conditional** — collapse/expand toggle *if* the embed snippet can dynamically
|
||||
resize the iframe (`postMessage` → host resize handshake), **else fall back to Option B** (omit the
|
||||
button); A preferred, B fallback, deciding factor = iframe-resize feasibility, **determined during
|
||||
17.3**.
|
||||
- **OQ1** → **Option A, confirmed (17.3)** — collapse/expand toggle with `postMessage` → host resize
|
||||
handshake implemented. `EmbedSnippetBuilder.ForRelease` carries the host-side listener; `embed-frame.ts`
|
||||
posts height from the iframe. Degrades safely to Option B behaviour if the host strips the script.
|
||||
- **OQ2** → **yes, both modes** — clicking a queued row jumps playback to that track in the docked
|
||||
overlay *and* the read-only embed; reuses `PlayRelease(Items, index)`.
|
||||
- **OQ3 + OQ11** (jointly) → **the currently-playing track cannot be removed at all** — no "remove
|
||||
@@ -339,6 +342,107 @@ the open-question set: `product-notes/phase-17-player-queue-view.md`.
|
||||
|
||||
---
|
||||
|
||||
## Phase 19 — AuthBlocks User Management (CMS-only: admin surfaces + public self-registration)
|
||||
|
||||
Wire **all three** AuthBlocks account-creation paths into the `DeepDrftManager` CMS — the admin
|
||||
user-administration surface (provision users, manage accounts, manage registration invites, manage role
|
||||
permissions) **and** the public-facing self-service registration form. **All three paths live on
|
||||
`DeepDrftManager` (the CMS app); there are NO changes to `DeepDrftPublic` in this phase.** Daniel's
|
||||
framing: *"already part of the AuthBlocks library so we just wire it up."* Correct — and **further along
|
||||
than it implies**: almost everything landed by side-effect of the prior startup separation. Full design,
|
||||
the verified three-path model, the already-done-vs-remaining split, the SkipperHaven pattern + concrete
|
||||
deltas, scope boundaries, and open questions: `product-notes/phase-19-user-management-cms.md`.
|
||||
|
||||
**The three account-creation paths (verified against AuthBlocks source 2026-06-19) — ALL CMS routes:**
|
||||
1. **Admin provisions directly** — `SuperRegister.razor` → `/account/superregister` → `POST
|
||||
api/auth/admin-register` (UserAdmin-gated, **working**). Creates a live account now.
|
||||
2. **Public self-service** — `Register.razor` → `/account/register` → `POST api/auth/register`
|
||||
(**unauthenticated, no role gate, working**). A **public-facing CMS route, exactly like the CMS
|
||||
`/account/login` page** — invited user redeems a code (pre-filled from the invite email's deep link)
|
||||
and self-registers, all on the CMS host.
|
||||
3. **Admin provisions a token + triggers the invite email** — `NewRegistration(Form).razor` →
|
||||
`/useradmin/registrations/new` → `POST api/pendingregistration/create` (UserAdmin-gated). **Sends a
|
||||
real email server-side** via Mailtrap (`RegistrationEmailTemplate` + `IGeneralEmailSender`, configured
|
||||
in DeepDrftAPI from `environment/authblocks.json`) — **not stubbed.**
|
||||
|
||||
**Host-model correction (Daniel, 2026-06-19).** A prior revision placed public registration (path 2) on
|
||||
`DeepDrftPublic` as a cold-start integration. **Wrong — there are NO `DeepDrftPublic` changes.** Public
|
||||
registration is an unauthenticated route *on the CMS app*, mirroring the CMS's already-public
|
||||
`/account/login`. The only genuinely stubbed surface is **Reset Password** (`Users.razor`, `// todo`; **no
|
||||
backing endpoint** in `AuthRoutes`) — handled separately by Daniel in the AuthBlocks repo (see
|
||||
`product-notes/authblocks-password-reset-brief.md`).
|
||||
|
||||
**Most wiring already landed by side-effect.** The AuthBlocks startup separation
|
||||
(`PLAN_authblocks_trackmanager.md`, 2026-05-25) + login/logout integration already put the entire surface
|
||||
in place on `DeepDrftManager`: `Cerebellum.AuthBlocks.Web` referenced, `ConfigureAuthServices` registers
|
||||
every client + ViewModel **and** the `JwtAuthenticationStateProvider` path 2 needs, the router discovers
|
||||
every page (`AdditionalAssemblies`) — **including the public `/account/register`** — and the DeepDrft
|
||||
`Admin` role **inherits** `UserAdmin` (the seeded admin passes the gate with no change). The pages ship in
|
||||
a published **RCL**, so the worried-about "extract pages into an RCL" fork **does not arise**.
|
||||
|
||||
**Two real gaps remain.** (a) **No nav** — `CmsLayout` is just an app bar + Home button, so nothing links
|
||||
to `/useradmin/*` or `/account/superregister` (admin surface invisible). (b) **Wrong layout for public
|
||||
pages** — `Routes.razor` uses a **static** `DefaultLayout="typeof(CmsLayout)"`, so an unauthenticated
|
||||
visitor to `/account/register` (or `/account/login`) lands in the authenticated app shell instead of the
|
||||
lean splash.
|
||||
|
||||
**SkipperHaven is the canonical pattern.** `SkipperHaven` (same AuthBlocks library) exposes login +
|
||||
register as public/unauthenticated routes correctly by making `Routes.razor`'s `DefaultLayout`
|
||||
**auth-state-driven** — unauthenticated → home/lean layout, authenticated → app shell (resolved in
|
||||
`OnParametersSetAsync` off the cascaded `AuthenticationState`). **The concrete delta DeepDrftManager
|
||||
needs is exactly one change** (spec §2c): make its `DefaultLayout` auth-state-driven, resolving
|
||||
`CmsHomeLayout` (unauth) vs. `CmsLayout` (auth). Everything else SkipperHaven does — service wiring, page
|
||||
discovery, both layouts — DeepDrftManager **already has** (it even already ships `CmsHomeLayout`, used by
|
||||
the `/` home splash). So path 2 is **one router edit**, not a host integration.
|
||||
|
||||
**One host (`DeepDrftManager`), two parallel tracks** (different files), then verify + theme.
|
||||
|
||||
- **19.1 — CmsLayout navigation (admin-nav track; the main code wave). DECIDED nav shape: G1-b.** Add a
|
||||
`MudDrawer` + toggle to `CmsLayout.razor`; mount the shipped `UserAdminMenu` fragment (self-gates to
|
||||
`UserAdmin`+) alongside the existing CMS destinations (Catalogue / Releases / Upload); surface **both**
|
||||
admin account paths (path 1 `SuperRegister` + path 3 via the Registrations link); do **not** surface the
|
||||
redundant bare `NewUser` (OQ2 resolved). Scope: `CmsLayout.razor`. **No service, API, data, or
|
||||
AuthBlocks-source change.** **Landed:** 2026-06-19 on dev.
|
||||
- **19.2 — Public-route layout (public-route track; parallel to 19.1). DECIDED: G0-a.** Make
|
||||
`Routes.razor`'s `DefaultLayout` auth-state-driven (mirroring SkipperHaven, spec §2c D1): cascade
|
||||
`Task<AuthenticationState>`, resolve `_currentLayout = authed ? CmsLayout : CmsHomeLayout`, bind
|
||||
`DefaultLayout="@_currentLayout"`. This renders `/account/register` (path 2) **and** `/account/login` in
|
||||
the lean `CmsHomeLayout` for unauthenticated visitors. Scope: `Routes.razor` only. **No new layout (both
|
||||
exist), no package, no service, no AuthBlocks-source change.** **Landed:** 2026-06-19 on dev.
|
||||
- **19.3 — End-to-end verification (after 19.1 + 19.2).** Exercise provision-now (path 1), **invite-email
|
||||
send (path 3) incl. that the invite link `{ReturnHost}` points at the CMS origin**, list/deactivate
|
||||
users, permissions against a running DeepDrftAPI; confirm cross-host token + CORS, and **the full
|
||||
path-3→path-2 loop on the single CMS host** (admin provisions → email arrives → invitee redeems on the
|
||||
CMS `/account/register` in the lean layout). Mostly test; any break is likely a one-line config fix
|
||||
(esp. Mailtrap creds + return host) or an upstream AuthBlocks issue.
|
||||
- **19.4 — Theming legibility sweep (after 19.1 + 19.2, parallel-ok with 19.3).** Accept the CMS palette
|
||||
for the MudBlazor-default grids and the public pages now in `CmsHomeLayout`; fix only contrast/legibility
|
||||
breaks. Bespoke restyle deferred.
|
||||
|
||||
**Deferred (note, don't build):** admin dashboard landing (G1-c); working **Reset Password** (separate
|
||||
AuthBlocks-repo effort); bespoke restyle of the AuthBlocks grids; a visible public Register nav link
|
||||
(invite-only — the email deep link is the entry point); bumping `Cerebellum.AuthBlocks.Web` 10.3.33 →
|
||||
10.3.35 (housekeeping).
|
||||
|
||||
**Explicitly not needed:** any change to `DeepDrftPublic` (corrected host model — all three paths are CMS);
|
||||
extracting AuthBlocks pages into a new RCL; new DI/service wiring, role seeding, or Auth connection string
|
||||
(all present); editing the AuthBlocks `Login`/`Register` pages' layout (impossible without forking the
|
||||
RCL — G0-a fixes layout host-side instead).
|
||||
|
||||
**Open questions for Daniel (spec §6).** *Resolved:* (1) nav shape **G1-b**; (2) surface path 1 + path 3,
|
||||
hide bare `NewUser`; (5) Reset Password non-functional in v1, handled separately; (6) **host model — all
|
||||
three on the CMS, no `DeepDrftPublic` changes**; (7) **public-route layout G0-a** (auth-state-driven
|
||||
`DefaultLayout`, reusing `CmsHomeLayout`). *Still open:* (3) admin dashboard defer (recommend defer); (4)
|
||||
package bump (recommend leave); (8) a logged-in admin visiting `/account/register` sees it in the app
|
||||
shell under G0-a (recommend accept). None block 19.1 or 19.2.
|
||||
|
||||
**Adjacency to the deferred Identity / accounts backlog item (below).** That item is about *public,
|
||||
per-user* identity (favourites, listening history, playlists). This phase is *CMS* account management only
|
||||
(admin surfaces + invite-based self-registration) — same AuthBlocks substrate, different surface. They are
|
||||
not the same work; this phase does not satisfy or depend on that one.
|
||||
|
||||
---
|
||||
|
||||
## Working with this file
|
||||
|
||||
- **Add items by extending an existing phase first**; only create a new phase when the addition genuinely doesn't fit any of 1–5. Phase numbers are organisational, not sequencing.
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
# Team Brief — Email-Backed Password Reset for AuthBlocks
|
||||
|
||||
**Audience:** an orchestrator (and its implementers) working **only** in the AuthBlocks repository at
|
||||
`C:\Development\AuthBlocks`. You do not need, and should not assume, any knowledge of the products that
|
||||
consume AuthBlocks. Everything you need is in this brief or in that repo.
|
||||
|
||||
**Status:** scoped request, not yet started. Author: product-designer (for a downstream consumer team).
|
||||
Date: 2026-06-19.
|
||||
|
||||
---
|
||||
|
||||
## 1. The goal in one sentence
|
||||
|
||||
Replace the non-functional "Reset Password" stub on the AuthBlocks user-administration **Users** page
|
||||
with a real, email-backed password-reset flow — so that triggering "Reset Password" for a user sends
|
||||
that user an email containing a secure, time-limited reset link, and following the link lets them set a
|
||||
new password.
|
||||
|
||||
This is an **upstream library feature**, delivered entirely inside AuthBlocks and published as a normal
|
||||
version bump. Consumers pick it up by referencing the new package version.
|
||||
|
||||
---
|
||||
|
||||
## 2. Where the stub lives today
|
||||
|
||||
`AuthBlocksWeb/Components/Pages/UserAdmin/Users/Users.razor` — the user grid has a per-row **Reset
|
||||
Password** `MudButton` whose handler is empty:
|
||||
|
||||
```csharp
|
||||
private async Task ResetPassword(UserInputModel? item)
|
||||
{
|
||||
// todo integrate with email for secure reset
|
||||
}
|
||||
```
|
||||
|
||||
There is **no backing API endpoint** for this action. `AuthBlocksLib/Routes/AuthRoutes.cs` maps
|
||||
`login`, `register`, `admin-register`, `refresh`, `logout`, `me`, `roles` — and nothing for password
|
||||
reset. So this is a build-from-scratch flow on both the API side (new endpoints) and the Web side
|
||||
(wire the button + add a public reset page), reusing AuthBlocks' existing email and token machinery.
|
||||
|
||||
---
|
||||
|
||||
## 3. What AuthBlocks already has that you should reuse
|
||||
|
||||
**The pending-registration flow is your template.** AuthBlocks already does almost exactly this shape
|
||||
of work for invitations — generate a secure token, email a link, validate the token when the user
|
||||
returns. Read it end-to-end before designing reset; you are building a sibling flow:
|
||||
|
||||
- **Email sending is real and wired.** `AuthBlocksLib/AuthBlocksExtensions.cs` (~line 109) registers
|
||||
`services.AddScoped<IGeneralEmailSender, MailtrapEmailSender>();`. The `IGeneralEmailSender`
|
||||
abstraction and `MailtrapEmailSender` implementation come from the shared NetBlocks library
|
||||
(namespace `API.Common.Email.Mailtrap`). The send signature in use is:
|
||||
|
||||
```csharp
|
||||
await emailSender.SendEmailAsync(toAddress, cc: null, subject, htmlBody);
|
||||
```
|
||||
|
||||
See it called for real at `AuthBlocksLib/Routes/PendingRegistrationRoutes.cs:124`.
|
||||
|
||||
- **Email connection config.** The host populates `AuthBlocksOptions.EmailConnection` (a NetBlocks
|
||||
`EmailConnection` with `Host` + `Token`) plus `ApplicationName` and `SupportEmail` when it calls
|
||||
`AddAuthBlocks(options => { ... })`. Those flow into `AuthBlocksExtensions` and are available to your
|
||||
reset endpoint exactly as they are to the registration endpoint. **You do not need to invent any new
|
||||
config or sender** — reuse `IGeneralEmailSender` and `AuthBlocksOptions`.
|
||||
|
||||
- **An HTML email template pattern.** `AuthBlocksLib/Common/RegistrationEmailTemplate.cs` is a static
|
||||
`Create(token, link, applicationName, supportEmail)` returning a styled HTML string. Build a sibling
|
||||
`PasswordResetEmailTemplate.Create(...)` in the same file's neighbourhood and the same house style
|
||||
(the registration template is teal-branded, table-layout, support-line-collapses-when-empty — match
|
||||
it). Do **not** reuse the registration template verbatim; the copy is invitation-specific.
|
||||
|
||||
- **A token service pattern.** `AuthBlocksLib/Services/RegistrationTokenService.cs` generates a random
|
||||
token, SHA-256-hashes `{email}::{token}`, persists the hash with a 7-day expiry, and validates /
|
||||
consumes it. **However — for password reset, prefer ASP.NET Identity's built-in reset token** (see
|
||||
§4) rather than re-implementing this hand-rolled scheme. The registration token service is a *style*
|
||||
reference for endpoint shape and email-link construction, not necessarily the token mechanism.
|
||||
|
||||
- **The deep-link construction idiom.** The registration flow builds its link with
|
||||
`QueryHelpers.AddQueryString(returnHost, { UserEmail, RegistrationToken })` and the public register
|
||||
page reads those query params and pre-fills (`Register.razor`, `[SupplyParameterFromQuery]`). Mirror
|
||||
this for the reset page: link carries `email` + `resetToken`; the reset page reads them.
|
||||
|
||||
- **Identity is fully present.** `UserService` wraps `UserManager<ApplicationUser>` (see
|
||||
`AuthBlocksData/Services/UserService.cs`). `UserManager` gives you the canonical reset primitives —
|
||||
use them.
|
||||
|
||||
---
|
||||
|
||||
## 4. Recommended mechanism: ASP.NET Identity's built-in reset token
|
||||
|
||||
Password reset is a solved problem in ASP.NET Identity, and rolling your own token store for it is an
|
||||
avoidable security surface. **Strong recommendation:** use `UserManager<ApplicationUser>`'s built-in
|
||||
reset tokens rather than the hand-rolled `RegistrationTokenService` SHA-256 scheme.
|
||||
|
||||
- `var token = await userManager.GeneratePasswordResetTokenAsync(user);` — produces a token bound to
|
||||
the user's security stamp; invalidated when the password changes or the stamp rotates.
|
||||
- `var result = await userManager.ResetPasswordAsync(user, token, newPassword);` — validates and
|
||||
applies in one call; enforces the configured password policy.
|
||||
- Token lifetime is governed by `DataProtectionTokenProviderOptions.TokenLifespan` (default 1 day) —
|
||||
confirm/configure to a sensible reset window (recommend 1–2 hours for reset, tighter than the 7-day
|
||||
registration window).
|
||||
|
||||
This means you likely **do not** need a new DB table or migration for reset (unlike registration,
|
||||
which persists pending rows). Confirm whether the default token providers are registered in the
|
||||
AuthBlocks Identity setup; if `AddDefaultTokenProviders()` (or equivalent) is not already called in the
|
||||
Identity configuration, add it — that is the one wiring prerequisite for `GeneratePasswordResetTokenAsync`
|
||||
to work.
|
||||
|
||||
*Alternative considered (and not recommended):* extend `RegistrationTokenService` / `PendingRegistration`
|
||||
into a generic token table that also serves reset. Rejected — it couples two unrelated flows, re-implements
|
||||
what Identity already does correctly, and adds a migration for no benefit. Use it only if there is a
|
||||
hard reason the Identity token provider cannot be enabled in this setup.
|
||||
|
||||
---
|
||||
|
||||
## 5. The surfaces to build
|
||||
|
||||
Three pieces, mirroring the registration flow's API-endpoint + email-template + web-page triad.
|
||||
|
||||
### 5.1 API endpoints (`AuthBlocksLib/Routes/AuthRoutes.cs`)
|
||||
|
||||
Add to the `api/auth` group. Two endpoints, both **unauthenticated** (a user resetting a forgotten
|
||||
password is by definition not logged in — the admin "Reset Password" button triggers the *first* of
|
||||
these on the user's behalf, but the endpoint itself authenticates via the token, not a bearer):
|
||||
|
||||
1. **`POST api/auth/forgot-password`** — body `{ email, returnHost }`. Looks up the user; if found,
|
||||
generates a reset token and emails the reset link (`{returnHost}?email=&resetToken=`). **Always
|
||||
return success** regardless of whether the email exists — do **not** leak account existence (a known
|
||||
reset-flow security requirement; the registration flow's "user already exists" message is acceptable
|
||||
for an *admin-gated* invite but a *public* forgot-password must not reveal it). On email-send failure,
|
||||
log and return a generic failure.
|
||||
|
||||
2. **`POST api/auth/reset-password`** — body `{ email, resetToken, newPassword }`. Resolves the user,
|
||||
calls `ResetPasswordAsync(user, token, newPassword)`, returns the Identity result mapped to the
|
||||
AuthBlocks `Result`/`ApiResult` convention (see how `Register` maps results in `AuthRoutes.cs`).
|
||||
|
||||
Follow the existing `AuthRoutes` conventions exactly: `ApiResult<T>` / `ApiResultDto<T>` wrapping,
|
||||
`ILogger<AuthLogger>` for logging, `Results.Ok` / `Results.BadRequest` / `Results.Json(..., 500)` shapes.
|
||||
|
||||
### 5.2 Email template (`AuthBlocksLib/Common/PasswordResetEmailTemplate.cs`)
|
||||
|
||||
New static `Create(resetLink, applicationName, supportEmail)` in the visual style of
|
||||
`RegistrationEmailTemplate`. Reset copy: a clear "you (or an admin) requested a password reset," the CTA
|
||||
button to the reset link, an expiry notice matching the token lifespan, and "ignore this email if you
|
||||
didn't request it." No registration code box — reset uses an opaque token in the link, not a
|
||||
user-typed code (recommended; do not show the Identity token as a copy-paste code — it is long and
|
||||
URL-encoded).
|
||||
|
||||
### 5.3 Web surfaces (`AuthBlocksWeb`)
|
||||
|
||||
- **Wire the admin button.** In `Users.razor`, replace the empty `ResetPassword` handler with a call to
|
||||
an `IAuthApiClient` (or the appropriate existing client) method that hits `POST api/auth/forgot-password`
|
||||
for `item.Email`, and show a confirmation (a `StatusMessage` / dialog: "Reset email sent to {email}").
|
||||
This is the admin-initiated trigger.
|
||||
- **Add a public reset page.** New `AuthBlocksWeb/Components/Pages/Account/ResetPassword.razor`,
|
||||
`@page "/account/reset-password"`, `@rendermode InteractiveServer`, **no role gate** (a forgotten-password
|
||||
user is unauthenticated). Read `email` + `resetToken` from query params (mirror `Register.razor`'s
|
||||
`[SupplyParameterFromQuery]` pre-fill), present new-password + confirm fields, submit to
|
||||
`POST api/auth/reset-password`, and on success route to `/account/login` with a success message. Match
|
||||
`Register.razor`'s form structure and validation idiom.
|
||||
- **Optional: a public "forgot password?" entry.** Consider a `/account/forgot-password` page (link from
|
||||
`Login.razor`) where a user enters their email to self-initiate reset — same `forgot-password` endpoint.
|
||||
Decide whether this is in scope or whether reset is admin-initiated only (see open questions).
|
||||
|
||||
### 5.4 Client method
|
||||
|
||||
Add the `forgot-password` / `reset-password` calls to whichever API client the Web project uses for auth
|
||||
(the registration/login flows go through `JwtAuthenticationStateProvider` / `IAuthApiClient` — follow the
|
||||
same pattern; do not introduce a new HTTP client).
|
||||
|
||||
---
|
||||
|
||||
## 6. Constraints
|
||||
|
||||
- **No account-existence leak** on the public `forgot-password` path (§5.1).
|
||||
- **Reuse, don't reinvent:** `IGeneralEmailSender` for sending, `AuthBlocksOptions` for config, Identity's
|
||||
token provider for tokens, the existing `Result`/`ApiResult` conventions for endpoint returns, and the
|
||||
`RegistrationEmailTemplate` house style for the email.
|
||||
- **Match the existing route + result conventions** in `AuthRoutes.cs` precisely — this is a library;
|
||||
consumers rely on the shape staying idiomatic.
|
||||
- **Versioning:** this lands as a normal AuthBlocks version bump (packed/pushed by `pack.ps1` like the
|
||||
other packages). Note the new version so consumers can pin to it.
|
||||
- **Token lifespan** for reset should be short (recommend 1–2 hours), distinct from the 7-day
|
||||
registration token.
|
||||
- **Password policy** is enforced by `ResetPasswordAsync` automatically — do not duplicate validation,
|
||||
but surface the Identity error messages back through the result.
|
||||
|
||||
---
|
||||
|
||||
## 7. Acceptance criteria
|
||||
|
||||
1. Clicking "Reset Password" for a user on the Users admin page sends that user a styled email with a
|
||||
working reset link, and shows the admin a confirmation. No unhandled exception, no silent no-op.
|
||||
2. Following the reset link lands on `/account/reset-password` with the email pre-filled; setting a new
|
||||
password that meets policy succeeds and the user can immediately log in with the new password.
|
||||
3. An expired or tampered token is rejected with a clear, non-leaky error.
|
||||
4. The public `forgot-password` endpoint returns the same response whether or not the email maps to a
|
||||
real account (no existence leak).
|
||||
5. Email send is exercised through the real `IGeneralEmailSender` (Mailtrap in the configured
|
||||
environment) — verify an email actually arrives.
|
||||
6. No new required config beyond what `AddAuthBlocks` already accepts (reset reuses the existing email
|
||||
connection + application-name + support-email options). If a token-provider registration was missing,
|
||||
it is added and documented.
|
||||
7. Published as a version bump; the new version is recorded.
|
||||
|
||||
---
|
||||
|
||||
## 8. Open questions for the implementing team / its sponsor
|
||||
|
||||
1. **Admin-initiated only, or also public self-serve?** Is the only entry point the admin "Reset
|
||||
Password" button (§5.3 first bullet), or do you also want a public "forgot password?" link from the
|
||||
login page (§5.3 last bullet)? The endpoints support both; the question is which Web surfaces to build.
|
||||
*Recommendation: build both endpoints, ship the admin button now, and add the public forgot-password
|
||||
page in the same pass since it is nearly free once the endpoint exists.*
|
||||
2. **Token mechanism — confirm Identity's built-in is acceptable** (§4 recommendation) vs. a hard
|
||||
requirement to use the hand-rolled hashed-token scheme. *Recommendation: Identity built-in.*
|
||||
3. **Reset token lifespan** — confirm the window (recommend 1–2 hours).
|
||||
4. **Return host / link base** — the registration flow has the *caller* pass `returnHost`. Confirm the
|
||||
reset flow does the same (the consumer supplies the base URL of its public reset page), vs. AuthBlocks
|
||||
configuring a reset base URL in options. *Recommendation: pass `returnHost` per-call, mirroring
|
||||
registration, so AuthBlocks stays host-agnostic.*
|
||||
5. **Does the public reset page (`/account/reset-password`) need to render in a consumer's own layout?**
|
||||
The page ships in the AuthBlocks RCL with no `@layout`, so it inherits whatever the consuming host sets
|
||||
as default — same as `Register.razor`. Confirm this is acceptable (it should be; it is how registration
|
||||
already behaves).
|
||||
|
||||
---
|
||||
|
||||
## 9. Suggested reading order in the repo
|
||||
|
||||
1. `AuthBlocksLib/Routes/PendingRegistrationRoutes.cs` — the email-sending endpoint to mirror.
|
||||
2. `AuthBlocksLib/Routes/AuthRoutes.cs` — where your endpoints go; the result/logging conventions.
|
||||
3. `AuthBlocksLib/Common/RegistrationEmailTemplate.cs` — the email house style.
|
||||
4. `AuthBlocksWeb/Components/Pages/Account/Register.razor` — the public-page + query-param-prefill pattern
|
||||
for your reset page.
|
||||
5. `AuthBlocksWeb/Components/Pages/UserAdmin/Users/Users.razor` — the stub to replace.
|
||||
6. `AuthBlocksLib/AuthBlocksExtensions.cs` + `AuthBlocksOptions.cs` — the email sender + options wiring you
|
||||
reuse (and where to add a token-provider registration if one is missing).
|
||||
7. `AuthBlocksData/Services/UserService.cs` — the `UserManager<ApplicationUser>` access point for the
|
||||
Identity reset primitives.
|
||||
@@ -0,0 +1,38 @@
|
||||
# Index — `EditModalSaveContextHolder` Missing DI Registration (BlazorBlocks / AuthBlocks)
|
||||
|
||||
**Status:** ✅ RESOLVED — shipped in `Cerebellum.BlazorBlocks.Web` 10.3.33 + `Cerebellum.AuthBlocks.Web` 10.3.36 (2026-06-20). ~~scoped, not yet started. Confirmed against `Cerebellum.BlazorBlocks.Web` 10.3.32 / `Cerebellum.AuthBlocks.Web` 10.3.33. Author: product-designer. Date: 2026-06-19.~~
|
||||
|
||||
> **Resolution (2026-06-20):** `AddBlazorBlocksWeb()` landed in `Cerebellum.BlazorBlocks.Web` 10.3.33 and `ConfigureAuthServices` calls it in `Cerebellum.AuthBlocks.Web` 10.3.36; DeepDrftManager picked up 10.3.36 and removed its local `EditModalSaveContextHolder` stopgap.
|
||||
> This brief is retained as historical record — no further action required.
|
||||
|
||||
## The defect
|
||||
|
||||
BlazorBlocks' `ModelView` / `EditModelModal` components have a `required [Inject]` dependency on
|
||||
`Web.Maintenance.Entities.EditModalSaveContextHolder` (a per-circuit save bridge), but the BlazorBlocks
|
||||
`Web` package ships no registration extension for it and AuthBlocks' `ConfigureAuthServices` never registers
|
||||
it either. Any consumer of a `ModelView`-based page (e.g. AuthBlocks' `Users.razor` /
|
||||
`Registrations.razor`) crashes the Blazor circuit on navigation with an unregistered-service
|
||||
`InvalidOperationException`. Two independent downstream products have each hand-registered the internal
|
||||
service as a stopgap — the tell that this is a leaked library registration.
|
||||
|
||||
## The two-team layered fix (ordered — do not reorder)
|
||||
|
||||
1. **BlazorBlocks ships first.** Add a Web-side `AddBlazorBlocksWeb()` extension that registers the holder
|
||||
via `TryAddScoped` (scoped is required). Bump `Cerebellum.BlazorBlocks.Web` from 10.3.32; report the new
|
||||
version.
|
||||
2. **AuthBlocks ships second.** Bump its `Cerebellum.BlazorBlocks.Web` reference to that new version, call
|
||||
`AddBlazorBlocksWeb()` from `ConfigureAuthServices`, bump `Cerebellum.AuthBlocks.Web` from 10.3.33.
|
||||
|
||||
AuthBlocks is blocked until BlazorBlocks' new version is published. Registration lives with its owner
|
||||
(BlazorBlocks); AuthBlocks stays self-contained by composing it. MudBlazor (`AddMudServices`) stays a
|
||||
caller-owned prerequisite throughout.
|
||||
|
||||
## The detail lives in the two team briefs
|
||||
|
||||
Each is fully self-contained for an orchestrator working in only that one repo:
|
||||
|
||||
- **BlazorBlocks team** → [`team-brief-blazorblocks-modelview-di.md`](./team-brief-blazorblocks-modelview-di.md)
|
||||
(root cause, `[Inject]` audit, lifetime rationale, the new extension, version bump, acceptance criteria).
|
||||
- **AuthBlocks team** → [`team-brief-authblocks-modelview-di.md`](./team-brief-authblocks-modelview-di.md)
|
||||
(the blocking BlazorBlocks prerequisite, the `ConfigureAuthServices` call, version bump, acceptance
|
||||
criteria).
|
||||
@@ -0,0 +1,464 @@
|
||||
# Phase 19 — AuthBlocks User Management in the CMS
|
||||
|
||||
Status: proposed (rev. 3 — host model corrected by Daniel 2026-06-19). Author: product-designer.
|
||||
Date: 2026-06-19. Implementer: TBD (separate delegation).
|
||||
|
||||
Wire **all three** AuthBlocks account-creation paths into the `DeepDrftManager` CMS so an admin can run
|
||||
account management from inside the same CMS they already use, **and** so an invited user can redeem a
|
||||
registration code and create their own account — **all on `DeepDrftManager` (the CMS app,
|
||||
demoapp.deepdrft.com)**. There are **no changes to `DeepDrftPublic` in this phase.**
|
||||
|
||||
Daniel's framing: *"this is already part of the AuthBlocks library so we just need to wire it up
|
||||
properly."* **That framing is correct, and the wiring is further along than it implies.** Almost the
|
||||
entire integration already landed as a side-effect of the prior AuthBlocks startup separation
|
||||
(`PLAN_authblocks_trackmanager.md`, landed 2026-05-25) and the login/logout integration; what remains
|
||||
is a thin **navigation + public-route-exposure + verification + polish** slice, not an integration
|
||||
project.
|
||||
|
||||
**Host-model correction (Daniel, 2026-06-19 — the crux of this revision).** Rev. 2 placed public
|
||||
self-service registration (path 2) on `DeepDrftPublic` as a cold-start integration. **That was wrong.**
|
||||
Public registration belongs on the **CMS app**, exactly where login already lives: the CMS app
|
||||
*already hosts a public-facing, unauthenticated `/account/login` page* (reachable without being signed
|
||||
in). The registration redemption page is public-facing **in exactly the same way** — an unauthenticated
|
||||
route on the CMS app itself. An invited user clicks the email link, lands on the CMS app's public
|
||||
registration route (`/account/register`), redeems their code, sets a password. **No second host, no
|
||||
`DeepDrftPublic` involvement.** The entire rev-2 "public-site track" (wave 19.4) and its open questions
|
||||
(OQ6–OQ9) are **deleted** — they were artifacts of the wrong host assumption.
|
||||
|
||||
So all three paths live on `DeepDrftManager`. The real remaining questions for path 2 are narrow: it is
|
||||
likely already *route-reachable* (the CMS router discovers `/account/register` via `AdditionalAssemblies`,
|
||||
same as it discovers `/account/login`), so the work is (a) confirming it is correctly **unauthenticated**
|
||||
(no role gate — verified below, it has none), and (b) giving an unauthenticated visitor the **right
|
||||
layout** (the lean splash chrome, not the authenticated app shell), mirroring how login should render.
|
||||
SkipperHaven — another app on the **same AuthBlocks library** — already implements this dual public
|
||||
login/register pattern, and is the canonical reference (§2c).
|
||||
|
||||
---
|
||||
|
||||
## 0. The three account-creation paths (verified against AuthBlocks source) — ALL on the CMS
|
||||
|
||||
Verified against `C:\Development\AuthBlocks` source. Daniel's three-path understanding is **correct and
|
||||
complete**, and all three are CMS routes:
|
||||
|
||||
| # | Path | Component(s) | Route | Gate | Backed by | Email? |
|
||||
|---|------|--------------|-------|------|-----------|--------|
|
||||
| 1 | **Admin provisions a user directly** (bypasses email/code loop) | `SuperRegister.razor` | `/account/superregister` | UserAdmin | `POST api/auth/admin-register` — **working** | No |
|
||||
| 2 | **Public self-service** — invited user redeems a code and self-registers | `Register.razor` | `/account/register` | **none (public)** | `POST api/auth/register` — **working** | No (consumes code) |
|
||||
| 3 | **Admin provisions a registration token + triggers the invite email** | `NewRegistration.razor` → `NewRegistrationForm.razor` | `/useradmin/registrations/new` | UserAdmin | `POST api/pendingregistration/create` — **working, sends email server-side** | **Yes — real, not stubbed** |
|
||||
|
||||
All three are **CMS routes on `DeepDrftManager`.** Paths 1 and 3 are admin-gated (UserAdmin). Path 2 is
|
||||
**public-facing**, reachable by an unauthenticated visitor — exactly like the CMS `/account/login` page,
|
||||
which is also unauthenticated and on the same host.
|
||||
|
||||
**Path 2 has no role gate (verified).** `Register.razor` declares `@page "/account/register"` +
|
||||
`@rendermode InteractiveServer` and **no** `[HierarchicalRoleAuthorize]` attribute — identical in this
|
||||
respect to `Login.razor` (`@page "/account/login"`, no gate). It reads `UserEmail` + `RegistrationToken`
|
||||
from the query string and pre-fills, so the invite email's deep link lands ready to submit; it calls
|
||||
`AuthStateProvider.RegisterAsync` → `POST api/auth/register`. It is meant to be reached by an
|
||||
unauthenticated visitor.
|
||||
|
||||
**Path 3's email is real.** `PendingRegistrationRoutes.Create`
|
||||
(`AuthBlocksLib/Routes/PendingRegistrationRoutes.cs:62`) generates a token, persists the pending
|
||||
registration, builds the invite link (`{ReturnHost}?UserEmail=&RegistrationToken=`), renders
|
||||
`RegistrationEmailTemplate.Create(...)`, and **sends it via `IGeneralEmailSender.SendEmailAsync`** —
|
||||
a Mailtrap-backed `MailtrapEmailSender` registered in `AuthBlocksExtensions` (line 109) and configured
|
||||
in **DeepDrftAPI** from `environment/authblocks.json` (`AuthBlocks:Email:Host` / `:Token`,
|
||||
`Program.cs:106–109`; `ApplicationName="DeepDrft"`, `SupportEmail` from config). On email-send failure
|
||||
the route **rolls back** the pending-registration row and returns 500. The full invite→email→redeem
|
||||
loop is functional end-to-end across paths 2 and 3, **entirely within the CMS host**: an admin
|
||||
provisions (path 3, CMS) → the prospective user receives an email with a code + link → they land on the
|
||||
CMS app's public `/account/register` (path 2, CMS) with email + token pre-filled → they set a password
|
||||
and the account is created.
|
||||
|
||||
> **Note on `{ReturnHost}`.** The invite email's deep link is built from a configured return host. For
|
||||
> the loop to land on the CMS app, that host must point at the CMS origin (demoapp.deepdrft.com), not the
|
||||
> public site. Verify this config value in 19.3 (it is the one place the wrong-host assumption could be
|
||||
> baked into a *config* rather than code).
|
||||
|
||||
**The one genuinely stubbed surface is Reset Password** — `Users.razor:55` (`// todo integrate with
|
||||
email for secure reset`) has an empty handler and **no backing API endpoint exists** (`AuthRoutes`
|
||||
maps login/register/admin-register/refresh/logout/me/roles — no reset route). That is the subject of
|
||||
the separate `authblocks-password-reset-brief.md`; it must **not** be filed as a DeepDrft bug.
|
||||
|
||||
**Two distinct admin "create" verbs — both stay, they are not duplicates.** `SuperRegister` (path 1)
|
||||
creates a *live account immediately* with a password the admin sets. The registration-token form (path
|
||||
3) creates a *pending invite* — no account yet — and lets the user set their own password via email. They
|
||||
serve different needs (provision-now vs. invite-by-email); both belong in the CMS nav. (The older
|
||||
`NewUser` `ModelView` create form at `/useradmin/users/new` still exists as a third bare admin create
|
||||
path, but it is **not** one of Daniel's three; treat it as redundant with `SuperRegister` and do not
|
||||
surface it in nav. See OQ2.)
|
||||
|
||||
---
|
||||
|
||||
## 1. What AuthBlocks ships, and how it is packaged
|
||||
|
||||
Read from local source at `C:\Development\AuthBlocks`. The key question — *is the user-admin surface
|
||||
consumable or host-bound?* — resolves cleanly: **it is consumable.**
|
||||
|
||||
### The user-admin surface is a published RCL, despite the "Web" name
|
||||
|
||||
`AuthBlocksWeb` is an `Microsoft.NET.Sdk.Razor` project (not `Sdk.Web`) with **no `Program.cs`** — it
|
||||
is a Razor Class Library, not a runnable host. `pack.ps1` packs it as **`Cerebellum.AuthBlocks.Web`**
|
||||
and pushes it to nuget.org. So the user-admin Razor components are distributed as a normal RCL and
|
||||
consumed by reference. **No extraction fork is needed** — the pages are already in the RCL.
|
||||
|
||||
### What's in the package (the consumable surface)
|
||||
|
||||
Components under `AuthBlocksWeb/Components/`:
|
||||
|
||||
- **Account pages** (`Pages/Account/`):
|
||||
- `Login.razor` → `/account/login` (**public — no gate, no `@layout`**; `@rendermode InteractiveServer`).
|
||||
- `Register.razor` → `/account/register` (**path 2** — public self-service via invite code; `@rendermode
|
||||
InteractiveServer`; **no role gate, no `@layout`**; reads `UserEmail` + `RegistrationToken` from the
|
||||
query string and pre-fills; calls `AuthStateProvider.RegisterAsync` → `POST api/auth/register`).
|
||||
- `Logout`, `AccessDenied`.
|
||||
- `SuperRegister.razor` → `/account/superregister` (**path 1** — admin creates a live account
|
||||
immediately, role multiselect; gated `[HierarchicalRoleAuthorize(UserAdmin)]`; calls
|
||||
`IAuthApiClient.AdminRegisterAsync` → `POST api/auth/admin-register`).
|
||||
- **User admin pages** (`Pages/UserAdmin/`), each `@page`-routed and gated
|
||||
`[HierarchicalRoleAuthorize(SystemRoleConstants.UserAdmin)]`:
|
||||
- `Users/Users.razor` → `/useradmin/users` — searchable user grid; per-row Reset Password
|
||||
(**stubbed — no backing endpoint**), Deactivate/Reactivate, edit modal.
|
||||
- `Users/NewUser.razor` → `/useradmin/users/new` — bare create-user form (redundant with `SuperRegister`;
|
||||
not one of Daniel's three paths — do not surface in nav).
|
||||
- `Registrations/Registrations.razor` → `/useradmin/registrations` — pending-invite grid, with
|
||||
`NewRegistration.razor` → `/useradmin/registrations/new` (**path 3** — `NewRegistrationForm` posts to
|
||||
`PendingRegistrationClient.CreatePendingRegistration` → `POST api/pendingregistration/create`, which
|
||||
mints the token **and sends the invite email**) and the edit-registration modal.
|
||||
- `Permissions/Permissions.razor` → `/useradmin/permissions` — user↔role assignment.
|
||||
- **Menu fragments** (`Components/Layout/`): `AccountNavMenu`, `UserAdminMenu` (a `MudNavGroup`
|
||||
with the three user-admin `MudNavLink`s, itself wrapped in a `HierarchicalRoleAuthorizeView` so it
|
||||
only renders for `UserAdmin`+).
|
||||
- **Shared** (`Components/Shared/`): `LogoutButton`, `StatusMessage`.
|
||||
- **DI entry point** (`Startup.cs`): `ConfigureAuthServices(IServiceCollection, string apiBaseUrl)`
|
||||
registers the cascading auth state, the JWT client stack, **and every user-admin client + ViewModel**,
|
||||
all pointed at `apiBaseUrl`.
|
||||
|
||||
The pages lean on `Cerebellum.BlazorBlocks.Web` for grid scaffolding and MudBlazor for chrome — both
|
||||
already present in the CMS.
|
||||
|
||||
### The API side is already hosted
|
||||
|
||||
The clients those ViewModels use call the AuthBlocks **API** surface, which `DeepDrftAPI` already
|
||||
mounts via `app.MapAuthBlocks()` (`Program.cs:184`): `api/auth/*` (incl. `admin-register`, `register`,
|
||||
`roles`), `api/users/*`, `api/roles/*`, `api/user-roles/*`, `api/pendingregistration/*`. `AddAuthBlocks`
|
||||
+ `UseAuthBlocksStartupAsync` (migrate + seed) are wired, and the Auth DB + secrets live in
|
||||
`DeepDrftAPI/environment/`. This all landed with the startup separation.
|
||||
|
||||
---
|
||||
|
||||
## 2. What is ALREADY wired in DeepDrftManager (do not redo)
|
||||
|
||||
Verified against the current `DeepDrftManager` source. These are the integration steps a naive plan
|
||||
would propose — and they are **already done**:
|
||||
|
||||
1. **Package reference.** `DeepDrftManager.csproj:11` references `Cerebellum.AuthBlocks.Web` (10.3.33),
|
||||
which transitively brings `AuthBlocksWeb.Client`, `AuthBlocksLib`, `AuthBlocksModels`.
|
||||
2. **Service wiring.** `Program.cs:35` calls
|
||||
`AuthBlocksWeb.Startup.ConfigureAuthServices(builder.Services, contentApiUrl)` — the user-admin
|
||||
clients and ViewModels are **already in the container**, already pointed at DeepDrftAPI. **This same
|
||||
wiring also registers the `JwtAuthenticationStateProvider` that `Register.razor` (path 2) depends on**
|
||||
— so path 2's service dependency is already satisfied (it is the same provider login uses).
|
||||
3. **Page discovery.** `Routes.razor:2` sets
|
||||
`AdditionalAssemblies="new[] { typeof(AuthBlocksWeb._Imports).Assembly }"` and `Program.cs:131`
|
||||
mirrors it for endpoint mapping. **The Blazor router already discovers every AuthBlocksWeb page**,
|
||||
including `/account/login`, `/account/register`, `/account/superregister`, and the `/useradmin/*`
|
||||
pages. They are route-reachable *today* by typing the URL — **including the public `/account/register`.**
|
||||
4. **Default layout.** `Routes.razor:6` sets `DefaultLayout="typeof(Layout.CmsLayout)"`. Since the
|
||||
AuthBlocks pages declare no `@layout`, they **render inside CmsLayout chrome** — the authenticated app
|
||||
shell. **This is the one wrong thing for the public pages** (login, register): an unauthenticated
|
||||
visitor sees the full authenticated CMS shell rather than the lean splash. See §2b.
|
||||
5. **Role gating already satisfied.** The admin pages gate on `SystemRoleConstants.UserAdmin`. The
|
||||
DeepDrft admin is seeded in role **`Admin`**, parent of `UserAdmin` — hierarchical authorize means
|
||||
**the existing admin already passes the `UserAdmin` gate** with no role change, no new seed, no DB edit.
|
||||
6. **Auth-state + redirect plumbing.** `AuthorizeRouteView` with `RedirectToLogin` /
|
||||
`RedirectToAccessDenied` (`Routes.razor`) already protects the gated surface coherently, and the
|
||||
public pages (no gate) pass straight through it.
|
||||
|
||||
**Net:** an authenticated DeepDrft admin can navigate to `/useradmin/users` today and the page should
|
||||
render and call DeepDrftAPI; and an unauthenticated visitor can reach `/account/register` today. The
|
||||
reasons it *feels* unbuilt: (a) **nothing in the CMS UI links to the admin pages** — `CmsLayout` has no
|
||||
nav drawer at all, so the admin surface is invisible (§G1); and (b) **the public pages render in the
|
||||
wrong (authenticated-shell) layout** for an unauthenticated visitor (§G0/§2b).
|
||||
|
||||
This is the crux: the CMS work is not *integration*, it is *exposure + layout-fix + verification +
|
||||
fit-and-finish*.
|
||||
|
||||
---
|
||||
|
||||
## 2b. The public-route layout gap (path 2 + login) — the one real public-facing fix
|
||||
|
||||
The public pages — `/account/login` and `/account/register` — are route-reachable and unauthenticated,
|
||||
but DeepDrftManager's router uses a **static** `DefaultLayout="typeof(Layout.CmsLayout)"`. Because the
|
||||
AuthBlocks public pages declare no `@layout`, an **unauthenticated visitor** lands inside the
|
||||
**authenticated app shell** (`CmsLayout` — the dense admin app bar with a Catalogue/Home button, and
|
||||
soon a nav drawer linking to gated admin surfaces). That is the wrong frame: a visitor who is not
|
||||
signed in should see the lean splash chrome the site already uses for its `/` home splash
|
||||
(`CmsHomeLayout`), not the admin shell.
|
||||
|
||||
DeepDrftManager **already has both layouts**:
|
||||
- `CmsLayout` — the authenticated app shell (`MudThemeProvider` + app bar + main content; gains the nav
|
||||
drawer in 19.1).
|
||||
- `CmsHomeLayout` — the lean splash (`MudThemeProvider` + minimal app bar, centered narrow container),
|
||||
already used by `Home.razor` (`@layout Layout.CmsHomeLayout`) for the unauthenticated `/` splash.
|
||||
|
||||
The fix is to render the **public auth pages in the lean layout** for unauthenticated visitors, and the
|
||||
**gated pages in the app shell** — exactly the SkipperHaven pattern (§2c). The two clean shapes:
|
||||
|
||||
- **G0-a — Auth-state-driven `DefaultLayout` in `Routes.razor` (the SkipperHaven pattern; recommended).**
|
||||
Make the router's `DefaultLayout` a function of auth state: unauthenticated → `CmsHomeLayout`,
|
||||
authenticated → `CmsLayout`. This is exactly what SkipperHaven does (its `Routes.razor` swaps
|
||||
`AuthenticatedLayout`/`UnauthenticatedLayout` in `OnParametersSetAsync` off the cascaded
|
||||
`AuthenticationState`). **DeepDrftManager already has both target layouts**, so this is a small
|
||||
router change, no new layout to author. *Cost:* the gated admin pages also resolve their layout via
|
||||
this switch — but an unauthenticated visitor to a gated page is redirected to login by `NotAuthorized`
|
||||
before layout matters, and an authenticated admin gets `CmsLayout`, so it composes correctly.
|
||||
*Caveat:* a logged-in admin who visits `/account/register` would see it in `CmsLayout` (the app shell)
|
||||
— acceptable, and arguably correct (an admin poking at the public form is in an admin session).
|
||||
- **G0-b — Per-page `@layout` on the public pages.** Add `@layout CmsHomeLayout` to the AuthBlocks
|
||||
public pages. **Rejected — not possible without forking the RCL:** `Login.razor`/`Register.razor` ship
|
||||
inside `Cerebellum.AuthBlocks.Web`; we cannot edit them, and there is no host-side override for an RCL
|
||||
page's `@layout`. G0-a is the only no-fork path.
|
||||
|
||||
**DECIDED direction: G0-a** (auth-state-driven `DefaultLayout`), mirroring SkipperHaven. It is the
|
||||
supported, no-fork way to give the public auth pages the lean layout, it reuses the two layouts
|
||||
DeepDrftManager already has, and it fixes login's layout at the same time as registration's.
|
||||
|
||||
---
|
||||
|
||||
## 2c. SkipperHaven — the canonical pattern, and the concrete DeepDrftManager deltas
|
||||
|
||||
`SkipperHaven` (`C:\Development\skipper\SkipperHaven\SkipperHaven`) consumes the **same AuthBlocks
|
||||
library** and already exposes login + register as public/unauthenticated routes with the right layout.
|
||||
The load-bearing piece is its `Components/Routes.razor`:
|
||||
|
||||
- It declares **`[Parameter] AuthenticatedLayout`** (`MainApplicationLayout`) and **`[Parameter]
|
||||
UnauthenticatedLayout`** (`MainHomeLayout`), takes the **cascaded `Task<AuthenticationState>`**, and in
|
||||
`OnParametersSetAsync` sets `_currentLayout` to the authenticated layout iff
|
||||
`authState.User.Identity?.IsAuthenticated == true`, else the unauthenticated layout.
|
||||
- `AuthorizeRouteView` uses **`DefaultLayout="@_currentLayout"`** (the resolved switch), **not** a static
|
||||
type. So the AuthBlocks public pages (login, register — both layout-less) render in `MainHomeLayout`
|
||||
for a signed-out visitor and the app shell once signed in.
|
||||
- Its `NotAuthorized` renders `RedirectToLogin` for unauthenticated and an inline "not authorized" for
|
||||
authenticated-but-unprivileged.
|
||||
- Wiring is otherwise identical to DeepDrftManager: `AuthBlocksWeb.Startup.ConfigureAuthServices(...,
|
||||
apiBaseUrl)` in `Program.cs`, and `AddAdditionalAssemblies(typeof(AuthBlocksWeb._Imports).Assembly)` on
|
||||
the mapped components. (Skipper also adds `AuthBlocksWeb.Client` assemblies because it uses the
|
||||
client-rendered auth surface; **DeepDrftManager is server-rendered `InteractiveServer` and does not need
|
||||
the `.Client` assembly** — its single `AuthBlocksWeb._Imports` entry is sufficient.)
|
||||
|
||||
**Concrete deltas DeepDrftManager needs to match the pattern (this is the whole public-route slice):**
|
||||
|
||||
| # | SkipperHaven | DeepDrftManager today | Delta |
|
||||
|---|--------------|-----------------------|-------|
|
||||
| D1 | `Routes.razor` resolves `DefaultLayout` from auth state (`AuthenticatedLayout` / `UnauthenticatedLayout`, switched in `OnParametersSetAsync` off the cascaded `AuthenticationState`) | `Routes.razor` uses a **static** `DefaultLayout="typeof(Layout.CmsLayout)"` | **Make `DefaultLayout` auth-state-driven**: cascade `Task<AuthenticationState>`, resolve `_currentLayout` = authed ? `CmsLayout` : `CmsHomeLayout`, bind `DefaultLayout="@_currentLayout"`. (G0-a.) **The only required public-route code change.** |
|
||||
| D2 | Two layouts exist (`MainApplicationLayout`, `MainHomeLayout`) | **Already has both** (`CmsLayout`, `CmsHomeLayout`) | **None** — no new layout to author. DeepDrftManager is ahead of Skipper here. |
|
||||
| D3 | `ConfigureAuthServices(..., apiBaseUrl)` in `Program.cs` (registers `JwtAuthenticationStateProvider` etc.) | **Already wired** (`Program.cs:35`) | **None.** |
|
||||
| D4 | AuthBlocks `_Imports` in router `AdditionalAssemblies` + mapped components | **Already wired** (`Routes.razor:2`, `Program.cs:131`) | **None** — `/account/register` is already route-reachable. |
|
||||
| D5 | `NotAuthorized` → `RedirectToLogin` (unauth) / inline message (authed) | `NotAuthorized` → `RedirectToLogin` (unauth) / `RedirectToAccessDenied` (authed) | **None functionally** — DeepDrftManager's existing handling is equivalent (it redirects rather than inlines; fine). |
|
||||
|
||||
**So the public-registration "track" reduces to a single change: D1 (auth-state-driven `DefaultLayout`).**
|
||||
Everything else SkipperHaven does is already present in DeepDrftManager. This is why path 2 is no longer
|
||||
its own host track — it is one router edit, parallelizable with (and smaller than) the admin-nav work.
|
||||
|
||||
---
|
||||
|
||||
## 3. The genuine remaining work
|
||||
|
||||
### G0 — Public-route layout (the path-2 + login fix) — see §2b/§2c
|
||||
|
||||
Make `Routes.razor`'s `DefaultLayout` auth-state-driven (G0-a), so the public `/account/login` and
|
||||
`/account/register` pages render in `CmsHomeLayout` for unauthenticated visitors. Single router change;
|
||||
no new layout (both already exist); no AuthBlocks-source change. This is **independent** of the admin-nav
|
||||
work below and can run in parallel.
|
||||
|
||||
### G1 — Navigation: there is no way to reach the admin surface from the UI *(the real admin gap)*
|
||||
|
||||
`CmsLayout.razor` is an app bar + a single Home `MudIconButton` — **no `MudDrawer`, no nav menu.** The
|
||||
catalogue, releases, upload, and user-admin surfaces are all reachable only by typed URL or in-page
|
||||
buttons. Mounting `UserAdminMenu` requires a navigation container to mount it *into*.
|
||||
|
||||
Three shapes were considered (diverge-before-converge): G1-a app-bar overflow menu (doesn't scale);
|
||||
**G1-b a real `MudDrawer` nav** mounting the existing CMS destinations + the shipped `UserAdminMenu`
|
||||
fragment; G1-c a maximal dedicated Administration section with its own dashboard (scope creep for v1).
|
||||
|
||||
**DECIDED: G1-b (Daniel, 2026-06-19).** A real `MudDrawer` nav in `CmsLayout` (toggle in the app bar)
|
||||
holding the existing primary destinations (Catalogue `/catalogue`, Releases `/releases`, Upload
|
||||
`/tracks/upload`) **and** the shipped `UserAdminMenu` fragment (self-gates to `UserAdmin`+, so it only
|
||||
shows for admins). Surface **both** admin account paths: path 1 (`SuperRegister`,
|
||||
`/account/superregister`) and path 3 (via the `UserAdminMenu` Registrations link → its New button). Do
|
||||
**not** surface the redundant bare `NewUser` (OQ2). It solves the actual gap (no nav) with the least
|
||||
bespoke code, reuses AuthBlocks' own `MudNavGroup` component verbatim, and gives the CMS the navigation
|
||||
spine it's missing. G1-c's admin dashboard remains deferred; G1-a is the rejected stopgap.
|
||||
|
||||
> **Borrowed precedent:** the standard MudBlazor admin-template layout (persistent left `MudDrawer` +
|
||||
> `MudNavMenu`/`MudNavGroup`), which `UserAdminMenu` is already authored against. SkipperHaven's
|
||||
> `MainApplicationLayout`/`NavMenu` is the same shape on the same library — a second confirmation this is
|
||||
> the idiom, not an invention.
|
||||
|
||||
### G2 — Verification pass (the surface is wired but unproven end-to-end)
|
||||
|
||||
Because nothing exercised these pages in the CMS, treat first-light as verification, not assumption.
|
||||
Confirm against a running DeepDrftAPI + Auth DB:
|
||||
|
||||
- `/account/register` (**path 2**) renders for an **unauthenticated** visitor in the **lean
|
||||
`CmsHomeLayout`** (post-G0), pre-fills `UserEmail` + `RegistrationToken` from the query string, and
|
||||
creates the account (consuming the `pending_registration` row) on submit.
|
||||
- `/account/login` likewise renders in the lean layout for an unauthenticated visitor (G0 fixes login's
|
||||
layout as a side benefit).
|
||||
- `/useradmin/users` lists users (the `UsersClient` → `api/users/*` round-trip works cross-host with the
|
||||
bearer token the CMS holds).
|
||||
- `/account/superregister` (**path 1**) creates a live account immediately — `admin-register` is
|
||||
`UserAdmin`-gated server-side; the admin's token must carry the role claim end-to-end.
|
||||
- `/useradmin/registrations/new` (**path 3**) provisions a token **and sends the invite email** — verify
|
||||
the email arrives (Mailtrap), the link/code are correct, the rollback fires on send failure, and
|
||||
**critically that the invite link's `{ReturnHost}` points at the CMS origin** so the deep link lands on
|
||||
the CMS `/account/register` (the place the wrong-host assumption could hide as config — §0 note). This
|
||||
is the surface most likely to surface a *config* gap (`AuthBlocks:Email:Host`/`:Token` + the return
|
||||
host in DeepDrftAPI's `environment/authblocks.json`).
|
||||
- **The full path-3→path-2 loop on one host:** admin provisions in the CMS → email arrives → invited user
|
||||
opens the deep link → lands on the CMS `/account/register` (lean layout) → redeems → account created.
|
||||
- `/useradmin/registrations` lists invites; `/useradmin/permissions` reads + assigns roles.
|
||||
- **CORS / token presentation:** the prior plan widened DeepDrftAPI CORS for the Manager origin for
|
||||
login; confirm the *same* allowance covers `api/users/*` / `api/pendingregistration/*` / `api/auth/register`
|
||||
(it should — same origin, same policy).
|
||||
|
||||
This pass is where any *latent* break surfaces (a client config typo, a missing role claim, a wrong
|
||||
return host, a package-version mismatch). Real work even though no code may change if it all passes.
|
||||
|
||||
### G3 — Theming / fit-and-finish
|
||||
|
||||
The AuthBlocks pages are MudBlazor-default-styled, authored against AuthBlocks' own theme, not the
|
||||
DeepDrft CMS palette (`DeepDrftPalettes.Cms`). Both `CmsLayout` and `CmsHomeLayout` mount a
|
||||
`MudThemeProvider` with that palette, so the pages inherit it for free. Scope for v1: **accept
|
||||
MudBlazor-default styling inside the CMS palette** and only fix outright legibility/contrast breaks
|
||||
(especially the public `/account/register` + `/account/login` now rendering in `CmsHomeLayout`). A deeper
|
||||
bespoke restyle of the AuthBlocks grids is explicitly **out of v1** — deferred polish.
|
||||
|
||||
### G4 — Package version alignment *(housekeeping, flag don't gate)*
|
||||
|
||||
DeepDrftManager references `Cerebellum.AuthBlocks.Web` **10.3.33**; AuthBlocks source is at **10.3.35**.
|
||||
Minor lag. Bumping is low-risk but **not required** for this phase. Note it; Daniel's call on timing.
|
||||
|
||||
---
|
||||
|
||||
## 4. Scope boundaries
|
||||
|
||||
**In for v1 (one host — `DeepDrftManager` — two parallel tracks):**
|
||||
|
||||
*Admin-nav track:*
|
||||
- G1-b: a `MudDrawer` nav in `CmsLayout` mounting `UserAdminMenu` (+ the existing CMS destinations).
|
||||
- All three account paths reachable in the CMS: path 1 (`SuperRegister`, provision-now) and path 3
|
||||
(`/useradmin/registrations/new`, invite-by-email) via nav, plus the users/permissions grids; path 2
|
||||
(`/account/register`) via the public-route track below.
|
||||
|
||||
*Public-route track (the corrected, much smaller path-2 work):*
|
||||
- G0-a: auth-state-driven `DefaultLayout` in `Routes.razor` so `/account/register` (path 2) **and**
|
||||
`/account/login` render in the lean `CmsHomeLayout` for unauthenticated visitors. **One router edit**
|
||||
(§2c D1); both target layouts already exist. The invite email's deep link is the entry point.
|
||||
|
||||
*Shared:*
|
||||
- G2: end-to-end verification of list/create/deactivate users, registrations (incl. the **real invite
|
||||
email** send + correct return host), permissions, **and the path-3→path-2 loop on one host**.
|
||||
- G3: accept-the-palette theming; fix only legibility breaks (incl. the public pages in `CmsHomeLayout`).
|
||||
|
||||
**Deferred (note, don't build):**
|
||||
|
||||
- **Admin dashboard (G1-c)** — a user-admin landing summarizing counts / pending invites. Good later;
|
||||
not a v1 gate.
|
||||
- **Reset Password** — the AuthBlocks `Users` page stubs it; **no backing endpoint exists** in
|
||||
`AuthRoutes`. An *upstream AuthBlocks* gap, not a DeepDrft wiring task. Daniel is handling it as a
|
||||
**separate AuthBlocks-repo effort** — see the standalone `product-notes/authblocks-password-reset-brief.md`.
|
||||
**Do not implement password reset inside DeepDrftHome.**
|
||||
- **Bespoke restyle** of the AuthBlocks grids to the editorial DeepDrft aesthetic.
|
||||
- A visible public "Register" nav link. Registration is invite-only (the email deep link is the entry
|
||||
point); a visible Register link with no self-serve code issuance invites confusion/abuse.
|
||||
**Recommend: no nav link; deep link only.** (Carried over from the dropped OQ9 — still the right call,
|
||||
now trivially so since the form lives on the CMS host the admin already knows.)
|
||||
- **G4 version bump** — housekeeping, Daniel's call on timing.
|
||||
|
||||
**Explicitly not needed:**
|
||||
|
||||
- **Any change to `DeepDrftPublic`.** The corrected host model puts all three paths on the CMS. The public
|
||||
site is untouched. (This deletes the entire rev-2 cold-start track.)
|
||||
- Extracting AuthBlocks pages into a new RCL. They ship in `Cerebellum.AuthBlocks.Web`.
|
||||
- New DI/service wiring, new role seeding, new Auth connection string. All present.
|
||||
- Editing the AuthBlocks `Login`/`Register` pages to set their layout — impossible without forking the
|
||||
RCL, and unnecessary (G0-a handles layout host-side).
|
||||
|
||||
---
|
||||
|
||||
## 5. Phased breakdown (for clean dispatch)
|
||||
|
||||
**One host (`DeepDrftManager`), two parallel tracks.** The admin-nav track (19.1) exposes the gated
|
||||
admin surfaces; the public-route track (19.2) fixes the public auth pages' layout. They touch different
|
||||
files (`CmsLayout.razor` vs. `Routes.razor`) and are independent — kick both off together. Verification
|
||||
(19.3) follows both; theming (19.4) follows and is parallel-ok with verification.
|
||||
|
||||
- **19.1 — CmsLayout navigation (admin-nav track; the main CMS code wave). DECIDED nav shape: G1-b.**
|
||||
Add a `MudDrawer` + toggle to `CmsLayout.razor`; mount the shipped `UserAdminMenu` fragment
|
||||
(self-gates to `UserAdmin`+) and the existing CMS destinations (Catalogue `/catalogue`, Releases
|
||||
`/releases`, Upload `/tracks/upload`). Surface **both** admin account paths: path 1 (`SuperRegister`,
|
||||
`/account/superregister`) and path 3 (`/useradmin/registrations/new`, via the `UserAdminMenu`
|
||||
Registrations link → its New button). Do **not** surface the redundant bare `NewUser` (OQ2). Scope:
|
||||
`CmsLayout.razor` (+ a small `.razor.css` if the drawer needs sizing). **No service, API, data, or
|
||||
AuthBlocks-source change.**
|
||||
- Acceptance: an authenticated `Admin` sees a nav drawer; the User Administration group appears and
|
||||
links to Users / Registrations / Permissions; a "Create user" affordance reaches `SuperRegister`; a
|
||||
non-`UserAdmin` user does not see the group; existing CMS destinations are reachable from the drawer.
|
||||
- **19.2 — Public-route layout (public-route track; parallel to 19.1). DECIDED: G0-a.** Make
|
||||
`Routes.razor`'s `DefaultLayout` auth-state-driven, mirroring SkipperHaven (§2c D1): cascade
|
||||
`Task<AuthenticationState>`, resolve `_currentLayout = authed ? CmsLayout : CmsHomeLayout`, bind
|
||||
`DefaultLayout="@_currentLayout"`. Scope: `DeepDrftManager/Components/Routes.razor` only. **No new
|
||||
layout (both exist), no package, no service, no AuthBlocks-source change.**
|
||||
- Acceptance: an **unauthenticated** visitor to `/account/register` sees the form in the lean
|
||||
`CmsHomeLayout` (not the admin app shell), can pre-fill from the deep link, and self-registers;
|
||||
`/account/login` likewise renders in the lean layout for an unauthenticated visitor; an authenticated
|
||||
admin still gets `CmsLayout` for the gated pages.
|
||||
- **19.3 — End-to-end verification (after 19.1 + 19.2).** Exercise G2 against a running DeepDrftAPI.
|
||||
Confirm list/create/deactivate users, **invite-email send (path 3) + correct `{ReturnHost}` → CMS
|
||||
origin**, permission round-trips, cross-host token + CORS, and the **full path-3→path-2 loop on the
|
||||
single CMS host**. File any latent break as a follow-up (likely a one-line config fix — esp. the
|
||||
Mailtrap creds + return host — or an upstream AuthBlocks issue). **Mostly test, not code.**
|
||||
- **19.4 — Theming legibility sweep (after 19.1 + 19.2, parallel-ok with 19.3).** Walk each user-admin
|
||||
page in the CMS palette, plus the public `/account/register` + `/account/login` in `CmsHomeLayout`; fix
|
||||
only contrast/legibility breaks. Defer bespoke restyle.
|
||||
|
||||
**Dependency shape:** `{19.1, 19.2} → 19.3`; `19.4` follows `{19.1, 19.2}` and is parallel-ok with
|
||||
`19.3`. 19.1 and 19.2 are mutually independent (different files) and should kick off together. The
|
||||
path-3→path-2 acceptance in 19.3 needs 19.1 (to generate an invite) and 19.2 (to land the redeem in the
|
||||
lean layout); a token minted directly via the API can verify path 2 ahead of 19.1 if needed.
|
||||
|
||||
---
|
||||
|
||||
## 6. Open questions for Daniel
|
||||
|
||||
**Resolved (Daniel, 2026-06-19):**
|
||||
|
||||
1. **Nav shape (G1) — DECIDED G1-b.** Real `MudDrawer` nav mounting `UserAdminMenu` + existing CMS
|
||||
destinations.
|
||||
2. **Admin create paths — DECIDED: surface path 1 (`SuperRegister`) + path 3 (registration-token form);
|
||||
do NOT surface the bare `NewUser`.** Both admin paths stay (provision-now vs. invite-by-email — not
|
||||
duplicates); `NewUser` is redundant with `SuperRegister` and hidden from nav.
|
||||
5. **Reset Password — DECIDED: non-functional in v1, handled separately** as an upstream AuthBlocks-repo
|
||||
effort (see `authblocks-password-reset-brief.md`). The 19.3 verification pass must not file it.
|
||||
6. **Host model — DECIDED (this revision): all three paths on `DeepDrftManager`; NO `DeepDrftPublic`
|
||||
changes.** Public registration is a public/unauthenticated CMS route exactly like the CMS login. The
|
||||
public-route work reduces to one router edit (G0-a, §2c D1).
|
||||
7. **Public-route layout — DECIDED G0-a:** auth-state-driven `DefaultLayout` in `Routes.razor`, mirroring
|
||||
SkipperHaven; reuses the existing `CmsHomeLayout`. (G0-b — per-page `@layout` — rejected: requires
|
||||
forking the RCL.)
|
||||
|
||||
**Still open:**
|
||||
|
||||
3. **Admin dashboard (G1-c) — defer or include?** **Recommend defer.** Net-new surface beyond what
|
||||
AuthBlocks ships; v1 should expose the working pages, not build a new one.
|
||||
4. **Package bump (G4) — now or separate?** Bump `Cerebellum.AuthBlocks.Web` 10.3.33 → 10.3.35 in this
|
||||
pass, or leave it? **Recommend leave it** unless 19.3 surfaces a fix that needs it.
|
||||
8. **Logged-in admin visiting `/account/register`.** Under G0-a, an authenticated admin who navigates to
|
||||
the public register page sees it in `CmsLayout` (the app shell) rather than the lean layout. **Recommend
|
||||
accept** — it is coherent (an admin in a session sees the admin shell) and the page still works; the
|
||||
primary audience (unauthenticated invitees) gets the lean layout correctly. Flag only if Daniel wants
|
||||
the register page forced lean regardless of session.
|
||||
|
||||
None block 19.1 or 19.2.
|
||||
@@ -0,0 +1,205 @@
|
||||
# Team Brief — AuthBlocks: Register `ModelView`'s Missing Dependency via `ConfigureAuthServices`
|
||||
|
||||
**Audience:** an orchestrator (and its implementers) working **only** in the AuthBlocks repository at
|
||||
`C:\Development\AuthBlocks`. You do not need, and should not assume, any knowledge of the products that
|
||||
consume AuthBlocks. Everything you need is in this brief or in that one repo (plus a single new BlazorBlocks
|
||||
package version — see §2, the blocking prerequisite).
|
||||
|
||||
**Status:** ✅ RESOLVED — shipped in `Cerebellum.BlazorBlocks.Web` 10.3.33 + `Cerebellum.AuthBlocks.Web` 10.3.36 (2026-06-20). ~~scoped request, blocked on a BlazorBlocks publish (see §2). Confirmed at runtime against `Cerebellum.AuthBlocks.Web` 10.3.33 / `Cerebellum.BlazorBlocks.Web` 10.3.32. Author: product-designer (for a downstream consumer team). Date: 2026-06-19.~~
|
||||
|
||||
> **Resolution (2026-06-20):** `AddBlazorBlocksWeb()` landed in `Cerebellum.BlazorBlocks.Web` 10.3.33 and `ConfigureAuthServices` calls it in `Cerebellum.AuthBlocks.Web` 10.3.36; DeepDrftManager picked up 10.3.36 and removed its local `EditModalSaveContextHolder` stopgap.
|
||||
> This brief is retained as historical record — no further action required.
|
||||
|
||||
**This is one half of a two-team, ordered fix. AuthBlocks ships second — see §2 and §9.**
|
||||
|
||||
---
|
||||
|
||||
## 1. The defect in one sentence
|
||||
|
||||
`AuthBlocksWeb` ships `Users.razor` and `Registrations.razor`, both of which render BlazorBlocks'
|
||||
`<ModelView>` component — but `ConfigureAuthServices` (the single DI entry point consumers call to light up
|
||||
the AuthBlocks Web surface) does **not** ensure `ModelView`'s required service
|
||||
`Web.Maintenance.Entities.EditModalSaveContextHolder` is registered. So a consumer that wires up AuthBlocks
|
||||
the normal way gets an unhandled `InvalidOperationException` that **terminates the Blazor circuit on
|
||||
navigation** to either page, unless that consumer manually hand-registers an internal BlazorBlocks service
|
||||
in its own `Program.cs`.
|
||||
|
||||
The fix: have `ConfigureAuthServices` call BlazorBlocks' new `AddBlazorBlocksWeb()` extension (which
|
||||
registers the holder), so AuthBlocks stays self-contained for *its* consumers.
|
||||
|
||||
---
|
||||
|
||||
## 2. Blocking prerequisite — BlazorBlocks must ship first
|
||||
|
||||
This fix **cannot land until BlazorBlocks publishes a new `Cerebellum.BlazorBlocks.Web` version that
|
||||
exposes a Web-side registration extension** (working name `AddBlazorBlocksWeb()`). That extension is what
|
||||
actually registers `EditModalSaveContextHolder`; AuthBlocks' job is only to *call* it.
|
||||
|
||||
- AuthBlocks is currently on `Cerebellum.BlazorBlocks.Web` **10.3.32**, which has **no** such extension.
|
||||
- The BlazorBlocks team is shipping the extension and bumping the package as the first half of this fix.
|
||||
- **Reference the specific new version BlazorBlocks publishes for this fix** — fill in the exact version
|
||||
number once the BlazorBlocks team reports it. Do not proceed against 10.3.32; the method will not exist.
|
||||
|
||||
If you reach this work before the BlazorBlocks version is available, stop and wait for the published version
|
||||
number. The AuthBlocks change is small once the prerequisite is in hand.
|
||||
|
||||
---
|
||||
|
||||
## 3. The confirmed failure
|
||||
|
||||
### Stack trace (captured from a consuming app on navigation to the Users admin page)
|
||||
|
||||
```
|
||||
System.InvalidOperationException: Cannot provide a value for property 'SaveContextHolder' on type
|
||||
'Web.Maintenance.Entities.ModelView`5[[AuthBlocksModels.InputModels.UserInputModel, ...],
|
||||
[AuthBlocksModels.Models.UserModel, ...],[AuthBlocksWeb.Components.Pages.UserAdmin.Users.UserEditModal, ...],
|
||||
[AuthBlocksWeb.Components.Pages.UserAdmin.Users.UsersViewModel, ...],
|
||||
[AuthBlocksModels.Converters.UserModelToInputConverter, ...]]'.
|
||||
There is no registered service of type 'Web.Maintenance.Entities.EditModalSaveContextHolder'.
|
||||
at Microsoft.AspNetCore.Components.ComponentFactory...CreatePropertyInjector...
|
||||
```
|
||||
|
||||
`ModelView` declares `SaveContextHolder` as `required` with `[Inject]`, so Blazor's component factory
|
||||
throws during component activation. There is no try/catch around component instantiation in the render path,
|
||||
so the exception propagates and tears down the circuit. The user sees a dead page / "An unhandled error has
|
||||
occurred" and must reload.
|
||||
|
||||
### Which AuthBlocks pages trigger it
|
||||
|
||||
`AuthBlocksWeb` ships two user-admin pages that render `<ModelView>`:
|
||||
|
||||
- `AuthBlocksWeb/Components/Pages/UserAdmin/Users/Users.razor`
|
||||
- `AuthBlocksWeb/Components/Pages/UserAdmin/Registrations/Registrations.razor`
|
||||
|
||||
Any consumer that routes to either page hits the defect on first navigation.
|
||||
|
||||
---
|
||||
|
||||
## 4. Why this is AuthBlocks' gap to close (not the consumer's)
|
||||
|
||||
`AuthBlocksWeb/Startup.cs` exposes `ConfigureAuthServices(IServiceCollection, string apiBaseUrl)` — the
|
||||
**single DI entry point** consumers call to light up the AuthBlocks Web surface. It already registers all
|
||||
the user-admin viewmodels and clients (`UsersViewModel`, `RegistrationsViewModel`, `PermissionsViewModel`,
|
||||
their clients, auth state, hierarchical authorization, etc.). It does **not** register
|
||||
`EditModalSaveContextHolder` and does **not** call any BlazorBlocks Web extension.
|
||||
|
||||
So AuthBlocks ships pages that depend on `ModelView` while leaving one of `ModelView`'s required services
|
||||
unregistered. The consumer is silently expected to fill the gap — and that is exactly what happened:
|
||||
**two independent downstream products** each had to hand-register the internal BlazorBlocks service
|
||||
(`AddScoped<Web.Maintenance.Entities.EditModalSaveContextHolder>()`) in their own `Program.cs` to make
|
||||
AuthBlocks' shipped pages work. The whole value of a single `ConfigureAuthServices` entry point is that a
|
||||
consumer calling it (plus the already-required `AddMudServices`) gets a working surface with zero manual
|
||||
registrations. Today they don't. Closing this gap inside `ConfigureAuthServices` restores that promise.
|
||||
|
||||
Note: `IDialogService` / `ISnackbar` (also injected by `ModelView`) come from MudBlazor's
|
||||
`AddMudServices()`, which every AuthBlocks Web consumer already calls as a documented prerequisite — those
|
||||
are not the gap. The single library-owned gap is `EditModalSaveContextHolder`.
|
||||
|
||||
---
|
||||
|
||||
## 5. The fix
|
||||
|
||||
### 5.1 Bump the BlazorBlocks reference
|
||||
|
||||
Update the `Cerebellum.BlazorBlocks.Web` package reference (currently 10.3.32) to the new version
|
||||
BlazorBlocks publishes for this fix (see §2 — fill in the exact version). This is what makes
|
||||
`AddBlazorBlocksWeb()` available.
|
||||
|
||||
### 5.2 Call the extension from `ConfigureAuthServices`
|
||||
|
||||
```csharp
|
||||
// AuthBlocksWeb/Startup.cs, inside ConfigureAuthServices(...)
|
||||
services.AddBlazorBlocksWeb(); // registers EditModalSaveContextHolder for the ModelView-based pages
|
||||
```
|
||||
|
||||
Order does not matter for this scoped service; place it near the other registrations. The
|
||||
`AddBlazorBlocksWeb()` extension registers `EditModalSaveContextHolder` as scoped per circuit (its correct
|
||||
lifetime — the holder is per-circuit mutable state that `ModelView` writes and `EditModelModal` reads).
|
||||
|
||||
### 5.3 Why this belongs in `ConfigureAuthServices`, not pushed onto consumers
|
||||
|
||||
- `ConfigureAuthServices` is AuthBlocks' **single DI entry point**. A consumer calling only
|
||||
`AddMudServices()` + `ConfigureAuthServices(...)` should get a fully working user-admin surface. Folding
|
||||
the BlazorBlocks call into the existing entry point keeps that promise; introducing a second method the
|
||||
consumer must remember to call (or expecting them to call `AddBlazorBlocksWeb()` themselves) just relocates
|
||||
the leaked registration one layer up.
|
||||
- AuthBlocks must **not** register `EditModalSaveContextHolder` directly (reaching into BlazorBlocks'
|
||||
internal `Web.Maintenance.Entities` namespace) — that is the same smell the two consumers exhibited,
|
||||
merely relocated. Compose BlazorBlocks' own `Add*` extension instead; the registration lives with its
|
||||
owner, and AuthBlocks stays self-contained by calling it. This is the standard ASP.NET Core layering:
|
||||
each library exposes an `Add*` for its own services, and a higher-level library's `Add*` calls the lower
|
||||
one's.
|
||||
|
||||
---
|
||||
|
||||
## 6. Constraints
|
||||
|
||||
- **Do not register `EditModalSaveContextHolder` directly** in AuthBlocks. Call `AddBlazorBlocksWeb()`;
|
||||
let BlazorBlocks own its type (§5.3).
|
||||
- **Keep `ConfigureAuthServices` the single AuthBlocks entry point.** Do not introduce a second method
|
||||
consumers must remember to call; fold the BlazorBlocks call into the existing one.
|
||||
- **MudBlazor remains a caller-owned prerequisite.** AuthBlocks already relies on consumers calling
|
||||
`AddMudServices()` for all its MudBlazor-based pages; do not absorb it into `ConfigureAuthServices`.
|
||||
- **Versioning:** AuthBlocks Web packs/pushes via its `pack.ps1` / packaging script. `AuthBlocksWeb` is
|
||||
currently `Cerebellum.AuthBlocks.Web` **10.3.33**; bump to the next version after referencing the new
|
||||
BlazorBlocks version and adding the `AddBlazorBlocksWeb()` call. **Record the new version** so consumers
|
||||
can pin.
|
||||
|
||||
---
|
||||
|
||||
## 7. Acceptance criteria
|
||||
|
||||
1. The `Cerebellum.BlazorBlocks.Web` package reference is bumped to the new version BlazorBlocks published
|
||||
for this fix, and `ConfigureAuthServices` calls `AddBlazorBlocksWeb()`.
|
||||
2. A fresh consumer that calls **only** `AddMudServices()` and
|
||||
`AuthBlocksWeb.Startup.ConfigureAuthServices(...)` — and **registers nothing else by hand** — can
|
||||
navigate to the Users admin page and the Registrations admin page with **no `InvalidOperationException`**
|
||||
and **no circuit teardown**.
|
||||
3. Working behavior means not just page load: opening the edit dialog on a user, saving a valid change,
|
||||
**succeeds** — i.e. the save bridge actually works end-to-end through AuthBlocks' pages.
|
||||
4. No AuthBlocks consumer needs to touch `Web.Maintenance.Entities` directly; no manual registrations are
|
||||
required beyond the documented `AddMudServices`.
|
||||
5. `AuthBlocksWeb` is published as a version bump from 10.3.33, and the new version number is recorded.
|
||||
|
||||
---
|
||||
|
||||
## 8. Open questions for the implementing team / its sponsor
|
||||
|
||||
1. **Exact BlazorBlocks version to reference.** Pending the BlazorBlocks team's publish (§2). Confirm the
|
||||
published version number and the exact extension method name (proposed `AddBlazorBlocksWeb()`) before
|
||||
landing the call.
|
||||
2. **Placement within `ConfigureAuthServices`.** Anywhere in the method works (scoped service, order-
|
||||
independent). Confirm there is no existing convention in `Startup.cs` for grouping third-party `Add*`
|
||||
calls that this should follow.
|
||||
3. **Any other AuthBlocks pages built on `ModelView`/`NewModelView`?** This brief identified Users and
|
||||
Registrations. If the team expects to add more maintenance pages, note that calling `AddBlazorBlocksWeb()`
|
||||
once covers them all (it is the single home for the maintenance-component deps).
|
||||
|
||||
---
|
||||
|
||||
## 9. Suggested reading order in the repo
|
||||
|
||||
1. `AuthBlocksWeb/Startup.cs` — `ConfigureAuthServices`, the single entry point; add the
|
||||
`AddBlazorBlocksWeb()` call here.
|
||||
2. `AuthBlocksWeb/Components/Pages/UserAdmin/Users/Users.razor` — renders `<ModelView>`; triggers the
|
||||
defect on navigation.
|
||||
3. `AuthBlocksWeb/Components/Pages/UserAdmin/Registrations/Registrations.razor` — the second page that
|
||||
renders `<ModelView>`.
|
||||
4. The AuthBlocks Web `.csproj` — the `Cerebellum.BlazorBlocks.Web` package reference to bump.
|
||||
5. The AuthBlocks `pack.ps1` / packaging script — to bump and publish `AuthBlocksWeb` after referencing the
|
||||
new BlazorBlocks version.
|
||||
|
||||
---
|
||||
|
||||
## 10. Cross-team ordering (important — you ship second)
|
||||
|
||||
This fix is layered across two repos and **must land in order**:
|
||||
|
||||
1. **BlazorBlocks ships first.** It adds `AddBlazorBlocksWeb()`, bumps `Cerebellum.BlazorBlocks.Web` from
|
||||
10.3.32, packs/pushes, and reports the new version number.
|
||||
2. **Then AuthBlocks (this team)** bumps its `Cerebellum.BlazorBlocks.Web` reference to that published
|
||||
version, calls `AddBlazorBlocksWeb()` from `ConfigureAuthServices`, bumps `Cerebellum.AuthBlocks.Web` from
|
||||
10.3.33, and publishes.
|
||||
|
||||
This team **cannot complete its part until the BlazorBlocks version is published** (§2). Confirm that
|
||||
version number is in hand before starting.
|
||||
@@ -0,0 +1,277 @@
|
||||
# Team Brief — AuthBlocks: Normalize the Account-Creation Pages (NewUser vs. Registration vs. SuperRegister)
|
||||
|
||||
**Audience:** an orchestrator (and its implementers) working **only** in the AuthBlocks repository at
|
||||
`C:\Development\AuthBlocks`. You do not need, and should not assume, any knowledge of the products that
|
||||
consume AuthBlocks. Everything you need is in this brief or in that one repo.
|
||||
|
||||
**Status:** scoped request, **decisions approved 2026-06-20** — ready for implementation. Author: product-designer (for a downstream consumer team).
|
||||
Date: 2026-06-20.
|
||||
|
||||
---
|
||||
|
||||
## 1. The problem in one sentence
|
||||
|
||||
AuthBlocks ships **three** account-creation pages whose identities have drifted: the **New User** page
|
||||
(`/useradmin/users/new`) is broken and has become a half-built *duplicate of the invite/registration flow*
|
||||
instead of the **direct admin-provisioning** path it is named for — while a separate page,
|
||||
**SuperRegister** (`/account/superregister`), is the page that actually performs direct admin provisioning.
|
||||
The two "create an account now" identities live in different places, one of them is broken, and the labels
|
||||
lie about what each does. This brief normalizes the three paths into distinct, correctly-named flows.
|
||||
|
||||
The intended end state (per the consuming team):
|
||||
|
||||
1. **Direct provision** — admin creates a *live* account immediately (username + email + password + roles),
|
||||
bypassing email entirely. This is what **New User** should be.
|
||||
2. **Admin invite-by-email** — admin sends a registration code + link to an email; the recipient redeems it
|
||||
to create their own account. This is the **Registration** flow.
|
||||
3. **Public self-service redeem** — the recipient lands on the public **Register** page and completes
|
||||
account creation with the code. (Already correct; included for completeness.)
|
||||
|
||||
---
|
||||
|
||||
## 2. Current-state analysis (read this before designing — it is the crux)
|
||||
|
||||
### 2.1 New User — `/useradmin/users/new` (BROKEN + mislabeled + duplicate)
|
||||
|
||||
- **Page:** `AuthBlocksWeb/Components/Pages/UserAdmin/Users/NewUser.razor` — just renders `<NewUserForm/>`.
|
||||
- **Form markup:** `.../Users/NewUserForm.razor`. The card header reads **"Activate New User"**. The body
|
||||
text reads:
|
||||
> *"Create a new user account, bypassing email registration. The password must be provided now."*
|
||||
This message describes **direct provisioning** — and it is the **wrong message for what the page actually
|
||||
does**, because:
|
||||
- The form has **only an Email field**. There is **no username field, no password field, and no role
|
||||
selector** — despite the copy promising "the password must be provided now."
|
||||
- The submit button is labeled **"Send Registration Code"** — i.e. invite-flow language, contradicting the
|
||||
"bypassing email registration" body copy directly above it.
|
||||
- **What it actually does:** **nothing — it throws.** The form's `OnValidSubmit` is wired to a stub:
|
||||
```csharp
|
||||
// NewUserForm.razor.cs
|
||||
private void X()
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
```
|
||||
The code-behind also carries a **commented-out body** that, if enabled, would call
|
||||
`Client.CreatePendingRegistration(...)` and pop a `UserSubmittedModal` — i.e. it would make NewUser
|
||||
**identical to the invite/Registration flow**. So the page is mid-migration: someone started turning the
|
||||
direct-provision page into a second copy of the invite page, didn't finish, and left a throwing stub.
|
||||
- **Backing model is the invite model, not the provision model:** `NewUserForm` binds
|
||||
`PendingRegistrationInputModel Input` and injects `PendingRegistrationClient` — the *registration* model
|
||||
and client, **not** `AdminRegisterRequest` / the admin-register path. This is the concrete duplication:
|
||||
NewUser is plumbed for invites, not for direct provisioning.
|
||||
|
||||
**In short:** NewUser is named for direct provisioning, *says* it does direct provisioning ("bypassing email
|
||||
registration… password must be provided now"), is *wired* for invites (PendingRegistration model/client +
|
||||
commented-out invite call), is *labeled* for invites ("Send Registration Code"), and **actually throws
|
||||
`NotImplementedException`**. Every layer disagrees with every other layer.
|
||||
|
||||
### 2.2 SuperRegister — `/account/superregister` (the REAL direct-provision page, working)
|
||||
|
||||
- **Page:** `AuthBlocksWeb/Components/Pages/Account/SuperRegister.razor`. `[HierarchicalRoleAuthorize(UserAdmin)]`,
|
||||
`@rendermode InteractiveServer`. Title "Admin Register"; header "Create a new account."
|
||||
- **This is the genuine direct-provision UI.** Full form: Username, Email, Password, Confirm Password, and a
|
||||
**multi-select Roles** dropdown populated from `AuthApiClient.GetRolesAsync`.
|
||||
- **Backing model + call:** binds `AdminRegisterRequest` (UserName, Email, Password, ConfirmPassword,
|
||||
RoleIds) and calls `AuthApiClient.AdminRegisterAsync(Input, token)` → `POST api/auth/admin-register`.
|
||||
- **What that endpoint does** (`AuthBlocksLib/Routes/AuthRoutes.cs`, `AdminRegister`, role-gated to
|
||||
`UserAdmin`): rejects a duplicate email; resolves each `RoleId` to a role name up front (fail-fast);
|
||||
creates the user via `userService.Add(user, request.Password)` with `EmailConfirmed = true`; assigns
|
||||
roles (deleting the half-created user if a role assignment fails); returns an `AuthResponse`. **No email,
|
||||
no token, no pending row — a live account immediately.** This is exactly the behavior the consumer wants
|
||||
*New User* to have.
|
||||
|
||||
### 2.3 New Registration — `/useradmin/registrations/new` (the invite flow, working)
|
||||
|
||||
- **Page:** `AuthBlocksWeb/Components/Pages/UserAdmin/Registrations/NewRegistration.razor` → `<NewRegistrationForm/>`.
|
||||
- **Form:** `.../Registrations/NewRegistrationForm.razor`. Header **"Provision New User"** (note: this label
|
||||
is *also* slightly off — it performs an *invite*, not a provision; see decisions §5). Body: *"A registration
|
||||
code and link will be sent to the email address provided."* Fields: Email + multi-select Roles. Button:
|
||||
"Send Registration Code".
|
||||
- **Backing model + call:** binds `PendingRegistrationInputModel`, injects `PendingRegistrationClient`, and
|
||||
on submit calls `Client.CreatePendingRegistration(email, roles, returnHost=.../account/register)`, then
|
||||
shows `RegistrationSubmittedModal` (which reports "An email has been sent to: …").
|
||||
- **What that hits:** `POST api/pendingregistration/create` (`AuthBlocksLib/Routes/PendingRegistrationRoutes.cs`,
|
||||
`Create`, group role-gated to `UserAdmin`): rejects existing user / existing pending registration;
|
||||
generates a registration token; persists a `PendingRegistration` row with the token **hash** and an expiry;
|
||||
emails a code + a deep link (`returnHost?UserEmail=&RegistrationToken=`) via `IGeneralEmailSender`. The
|
||||
recipient later redeems at the public **Register** page (`POST api/auth/register`, which validates the
|
||||
code, creates the user, consumes the token).
|
||||
|
||||
### 2.4 The duplication, stated precisely
|
||||
|
||||
`NewUserForm` and `NewRegistrationForm` **bind the same model (`PendingRegistrationInputModel`) and the same
|
||||
client (`PendingRegistrationClient`), and NewUser's commented-out handler is a near-copy of
|
||||
NewRegistration's `CreatePendingRegistration` handler.** NewUser is, in its half-built state, a strictly
|
||||
worse duplicate of NewRegistration (no roles field, throwing stub) — while the page that *should* own
|
||||
NewUser's intended behavior (direct provision) is the separate `SuperRegister`. The system has:
|
||||
|
||||
- **two pages aimed at the invite flow** (Registration = working; NewUser = broken duplicate), and
|
||||
- **one page doing direct provision under a different name/route** (SuperRegister),
|
||||
- **zero working pages at the New User route doing what "New User" implies.**
|
||||
|
||||
### 2.5 The API is already correct — this is a Web-layer normalization
|
||||
|
||||
Both endpoints exist and work today: `admin-register` (direct provision, role-gated) and
|
||||
`pendingregistration/create` (invite). **No new API endpoints are required.** This task is about pointing
|
||||
the right *page* at the right *existing* endpoint, with honest labels and routes. (Contrast the
|
||||
password-reset brief, which was build-from-scratch on both tiers.)
|
||||
|
||||
---
|
||||
|
||||
## 3. The normalization design
|
||||
|
||||
End state: **three crisp paths, each at one page, each correctly labeled, each on the right endpoint.**
|
||||
|
||||
| Path | Page (canonical) | Route | Endpoint | Result |
|
||||
|---|---|---|---|---|
|
||||
| Direct provision | **New User** | `/useradmin/users/new` | `POST api/auth/admin-register` | live account now |
|
||||
| Admin invite | **New Registration** | `/useradmin/registrations/new` | `POST api/pendingregistration/create` | emailed code + link |
|
||||
| Public redeem | **Register** | `/account/register` | `POST api/auth/register` | recipient self-creates |
|
||||
|
||||
The core move: **make `NewUser` the canonical direct-provision page by absorbing SuperRegister's behavior**,
|
||||
fix its copy, and resolve the now-redundant SuperRegister. Registration stays as-is (modulo a label tidy).
|
||||
|
||||
### 3.1 Recommended approach — "Absorb into NewUser, retire SuperRegister"
|
||||
|
||||
1. **Rebuild `NewUserForm` as the direct-provision form.** Re-bind it from `PendingRegistrationInputModel` /
|
||||
`PendingRegistrationClient` to **`AdminRegisterRequest`** and the **auth client** that calls
|
||||
`AdminRegisterAsync` (the `IAuthApiClient` + `IAuthSession` token pattern SuperRegister already uses).
|
||||
Bring across SuperRegister's full field set: Username, Email, Password, Confirm Password, and the
|
||||
role multi-select sourced from `GetRolesAsync`. Delete the throwing `X()` stub and the commented-out
|
||||
invite handler. Keep the existing card/`MudContainer` chrome so it matches the other UserAdmin pages
|
||||
(NewUser/NewRegistration share a card layout that the standalone SuperRegister does not).
|
||||
2. **Fix the copy.** Header → e.g. **"New User — Direct Provision"** (or "Activate New User", kept, now that
|
||||
the page genuinely activates one). Body → keep the accurate *"Create a live account now, bypassing email
|
||||
registration. Set the password directly."* Button → **"Create Account"** (retire the misleading
|
||||
"Send Registration Code" on this page). On success, show a confirmation and route back to
|
||||
`/useradmin/users` (force-reload so the grid refreshes — mirror NewRegistration's post-submit nav).
|
||||
3. **Retire `SuperRegister`.** Once NewUser owns direct provision, SuperRegister is a duplicate. Preferred:
|
||||
**delete the page and redirect `/account/superregister` → `/useradmin/users/new`** so any existing
|
||||
bookmark or consumer nav link doesn't 404 during the consumer's catch-up window (see §7). Keep the
|
||||
redirect lightweight (a `NavigationManager.NavigateTo` in a thin page, or a server redirect). Do **not**
|
||||
leave two separate-but-identical "create now" pages alive.
|
||||
4. **Tidy Registration's label** (small, optional but recommended for the normalization to be coherent):
|
||||
the invite page header currently says **"Provision New User"**, which collides with the direct-provision
|
||||
concept now owned by NewUser. Rename it to **"Invite New User"** / **"New Registration"** so "provision"
|
||||
unambiguously means *direct* and "invite/registration" means *emailed code*. No behavior change.
|
||||
|
||||
**Why this approach:** NewUser's route (`/useradmin/users/new`) is where an admin looking at the Users grid
|
||||
expects to click "add a user," and it lives in the UserAdmin/Users area beside the grid — the natural home
|
||||
for the canonical create-now action. SuperRegister sits oddly under `/account/*` (the *public* auth area,
|
||||
alongside Login/Register) despite being an admin-only action; folding it into UserAdmin/Users fixes that
|
||||
mis-placement as a side effect. The API already supports it, so this is low-risk re-pointing, not new
|
||||
behavior.
|
||||
|
||||
### 3.2 Alternatives considered
|
||||
|
||||
- **B — Keep SuperRegister canonical; make NewUser a redirect to it.** Inverse of the recommendation:
|
||||
delete `NewUserForm`'s logic, point `/useradmin/users/new` → `/account/superregister`. Cheaper (no form
|
||||
rebuild), but it **enshrines the mis-placement** (admin-only page under `/account/*`) and leaves the
|
||||
visual inconsistency (SuperRegister doesn't use the UserAdmin card chrome). Rejected: it normalizes the
|
||||
*names* but not the *information architecture*. Choose this only if rebuilding the form is deemed
|
||||
out-of-budget for now — and even then, treat it as interim.
|
||||
- **C — Keep both pages, share one form component.** Extract a single `DirectProvisionForm` component and
|
||||
render it from both `NewUser.razor` and `SuperRegister.razor`. Eliminates code duplication but **leaves
|
||||
two routes for one action** — exactly the "two create-now pages" the consumer is asking to remove. Rejected
|
||||
for the explicit goal; the duplication the consumer dislikes is at the *page/route* level, not just code.
|
||||
- **D — Make NewUser a *chooser*** (two buttons: "Create now" vs. "Invite by email").** A single entry point
|
||||
that branches to the two real flows. Genuinely nice UX and worth noting as a *future* enhancement, but it
|
||||
is scope-creep on a "normalize what exists" request and introduces a fourth surface. Defer.
|
||||
|
||||
**Recommendation: A.** It produces the cleanest end state (correct names, correct routes, correct IA, no
|
||||
redundant page) at the cost of one form rebuild that is mostly a copy of SuperRegister's already-working
|
||||
form.
|
||||
|
||||
---
|
||||
|
||||
## 4. Constraints
|
||||
|
||||
- **No new API endpoints.** `admin-register` and `pendingregistration/create` already exist and are correct
|
||||
(§2.5). If you find yourself adding an endpoint, stop — you've taken a wrong turn.
|
||||
- **Preserve role-gating.** Direct provision must stay `[HierarchicalRoleAuthorize(UserAdmin)]` (SuperRegister
|
||||
has it; ensure rebuilt NewUser keeps it — NewUser currently inherits whatever the UserAdmin pages set, so
|
||||
verify the attribute is present on the page).
|
||||
- **Reuse the existing client + session pattern.** Direct provision uses `IAuthApiClient.AdminRegisterAsync`
|
||||
+ `IAuthSession.GetValidTokenAsync` (as SuperRegister does). Do not introduce a new client; do not route
|
||||
direct provision through `PendingRegistrationClient`.
|
||||
- **Match the UserAdmin page conventions.** NewUser/NewRegistration use a `MudContainer` + `MudCard` +
|
||||
back-button layout; keep the rebuilt NewUser in that house style rather than transplanting SuperRegister's
|
||||
bare `MudGrid` layout verbatim.
|
||||
- **No 404s for retired routes.** If SuperRegister is removed, `/account/superregister` must redirect, not
|
||||
break (§3.1.3, §7).
|
||||
- **Versioning:** lands as a normal AuthBlocks Web version bump, packed/pushed via `pack.ps1`. `AuthBlocksWeb`
|
||||
is currently `Cerebellum.AuthBlocks.Web` **10.3.36**; bump to **10.3.37** (or the next free patch if
|
||||
another bump has landed since this brief). **Record the published version** so the consumer can pin.
|
||||
|
||||
---
|
||||
|
||||
## 5. Decisions for the sponsor (Daniel) — resolved 2026-06-20
|
||||
|
||||
1. **Which page is canonical for direct provision?** **DECISION (2026-06-20): New User** (`/useradmin/users/new`)
|
||||
is the canonical direct-provision page, absorbing SuperRegister (§3.1). ✅ approved.
|
||||
2. **What happens to SuperRegister?** **DECISION (2026-06-20): delete + redirect** `/account/superregister` →
|
||||
`/useradmin/users/new`. ✅ approved.
|
||||
3. **Route naming.** **DECISION (2026-06-20):** keep `/useradmin/users/new` as the canonical direct-provision
|
||||
route. No `/account/*` route retained. ✅ approved.
|
||||
4. **Copy/wording on NewUser.** **DECISION (2026-06-20):** go with the recommended strings in §3.1.2 — header
|
||||
"New User — Direct Provision" or "Activate New User"; button "Create Account"; accurate direct-provision body
|
||||
copy. Implementer discretion within that intent. ✅ approved.
|
||||
5. **Tidy the Registration label** ("Provision New User" → "Invite New User"/"New Registration")?
|
||||
**DECISION (2026-06-20):** yes, in scope. ✅ approved.
|
||||
6. **Future chooser page (Alternative D)** — **DECISION (2026-06-20):** deferred; captured as a possible later
|
||||
enhancement. ✅ approved (defer).
|
||||
|
||||
---
|
||||
|
||||
## 6. Acceptance criteria
|
||||
|
||||
1. Navigating to `/useradmin/users/new` shows a **direct-provision form** with Username, Email, Password,
|
||||
Confirm Password, and a Roles multi-select — **no "Send Registration Code" language**, no throwing stub.
|
||||
2. Submitting that form with a valid username/email/password (and optional roles) calls
|
||||
`POST api/auth/admin-register`, creates a **live account immediately** (no email sent, no pending-
|
||||
registration row), assigns the selected roles, shows a success confirmation, and returns to
|
||||
`/useradmin/users` with the new user visible in the grid.
|
||||
3. The page's copy accurately describes direct provisioning; the button reads "Create Account" (or the
|
||||
sponsor-approved string).
|
||||
4. `NewUserForm` no longer binds `PendingRegistrationInputModel` / `PendingRegistrationClient`, no longer
|
||||
contains the `X()` stub or the commented-out invite handler.
|
||||
5. SuperRegister is resolved per the sponsor's decision: if retired, `/account/superregister` **redirects**
|
||||
to `/useradmin/users/new` (no 404, no second working create-now page); if kept as alias, it is explicitly
|
||||
an alias, not an independent duplicate.
|
||||
6. The invite flow at `/useradmin/registrations/new` still works unchanged (emails a code + link); if its
|
||||
label was tidied, the change is cosmetic only.
|
||||
7. Direct provision remains `UserAdmin`-role-gated; an unauthorized user cannot reach it.
|
||||
8. Published as a version bump from 10.3.36 (expected **10.3.37**); the new version number is recorded.
|
||||
|
||||
---
|
||||
|
||||
## 7. Downstream consequence (for the consumer to handle later — NOT this team's work)
|
||||
|
||||
The consuming product (DeepDrftManager) currently surfaces **SuperRegister (`/account/superregister`)** in
|
||||
its CMS navigation as the **"Provision User"** entry, alongside a **Registrations** link. If this
|
||||
normalization changes which page is canonical or its route — specifically if `/account/superregister` is
|
||||
retired in favor of `/useradmin/users/new` — the consumer's nav link will need a small follow-up update
|
||||
**after this AuthBlocks change ships and the consumer bumps its package reference**. The recommended
|
||||
delete-**and-redirect** (§3.1.3, decision §5.2) is precisely to keep that consumer working in the interval
|
||||
between this ship and the consumer's catch-up. **This is noted only so the implementing team understands why
|
||||
the redirect matters; updating the consumer's nav is out of scope for AuthBlocks.**
|
||||
|
||||
---
|
||||
|
||||
## 8. Suggested reading order in the repo
|
||||
|
||||
1. `AuthBlocksWeb/Components/Pages/UserAdmin/Users/NewUserForm.razor` + `.razor.cs` — the broken page; the
|
||||
wrong message, the missing fields, the `X()` stub, the commented-out invite handler. **Start here.**
|
||||
2. `AuthBlocksWeb/Components/Pages/Account/SuperRegister.razor` — the working direct-provision form to
|
||||
absorb (fields, role multi-select, `AdminRegisterAsync` call, `IAuthSession` token pattern).
|
||||
3. `AuthBlocksWeb/Components/Pages/UserAdmin/Registrations/NewRegistrationForm.razor` + `.razor.cs` — the
|
||||
invite flow NewUser was wrongly duplicating; the post-submit modal + force-reload nav pattern to mirror.
|
||||
4. `AuthBlocksLib/Routes/AuthRoutes.cs` — the `AdminRegister` endpoint (direct provision; what NewUser must
|
||||
call) and `Register` (public redeem); the `ApiResult`/result conventions.
|
||||
5. `AuthBlocksLib/Routes/PendingRegistrationRoutes.cs` — the `Create` endpoint (invite); confirms the two
|
||||
endpoints are already distinct and correct (no API work needed).
|
||||
6. `AuthBlocksModels/ApiModels/AuthModels.cs` — `AdminRegisterRequest` (UserName, Email, Password,
|
||||
ConfirmPassword, RoleIds) is the model NewUser should bind.
|
||||
7. `AuthBlocksWeb/ApiClients/IAuthApiClient.cs` / `AuthApiClient.cs` — `AdminRegisterAsync` + `GetRolesAsync`.
|
||||
8. `AuthBlocksWeb/AuthBlocksWeb.csproj` — `<Version>10.3.36</Version>` to bump.
|
||||
9. `pack.ps1` — pack/push after the bump; record the published version.
|
||||
@@ -0,0 +1,258 @@
|
||||
# Team Brief — BlazorBlocks: Register the Missing `EditModalSaveContextHolder` DI Dependency
|
||||
|
||||
**Audience:** an orchestrator (and its implementers) working **only** in the BlazorBlocks repository at
|
||||
`C:\Development\BlazorBlocks`. You do not need, and should not assume, any knowledge of AuthBlocks or of
|
||||
any product that consumes BlazorBlocks. Everything you need is in this brief or in that one repo.
|
||||
|
||||
**Status:** ✅ RESOLVED — shipped in `Cerebellum.BlazorBlocks.Web` 10.3.33 + `Cerebellum.AuthBlocks.Web` 10.3.36 (2026-06-20). ~~scoped request, not yet started. Confirmed at runtime against `Cerebellum.BlazorBlocks.Web` 10.3.32. Author: product-designer (for a downstream consumer team). Date: 2026-06-19.~~
|
||||
|
||||
> **Resolution (2026-06-20):** `AddBlazorBlocksWeb()` landed in `Cerebellum.BlazorBlocks.Web` 10.3.33 and `ConfigureAuthServices` calls it in `Cerebellum.AuthBlocks.Web` 10.3.36; DeepDrftManager picked up 10.3.36 and removed its local `EditModalSaveContextHolder` stopgap.
|
||||
> This brief is retained as historical record — no further action required.
|
||||
|
||||
**This is one half of a two-team, ordered fix. BlazorBlocks ships first — see §9.**
|
||||
|
||||
---
|
||||
|
||||
## 1. The defect in one sentence
|
||||
|
||||
The BlazorBlocks `ModelView` component (and the `EditModelModal` it drives) has a `required [Inject]`
|
||||
dependency on `Web.Maintenance.Entities.EditModalSaveContextHolder`, but **the `Web` package ships no
|
||||
`IServiceCollection` registration extension at all** — so the dependency is never registered by the
|
||||
library, and any consumer that surfaces a page built on `ModelView` gets an unhandled
|
||||
`InvalidOperationException` that **terminates the Blazor circuit on navigation**, unless that consumer
|
||||
manually hand-registers the internal service in its own `Program.cs`.
|
||||
|
||||
The fix is a BlazorBlocks library fix delivered as a version bump: add a Web-side `Add*` extension that
|
||||
registers the holder.
|
||||
|
||||
---
|
||||
|
||||
## 2. The confirmed failure (downstream symptom — evidence, not your concern to chase)
|
||||
|
||||
The defect was discovered by a consumer navigating to an admin page built on `ModelView`. The captured
|
||||
stack trace is included here as confirming evidence of the unregistered-service failure mode; the
|
||||
consumer's identity and its pages are out of scope for this team — your job is to register the dependency
|
||||
the library requires.
|
||||
|
||||
```
|
||||
System.InvalidOperationException: Cannot provide a value for property 'SaveContextHolder' on type
|
||||
'Web.Maintenance.Entities.ModelView`5[[AuthBlocksModels.InputModels.UserInputModel, ...],
|
||||
[AuthBlocksModels.Models.UserModel, ...],[AuthBlocksWeb.Components.Pages.UserAdmin.Users.UserEditModal, ...],
|
||||
[AuthBlocksWeb.Components.Pages.UserAdmin.Users.UsersViewModel, ...],
|
||||
[AuthBlocksModels.Converters.UserModelToInputConverter, ...]]'.
|
||||
There is no registered service of type 'Web.Maintenance.Entities.EditModalSaveContextHolder'.
|
||||
at Microsoft.AspNetCore.Components.ComponentFactory...CreatePropertyInjector...
|
||||
```
|
||||
|
||||
Because `SaveContextHolder` is declared `required` with `[Inject]`, Blazor's component factory throws
|
||||
during component activation. There is no try/catch around component instantiation in the render path, so
|
||||
the exception propagates and tears down the circuit. The end user sees a dead page / "An unhandled error
|
||||
has occurred" and must reload.
|
||||
|
||||
The tell that this is a library gap and not a consumer mistake: **two independent downstream products**
|
||||
have each had to hand-register the *same* internal BlazorBlocks service
|
||||
(`AddScoped<Web.Maintenance.Entities.EditModalSaveContextHolder>()`) in their own `Program.cs` to make
|
||||
`ModelView`-based pages work. When two unrelated consumers independently discover they must reach into a
|
||||
library's internal namespace (`Web.Maintenance.Entities`) and register a type the library never documented
|
||||
as a consumer responsibility, that is a leaked registration. The correct owner of the registration is the
|
||||
library — this team.
|
||||
|
||||
---
|
||||
|
||||
## 3. Root-cause findings (from reading the source)
|
||||
|
||||
### 3.1 The service and who needs it
|
||||
|
||||
`EditModalSaveContextHolder` (`C:\Development\BlazorBlocks\Web\Maintenance\Entities\EditModalSaveContextHolder.cs`)
|
||||
is a tiny per-circuit slot:
|
||||
|
||||
```csharp
|
||||
public sealed class EditModalSaveContextHolder
|
||||
{
|
||||
public IEditModalSaveContext? Current { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
It is injected as `required` in **two** library components:
|
||||
|
||||
- `Web/Maintenance/Entities/ModelView.razor.cs:42` — sets `SaveContextHolder.Current` before opening the
|
||||
edit dialog and clears it in a `finally` (see `EditItem`, lines 137–172).
|
||||
- `Web/Maintenance/Entities/EditModelModal.razor:40` — reads `SaveContextHolder.Current` to obtain the
|
||||
typed save callback (`SaveContext`).
|
||||
|
||||
The holder is deliberately the **per-circuit bridge** between these two components — it threads a save
|
||||
closure from the page-side `ModelView` into the generic `EditModelModal` without forcing a parameter
|
||||
through every per-page modal wrapper. So **both** components fail without it; the bug surfaces at
|
||||
`ModelView` activation simply because that component is constructed first.
|
||||
|
||||
### 3.2 Is there already a Web-side registration extension to fix?
|
||||
|
||||
**No Web-side registration extension exists.** A full scan of BlazorBlocks for `IServiceCollection`
|
||||
extension methods finds only:
|
||||
|
||||
- `Data.Postgres/ServiceCollectionExtensions.cs` → `AddBlazorBlocksPostgres()` (registers
|
||||
`IDbExceptionClassifier`; a data-layer concern, unrelated to the Web components).
|
||||
- `API/Errors/...AddResultMessagePolymorphism(...)` (a JSON resolver helper, not DI).
|
||||
|
||||
There is **no** `AddBlazorBlocks()` / `AddBlazorBlocksWeb()` / `AddMaintenance()` method in the `Web`
|
||||
project (`Cerebellum.BlazorBlocks.Web`). So `EditModalSaveContextHolder` is not "missing from an existing
|
||||
extension" — there is no Web-side extension at all. The library ships components with a hard DI dependency
|
||||
and provides no entry point to register that dependency.
|
||||
|
||||
### 3.3 Is `EditModalSaveContextHolder` the *only* missing dependency?
|
||||
|
||||
**It is the only library-owned missing registration.** Every `[Inject]` across the BlazorBlocks
|
||||
`Web/Maintenance` tree was enumerated:
|
||||
|
||||
| Component | Injected types |
|
||||
|---|---|
|
||||
| `ModelView<...>` | `TViewModel` (consumer), `NavigationManager` (framework), `IDialogService` (MudBlazor), `ISnackbar` (MudBlazor), **`EditModalSaveContextHolder` (BlazorBlocks — UNREGISTERED)** |
|
||||
| `EditModelModal<TModel>` | `ISnackbar` (MudBlazor), **`EditModalSaveContextHolder` (BlazorBlocks — UNREGISTERED)** |
|
||||
| `NewModelView<...>` | `TClient` (consumer), `NavigationManager` (framework), `IDialogService` (MudBlazor), `ISnackbar` (MudBlazor) |
|
||||
|
||||
Everything else resolves through services consumers already register:
|
||||
|
||||
- `NavigationManager` — Blazor framework.
|
||||
- `IDialogService`, `ISnackbar` — MudBlazor, registered by the consumer's `AddMudServices()` (a documented
|
||||
MudBlazor prerequisite).
|
||||
- `TViewModel` / `TClient` — the per-entity viewmodel/client, registered by the consumer (or by a
|
||||
higher-level library) for its own entities.
|
||||
|
||||
So `EditModalSaveContextHolder` is the single library-owned gap. Registering the one holder closes the
|
||||
whole set, provided the consumer has already called `AddMudServices()`.
|
||||
|
||||
---
|
||||
|
||||
## 4. The fix
|
||||
|
||||
Add a new `IServiceCollection` extension in the `Web` project that registers the maintenance components'
|
||||
library-owned dependencies. This is where `EditModalSaveContextHolder` belongs — it is BlazorBlocks'
|
||||
internal bridge, and any product using `ModelView` needs it.
|
||||
|
||||
```csharp
|
||||
// C:\Development\BlazorBlocks\Web\ServiceCollectionExtensions.cs (new)
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions; // for TryAddScoped
|
||||
using Web.Maintenance.Entities;
|
||||
|
||||
namespace Web;
|
||||
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers the services required by the BlazorBlocks maintenance UI
|
||||
/// components (ModelView / EditModelModal / NewModelView). Call this in
|
||||
/// your application's DI setup when using those components.
|
||||
/// Note: MudBlazor (AddMudServices) is a separate, caller-owned prerequisite
|
||||
/// and is intentionally NOT registered here.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddBlazorBlocksWeb(this IServiceCollection services)
|
||||
{
|
||||
// Per-circuit slot bridging ModelView's save callback into EditModelModal.
|
||||
// Scoped per circuit; see lifetime rationale. TryAddScoped => idempotent.
|
||||
services.TryAddScoped<EditModalSaveContextHolder>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The method should be the single place the maintenance components' library-owned deps are registered, so
|
||||
any future `ModelView` dependency is added here once and every consumer picks it up on the next bump.
|
||||
|
||||
### Lifetime rationale — scoped, not singleton, not transient
|
||||
|
||||
The extension must register the holder as **scoped** (`AddScoped` / `TryAddScoped`):
|
||||
|
||||
- **Why not singleton:** the holder is per-circuit mutable state — `ModelView.EditItem` writes `Current`
|
||||
immediately before opening the dialog and nulls it in `finally`; `EditModelModal` reads it. A singleton
|
||||
would share one slot across all users/circuits and cross-contaminate concurrent edits — a
|
||||
correctness/security bug.
|
||||
- **Why not transient:** a transient would hand `ModelView` and `EditModelModal` *different* instances, so
|
||||
the modal would never see the context the view set. The bridge would silently no-op and edits would fall
|
||||
back to the legacy "close with model" path (the `SaveContext is null` branch in `EditModelModal.Submit`).
|
||||
This is the dangerous failure mode: the page loads fine, but saves quietly take the wrong path.
|
||||
- The type's own XML doc states it is "Scoped per circuit." `AddScoped` in Blazor Server = one instance
|
||||
per circuit, which is exactly the intended semantics.
|
||||
|
||||
Document the scoped requirement in the extension's summary so it is not "tidied" to singleton later.
|
||||
|
||||
---
|
||||
|
||||
## 5. Constraints
|
||||
|
||||
- **Use scoped** for `EditModalSaveContextHolder` — not singleton, not transient (§4).
|
||||
- **Do not change `ModelView` / `EditModelModal` to drop the `required`/`[Inject]`** — the holder is the
|
||||
intentional design (the bridge described in §3.1, and in the type's own doc comment). The fix is to
|
||||
*register* the dependency, not to remove it.
|
||||
- **MudBlazor remains a caller-owned prerequisite.** `AddBlazorBlocksWeb` must **not** call
|
||||
`AddMudServices()` — consumers configure MudBlazor (theme, snackbar options) themselves, and
|
||||
double-registration would clobber their config. Document `AddMudServices` as a prerequisite in the
|
||||
extension's summary; do not absorb it.
|
||||
- **Versioning:** the `Web` package packs/pushes via `C:\Development\BlazorBlocks\pack.ps1`. `Web` is
|
||||
currently `Cerebellum.BlazorBlocks.Web` **10.3.32**; bump to the next version and pack/push. **Record the
|
||||
new version number** — the AuthBlocks team needs it to reference (see §9).
|
||||
|
||||
---
|
||||
|
||||
## 6. Acceptance criteria
|
||||
|
||||
1. The `Web` project exposes a new `IServiceCollection` extension (`AddBlazorBlocksWeb()` or the confirmed
|
||||
house name) that registers `EditModalSaveContextHolder` as **scoped**, with a summary documenting the
|
||||
scoped requirement and the `AddMudServices` prerequisite.
|
||||
2. A product that uses BlazorBlocks `ModelView` (with or without any higher-level library) can register
|
||||
the maintenance deps via a single call to the new extension and gets working behavior.
|
||||
3. Working behavior means not just page load: opening an edit dialog and **saving a valid change
|
||||
succeeds** — i.e. the save bridge actually works (the holder is scoped correctly so `EditModelModal`
|
||||
sees the context `ModelView` set). A page that loads but silently no-ops the save (the transient-lifetime
|
||||
trap in §4) does **not** pass.
|
||||
4. The `Web` package is published as a version bump from 10.3.32, and the new version number is recorded
|
||||
for the AuthBlocks team.
|
||||
|
||||
---
|
||||
|
||||
## 7. Open questions for the implementing team / its sponsor
|
||||
|
||||
1. **Extension method name.** `AddBlazorBlocksWeb()` is proposed for consistency with the existing
|
||||
`AddBlazorBlocksPostgres()`. Confirm, or pick the preferred house name (e.g.
|
||||
`AddBlazorBlocksMaintenance()` if the team expects to split Web concerns further). *Recommendation:
|
||||
`AddBlazorBlocksWeb()`.*
|
||||
2. **Idempotency.** Use `TryAddScoped` (recommended) so a consumer that calls the extension directly *and*
|
||||
via a higher-level library's setup gets a no-op on the second call rather than a duplicate registration.
|
||||
3. **Scope of the extension.** Register only `EditModalSaveContextHolder` now (the only current gap), or
|
||||
pre-emptively make `AddBlazorBlocksWeb()` the home for *all* future maintenance-component deps?
|
||||
*Recommendation: ship it with just the holder now, but frame it (name + doc) as the general home so
|
||||
future deps land in one place — no speculative registrations.*
|
||||
4. **Other unregistered library-owned `[Inject]` deps elsewhere?** This brief scoped the audit to the
|
||||
`Web/Maintenance` tree. If the team wants a clean bill of health, grep the whole `Web` project for
|
||||
`[Inject]` of BlazorBlocks-owned types and fold any others into the same extension. *Recommendation: do
|
||||
the quick full-project grep while you are in here; cheap insurance.*
|
||||
|
||||
---
|
||||
|
||||
## 8. Suggested reading order in the repo
|
||||
|
||||
1. `Web/Maintenance/Entities/EditModalSaveContextHolder.cs` — the unregistered service (and its scoped doc).
|
||||
2. `Web/Maintenance/Entities/ModelView.razor.cs` — `[Inject]` at line 42; `EditItem` (137–172) shows the
|
||||
holder being written/cleared around the dialog.
|
||||
3. `Web/Maintenance/Entities/EditModelModal.razor` — `[Inject]` at line 40; `Submit` shows the holder being
|
||||
read (and the `SaveContext is null` fallback that masks the bug into a wrong-behavior path if the
|
||||
lifetime is botched).
|
||||
4. `Data.Postgres/ServiceCollectionExtensions.cs` — the existing `Add*` convention to mirror.
|
||||
5. `Web/Web.csproj` — package id `Cerebellum.BlazorBlocks.Web`, current version (10.3.32), where to add the
|
||||
new `ServiceCollectionExtensions.cs`.
|
||||
6. `pack.ps1` — the pack/push flow for the version bump.
|
||||
|
||||
---
|
||||
|
||||
## 9. Cross-team ordering (important — you ship first)
|
||||
|
||||
This fix is layered across two repos and **must land in order**:
|
||||
|
||||
1. **BlazorBlocks (this team) ships first.** Add `AddBlazorBlocksWeb()`, bump `Cerebellum.BlazorBlocks.Web`
|
||||
from 10.3.32, pack/push, and **report the new version number**.
|
||||
2. **Then AuthBlocks** bumps its `Cerebellum.BlazorBlocks.Web` reference to the version this team just
|
||||
published and calls `AddBlazorBlocksWeb()` from its own setup entry point.
|
||||
|
||||
The AuthBlocks team **cannot complete its part until this team's new version is published**. This team is
|
||||
not blocked by anyone — just ship the extension, bump the version, and hand off the new version number.
|
||||
You do not need to touch or know anything about AuthBlocks.
|
||||
@@ -0,0 +1,243 @@
|
||||
# Theme / Dark-Mode Remediation — DRY token pass
|
||||
|
||||
Status: proposed. Author: product-designer. Date: 2026-06-19. Implementer: TBD (separate delegation).
|
||||
|
||||
A design analysis of the DeepDrft theme system, focused on the dark theme, with a DRY
|
||||
remediation plan that resolves a punch-list of six reported symptoms through **shared
|
||||
theme tokens** rather than per-component patches. Daniel reported the symptoms; this note
|
||||
maps the architecture, isolates the root causes, and sequences the fix.
|
||||
|
||||
Prior art this borrows from: `product-notes/track-card-theming.md` (landed 2026-06-05) —
|
||||
the same class of problem (theme-aware recolor under `.deepdrft-theme-dark`, legible in
|
||||
both palettes) solved once already with the same mechanism. This note generalizes that
|
||||
fix from one component to the recurring pattern behind it.
|
||||
|
||||
---
|
||||
|
||||
## 1. How the theme system is wired today (the map)
|
||||
|
||||
There are **three** colour layers, and the bugs all live in how the third one bypasses the
|
||||
first two.
|
||||
|
||||
### Layer A — MudBlazor palettes (C#)
|
||||
`DeepDrftShared.Client/Common/DeepDrftPalettes.cs` defines `PaletteLight Light`,
|
||||
`PaletteDark Dark` (+ `CmsLight`, `EmbedLight`, `EmbedDark`). `MainLayout.razor` mounts
|
||||
`<MudThemeProvider Theme="DeepDrftPalettes.Default" IsDarkMode="_isDarkMode" />`. MudBlazor
|
||||
injects these as `--mud-palette-*` CSS variables that **flip automatically** when
|
||||
`IsDarkMode` toggles. This is the part that works: anything reading `--mud-palette-surface`,
|
||||
`--mud-palette-background`, `--mud-palette-text-primary` inverts correctly for free.
|
||||
|
||||
### Layer B — DeepDrft design tokens (CSS)
|
||||
`DeepDrftShared.Client/wwwroot/styles/deepdrft-tokens.css` defines two token families:
|
||||
|
||||
- **Source tokens** — raw brand colours, *constant across both themes*:
|
||||
`--deepdrft-navy (#112338)`, `--deepdrft-white (#FAFAF8)`, `--deepdrft-green-accent
|
||||
(#3D7A68)`, `--deepdrft-soft (#e3e7ec)`, etc. These never change between light and dark.
|
||||
- **Theme-aware aliases** — `--theme-surface`, `--theme-surface-soft`, `--theme-primary…senary`,
|
||||
`--gradient-base/accent/warm/light`, `--deepdrft-surface`, `--deepdrft-background`. These
|
||||
**are** redefined inside the `.deepdrft-theme-dark` block (the wrapper class
|
||||
`MainLayout.ThemeWrapperClass` puts on the root div), so they flip.
|
||||
|
||||
The token file's own header comment establishes the intended discipline: source tokens are
|
||||
"source of truth"; theme-aware aliases are what page CSS is *supposed* to consume so it
|
||||
"resolve[s] coherently across themes."
|
||||
|
||||
### Layer C — component / page CSS
|
||||
Scoped `*.razor.css` files and the global `DeepDrftPublic/wwwroot/styles/deepdrft-styles.css`.
|
||||
**This is where the discipline breaks.** Page sections that should track the theme surface
|
||||
instead reach straight past Layer B and bind to the *constant source tokens* of Layer A
|
||||
(`--deepdrft-white`, `--deepdrft-navy`, `--deepdrft-soft`). A constant cannot invert — so
|
||||
these surfaces stay light-on-navy-site no matter the mode.
|
||||
|
||||
---
|
||||
|
||||
## 2. Root causes (six symptoms → three causes)
|
||||
|
||||
The six reported symptoms collapse to **three** root causes. That collapse is the whole
|
||||
point of doing this as one coherent pass rather than six patches.
|
||||
|
||||
### Cause 1 — "neutral surface" sections bind to constant source tokens, so they never invert
|
||||
*(Symptoms: Home hero-left + footer (#3); About light sections (#4))*
|
||||
|
||||
These rules are the smoking gun (all bind a constant, not a theme alias):
|
||||
|
||||
- `Home.razor.css` — `.hero-left`, `.section`, `.section-divider`, `.section-body p`,
|
||||
`.medium-card`, `.split-right`, `.connect-*` → `background: var(--deepdrft-white)`,
|
||||
text `color: var(--deepdrft-navy)`.
|
||||
- `About.razor.css` — `.hero-left`, `.hero-image-pane`, `.bio`/process gradients →
|
||||
`background: var(--deepdrft-white)`, text on `--deepdrft-navy`.
|
||||
- `DeepDrftFooter.razor.css` — `.deepdrft-footer` → `background: var(--deepdrft-white)`,
|
||||
logo/links text on `--deepdrft-navy` / `--deepdrft-muted`.
|
||||
|
||||
`--deepdrft-white` is `#FAFAF8` in **both** `:root` and `.deepdrft-theme-dark` — it is a
|
||||
brand constant, never re-aliased. So in dark mode these read as bright off-white panels with
|
||||
dark text floating in a navy site. The fix is **not** to hardcode a dark colour; it is to
|
||||
**bind these surfaces to a theme-aware alias** that already inverts.
|
||||
|
||||
**Critical nuance Daniel flagged:** the fix must be *neutral to the existing navy and green
|
||||
accent sections.* The page already has sections that are **intentionally** navy/green in
|
||||
both modes — `.section-dark` (navy), `.split-left` (green), `.cta-banner` (navy), the
|
||||
`ReleaseHeroOverlay` (dark image). Those are decorative-by-design and must **not** be touched
|
||||
by the inversion. Only the "default page surface" sections (the ones currently white-because-
|
||||
light) should flip. This is a *classification* problem first, a recolor second: separate
|
||||
"neutral surface" from "decorative accent" and only re-token the former.
|
||||
|
||||
### Cause 2 — the play-icon chip background binds `--deepdrft-soft` (constant light grey)
|
||||
*(Symptoms: greyed-out play icon on release heroes / track lists (#5); too-bright player-bar play button (#6))*
|
||||
|
||||
`PlayStateIcon.razor.css` `.icon-container` hardcodes `background-color: var(--deepdrft-soft)`
|
||||
(`#e3e7ec` — a light grey, constant across both themes). `PlayStateIcon` is the **single**
|
||||
glyph component used by the release heroes, the Cut track rows, *and* the player bar. So one
|
||||
constant drives all of these:
|
||||
|
||||
- Over a **dark hero image / navy track list** → the light-grey chip reads dull and
|
||||
"greyed-out" (#5). Daniel wants: **moss-green chip background, navy play glyph** in dark mode.
|
||||
- On the **bright player-surface** → the same light-grey chip reads "very bright" against the
|
||||
navy dock (#6). Daniel wants: **same green, much less opaque** (a translucent green wash,
|
||||
not a solid bright fill).
|
||||
|
||||
Both are the same `--deepdrft-soft` constant failing to be theme-aware. One component, one
|
||||
token — fix the token's dark-mode value and both surfaces resolve. Note the two contexts want
|
||||
*different green treatments* (solid green chip on the hero; translucent green wash in the
|
||||
player bar), so the chip background should be a **token the player-bar context can override**,
|
||||
not a single flat value — see §3.
|
||||
|
||||
### Cause 3 — popover surface has no theme-aware token; light mode reads "too dark"
|
||||
*(Symptom: light-theme popover background too dark, wants desaturated navy (#1))*
|
||||
|
||||
Two different popover families exist and they are styled inconsistently:
|
||||
|
||||
- **Bespoke panels** (visualizer controls, queue, privacy) deliberately use
|
||||
`--deepdrft-panel-ground` (`#1a1c22`, a dark charcoal) for their dark-glass chrome. These
|
||||
are *meant* to be dark in both modes — leave them.
|
||||
- **MudBlazor default popovers** (selects, menus, tooltips, the share popover body) inherit
|
||||
`--mud-palette-surface`. In light mode `Surface = #FAFAF8`, but elevation-overlay tinting +
|
||||
the `--deepdrft-panel-ground` charcoal leaking through shared chrome is making them read
|
||||
darker/muddier than intended. Daniel's ask — "a more desaturated navy" — says the *target*
|
||||
isn't pure white; it's a **soft desaturated-navy surface**. There is no token for that today,
|
||||
so each popover improvises.
|
||||
|
||||
The fix is a **dedicated theme-aware popover-surface token** (`--deepdrft-popover-surface`)
|
||||
with a desaturated-navy value in light mode and the existing panel-ground in dark mode, bound
|
||||
once at the MudPopover surface so every default popover picks it up.
|
||||
|
||||
---
|
||||
|
||||
## 3. The DRY remediation — token structure
|
||||
|
||||
The unifying move: **page/component CSS must bind theme-aware aliases, and any surface that
|
||||
must invert gets a named alias in `deepdrft-tokens.css` (defined twice — `:root` + `.deepdrft-theme-dark`).**
|
||||
No surface colour is hardcoded at the component level. This is exactly the Layer-B discipline
|
||||
the token file's header already declares; the work is making the consumers obey it.
|
||||
|
||||
### New / clarified tokens (in `deepdrft-tokens.css`)
|
||||
|
||||
| Token | Light (`:root`) | Dark (`.deepdrft-theme-dark`) | Replaces |
|
||||
|---|---|---|---|
|
||||
| `--deepdrft-page-surface` | `var(--deepdrft-white)` | `var(--deepdrft-navy)` (ground) or `--deepdrft-navy-mid` (elevated) | the literal `--deepdrft-white` on neutral page sections |
|
||||
| `--deepdrft-page-text` | `var(--deepdrft-navy)` | `var(--deepdrft-white)` | the literal `--deepdrft-navy` text on neutral sections |
|
||||
| `--deepdrft-page-text-muted` | `var(--deepdrft-muted)` | `color-mix(... lighter)` | muted body/eyebrow text that must stay legible on dark |
|
||||
| `--deepdrft-play-chip` | `var(--deepdrft-soft)` | `var(--deepdrft-green-accent)` | `.icon-container` background |
|
||||
| `--deepdrft-play-glyph` | (current) | `var(--deepdrft-navy)` | play glyph colour in dark |
|
||||
| `--deepdrft-play-chip-soft` | derived | `color-mix(green-accent ~30%, transparent)` | player-bar translucent variant (#6) |
|
||||
| `--deepdrft-popover-surface` | desaturated navy (e.g. `color-mix(navy 8%, white)`) | `var(--deepdrft-panel-ground)` | MudPopover default surface (#1) |
|
||||
|
||||
Values above are *direction, not final*. Per project memory (decorative-palette contrast
|
||||
targets the actual WCAG threshold for the element type — large text 3:1, pushing toward
|
||||
vibrancy), the implementer should tune the exact mixes on screen; the **structure** is the
|
||||
deliverable here, the hex is theirs to land.
|
||||
|
||||
### Why tokens, not per-component fixes
|
||||
|
||||
- **One source of truth per concept.** "Neutral page surface," "play chip," "popover surface"
|
||||
each become *one* token. A future page that needs a neutral surface binds the token and
|
||||
inverts for free — no new dark-mode rule to remember (the backfill-cliff smell the
|
||||
*design-for-adaptability* memory warns against).
|
||||
- **Neutrality to accents is structural, not vigilance-based.** Because only neutral-surface
|
||||
sections get re-tokened and the decorative navy/green sections keep their explicit brand
|
||||
colours, the inversion *cannot* accidentally flip a section that's meant to stay navy. The
|
||||
classification is encoded in *which token a section binds*, not in a reviewer noticing.
|
||||
- **Player-bar vs. hero divergence is expressible.** Cause 2 needs the same green in two
|
||||
opacities. A `--deepdrft-play-chip` token + a `--deepdrft-play-chip-soft` override the
|
||||
player-bar context sets means one green, two contexts, zero duplication.
|
||||
|
||||
### What stays untouched (the neutrality guardrail)
|
||||
`.section-dark`, `.split-left`, `.cta-banner` (Home + About), `ReleaseHeroOverlay` dark-image
|
||||
chrome, and the bespoke `--deepdrft-panel-ground` panels (visualizer/queue/privacy) keep their
|
||||
explicit brand colours. They are decorative-by-design and already correct in both modes. The
|
||||
remediation must **not** route them through the new neutral-surface tokens.
|
||||
|
||||
---
|
||||
|
||||
## 4. Track / wave breakdown (for clean dispatch)
|
||||
|
||||
Sequenced so the token layer lands first and the component re-pointing fans out behind it.
|
||||
Tracks T2–T4 are parallel once T1 is in.
|
||||
|
||||
### T1 — Token foundation *(cold-start prerequisite)*
|
||||
Add the theme-aware tokens from §3 to `deepdrft-tokens.css` — each defined in **both** `:root`
|
||||
and `.deepdrft-theme-dark`. No component consumes them yet; this is a pure additive token
|
||||
slice. Tune the dark-mode values on screen. **Load-bearing for everything below.**
|
||||
- Scope: `DeepDrftShared.Client/wwwroot/styles/deepdrft-tokens.css` only.
|
||||
- Acceptance: tokens resolve to the right value in each mode (verify via devtools); no visual
|
||||
change yet (nothing binds them).
|
||||
|
||||
### T2 — Neutral-surface inversion *(Cause 1 → symptoms #3, #4)*
|
||||
Re-point the neutral page-surface sections from constant source tokens to `--deepdrft-page-surface`
|
||||
/ `--deepdrft-page-text` / `--deepdrft-page-text-muted`. **Classify first** — only the neutral
|
||||
sections; leave `.section-dark` / `.split-left` / `.cta-banner` / hero-overlay alone.
|
||||
- Scope: `Home.razor.css`, `About.razor.css`, `DeepDrftFooter.razor.css`.
|
||||
- Acceptance: in dark mode the Home hero-left, the medium grid, the footer, and the About
|
||||
light sections render dark-surface/light-text; the navy and green accent sections are
|
||||
visually unchanged; light mode is pixel-identical to today.
|
||||
- Risk: the appbar already has dark-mode handling (`deepdrft-styles.css §5`); confirm the
|
||||
footer/hero changes don't double-invert anything the appbar rules already cover.
|
||||
|
||||
### T3 — Play-chip theming *(Cause 2 → symptoms #5, #6)*
|
||||
Re-point `.icon-container` background from `--deepdrft-soft` to `--deepdrft-play-chip`; set the
|
||||
dark play glyph to `--deepdrft-play-glyph` (navy); in the **player-bar context only**, override
|
||||
the chip to the translucent `--deepdrft-play-chip-soft`.
|
||||
- Scope: `PlayStateIcon.razor.css` (+ a player-bar-scoped override, likely in
|
||||
`AudioPlayerBar.razor.css` or a context class on the bar's `.icon-container`).
|
||||
- Acceptance (dark mode): release-hero + Cut-track-row play chips are **moss-green with a navy
|
||||
glyph**; the player-bar play button is the **same green but markedly less opaque**; light
|
||||
mode unchanged. Confirm hover states still read.
|
||||
- Note: `PlayStateIcon` is shared — verify the chip change is acceptable on **every** mount
|
||||
(heroes, track rows, player bar) and that the player-bar override is the only context-specific
|
||||
divergence.
|
||||
|
||||
### T4 — Popover surface token *(Cause 3 → symptom #1)*
|
||||
Introduce `--deepdrft-popover-surface` and bind MudBlazor's default popover surface to it so
|
||||
light-mode popovers read as soft desaturated-navy rather than the current too-dark muddle.
|
||||
**Do not** touch the bespoke `--deepdrft-panel-ground` panels.
|
||||
- Scope: `deepdrft-styles.css` (a `.mud-popover` / popover-surface rule binding the new token);
|
||||
token already added in T1.
|
||||
- Acceptance: light-mode default popovers (selects/menus/share body) render desaturated-navy;
|
||||
dark-mode popovers unchanged; the visualizer/queue/privacy panels are untouched.
|
||||
- Open question (resolve during T4): confirm whether the "too dark" popover is a MudBlazor
|
||||
elevation-overlay artifact or panel-ground leakage — the fix differs slightly (override the
|
||||
overlay tint vs. set the surface). One devtools inspection settles it; flagged so the
|
||||
implementer checks rather than guesses.
|
||||
|
||||
### Dependency shape
|
||||
`T1 → {T2, T3, T4}`. T1 is the only cold-start item. T2/T3/T4 are independent of each other
|
||||
and can land in any order or in parallel once T1 is in. None of them touch source code, the
|
||||
data layer, or the streaming seam — this is a pure CSS-token pass.
|
||||
|
||||
---
|
||||
|
||||
## 5. Open questions for Daniel
|
||||
|
||||
1. **Dark neutral-surface = ground or elevated?** Should the inverted Home/About/footer
|
||||
surfaces be the navy *ground* (`--deepdrft-navy`, matching the site background — sections
|
||||
dissolve into one continuous dark field) or *elevated* navy-mid (`--deepdrft-navy-mid` —
|
||||
sections read as distinct raised panels)? Recommend **ground** for the footer/hero (continuous
|
||||
field, less busy) and let the medium-cards stay as bordered panels on that ground. This is a
|
||||
taste call; flag for Daniel.
|
||||
2. **Popover target colour (#1).** "Desaturated navy" — how far from white? Recommend a light
|
||||
wash (`color-mix(navy ~8%, white)`) so it stays clearly a light-mode surface, not a dark one.
|
||||
Confirm direction on screen.
|
||||
3. Everything else (exact green opacity for the player-bar chip, exact muted-text mix) is a
|
||||
tune-on-screen detail, not a decision gate.
|
||||
|
||||
These are the only items that change the shape of the work; the rest is mechanical.
|
||||
Reference in New Issue
Block a user