diff --git a/.github/scripts/docs_lint.py b/.github/scripts/docs_lint.py index 8c328f1e0..1fd48c048 100644 --- a/.github/scripts/docs_lint.py +++ b/.github/scripts/docs_lint.py @@ -47,19 +47,6 @@ "architecture (publish_wiki.py/publish_site.py) — not a live " "reference, not a forward-reference to something not yet built" ), - "frontend/src/features/display/useProjectDraftBackup.ts": ( - "docs/proposals/proposal-h-display-layout-spec.md's ADDENDUM D9/F1 — " - "a forward reference to a proposed-but-not-yet-built file (the deck " - "auto-backup hook), explicitly flagged in that same doc's §A2 as " - "needing its own future issue; issue #267's implementation (this " - "repo's most recent /display PR) deliberately did not build it" - ), - "frontend/src/pages/print.tsx": ( - "docs/proposals/proposal-h-display-layout-spec.md's ADDENDUM D10/F5 — " - "a forward reference to a proposed-but-not-yet-built thin route " - "wrapper (the Print-page rehoming), tracked under issue #272 item 3; " - "issue #267's implementation deliberately did not build it" - ), } PATH_EXTENSIONS = ( diff --git a/docs/features/pdf-generator.md b/docs/features/pdf-generator.md index dcbafc84d..519760870 100644 --- a/docs/features/pdf-generator.md +++ b/docs/features/pdf-generator.md @@ -164,8 +164,13 @@ because the two hooks return differently: `await saveToDrive()` already gives the real success/cancelled value directly. -The same hook/component pair is also mounted from `frontend/src/features/ display/DisplayPage.tsx`'s own inline export (Proposal H, item 2) — one -implementation shared by both real export surfaces, not two. See +This used to also be mounted from `DisplayPage.tsx`'s own inline export +(Proposal H, item 2) — issue #275 retired that pipeline entirely (the +memory-heavy Generate PDF/Save-to-Drive operations now live solely here, +reached from `/display`'s Finish footer via a pre-print save gate; see +`docs/proposals/proposal-h-display-layout-spec.md`'s ADDENDUM D9/D10), so +this component's mounts are now: this tab, `PDFGeneratorModal.tsx`, and +`ProjectEditor.tsx` — one implementation, not a forked second copy. See `docs/features/printing-tags.md`'s own entry for the full detail (why `/whatsthat` and not a new route, the `sessionStorage`-backed "never repeats within a session" rule) and `docs/features/print-export-page.md` diff --git a/docs/features/print-export-page.md b/docs/features/print-export-page.md index e516a85ff..827eb370a 100644 --- a/docs/features/print-export-page.md +++ b/docs/features/print-export-page.md @@ -29,14 +29,19 @@ requiring hand-rolled SVG specifically. ## Post-export contribution prompt (issue #166) -The `PDFGenerator.tsx` mounted inside this tab's own "PDF" sub-tab carries the -same post-export contribution prompt the unified `/display` page's inline -export does — a dismissible `Alert` shown once per session after a genuine -"Generate PDF"/"Save PDF to Google Drive" success, linking to `/whatsthat`. -One shared implementation (`frontend/src/features/export/ usePostExportContributionPrompt.ts` + `PostExportContributionPrompt.tsx`), +The `PDFGenerator.tsx` mounted inside this tab's own "PDF" sub-tab carries a +dismissible `Alert` shown once per session after a genuine "Generate +PDF"/"Save PDF to Google Drive" success, linking to `/whatsthat`. One shared +implementation (`frontend/src/features/export/ usePostExportContributionPrompt.ts` + `PostExportContributionPrompt.tsx`), mounted from `PDFGenerator.tsx` itself so every real caller of that -component — this tab, `PDFGeneratorModal.tsx`, `ProjectEditor.tsx` — gets it -for free, rather than wiring it into `FinishedMyProject.tsx` separately. See +component — this tab (reachable both via the classic editor's "Print!" tab +and, since issue #275, standalone at `pages/print.tsx`), `PDFGeneratorModal.tsx`, +`ProjectEditor.tsx` — gets it for free, rather than wiring it into +`FinishedMyProject.tsx` separately. (Issue #275 also retired the unified +`/display` page's OWN separate inline export pipeline and its own mount of +this same prompt — PDF generation now lives solely here, reached from +`/display`'s Finish footer via a pre-print save gate; see +`docs/proposals/proposal-h-display-layout-spec.md`'s ADDENDUM D9/D10.) See `docs/features/printing-tags.md`'s own entry for the full detail (session- scoped `sessionStorage` flag, success-detection mechanism, why it's a funnel entry point rather than a parallel one) and @@ -48,6 +53,9 @@ of the wiring. - `frontend/src/features/export/FinishedMyProject.tsx` - `frontend/src/components/flags.tsx` - `frontend/public/*.svg` (vendored flag icons) +- `frontend/src/pages/print.tsx` (issue #275, D10/F5) — thin standalone route + wrapper mounting `FinishedMyProject` unchanged, mirroring `pages/myDecks.tsx`; + the funnel destination `/display`'s Finish footer navigates to ## Status diff --git a/docs/features/printing-tags.md b/docs/features/printing-tags.md index 378b2f50b..c22fedbf4 100644 --- a/docs/features/printing-tags.md +++ b/docs/features/printing-tags.md @@ -406,12 +406,15 @@ printings, artists, tags, and moderation from one screen. after a genuinely successful PDF export (either "Generate PDF" or "Save PDF to Google Drive"), linking straight to `/whatsthat` via the exact same route `Navbar.tsx`/`HomepagePanel.tsx` already use. Mounted from - both real export surfaces — `frontend/src/features/display/DisplayPage.tsx`'s - own inline export (Proposal H, item 2) and `PDFGenerator.tsx` itself - (so the classic "Print!" tab / `PDFGeneratorModal.tsx` / `ProjectEditor.tsx` - mounts get it too, since they all render the same component) — one + `PDFGenerator.tsx` itself (so the classic "Print!" tab / standalone + `pages/print.tsx` (issue #275) / `PDFGeneratorModal.tsx` / `ProjectEditor.tsx` + mounts all get it, since they render the same component) — one `usePostExportContributionPrompt`/`PostExportContributionPrompt.tsx` - pair (`frontend/src/features/export/`), not two copies. "Never repeats + pair (`frontend/src/features/export/`), not two copies. This used to + ALSO be mounted from `DisplayPage.tsx`'s own inline export (Proposal H, + item 2) — issue #275 retired that pipeline entirely, so PDF generation + (and this prompt) now lives solely on the Print page, reached from + `/display`'s Finish footer via a pre-print save gate. "Never repeats within a session" (this is the "separate post-export contribution toast, task #31" the unified-display-page proposal's own §4.4′ footnote references) — a `sessionStorage` flag set the moment the prompt is diff --git a/docs/proposals/proposal-h-display-layout-spec.md b/docs/proposals/proposal-h-display-layout-spec.md index ff3528516..e536171aa 100644 --- a/docs/proposals/proposal-h-display-layout-spec.md +++ b/docs/proposals/proposal-h-display-layout-spec.md @@ -38,8 +38,26 @@ removed — a soft warning (never a hard clamp) now surfaces when the current bl selected profile's D6-table cap for a 4-column sheet, computed dynamically (`maxBleedForFourColumns`) rather than copying the table's numbers verbatim. At these shipped defaults, /display's sheet now renders the spec's own 4×2 grid (D4) exactly, not the 4×1 this -doc previously reported as the interim state before D5/D6 landed. Deliberately NOT built here -(this PR, and not by #266/#267/#268/#284 above either): D9–D11/D14/D16 (own future issues, per +doc previously reported as the interim state before D5/D6 landed. **D9/D10 shipped (this PR — +issue #275):** the right-rail Finish footer (`FinishFooter.tsx`) now holds `Save Deck` and +`Print / Export →` as co-equal `btn-primary` buttons plus the unchanged `Export ▾`, replacing +the old three-button "Prepare Print" stack; the memory-heavy Generate PDF/Save PDF to Google +Drive operations (and this page's own item-2 inline export pipeline that drove them) are +removed from `/display` outright, not merely hidden — PDF generation now lives solely on the new +`pages/print.tsx` (D10/F5, a thin wrapper mounting the unchanged `FinishedMyProject`, mirroring +`pages/myDecks.tsx`). `useProjectDraftBackup.ts` (F1) mirrors the working project to +`localStorage` (indexes/settings only) on a debounce, offers a restore nudge on the empty-project +landing, and fires D9(2)'s promotion nudge post-import; `PrePrintSaveGate.tsx` (F3) runs the +D9(3) flush-then-optionally-prompt-then-navigate sequence the footer's `Print / Export →` button +triggers. Deliberately NOT built by this PR: the Print page's own tab REORDER (owner order PDF · +MakePlayingCards · NotMPC · PringlePrints, PDF default) and its PDF tab's preview removal (D10's +own owner addendum, both tracked as their own follow-up), and a genuine, out-of-scope gap this +PR leaves documented rather than silently accepted — `/display`'s own Page Setup controls (paper +size/bleed edge/guides, plain component state, never persisted) don't carry over to the Print +page's classic `PDFGenerator`, which has its own separate settings and doesn't read this page's +margin-profile/card-spacing redux slices either; a future issue, not D9/D10's own scope (save- +vs-print ordering and route linkage, not settings portability). Deliberately NOT built here +(this PR, and not by #266/#267/#268/#284 above either): D11/D14/D16 (own future issues, per §A2's own issue mapping). Issue mapping (explicit): @@ -955,12 +973,13 @@ Card spacing control (D19) — **SHIPPED** (this PR, alongside R7/D17/D18): item 3 → D10 Print-page rehoming (F5); item 4 → D11 FinishSettings (F6); item 5 → D12 browse (F9/F10); item 7 → D16 cardback swatch (F7); item 1 → D15 import variety (F13). -- **NEW issue needed** — **D9 finish footer + deck auto-backup** (F1–F4). The - owner named this in #272's comment as polish-round scope but it has no issue - number of its own; it is the one genuinely-new surface here (local-draft - persistence + co-equal Save/Print + pre-print save gate) and should be filed - as its own issue at implementation time. D10's `pages/print.tsx` route - (F5) can ride #272 item 3 or the same new issue. +- **#275 (filed, shipped)** — **D9 finish footer + deck auto-backup** (F1–F4), + plus D10's `pages/print.tsx` route (F5) — the owner named this in #272's + comment as polish-round scope; it was filed as its own issue and shipped + (see this doc's own "Implementation status" line above). The tab + REORDER/PDF-tab-preview-removal half of D10's own owner addendum was + deliberately NOT built by #275 — tracked as its own follow-up, not silently + dropped. ## A3. Conflicts / tensions (new, honest) diff --git a/docs/upstreaming/extractable-primitives.md b/docs/upstreaming/extractable-primitives.md index 9dcebe795..c29b51f17 100644 --- a/docs/upstreaming/extractable-primitives.md +++ b/docs/upstreaming/extractable-primitives.md @@ -87,22 +87,23 @@ coupling to the vote system is. ## Frontend — PDF / export -| Primitive | File(s) | Problem solved | Candidate consumers | Entanglement | License note | -| ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| PDF render core | `frontend/src/features/pdf/useRenderPDF.ts`, `pdfRenderService.ts`, `pdf.worker.ts`, `PDF.tsx` | Off-main-thread PDF generation via a comlink-wrapped Web Worker + `@react-pdf/renderer` document tree | upstream, proxies-at-home | CLEAN | — | -| Eager-WASM lazy-mount fix | `frontend/src/features/pdf/PDFGeneratorModal.tsx`, `frontend/src/features/export/FinishedMyProject.tsx`, `frontend/src/components/ProjectEditor.tsx` | `next/dynamic({ssr:false})` + `mountOnEnter` on the PDF tab so `@react-pdf/renderer`'s WASM doesn't eagerly load (and phantom-download) before the PDF tab is opened | upstream, proxies-at-home | CLEAN (pattern-level — the call sites live in larger entangled files, so this is a pattern to replicate, not a file to lift) | — | -| Page-layout math | `frontend/src/features/pdf/layout.ts` | Pure page/card/bleed/margin/spacing geometry, page-absolute slot rects out; zero imports | upstream, proxies-at-home | CLEAN | — | -| PDF canvas preview | `frontend/src/features/pdf/PDFCanvasPreview.tsx` | pdf.js canvas-based render preview | upstream, proxies-at-home | CLEAN | — | -| Generic concurrency helpers | `frontend/src/common/semaphore.ts`, `frontend/src/common/concurrencyLimit.ts` | Bounded-concurrency gate (`Semaphore`) and a `mapWithConcurrencyLimit` helper | upstream, proxies-at-home | CLEAN | — | -| Paced/retrying image fetch | `frontend/src/features/pdf/pdfImage.ts` | Semaphore-gated, exponential-backoff full-resolution image fetch for PDF export | upstream, proxies-at-home (with their own CDN) | entangled-with-image-cdn-infra | — | -| Bleed-prior vote resolution | `frontend/src/features/pdf/bleedPriorResolution.ts` | Derives a per-card bleed lean from the weighted-vote consensus system | — (fork-only by design) | entangled-with-vote-consensus | — | -| Ordering-service flag icons | `frontend/src/components/flags.tsx` | Small flag-icon wrappers for print-service country badges | upstream, proxies-at-home | CLEAN | SVGs vendored from `lipis/flag-icons` (MIT) | -| Ordering-service link components | `frontend/src/components/MakePlayingCardsLink.tsx`, `NotMPCLink.tsx`, `PringlePrintsLink.tsx` | Thin link-out wrappers around a name+URL constant | upstream, proxies-at-home | CLEAN (constants are product-specific — expected substitution, not entanglement) | — | -| Sheet pagination helper | `frontend/src/features/display/displayPagination.ts` | Chunks project members into per-sheet slot groups for the print-sheet preview | upstream, proxies-at-home | CLEAN | — | -| Responsive-tier `matchMedia` hook | `frontend/src/features/display/useViewportTier.ts` | Resolves the current viewport into one of four named Bootstrap-breakpoint tiers via `matchMedia` listeners, for driving a component's placement/behavior beyond what CSS alone (or react-bootstrap `Offcanvas`'s own single `responsive` breakpoint) can express | upstream, proxies-at-home | CLEAN | — | -| Linked/unlinked axis-pair control | `frontend/src/features/display/CardSpacingControl.tsx` | Horizontal/Vertical numeric inputs with a link/unlink toggle (linked ⇒ one value drives both axes); plain props in/callbacks out, no redux/slice coupling in the component itself | upstream, proxies-at-home | CLEAN | Behavior emulated from an owner-provided screenshot of an AGPL tool — patterns only, no source consulted (see proposal-h-display-layout-spec.md's D19 provenance note) | -| Named-preset margin-profile control | `frontend/src/features/display/MarginProfileControl.tsx` | `Form.Select` of named margin presets + a soft (never hard-clamped) cap warning derived from the current bleed value; plain props in/callback out, no redux/slice coupling in the component itself | upstream, proxies-at-home | CLEAN | — | -| 4-column bleed-cap math | `frontend/src/features/display/marginProfiles.ts` | `maxBleedForFourColumns` — the largest bleed edge a fixed column count can carry given page width/margins/card width/spacing, mirroring `layout.ts`'s own `fitCardsInDimension` boundary inverted for a fixed count; pure data + math, no redux/vote coupling | upstream, proxies-at-home | CLEAN | — | +| Primitive | File(s) | Problem solved | Candidate consumers | Entanglement | License note | +| ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| PDF render core | `frontend/src/features/pdf/useRenderPDF.ts`, `pdfRenderService.ts`, `pdf.worker.ts`, `PDF.tsx` | Off-main-thread PDF generation via a comlink-wrapped Web Worker + `@react-pdf/renderer` document tree | upstream, proxies-at-home | CLEAN | — | +| Eager-WASM lazy-mount fix | `frontend/src/features/pdf/PDFGeneratorModal.tsx`, `frontend/src/features/export/FinishedMyProject.tsx`, `frontend/src/components/ProjectEditor.tsx` | `next/dynamic({ssr:false})` + `mountOnEnter` on the PDF tab so `@react-pdf/renderer`'s WASM doesn't eagerly load (and phantom-download) before the PDF tab is opened | upstream, proxies-at-home | CLEAN (pattern-level — the call sites live in larger entangled files, so this is a pattern to replicate, not a file to lift) | — | +| Page-layout math | `frontend/src/features/pdf/layout.ts` | Pure page/card/bleed/margin/spacing geometry, page-absolute slot rects out; zero imports | upstream, proxies-at-home | CLEAN | — | +| PDF canvas preview | `frontend/src/features/pdf/PDFCanvasPreview.tsx` | pdf.js canvas-based render preview | upstream, proxies-at-home | CLEAN | — | +| Generic concurrency helpers | `frontend/src/common/semaphore.ts`, `frontend/src/common/concurrencyLimit.ts` | Bounded-concurrency gate (`Semaphore`) and a `mapWithConcurrencyLimit` helper | upstream, proxies-at-home | CLEAN | — | +| Paced/retrying image fetch | `frontend/src/features/pdf/pdfImage.ts` | Semaphore-gated, exponential-backoff full-resolution image fetch for PDF export | upstream, proxies-at-home (with their own CDN) | entangled-with-image-cdn-infra | — | +| Bleed-prior vote resolution | `frontend/src/features/pdf/bleedPriorResolution.ts` | Derives a per-card bleed lean from the weighted-vote consensus system | — (fork-only by design) | entangled-with-vote-consensus | — | +| Ordering-service flag icons | `frontend/src/components/flags.tsx` | Small flag-icon wrappers for print-service country badges | upstream, proxies-at-home | CLEAN | SVGs vendored from `lipis/flag-icons` (MIT) | +| Ordering-service link components | `frontend/src/components/MakePlayingCardsLink.tsx`, `NotMPCLink.tsx`, `PringlePrintsLink.tsx` | Thin link-out wrappers around a name+URL constant | upstream, proxies-at-home | CLEAN (constants are product-specific — expected substitution, not entanglement) | — | +| Sheet pagination helper | `frontend/src/features/display/displayPagination.ts` | Chunks project members into per-sheet slot groups for the print-sheet preview | upstream, proxies-at-home | CLEAN | — | +| Responsive-tier `matchMedia` hook | `frontend/src/features/display/useViewportTier.ts` | Resolves the current viewport into one of four named Bootstrap-breakpoint tiers via `matchMedia` listeners, for driving a component's placement/behavior beyond what CSS alone (or react-bootstrap `Offcanvas`'s own single `responsive` breakpoint) can express | upstream, proxies-at-home | CLEAN | — | +| Linked/unlinked axis-pair control | `frontend/src/features/display/CardSpacingControl.tsx` | Horizontal/Vertical numeric inputs with a link/unlink toggle (linked ⇒ one value drives both axes); plain props in/callbacks out, no redux/slice coupling in the component itself | upstream, proxies-at-home | CLEAN | Behavior emulated from an owner-provided screenshot of an AGPL tool — patterns only, no source consulted (see proposal-h-display-layout-spec.md's D19 provenance note) | +| Named-preset margin-profile control | `frontend/src/features/display/MarginProfileControl.tsx` | `Form.Select` of named margin presets + a soft (never hard-clamped) cap warning derived from the current bleed value; plain props in/callback out, no redux/slice coupling in the component itself | upstream, proxies-at-home | CLEAN | — | +| 4-column bleed-cap math | `frontend/src/features/display/marginProfiles.ts` | `maxBleedForFourColumns` — the largest bleed edge a fixed column count can carry given page width/margins/card width/spacing, mirroring `layout.ts`'s own `fitCardsInDimension` boundary inverted for a fixed count; pure data + math, no redux/vote coupling | upstream, proxies-at-home | CLEAN | — | +| Local-only project draft auto-backup | `frontend/src/features/display/useProjectDraftBackup.ts` | Debounced `localStorage` mirror of the working project (indexes/settings only, never image pixels) with a restore-on-return nudge; reuses `deckPayload.ts`'s plaintext `buildDeckPayload`/`projectFromDeckPayload` (not its encryption path) — no crypto session, no server round-trip, no auth query anywhere in the file | upstream, proxies-at-home | CLEAN | — | ## Backend diff --git a/frontend/src/features/display/DisplayPage.tsx b/frontend/src/features/display/DisplayPage.tsx index e23a45948..4b31bc50d 100644 --- a/frontend/src/features/display/DisplayPage.tsx +++ b/frontend/src/features/display/DisplayPage.tsx @@ -48,10 +48,13 @@ * * Issue #166 (post-export contribution prompt) - after a genuinely successful "Generate PDF" or * "Save PDF to Google Drive", a dismissible prompt points the user at the existing "What's That - * Card?" vote-queue funnel (docs/features/printing-tags.md). See - * features/export/postExportContributionPrompt.ts + usePostExportContributionPrompt.ts for the - * success-detection and show-once-per-session logic - the same hook/component pair is also - * mounted from PDFGenerator.tsx itself, so the classic "Print!" tab gets it too. + * Card?" vote-queue funnel (docs/features/printing-tags.md), via + * features/export/postExportContributionPrompt.ts + usePostExportContributionPrompt.ts's + * success-detection and show-once-per-session logic. Originally mounted from BOTH this page's + * own inline export (item 2, below) and PDFGenerator.tsx itself; issue #275 removed this page's + * inline export entirely (see that issue's own module comment further down), so PDFGenerator.tsx + * - now the sole place PDF generation happens, reached via the Print page (D10, pages/print.tsx) + * - is this feature's only remaining mount. * * Issue #238 (deck-input landing, design doc §4.1) - the `isProjectEmpty` early return used to * render only a plain "head to /editor" link, meaning this page could never start a project @@ -77,9 +80,8 @@ * three toolbar-parity findings from the same audit. DisplayExportMenu.tsx composes the same * unchanged ExportXML/ExportImages/ExportDecklist Dropdown.Items Export.tsx already mounts on the * classic editor's own "Download" dropdown - same hooks, same gating selectors. ExportPDF.tsx's - * own item is deliberately excluded, since this page's Generate PDF button already reuses - * useDownloadPDF directly rather than opening the classic PDFGenerator modal that item dispatches - * to (see the inline-export region comment below). + * own item is deliberately excluded, since PDF generation itself lives on the Print page (D10, + * pages/print.tsx) now, not this one - see issue #275's own module comment further down. * * Issue #266 (mobile responsive shell - docs/proposals' /display layout spec, owner-approved * 2026-07-21, §2/§4/§6 rows R1/R2/R4/R5/R6) replaced the single always-rendered `RailWrapper` @@ -158,6 +160,37 @@ * empty shell) for an anonymous session or one with zero saved rows ever created; see * SavedDecksLandingPanel.tsx's own module comment for the full two-tier visibility rationale, * including why the locked-session unlock prompt is deliberately NOT suppressed here. + * + * Issue #275 (proposal-h-display-layout-spec.md ADDENDUM D9/D10) replaces the old "Prepare Print + * footer" three-button stack (Export ▾/Save PDF to Google Drive/Generate PDF) with the new + * FinishFooter.tsx: `Save Deck` and `Print / Export →` as CO-EQUAL `btn-primary` buttons, plus + * the same unchanged `Export ▾` below them. Per D9's own hard owner constraint ("save deck + * should come before PDF completes because we have to rely on clients available mem for the + * PDF"), the memory-heavy Generate PDF / Save PDF to Google Drive operations - and this page's + * entire item-2 inline export pipeline that drove them (useDownloadPDF/useSaveToDrivePDF/ + * ImageFailureConfirmModal/the fetch-progress bar/the post-export contribution prompt) - are + * REMOVED from this page outright, not merely hidden: the Print page (D10, `pages/print.tsx`) + * now owns PDF generation exclusively, via its own unchanged `FinishedMyProject`/`PDFGenerator`, + * which already mounts `PostExportContributionPrompt` itself (so the /whatsthat funnel still + * fires there, for free - no regression). `useProjectDraftBackup.ts` (F1) mirrors the working + * project to `localStorage` (indexes/settings only, governing premise "we index, we do not + * store images") on a debounce, offers a restore nudge on `DeckInputLanding` when a prior + * session's draft outlives an emptied project, and fires D9(2)'s promotion nudge post-import; + * `PrePrintSaveGate.tsx` (F3) runs the D9(3) flush-then-optionally-prompt-then-navigate sequence + * the Finish footer's "Print / Export →" button triggers, landing on the new `pages/print.tsx` + * (D10/F5) - a thin wrapper mirroring `pages/myDecks.tsx`'s own `MyDecksPage` pattern, + * `FinishedMyProject.tsx` itself UNCHANGED. Deliberately NOT built here (D10's own owner + * addendum, tracked as its own follow-up): the Print page's tab REORDER/new PDF default, and the + * PDF tab's own preview removal - see `pages/print.tsx`'s own module comment. + * + * Known, deliberately-out-of-scope gap this leaves (documented, not silently accepted): this + * page's own Page Setup controls (paper size/bleed edge/guides - plain `DisplaySheetSettings` + * component state, never persisted) don't carry over to the Print page's classic `PDFGenerator`, + * which has always had its own separate settings and doesn't read this page's margin-profile/ + * card-spacing redux slices either. A user who configures those here and then prints lands on a + * PDFGenerator with its own unrelated defaults - a genuine settings-parity gap, out of scope for + * this issue (D9/D10 resolve the SAVE-vs-PRINT ordering and the route linkage, not settings + * portability), left for a future issue. */ import styled from "@emotion/styled"; import React, { @@ -172,7 +205,6 @@ import Button from "react-bootstrap/Button"; import Col from "react-bootstrap/Col"; import Form from "react-bootstrap/Form"; import Offcanvas, { OffcanvasPlacement } from "react-bootstrap/Offcanvas"; -import ProgressBar from "react-bootstrap/ProgressBar"; import Row from "react-bootstrap/Row"; import ToggleButton from "react-bootstrap/ToggleButton"; import ToggleButtonGroup from "react-bootstrap/ToggleButtonGroup"; @@ -189,30 +221,29 @@ import { import { AutofillCollapse } from "@/components/AutofillCollapse"; import { RightPaddedIcon } from "@/components/icon"; import { RenderIfVisible } from "@/components/RenderIfVisible"; -import { Spinner } from "@/components/Spinner"; import { CardbackToolbarButton } from "@/features/card/CommonCardback"; import { DeckbuilderConfirmAffordance } from "@/features/card/DeckbuilderConfirmAffordance"; import { RequestedPrintingBadge } from "@/features/card/RequestedPrintingBadge"; -import { useClientSearchContext } from "@/features/clientSearch/clientSearchContext"; import { ArtistSection } from "@/features/display/ArtistSection"; import { AttributesSection } from "@/features/display/AttributesSection"; import { CardSpacingControl } from "@/features/display/CardSpacingControl"; import { CatalogBrowseResults } from "@/features/display/CatalogBrowseResults"; import { paginateSlotsForDisplay } from "@/features/display/displayPagination"; +import { FinishFooter } from "@/features/display/FinishFooter"; import { MarginProfileControl } from "@/features/display/MarginProfileControl"; import { MARGIN_PROFILES } from "@/features/display/marginProfiles"; +import { usePrePrintSaveGate } from "@/features/display/PrePrintSaveGate"; import { PrintOptionsSection } from "@/features/display/PrintOptionsSection"; import { SavedDecksLandingPanel, useHasSavedDecksForLanding, } from "@/features/display/SavedDecksLandingPanel"; import { SlotActionsSection } from "@/features/display/SlotActionsSection"; +import { + ProjectDraftSummary, + useProjectDraftBackup, +} from "@/features/display/useProjectDraftBackup"; import { useViewportTier } from "@/features/display/useViewportTier"; -import { DisplayExportMenu } from "@/features/export/DisplayExportMenu"; -import { PostExportContributionPrompt } from "@/features/export/PostExportContributionPrompt"; -import { wasLatestCardsPdfDownloadSuccessful } from "@/features/export/postExportContributionPrompt"; -import { usePostExportContributionPrompt } from "@/features/export/usePostExportContributionPrompt"; -import { isGoogleDriveAppConfigured } from "@/features/googleDrive/googleDriveConfig"; import { SelectVersionResults } from "@/features/gridSelector/SelectVersionResults"; import { useGridSelectorSearch } from "@/features/gridSelector/useGridSelectorSearch"; import { Import } from "@/features/import/Import"; @@ -227,13 +258,7 @@ import { PagePreview, PagePreviewSlotContent, } from "@/features/pdf/PagePreview"; -import { getPageSizeMM, PageSize, PDFProps } from "@/features/pdf/PDF"; -import { - ImageFailureConfirmModal, - useDownloadPDF, - useSaveToDrivePDF, -} from "@/features/pdf/PDFGenerator"; -import { ImageFetchFailure } from "@/features/pdf/pdfImage"; +import { getPageSizeMM, PageSize } from "@/features/pdf/PDF"; import { SavedDeckPanel } from "@/features/savedDecks/SavedDeckPanel"; import { SearchSettings } from "@/features/searchSettings/SearchSettings"; import { selectRemoteBackendURL } from "@/store/slices/backendSlice"; @@ -249,7 +274,6 @@ import { } from "@/store/slices/marginProfileSlice"; import { selectIsProjectEmpty, - selectManualOverrides, selectProjectCardback, selectProjectMember, selectProjectMembers, @@ -572,10 +596,53 @@ const ImportColumns = () => ( ); -const DeckInputLanding = () => { +interface DeckInputLandingProps { + // Issue #275 (design doc ADDENDUM D9(1)/F1) - useProjectDraftBackup's own restore nudge: + // non-null only when a prior session's draft outlives this one's now-empty project. Passed in + // rather than a second hook instance here, since DisplayPage already owns the one instance + // actually driving the debounced writes (see this file's own module comment). + restorableDraft: ProjectDraftSummary | null; + onRestoreDraft: () => void; + onDismissDraft: () => void; +} + +const DeckInputLanding = ({ + restorableDraft, + onRestoreDraft, + onDismissDraft, +}: DeckInputLandingProps) => { const hasSavedDecks = useHasSavedDecksForLanding(); return (
+ {restorableDraft != null && ( +
+ + Restore your unsaved work? A local backup of{" "} + {restorableDraft.memberCount} card + {restorableDraft.memberCount !== 1 ? "s" : ""} from this browser is + still here. + + + +
+ )} {hasSavedDecks ? ( @@ -873,6 +940,17 @@ export function DisplayPage() { const frontsVisible = useAppSelector(selectFrontsVisible); const cardDocumentsByIdentifier = useCardDocumentsByIdentifier(); + // Issue #275 (design doc ADDENDUM D9) - the silent local draft auto-backup (F1) and the + // pre-print save gate (F3). ONE instance of each, here - both FinishFooter and + // DeckInputLanding below are handed the relevant pieces as props rather than each mounting + // their own hook instance (see useProjectDraftBackup.ts's own module comment on why a second + // instance would duplicate the debounced-write effect). + const draftBackup = useProjectDraftBackup(); + const prePrintSaveGate = usePrePrintSaveGate({ + flushDraftNow: draftBackup.flushDraftNow, + notifyPromoteDraftPrePrint: draftBackup.notifyPromoteDraftPrePrint, + }); + const [settings, setSettings] = useState( DEFAULT_SHEET_SETTINGS ); @@ -999,142 +1077,10 @@ export function DisplayPage() { // the on-screen sheet and the exported PDF in lockstep with no extra plumbing. const spacing = useAppSelector(selectCardSpacing); - //# region inline export (item 2, owner's hands-on review) - the real export pipeline, run - // in-page rather than navigating to the classic PDF tab. Reuses PDFGenerator.tsx's own - // useDownloadPDF/useSaveToDrivePDF/ImageFailureConfirmModal verbatim (exported for this, not - // forked) - same #81 paced-fetcher/retry machinery, same in-app failure-confirm modal, same - // Google Drive upload path. Only this page's own settings feed it (paper size, bleed edge, - // guides, the sheet's current fronts/backs view) - every other PDFProps field neither exposed - // here nor meaningful for this page's default "export what you see" use case (card selection - // mode, cut-line geometry, quality/DPI, spacing/margins, SCM mode) takes PDFGenerator's own - // documented default, matching its classic-tab behavior exactly for anyone who hasn't touched - // those settings there either. - - const { clientSearchService } = useClientSearchContext(); + // Still needed below (ChooseImageSection's own backendURL prop) - issue #275 removed every + // OTHER consumer this used to have (the inline export pipeline, see this file's own module + // comment for where that pipeline moved). const backendURL = useAppSelector(selectRemoteBackendURL); - const manualOverrides = useAppSelector(selectManualOverrides); - - const [isDownloading, setIsDownloading] = useState(false); - const [isSavingToDrive, setIsSavingToDrive] = useState(false); - const [imageFetchProgress, setImageFetchProgress] = useState<{ - completed: number; - total: number; - } | null>(null); - // Determinate while images are still being fetched (a real count, "N of ~M"); indeterminate - // once every image has resolved but @react-pdf/renderer is still assembling the file itself - - // pdfRenderService/pdf.worker.ts only report per-image progress, so "assembling" is inferred - // the moment completed reaches total, not a separate signal the worker sends. - const [exportPhase, setExportPhase] = useState< - "fetching" | "assembling" | null - >(null); - const [pendingFailureConfirm, setPendingFailureConfirm] = useState<{ - failures: Array; - resolve: (value: boolean) => void; - } | null>(null); - const confirmDespiteFailures = ( - failures: Array - ): Promise => - new Promise((resolve) => setPendingFailureConfirm({ failures, resolve })); - - const setExportProgress = ( - progress: { completed: number; total: number } | null - ) => { - setImageFetchProgress(progress); - if (progress == null) { - setExportPhase(null); - } else { - setExportPhase( - progress.total > 0 && progress.completed >= progress.total - ? "assembling" - : "fetching" - ); - } - }; - - // CUSTOM + explicit width/height, not the named pageSize alone - PDF.tsx's getPageSizeMM only - // honours pageWidth/pageHeight when pageSize is "CUSTOM" (otherwise it returns that name's own - // portrait dimensions), so this is what makes the exported file match this page's own - // landscape sheet (sheetWidthMM/sheetHeightMM, computed just below) rather than silently - // reverting to portrait. - const exportPdfProps: Omit = { - cardSelectionMode: "frontsAndDistinctBacks", - pageSize: "CUSTOM", - pageWidth: sheetWidthMM, - pageHeight: sheetHeightMM, - bleedEdgeMM: settings.bleedEdgeMM, - roundCorners: false, - drawCardCutLines: settings.showCutLines, - drawPageCutLines: true, - cutLineLengthMM: 2, - cutLineOffsetMM: 0, - cutLineThicknessMM: 0.2, - cutLineColor: "#FF0000", - cutLinePlacement: "Inside", - cutLineShape: "InsideOnly", - cardSpacingRowMM: spacing.row, - cardSpacingColMM: spacing.col, - pageMarginTopMM: margins.top, - pageMarginBottomMM: margins.bottom, - pageMarginLeftMM: margins.left, - pageMarginRightMM: margins.right, - cardDocumentsByIdentifier: cardDocumentsByIdentifier, - projectMembers: projectMembers, - projectCardback: projectCardback, - bleedOverrides: manualOverrides, - scmMode: false, - scmPaperSize: "letter", - scmVariant: "default", - scmRegistration: 3, - scmDuplex: true, - scmOffsetXMM: 0, - scmOffsetYMM: 0, - scmOffsetAngleDeg: 0, - imageQuality: "full-resolution", - imageDPI: 600, - jpgQuality: 100, - }; - - const generatePdf = useDownloadPDF( - exportPdfProps, - clientSearchService, - dispatch, - setIsDownloading, - backendURL, - setExportProgress, - confirmDespiteFailures - ); - const saveToDrive = useSaveToDrivePDF( - exportPdfProps, - clientSearchService, - dispatch, - setIsSavingToDrive, - backendURL, - setExportProgress, - confirmDespiteFailures - ); - - // Issue #166 - post-export contribution prompt. useDownloadPDF's returned promise resolves - // void (its own useDoFileDownload wrapper swallows the inner success boolean - see - // postExportContributionPrompt.ts's own comment), so success is read back out of the same - // fileDownloads redux slice the download manager UI already populates, once this exact click's - // download has finished. useSaveToDrivePDF has no such wrapper - .finally() passes its - // .then()'s resolved boolean straight through, so awaiting saveToDrive() directly already - // gives the real success/cancelled value. - const contributionPrompt = usePostExportContributionPrompt(); - const onGeneratePdfClick = async () => { - await generatePdf(); - if (wasLatestCardsPdfDownloadSuccessful()) { - contributionPrompt.notifyExportSucceeded(); - } - }; - const onSaveToDriveClick = async () => { - const succeeded = await saveToDrive(); - if (succeeded === true) { - contributionPrompt.notifyExportSucceeded(); - } - }; - - //# endregion const layout = useMemo( // Mirrors PagePreview's own computeLayout() call so cardsPerPage here matches exactly @@ -1268,36 +1214,15 @@ export function DisplayPage() { ); if (isProjectEmpty) { - return ; + return ( + + ); } - // Issue #266 (design doc §2/§4/§6 R4) - the export fetch-progress bar, extracted so both its - // old top-of-page placement's markup and its new right-rail-footer placement below render the - // exact same element rather than two hand-copied blocks that could drift. - const exportProgressBar = exportPhase != null && ( -
- {exportPhase === "fetching" && imageFetchProgress != null ? ( - 0 - ? (imageFetchProgress.completed / imageFetchProgress.total) * 100 - : 0 - } - label={`Fetching images: ${imageFetchProgress.completed} of ~${imageFetchProgress.total}`} - data-testid="display-export-progress-bar" - /> - ) : ( - - )} -
- ); - return (
{/* Issue #266 (design doc §3/§6 R4) - identity + the gear that opens the right rail; the @@ -1408,22 +1333,6 @@ export function DisplayPage() {
- {/* Issue #166 - shown once per session, immediately after this button's first genuine - export success (see usePostExportContributionPrompt.ts) - never blocks the export - result above it, dismissible, and never re-fires again this session per its own - show-once logic. */} - {contributionPrompt.visible && ( -
- -
- )} - {/* Issue #266 (design doc §4.1) - ONE node, all widths: inline sticky 380px column at `lg`+, `placement="start"` drawer on tablet, `placement="bottom"` 72vh sheet on phone @@ -1753,36 +1662,15 @@ export function DisplayPage() {
{/* Design doc §4.2's "Prepare Print footer - pinned, always visible at the rail's - bottom (flex column: body scrolls, footer doesn't)". */} -
- {exportProgressBar} - {/* Issue #241 (design doc §5's export-beyond-PDF row) - XML/Card Images/Decklist, - relocated unmodified from the classic editor's own "Download" dropdown. */} - - {isGoogleDriveAppConfigured() && ( - - )} - + bottom (flex column: body scrolls, footer doesn't)". Issue #275 (ADDENDUM D9/F2) + replaces the old three-button stack with FinishFooter's own co-equal Save + Deck/Print - Export pair + the unchanged Export dropdown - see this file's own + module comment for the full rationale. */} +
+
@@ -1800,17 +1688,7 @@ export function DisplayPage() { Card details - { - pendingFailureConfirm?.resolve(false); - setPendingFailureConfirm(null); - }} - onContinue={() => { - pendingFailureConfirm?.resolve(true); - setPendingFailureConfirm(null); - }} - /> + {prePrintSaveGate.element}
); } diff --git a/frontend/src/features/display/FinishFooter.tsx b/frontend/src/features/display/FinishFooter.tsx new file mode 100644 index 000000000..164b87876 --- /dev/null +++ b/frontend/src/features/display/FinishFooter.tsx @@ -0,0 +1,116 @@ +/** + * Proposal H ADDENDUM D9/F2 (docs/proposals/proposal-h-display-layout-spec.md, issue #275) - the + * right-rail pinned Finish footer: `Save Deck` and `Print / Export →` as CO-EQUAL `btn-primary` + * buttons of equal width side by side, a secondary `Export ▾` (`DisplayExportMenu.tsx`, + * lightweight XML/Card Images/Decklist only) below them, and the compact "✓ Draft backed up + * locally" note. Replaces the old three-button "Prepare Print footer" stack (Export ▾/Save PDF + * to Google Drive/Generate PDF) - the memory-heavy Generate PDF and Save PDF to Google Drive + * operations move OUT of this footer entirely, to the Print page (D10/pages/print.tsx), so this + * footer itself can never trigger the OOM D9's own hard constraint warns about ("save deck + * should come before PDF completes because we have to rely on clients available mem for the + * PDF"). + * + * `Save Deck` reuses useSaveDeckFlow.ts's own passphrase-setup/unlock/save modal chain (the same + * one SavedDeckPanel.tsx's toolbar Save button already drives) - this component owns its OWN + * hook instance (a second, independent one from the toolbar's), so both buttons work + * independently and neither can leave the other's modal state stuck open. + * + * Anonymous sessions: D9(2)'s "anonymous users' nudge routes through sign-in first" (server save + * is authenticated-only by construction) applies here too, not just to the promotion toast - the + * button becomes a direct sign-in link (the same backendURL+loginUrl+`?next=` construction + * AuthWidget.tsx already uses) labeled "Sign in to Save" rather than a disabled/dead control, so + * an anonymous user always has somewhere to go from this footer, never a no-op button. + */ +import React, { useEffect, useState } from "react"; +import Button from "react-bootstrap/Button"; + +import { useAppSelector } from "@/common/types"; +import { DisplayExportMenu } from "@/features/export/DisplayExportMenu"; +import { useSaveDeckFlow } from "@/features/savedDecks/useSaveDeckFlow"; +import { useGetWhoamiQuery } from "@/store/api"; +import { selectRemoteBackendURL } from "@/store/slices/backendSlice"; + +interface FinishFooterProps { + /** useProjectDraftBackup's own `hasBackedUpThisSession` - drives the compact note below the + * buttons. Passed in rather than re-instantiated here, since DisplayPage already owns the one + * hook instance actually driving the debounced writes. */ + hasBackedUpThisSession: boolean; + /** usePrePrintSaveGate's own `startPrintFlow` - runs the D9(3) persist-before-navigate sequence + * before any PDF render begins. */ + onPrintClick: () => void; +} + +export function FinishFooter({ + hasBackedUpThisSession, + onPrintClick, +}: FinishFooterProps) { + const { element, triggerSave, isAuthenticated, isProjectEmpty } = + useSaveDeckFlow(); + const backendURL = useAppSelector(selectRemoteBackendURL); + const whoami = useGetWhoamiQuery(); + + // window isn't available during the static export build - resolved client-only, mirroring + // AuthWidget.tsx's own identical pattern for the exact same `?next=` round-trip. + const [currentHref, setCurrentHref] = useState(null); + useEffect(() => { + setCurrentHref(window.location.href); + }, []); + + const loginHref = + backendURL != null && whoami.data?.loginUrl != null && currentHref != null + ? `${backendURL}${whoami.data.loginUrl}?next=${encodeURIComponent( + currentHref + )}` + : undefined; + + return ( +
+
+ {isAuthenticated ? ( + + ) : ( + + )} + +
+ {/* Issue #241 (design doc §5's export-beyond-PDF row) - XML/Card Images/Decklist, unchanged + and unforked; the ONLY export surface this footer still owns directly, per D9's own + "memory-heavy operations move OUT" line. */} + + {hasBackedUpThisSession && ( +
+ + Draft backed up locally +
+ )} + {element} +
+ ); +} diff --git a/frontend/src/features/display/PrePrintSaveGate.tsx b/frontend/src/features/display/PrePrintSaveGate.tsx new file mode 100644 index 000000000..d8d550c45 --- /dev/null +++ b/frontend/src/features/display/PrePrintSaveGate.tsx @@ -0,0 +1,140 @@ +/** + * Proposal H ADDENDUM D9(3)/F3 (docs/proposals/proposal-h-display-layout-spec.md, issue #275) - + * the pre-print save gate. Pressing the Finish footer's "Print / Export →" runs a persist step + * FIRST, before any navigation (and therefore any PDF render) begins: + * (a) flush the local draft synchronously (useProjectDraftBackup's `flushDraftNow`) - never + * debounced, so the crash/OOM safety net is guaranteed current the instant before whatever + * happens next; + * (b) if authenticated AND the project is dirty (savedDeckSessionSlice's own dirty-check, + * selectIsCurrentProjectDirty), show a lightweight "Save before printing?" prompt - Save + * (opens useSaveDeckFlow.ts's own passphrase-setup/unlock/save chain, the same one the + * Finish footer's own Save Deck button and SavedDeckPanel's toolbar Save button use) or + * Skip - mirroring LoadSafetyModal.tsx's existing "always take a safety copy before a + * destructive step" pattern, here applied to the PDF-render step instead of a deck-load + * step; + * (c) only after persistence resolves (Save completes, or Skip/no-save-needed) does + * client-side navigation to the Print page (D10, pages/print.tsx) begin. + * + * "Saving gates PDF; PDF never gates saving" (D9's own summary line) - this hook never blocks on + * anything PDF-related, only on the save choice itself, and an anonymous or clean (non-dirty) + * session skips the prompt entirely and navigates immediately - the gate only ever appears when + * there is genuinely something unsaved to decide about. + * + * Dismissing the prompt (close button/Escape/backdrop) is treated as cancelling the WHOLE print + * attempt, not as an implicit Skip - the user stays on /display with nothing navigated and + * nothing saved, which is the safer default for a modal that isn't itself a forced, + * no-cancel-option safety net (unlike LoadSafetyModal, which never offers a plain dismiss). + */ +import { useRouter } from "next/router"; +import React, { useState } from "react"; +import Button from "react-bootstrap/Button"; +import Modal from "react-bootstrap/Modal"; + +import { useAppSelector } from "@/common/types"; +import { selectIsCurrentProjectDirty } from "@/features/savedDecks/selectors"; +import { useSaveDeckFlow } from "@/features/savedDecks/useSaveDeckFlow"; +import { useGetWhoamiQuery } from "@/store/api"; + +/** The Print page's own route (D10/F5 - pages/print.tsx, a thin wrapper mounting + * FinishedMyProject/PrintPanel). */ +const PRINT_PAGE_ROUTE = "/print"; + +export interface UsePrePrintSaveGateOptions { + /** useProjectDraftBackup's own `flushDraftNow` - D9(3)a, always run first. */ + flushDraftNow: () => void; + /** useProjectDraftBackup's own `notifyPromoteDraftPrePrint` - D9(2)'s promotion nudge, + * pre-print half, fired alongside the flush. */ + notifyPromoteDraftPrePrint: () => void; +} + +export interface UsePrePrintSaveGateResult { + /** Render this once - the "Save before printing?" prompt plus whatever useSaveDeckFlow.ts's + * own modal chain needs, all in one place. */ + element: React.ReactElement; + /** The Finish footer's "Print / Export →" `onClick` - runs the full D9(3) sequence. */ + startPrintFlow: () => void; +} + +export function usePrePrintSaveGate({ + flushDraftNow, + notifyPromoteDraftPrePrint, +}: UsePrePrintSaveGateOptions): UsePrePrintSaveGateResult { + const router = useRouter(); + const whoami = useGetWhoamiQuery(); + const isAuthenticated = whoami.data?.authenticated === true; + const isProjectDirty = useAppSelector(selectIsCurrentProjectDirty); + const saveFlow = useSaveDeckFlow(); + + const [showPrompt, setShowPrompt] = useState(false); + + const proceedToPrint = () => { + router.push(PRINT_PAGE_ROUTE); + }; + + const startPrintFlow = () => { + // D9(3)a - flush first, unconditionally, before any branch below. D9(2)'s pre-print + // promotion nudge rides the same moment. + flushDraftNow(); + notifyPromoteDraftPrePrint(); + + if (isAuthenticated && isProjectDirty) { + setShowPrompt(true); + } else { + // Nothing dirty to offer saving (or no account to save to at all) - navigate immediately. + // "PDF never gates saving" cuts both ways: saving never gates a print attempt that has + // nothing new to save either. + proceedToPrint(); + } + }; + + const handleSave = () => { + setShowPrompt(false); + saveFlow.triggerSave(proceedToPrint); + }; + + const handleSkip = () => { + setShowPrompt(false); + proceedToPrint(); + }; + + const element = ( + <> + setShowPrompt(false)} + data-testid="pre-print-save-gate-modal" + > + + Save before printing? + + +

+ You have unsaved changes. Printing can use a lot of memory, so + it's safest to save your deck first - your local draft is + already backed up, but a real saved deck can be reached from any + device. +

+
+ + + + +
+ {saveFlow.element} + + ); + + return { element, startPrintFlow }; +} diff --git a/frontend/src/features/display/useProjectDraftBackup.test.tsx b/frontend/src/features/display/useProjectDraftBackup.test.tsx new file mode 100644 index 000000000..cf1c62402 --- /dev/null +++ b/frontend/src/features/display/useProjectDraftBackup.test.tsx @@ -0,0 +1,210 @@ +/** + * Proposal H ADDENDUM D9(1)/F1 - useProjectDraftBackup.ts's own test suite. Exercises the hook + * through a real component tree (Harness) against a real redux store (setupStore), mirroring + * SavedDeckPanel.test.tsx/useConsentToast.test.tsx's own precedent for testing a stateful hook + * this way rather than a bare renderHook call - restoreDraft/dismissRestoreDraft both need to be + * driven by real clicks against the hook's own returned callbacks. + */ +import { + act, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import React from "react"; +import { Provider } from "react-redux"; + +import { localBackend, projectSelectedImage1 } from "@/common/test-constants"; +import { + clearStoredProjectDraftForTests, + useProjectDraftBackup, +} from "@/features/display/useProjectDraftBackup"; +import { loadProject } from "@/store/slices/projectSlice"; +import { selectToastsNotifications } from "@/store/slices/toastsSlice"; +import { setupStore } from "@/store/store"; + +function Harness() { + const backup = useProjectDraftBackup(); + return ( +
+ + {String(backup.hasBackedUpThisSession)} + + + {backup.restorableDraft != null + ? JSON.stringify(backup.restorableDraft) + : "none"} + + + + + +
+ ); +} + +function renderHarness(preloadedState: Parameters[0]) { + const store = setupStore(preloadedState); + render( + + + + ); + return store; +} + +const DRAFT_STORAGE_KEY = "mpc-autofill-project-draft"; + +afterEach(() => { + clearStoredProjectDraftForTests(); +}); + +test("does not write a draft while the project is empty", () => { + renderHarness({ backend: localBackend }); + + fireEvent.click(screen.getByTestId("flush")); + + expect(window.localStorage.getItem(DRAFT_STORAGE_KEY)).toBeNull(); + expect(screen.getByTestId("has-backed-up")).toHaveTextContent("false"); +}); + +test("flushDraftNow writes indexes/settings only, never image pixels, and flips hasBackedUpThisSession", () => { + renderHarness({ backend: localBackend, project: projectSelectedImage1 }); + + fireEvent.click(screen.getByTestId("flush")); + + expect(screen.getByTestId("has-backed-up")).toHaveTextContent("true"); + const raw = window.localStorage.getItem(DRAFT_STORAGE_KEY); + expect(raw).not.toBeNull(); + const stored = JSON.parse(raw as string); + expect(stored.draftVersion).toBe(1); + expect(stored.payload.members).toHaveLength(1); + expect(stored.payload.members[0].front.query.query).toBe("my search query"); + expect(stored.payload.members[0].front.selectedImage).toBe( + projectSelectedImage1.members[0].front?.selectedImage + ); + // Governing premise (CLAUDE.md "we index, we do not store images") - the serialized draft + // never contains anything resembling image byte data, only the identifier/query index. + expect(raw).not.toMatch(/data:image/); +}); + +test("the debounced auto-write eventually persists without an explicit flush", async () => { + renderHarness({ backend: localBackend, project: projectSelectedImage1 }); + + expect(window.localStorage.getItem(DRAFT_STORAGE_KEY)).toBeNull(); + + await waitFor( + () => expect(window.localStorage.getItem(DRAFT_STORAGE_KEY)).not.toBeNull(), + { timeout: 2_000 } + ); +}); + +test("a restorable draft from a prior session surfaces once the project is empty again", async () => { + // First "session": a populated project backs itself up. + renderHarness({ backend: localBackend, project: projectSelectedImage1 }); + fireEvent.click(screen.getByTestId("flush")); + expect(window.localStorage.getItem(DRAFT_STORAGE_KEY)).not.toBeNull(); + + // Second "session": a fresh mount against an EMPTY project (localStorage persists across + // mounts in a real browser, and jsdom's own localStorage is shared across renders in this + // same test file the same way). + renderHarness({ backend: localBackend }); + + await waitFor(() => + expect(screen.getAllByTestId("restorable").at(-1)).not.toHaveTextContent( + "none" + ) + ); + expect(screen.getAllByTestId("restorable").at(-1)).toHaveTextContent( + '"memberCount":1' + ); +}); + +test("restoreDraft rehydrates the project from the stored draft", async () => { + renderHarness({ backend: localBackend, project: projectSelectedImage1 }); + fireEvent.click(screen.getByTestId("flush")); + + const store = renderHarness({ backend: localBackend }); + await waitFor(() => + expect(screen.getAllByTestId("restorable").at(-1)).not.toHaveTextContent( + "none" + ) + ); + + fireEvent.click(screen.getAllByTestId("restore").at(-1)!); + + expect(store.getState().project.members).toHaveLength(1); + expect(store.getState().project.members[0].front?.selectedImage).toBe( + projectSelectedImage1.members[0].front?.selectedImage + ); + await waitFor(() => + expect(screen.getAllByTestId("restorable").at(-1)).toHaveTextContent("none") + ); +}); + +test("dismissRestoreDraft hides the banner without deleting the underlying draft", async () => { + renderHarness({ backend: localBackend, project: projectSelectedImage1 }); + fireEvent.click(screen.getByTestId("flush")); + + renderHarness({ backend: localBackend }); + await waitFor(() => + expect(screen.getAllByTestId("restorable").at(-1)).not.toHaveTextContent( + "none" + ) + ); + + fireEvent.click(screen.getAllByTestId("dismiss").at(-1)!); + + await waitFor(() => + expect(screen.getAllByTestId("restorable").at(-1)).toHaveTextContent("none") + ); + // The safety net itself is untouched - only this session's banner was hidden. + expect(window.localStorage.getItem(DRAFT_STORAGE_KEY)).not.toBeNull(); +}); + +test("the post-import promotion nudge fires once, the moment the project flips from empty to populated", async () => { + const store = renderHarness({ backend: localBackend }); + + expect( + Object.values(selectToastsNotifications(store.getState())) + ).toHaveLength(0); + + act(() => { + store.dispatch(loadProject(projectSelectedImage1)); + }); + + await waitFor(() => { + const notifications = selectToastsNotifications(store.getState()); + expect( + Object.values(notifications).some((n) => n.name === "Backed up locally") + ).toBe(true); + }); +}); + +test("notifyPromoteDraftPrePrint dispatches the same promotion toast on demand", async () => { + const store = renderHarness({ + backend: localBackend, + project: projectSelectedImage1, + }); + + fireEvent.click(screen.getByTestId("notify-pre-print")); + + await waitFor(() => { + const notifications = selectToastsNotifications(store.getState()); + expect( + Object.values(notifications).some((n) => n.name === "Backed up locally") + ).toBe(true); + }); +}); diff --git a/frontend/src/features/display/useProjectDraftBackup.ts b/frontend/src/features/display/useProjectDraftBackup.ts new file mode 100644 index 000000000..6cd542bac --- /dev/null +++ b/frontend/src/features/display/useProjectDraftBackup.ts @@ -0,0 +1,306 @@ +/** + * Proposal H ADDENDUM D9(1)/F1 (docs/proposals/proposal-h-display-layout-spec.md) - the silent + * local draft auto-backup, the first of D9's three save-before-PDF layers. HARD OWNER CONSTRAINT + * (verbatim, D9): "save deck should come before PDF completes because we have to rely on clients + * available mem for the PDF" - PDF generation is the client's most memory-hungry step and can + * OOM/crash the tab, so the working project is mirrored to `localStorage` on every mutation, + * independent of (and strictly before) any PDF render. + * + * GOVERNING PREMISE (CLAUDE.md "we index, we do not store images"): this mirrors `buildDeckPayload`'s + * plaintext CONTENT shape - decklist identifiers, per-slot queries/overrides, and finish/page + * settings - never image pixels. Exactly the same invariant deckPayload.ts's own encrypted saved + * decks already honour, applied to this browser's own disk as strictly as to the network. + * + * Deliberately `localStorage`, not a saved-deck row: this is the ANONYMOUS-safe, account-free + * safety net (no crypto session, no server round-trip) - the "the local draft is the anonymous + * user's only persistence, and that is fine" line from D9(2). It is NOT a replacement for a real + * saved deck - see the promotion nudge below and PrePrintSaveGate.tsx for the two paths that + * invite promoting a draft into one. + * + * Serialization reuses deckPayload.ts's `buildDeckPayload` plaintext CONTENT shape (no version + * tag of its own - this hook stamps its own small `draftVersion` wrapper instead, so a future + * shape change can upgrade forward the same way `parseDeckPayload` already does for real saved + * decks, without coupling to DECK_PAYLOAD_VERSION itself). + * + * Two other responsibilities live here, both keyed off the exact same `isProjectEmpty` + * true->false/false->true transition this hook already has to watch for the debounced-write + * gate, rather than each growing its own separate effect: + * - The restore nudge (F1's own second half): when `/display` mounts (or the project is cleared) + * with an EMPTY project and a non-empty draft already sitting in `localStorage`, `restorableDraft` + * is populated so DisplayPage's `DeckInputLanding` can offer a one-line "Restore your unsaved + * work?" affordance - the draft is a genuine crash/OOM safety net, so this never auto-restores + * without the user's say-so, and dismissing it only hides the banner for this session, it does + * NOT delete the underlying draft. + * - `notifyPromoteDraftPrePrint` - D9(2)'s promotion nudge ("draft backed up - name and save it?") + * at the PRE-PRINT moment; PrePrintSaveGate.tsx calls this once per print attempt. The POST-IMPORT + * half of the same nudge fires automatically, right here, off the empty->populated transition - + * the same shape as SavedDeckPanel.tsx's own anonymous-to-login "adopt your project" toast, + * reusing the same plain-informational Toasts system (no action button - the message points at + * the Finish footer's "Save Deck" button, exactly like that existing precedent). + */ +import { useCallback, useEffect, useRef, useState } from "react"; + +import { useAppDispatch, useAppSelector } from "@/common/types"; +import { + buildDeckPayload, + DeckPayloadContent, + projectFromDeckPayload, +} from "@/features/savedDecks/deckPayload"; +import { + loadFinishSettings, + selectFinishSettings, +} from "@/store/slices/finishSettingsSlice"; +import { + loadProject, + selectIsProjectEmpty, + selectManualOverrides, + selectProjectCardback, + selectProjectMembers, +} from "@/store/slices/projectSlice"; +import { setNotification } from "@/store/slices/toastsSlice"; +import { RootState } from "@/store/store"; + +/** Not `deckPayload.ts`'s own `DECK_PAYLOAD_VERSION` - a separate, small counter for this hook's + * own wrapper shape, per this file's own module comment. Bump alongside a new upgrade branch in + * `parseStoredDraft` below if `StoredDraft`'s own shape ever changes. */ +const DRAFT_FORMAT_VERSION = 1; + +const DRAFT_STORAGE_KEY = "mpc-autofill-project-draft"; + +/** Debounce window between the last project mutation and the actual `localStorage` write - long + * enough that a burst of rapid edits (typing a decklist, dragging several slots) coalesces into + * one write, short enough that a crash moments after the last edit still has something recent to + * recover. */ +const DEBOUNCE_MS = 800; + +interface StoredDraft { + draftVersion: typeof DRAFT_FORMAT_VERSION; + savedAt: string; + payload: DeckPayloadContent; +} + +export interface ProjectDraftSummary { + memberCount: number; + savedAt: string; +} + +export interface UseProjectDraftBackupResult { + /** True once this hook has actually written a draft THIS session - drives the Finish footer's + * compact "✓ Draft backed up locally" note (D9's footer copy). Never true for an anonymous + * empty project - there is nothing to back up yet. */ + hasBackedUpThisSession: boolean; + /** Non-null only while the project is empty AND a real, non-empty draft is sitting in + * `localStorage` from an earlier session - DeckInputLanding's own restore-nudge banner reads + * this directly. */ + restorableDraft: ProjectDraftSummary | null; + /** Rehydrates the current project + finish settings from the stored draft. No-op if there is + * nothing restorable. */ + restoreDraft: () => void; + /** Hides the restore-nudge banner for the rest of this session WITHOUT deleting the underlying + * draft - it stays as a genuine safety net in case the user changes their mind, or a real crash + * still needs it. */ + dismissRestoreDraft: () => void; + /** Synchronous, non-debounced write - PrePrintSaveGate's own "flush the draft first" step + * (D9(3)a), so the safety net is guaranteed current the instant before any PDF render begins, + * rather than racing the debounce window above. */ + flushDraftNow: () => void; + /** D9(2)'s promotion nudge, PRE-PRINT half - PrePrintSaveGate calls this once per print + * attempt, right before the persist step it gates on. The POST-IMPORT half fires automatically + * from this hook's own empty->populated transition effect, below. */ + notifyPromoteDraftPrePrint: () => void; +} + +function readStoredDraft(): StoredDraft | null { + if (typeof window === "undefined") { + return null; + } + try { + const raw = window.localStorage.getItem(DRAFT_STORAGE_KEY); + if (raw == null) { + return null; + } + const parsed = JSON.parse(raw); + if ( + parsed?.draftVersion !== DRAFT_FORMAT_VERSION || + !Array.isArray(parsed?.payload?.members) || + parsed.payload.members.length === 0 + ) { + return null; + } + return parsed as StoredDraft; + } catch { + // Corrupted/foreign localStorage content - treat exactly like "no draft", this hook's own + // best-effort contract (see the module comment: it's a safety net, never a hard dependency). + return null; + } +} + +const PROMOTE_NUDGE_MESSAGE = + "Your project is backed up in this browser only - use Save Deck below to keep it permanently."; + +export function useProjectDraftBackup(): UseProjectDraftBackupResult { + const dispatch = useAppDispatch(); + const isProjectEmpty = useAppSelector(selectIsProjectEmpty); + const projectMembers = useAppSelector(selectProjectMembers); + const projectCardback = useAppSelector(selectProjectCardback); + const manualOverrides = useAppSelector(selectManualOverrides); + const finishSettings = useAppSelector(selectFinishSettings); + const cardDocuments = useAppSelector( + (state: RootState) => state.cardDocuments.cardDocuments + ); + + const [hasBackedUpThisSession, setHasBackedUpThisSession] = useState(false); + const [dismissed, setDismissed] = useState(false); + const [restorableDraft, setRestorableDraft] = + useState(null); + + const writeDraftNow = useCallback(() => { + if (isProjectEmpty || typeof window === "undefined") { + return; + } + const content = buildDeckPayload( + "", + { + members: projectMembers, + nextMemberId: 0, + cardback: projectCardback ?? null, + mostRecentlySelectedSlot: null, + manualOverrides, + }, + finishSettings, + cardDocuments + ); + const draft: StoredDraft = { + draftVersion: DRAFT_FORMAT_VERSION, + savedAt: new Date().toISOString(), + payload: content, + }; + try { + window.localStorage.setItem(DRAFT_STORAGE_KEY, JSON.stringify(draft)); + setHasBackedUpThisSession(true); + } catch { + // Storage quota/private-mode denial - this is a best-effort safety net, never a hard + // dependency (see module comment); silently skipping a write here is the correct + // degradation, not a crash. + } + }, [ + isProjectEmpty, + projectMembers, + projectCardback, + manualOverrides, + finishSettings, + cardDocuments, + ]); + + // D9(1) - the debounced auto-write. Coalesces a burst of rapid project mutations into one + // write per DEBOUNCE_MS of quiet, per this file's own module comment. + useEffect(() => { + if (isProjectEmpty) { + return; + } + const timeout = setTimeout(writeDraftNow, DEBOUNCE_MS); + return () => clearTimeout(timeout); + }, [isProjectEmpty, writeDraftNow]); + + // The restore-nudge check: only ever relevant while the project is EMPTY (a populated project + // has nothing to "restore" into) and not already dismissed this session. + useEffect(() => { + if (!isProjectEmpty || dismissed) { + setRestorableDraft(null); + return; + } + const stored = readStoredDraft(); + setRestorableDraft( + stored != null + ? { + memberCount: stored.payload.members.length, + savedAt: stored.savedAt, + } + : null + ); + }, [isProjectEmpty, dismissed]); + + // D9(2)'s promotion nudge, POST-IMPORT half: fires once per session the moment the project + // flips from empty to populated - the exact same transition-detection shape as + // SavedDeckPanel.tsx's own anonymous->authenticated "adopt your project" toast, reusing the + // same plain-informational Toasts system (no action button in that system at all - see + // Toasts.tsx - so, like that existing precedent, the message points at the footer's own Save + // Deck button rather than embedding an action here). + const firedPostImportNudge = useRef(false); + const previousIsProjectEmpty = useRef(isProjectEmpty); + useEffect(() => { + const was = previousIsProjectEmpty.current; + if ( + was === true && + isProjectEmpty === false && + !firedPostImportNudge.current + ) { + firedPostImportNudge.current = true; + dispatch( + setNotification([ + "draft-backup-promote-post-import", + { + name: "Backed up locally", + message: PROMOTE_NUDGE_MESSAGE, + level: "info", + }, + ]) + ); + } + previousIsProjectEmpty.current = isProjectEmpty; + }, [isProjectEmpty, dispatch]); + + const notifyPromoteDraftPrePrint = useCallback(() => { + dispatch( + setNotification([ + "draft-backup-promote-pre-print", + { + name: "Backed up locally", + message: PROMOTE_NUDGE_MESSAGE, + level: "info", + }, + ]) + ); + }, [dispatch]); + + const restoreDraft = useCallback(() => { + const stored = readStoredDraft(); + if (stored == null) { + return; + } + const { project, finishSettings: restoredFinishSettings } = + projectFromDeckPayload({ + ...stored.payload, + version: 2, + revision: 0, + modifiedAt: stored.savedAt, + }); + dispatch(loadProject(project)); + dispatch(loadFinishSettings(restoredFinishSettings)); + setRestorableDraft(null); + }, [dispatch]); + + const dismissRestoreDraft = useCallback(() => { + setDismissed(true); + setRestorableDraft(null); + }, []); + + return { + hasBackedUpThisSession, + restorableDraft, + restoreDraft, + dismissRestoreDraft, + flushDraftNow: writeDraftNow, + notifyPromoteDraftPrePrint, + }; +} + +/** Test-only escape hatch - a fresh session/tab in real use always starts with no stored draft; + * this exists so a single jest process can exercise the restore path without polluting other + * tests' own `localStorage` (mirrors postExportContributionPrompt.ts's own reset-for-tests + * precedent). */ +export function clearStoredProjectDraftForTests(): void { + if (typeof window !== "undefined") { + window.localStorage.removeItem(DRAFT_STORAGE_KEY); + } +} diff --git a/frontend/src/features/savedDecks/SavedDeckPanel.tsx b/frontend/src/features/savedDecks/SavedDeckPanel.tsx index 0466ae881..9cef0337b 100644 --- a/frontend/src/features/savedDecks/SavedDeckPanel.tsx +++ b/frontend/src/features/savedDecks/SavedDeckPanel.tsx @@ -13,23 +13,22 @@ * this component). The component itself is entirely route-agnostic (reads/writes projectSlice + * savedDeckSessionSlice only), so the only thing that differs between callers is spacing - hence * the className prop rather than a second, forked component. + * + * Issue #275 (proposal-h-display-layout-spec.md ADDENDUM D9) extracted the passphrase-setup/ + * unlock/save modal-chain orchestration below into useSaveDeckFlow.ts, so the new Finish footer's + * own "Save Deck" button (FinishFooter.tsx) and PrePrintSaveGate.tsx's "Save" choice can trigger + * the exact same flow without forking it - this component is its first, unchanged caller. */ -import React, { useEffect, useRef, useState } from "react"; +import React, { useEffect, useRef } from "react"; import Button from "react-bootstrap/Button"; import { useAppDispatch, useAppSelector } from "@/common/types"; -import { useCryptoSession } from "@/features/savedDecks/cryptoSession"; -import { PassphraseSetupModal } from "@/features/savedDecks/PassphraseSetupModal"; -import { SaveDeckModal } from "@/features/savedDecks/SaveDeckModal"; -import { UnlockModal } from "@/features/savedDecks/UnlockModal"; +import { useSaveDeckFlow } from "@/features/savedDecks/useSaveDeckFlow"; import { useGetWhoamiQuery } from "@/store/api"; -import { selectIsProjectEmpty } from "@/store/slices/projectSlice"; import { selectCurrentSavedDeck } from "@/store/slices/savedDeckSessionSlice"; import { setNotification } from "@/store/slices/toastsSlice"; -type PendingModal = "passphrase-setup" | "unlock" | "save" | null; - interface SavedDeckPanelProps { // Defaults to the original ProjectEditor placement's own spacing (a block stacked under the // panels above it in a vertical column) - DisplayPage.tsx passes "" instead, since there this @@ -40,12 +39,16 @@ interface SavedDeckPanelProps { export function SavedDeckPanel({ className = "pt-2" }: SavedDeckPanelProps) { const dispatch = useAppDispatch(); + // Kept as its own query (RTK Query dedupes against useSaveDeckFlow's own identical call) rather + // than reading `isAuthenticated` back out of the hook below - this effect needs the RAW + // `whoami.data?.authenticated` (`undefined` while the query is still loading), not the hook's + // already-coerced boolean, so an already-authenticated user's initial undefined->true + // resolution can't be mistaken for a genuine live sign-in transition (see the `was === false` + // check below - `undefined !== false`, so only a real anonymous->authenticated flip fires it). const whoami = useGetWhoamiQuery(); - const isAuthenticated = whoami.data?.authenticated === true; - const session = useCryptoSession(); const currentSavedDeck = useAppSelector(selectCurrentSavedDeck); - const isProjectEmpty = useAppSelector(selectIsProjectEmpty); - const [pendingModal, setPendingModal] = useState(null); + const { element, triggerSave, isAuthenticated, isProjectEmpty } = + useSaveDeckFlow(); // Anonymous -> login adopt-by-save toast: surfaced once, right at the moment whoami flips to // authenticated, only if there's actually a non-empty in-memory project worth offering to @@ -76,16 +79,6 @@ export function SavedDeckPanel({ className = "pt-2" }: SavedDeckPanelProps) { return null; } - const handleSaveClick = () => { - if (session.status === "no-profile") { - setPendingModal("passphrase-setup"); - } else if (session.status === "locked") { - setPendingModal("unlock"); - } else if (session.status === "unlocked") { - setPendingModal("save"); - } - }; - return ( <>
@@ -98,26 +91,12 @@ export function SavedDeckPanel({ className = "pt-2" }: SavedDeckPanelProps) { size="sm" variant="outline-primary" disabled={isProjectEmpty} - onClick={handleSaveClick} + onClick={() => triggerSave()} > Save
- setPendingModal(null)} - onComplete={() => setPendingModal("save")} - /> - setPendingModal(null)} - onUnlocked={() => setPendingModal("save")} - /> - setPendingModal(null)} - onSaved={() => setPendingModal(null)} - /> + {element} ); } diff --git a/frontend/src/features/savedDecks/useSaveDeckFlow.tsx b/frontend/src/features/savedDecks/useSaveDeckFlow.tsx new file mode 100644 index 000000000..4016137f1 --- /dev/null +++ b/frontend/src/features/savedDecks/useSaveDeckFlow.tsx @@ -0,0 +1,100 @@ +/** + * Extracted from SavedDeckPanel.tsx's own `handleSaveClick`/`pendingModal` orchestration + * (proposal-h-display-layout-spec.md ADDENDUM D9, issue #275) so a SECOND call site - the + * Finish footer's own "Save Deck" button (FinishFooter.tsx) and PrePrintSaveGate.tsx's "Save" + * choice - can trigger the exact same passphrase-setup/unlock/save modal chain without forking + * it, mirroring useLoadSavedDeck.ts's own precedent (extracted from MyDecksPage.tsx for the same + * reason). SavedDeckPanel.tsx itself is refactored to use this hook too, so there is exactly ONE + * place this three-modal sequencing lives - not the original plus a near-duplicate. + * + * Behaviour preserved verbatim from SavedDeckPanel.tsx: `no-profile` -> PassphraseSetupModal -> + * `unlocked` (session flips) -> SaveDeckModal shown next; `locked` -> UnlockModal -> same; + * `unlocked` already -> SaveDeckModal directly. `triggerSave` accepts an optional `onSaved` + * callback so a caller that needs to do something AFTER a successful save (PrePrintSaveGate's + * "navigate to the Print page only once persistence resolves", D9(3)c) can hook it, while + * SavedDeckPanel's own plain "just close the modal" caller can omit it. + */ +import React, { useCallback, useState } from "react"; + +import { useAppSelector } from "@/common/types"; +import { useCryptoSession } from "@/features/savedDecks/cryptoSession"; +import { PassphraseSetupModal } from "@/features/savedDecks/PassphraseSetupModal"; +import { SaveDeckModal } from "@/features/savedDecks/SaveDeckModal"; +import { UnlockModal } from "@/features/savedDecks/UnlockModal"; +import { useGetWhoamiQuery } from "@/store/api"; +import { selectIsProjectEmpty } from "@/store/slices/projectSlice"; + +type PendingModal = "passphrase-setup" | "unlock" | "save" | null; + +export interface UseSaveDeckFlowResult { + /** Render this once, near wherever `triggerSave` is called from - the whole modal surface + * (PassphraseSetupModal/UnlockModal/SaveDeckModal) this hook needs, and renders nothing until + * it's actually needed. */ + element: React.ReactElement; + /** Starts (or continues) the save flow: passphrase-setup/unlock first if the crypto session + * isn't ready, then SaveDeckModal. `onSaved` (optional) fires once the deck has actually been + * persisted - omit it for a plain "just save" caller. */ + triggerSave: (onSaved?: () => void) => void; + /** True once a real, non-authenticated session is confirmed - callers that need to branch on + * this themselves (the Finish footer's anonymous "Save Deck" state, D9's own "anonymous users' + * nudge routes through sign-in first") read it directly rather than re-querying whoami. */ + isAuthenticated: boolean; + /** Whether the current project has anything worth saving - SavedDeckPanel's own existing + * `disabled={isProjectEmpty}` gate on its Save button, exposed here so a second caller + * (the Finish footer) can apply the identical gate without re-deriving it. */ + isProjectEmpty: boolean; +} + +export function useSaveDeckFlow(): UseSaveDeckFlowResult { + const whoami = useGetWhoamiQuery(); + const isAuthenticated = whoami.data?.authenticated === true; + const session = useCryptoSession(); + const isProjectEmpty = useAppSelector(selectIsProjectEmpty); + + const [pendingModal, setPendingModal] = useState(null); + const [onSavedCallback, setOnSavedCallback] = useState< + (() => void) | undefined + >(undefined); + + const triggerSave = useCallback( + (onSaved?: () => void) => { + setOnSavedCallback(() => onSaved); + if (session.status === "no-profile") { + setPendingModal("passphrase-setup"); + } else if (session.status === "locked") { + setPendingModal("unlock"); + } else if (session.status === "unlocked") { + setPendingModal("save"); + } + }, + [session.status] + ); + + const handleSaved = useCallback(() => { + setPendingModal(null); + onSavedCallback?.(); + setOnSavedCallback(undefined); + }, [onSavedCallback]); + + const element = ( + <> + setPendingModal(null)} + onComplete={() => setPendingModal("save")} + /> + setPendingModal(null)} + onUnlocked={() => setPendingModal("save")} + /> + setPendingModal(null)} + onSaved={handleSaved} + /> + + ); + + return { element, triggerSave, isAuthenticated, isProjectEmpty }; +} diff --git a/frontend/src/pages/print.tsx b/frontend/src/pages/print.tsx new file mode 100644 index 000000000..a4fe30fc5 --- /dev/null +++ b/frontend/src/pages/print.tsx @@ -0,0 +1,74 @@ +import Head from "next/head"; +import Link from "next/link"; +import React from "react"; + +import { ProjectName } from "@/common/constants"; +import { useAppSelector } from "@/common/types"; +import { NoBackendDefault } from "@/components/NoBackendDefault"; +import { FinishedMyProject } from "@/features/export/FinishedMyProject"; +import Footer from "@/features/ui/Footer"; +import { ProjectContainer } from "@/features/ui/Layout"; +import { + useAnyBackendConfigured, + useProjectName, +} from "@/store/slices/backendSlice"; +import { selectIsProjectEmpty } from "@/store/slices/projectSlice"; +require("bootstrap-icons/font/bootstrap-icons.css"); + +/** + * Proposal H ADDENDUM D10/F5 (docs/proposals/proposal-h-display-layout-spec.md, issue #275) - a + * thin route wrapper mounting `FinishedMyProject` (the MakePlayingCards/NotMPC/PringlePrints + * supplier tabs + the PDF sub-tab), mirroring `pages/myDecks.tsx`'s own + * `MyDecksPage`/`pages/shared.tsx`'s `SharedDeckPage` wrapper pattern - compose, don't fork. + * `FinishedMyProject.tsx` itself is UNCHANGED; this file only gives it a standalone route so the + * Finish footer's "Print / Export →" button (FinishFooter.tsx, via PrePrintSaveGate.tsx) has + * somewhere to client-side-navigate to (D9's pre-print persist step runs BEFORE this navigation, + * never after). The classic /editor "Print!" tab keeps mounting the same component unchanged too + * (ProjectEditor.tsx's own `PrintPanel`) - both /display and /editor now funnel here. + * + * Deliberately NOT built here (D10's own owner addendum, explicitly out of THIS issue's scope per + * the task that shipped this file): the tab REORDER (owner order: PDF · MakePlayingCards · + * NotMPC · PringlePrints, PDF default - today's array order/default is unchanged) and the PDF + * tab's own preview removal (`showPreview={false}` prop plumbing so /display's own center sheet + * region becomes the sole preview). Both are tracked as their own follow-up against this same + * D10 addendum, not silently dropped - see that doc's own change inventory. + */ +function PrintPageOrDefault() { + const anyBackendConfigured = useAnyBackendConfigured(); + const isProjectEmpty = useAppSelector(selectIsProjectEmpty); + + if (!anyBackendConfigured) { + return ; + } + + // A direct/bookmarked nav here with nothing in the project yet has nothing for + // FinishedMyProject to usefully show (no cards to export) - point back at the funnel's own + // entry point rather than rendering an empty PDF/supplier-instructions surface. + if (isProjectEmpty) { + return ( +
+

Your project is empty - there's nothing to print yet.

+ Head to Display to add some cards +
+ ); + } + + return ; +} + +export default function Print() { + const projectName = useProjectName(); + return ( + + + {`${projectName} Print`} + + + +