diff --git a/README.md b/README.md index 7f33f9d..d010c4b 100644 --- a/README.md +++ b/README.md @@ -1361,6 +1361,16 @@ Things I chose while building, with the reasoning: phase-1 suite (43 checks, including the real route mapping against a stub Ollama returning a monthly-limit body, a two-device sync round-trip, and the generation guards below). +60. **There is exactly one loading screen.** The deck wizard had the good one — the app icon, the + rotating stage message as the headline, the progress bar, the stage checklist, a tip and + Cancel — while the homework flows showed a spinner with a title and the route loads a pulsing + icon with a smaller line. All three now render `components/LoadingState`: the wizard passes its + vision/generation stages (and keeps its deck tips), the homework passes the same shape with + homework wording (no tips — the tips are deck-specific), and the route screens pass a single + message with no stages or progress. Cancel stays a 44px touch target and the icon animation + respects `prefers-reduced-motion`. Verified by the freeze-loading, review, wizard and mobile + suites. + 59. **Adding a deck or a homework syncs itself.** Sync used to be push-on-demand only: a deck frozen on the laptop sat in `dirty` until someone opened Settings and pressed Sync now. The two stores whose writes mean "the user just made something" (`lib/storage`, `lib/homework`) now diff --git a/components/LoadingState.tsx b/components/LoadingState.tsx new file mode 100644 index 0000000..b08a9f8 --- /dev/null +++ b/components/LoadingState.tsx @@ -0,0 +1,129 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { motion, useReducedMotion } from "framer-motion"; + +import { Button, ProgressBar } from "@/components/ui"; +import { useI18n } from "@/lib/i18n"; + +/** + * The one loading screen for every model pipeline — deck/notes creation, + * homework, and the route-level "preparing…" screens: the app icon, the + * rotating stage message as the headline, the two-step progress bar, the + * checklist of stages, an optional tip and Cancel. + * + * Extracted from the deck wizard (the look people already know) so the + * homework flows and route loads read exactly the same. + */ + +export type LoadingStage = { + id: string; + /** First line of the checklist (= the stage's first message). */ + labelKey: string; + /** Rotating messages while this stage runs. */ + messageKeys: string[]; +}; + +export function LoadingState({ + stages, + activeStage, + message, + messageKey, + tipKeys, + onCancel, +}: { + /** Pipeline stages in order. Omit for a plain "working" screen. */ + stages?: LoadingStage[]; + /** Which stage is running now (matches a stage id). */ + activeStage?: string; + /** Live message from the server; wins until the first rotation tick. */ + message?: string; + /** Static message for screens without stages. */ + messageKey?: string; + /** Pick one of these at random and show it under the stages. */ + tipKeys?: string[]; + onCancel?: () => void; +}) { + const { t } = useI18n(); + const reduceMotion = useReducedMotion(); + const [tick, setTick] = useState(0); + const [tip, setTip] = useState(null); + + useEffect(() => { + if (!stages) return; + const timer = window.setInterval(() => setTick((value) => value + 1), 3200); + return () => window.clearInterval(timer); + }, [stages]); + + useEffect(() => { + if (!tipKeys || tipKeys.length === 0) return; + setTip(tipKeys[Math.floor(Math.random() * tipKeys.length)]); + }, [tipKeys]); + + const activeIndex = stages ? Math.max(0, stages.findIndex((stage) => stage.id === activeStage)) : 0; + const rotation = stages?.[activeIndex]?.messageKeys ?? []; + const shown = + tick === 0 && message + ? message + : rotation.length > 0 + ? t(rotation[tick % rotation.length]) + : messageKey + ? t(messageKey) + : ""; + + return ( +
+ {/* The app icon, not the bare snowflake — same logo as the header, + sidebar and empty states. */} + + {/* eslint-disable-next-line @next/next/no-img-element -- static app icon, no optimisation needed */} + + + +
+ + {shown} + + + {stages && stages.length > 0 ? ( + + ) : null} + + {stages && stages.length > 1 ? ( +
    + {stages.map((stage, index) => ( +
  1. + {index < activeIndex ? "✅" : index === activeIndex ? "🌀" : "⬜"} + {t(stage.labelKey)} +
  2. + ))} +
+ ) : null} + + {tip ?

{t(tip)}

: null} + + {onCancel ? ( + + ) : null} +
+
+ ); +} diff --git a/components/RouteLoading.tsx b/components/RouteLoading.tsx index ad6d8aa..513cc71 100644 --- a/components/RouteLoading.tsx +++ b/components/RouteLoading.tsx @@ -1,31 +1,17 @@ "use client"; -import { Spinner } from "@/components/ui"; -import { useI18n } from "@/lib/i18n"; +import { LoadingState } from "@/components/LoadingState"; /** * Route-level loading screen (used by `loading.tsx` boundaries and by client * screens that wait for local data). Rendered the moment a navigation starts, - * so a slow request never looks like a dead tap. + * so a slow request never looks like a dead tap. Same loading screen as the + * rest of the app, without pipeline stages. */ export function RouteLoading({ messageKey }: { messageKey: string }) { - const { t } = useI18n(); - return ( -
-
- {/* eslint-disable-next-line @next/next/no-img-element -- static app icon, no optimisation needed */} - -

- - {t(messageKey)} -

-
+
+
); } diff --git a/components/UploadScreen.tsx b/components/UploadScreen.tsx index 57914bb..4558cae 100644 --- a/components/UploadScreen.tsx +++ b/components/UploadScreen.tsx @@ -47,7 +47,8 @@ import { wizardBusy } from "@/lib/wizard-busy"; import { cn, uid } from "@/lib/utils"; import { CameraIcon, ChevronDownIcon, FolderIcon, IceCubeIcon, PlusIcon, SnowflakeIcon, SparklesIcon, SunIcon } from "@/components/icons"; import { SegmentedPill } from "@/components/SegmentedPill"; -import { Button, ProgressBar } from "@/components/ui"; +import { Button } from "@/components/ui"; +import { LoadingState, type LoadingStage } from "@/components/LoadingState"; import { WizardActionBar } from "@/components/WizardActionBar"; type Phase = "idle" | "preparing" | "loading" | "error"; @@ -98,6 +99,12 @@ const COUNT_OPTIONS = [8, 10, 12, 15]; const GENERATION_TIPS = ["upload.tip1", "upload.tip2", "upload.tip3", "upload.tip4"]; +/** Same loading screen as the rest of the app (components/LoadingState). */ +const UPLOAD_STAGES: LoadingStage[] = [ + { id: "vision", labelKey: "upload.vision1", messageKeys: ["upload.vision1", "upload.vision2", "upload.vision3"] }, + { id: "generation", labelKey: "upload.gen1", messageKeys: ["upload.gen1", "upload.gen2", "upload.gen3"] }, +]; + export function UploadScreen() { const router = useRouter(); const { t, plural } = useI18n(); @@ -599,7 +606,15 @@ export function UploadScreen() { : t("upload.addToStart"); if (phase === "loading") { - return abortRef.current?.abort()} />; + return ( + abortRef.current?.abort()} + /> + ); } /* Phones: the 3-step flow. Same state as the desktop card, different shape. */ @@ -1343,75 +1358,3 @@ export function UploadScreen() { /* Loading state */ /* ------------------------------------------------------------------ */ -const STAGE_STEPS: Array<{ stage: GenerateProgress["stage"]; keys: string[] }> = [ - { stage: "vision", keys: ["upload.vision1", "upload.vision2", "upload.vision3"] }, - { stage: "generation", keys: ["upload.gen1", "upload.gen2", "upload.gen3"] }, -]; - -function LoadingState({ progress, onCancel }: { progress: GenerateProgress; onCancel: () => void }) { - const { t } = useI18n(); - const reduceMotion = useReducedMotion(); - const [tick, setTick] = useState(0); - const [tip, setTip] = useState(0); - - useEffect(() => { - const timer = window.setInterval(() => setTick((value) => value + 1), 3200); - return () => window.clearInterval(timer); - }, []); - - useEffect(() => { - setTip(Math.floor(Math.random() * GENERATION_TIPS.length)); - }, []); - - const activeIndex = progress.stage === "vision" ? 0 : 1; - const keys = STAGE_STEPS[activeIndex].keys; - const message = progress.message && tick === 0 ? progress.message : t(keys[tick % keys.length]); - - return ( -
- {/* The app icon, not the bare snowflake — same logo as the header, - sidebar and empty states. */} - - {/* eslint-disable-next-line @next/next/no-img-element -- static app icon, no optimisation needed */} - - - -
- - {message} - - -
    - {STAGE_STEPS.map((step, index) => ( -
  1. - - {index < activeIndex ? "✅" : index === activeIndex ? "🌀" : "⬜"} - - {t(step.keys[0])} -
  2. - ))} -
-

{t(GENERATION_TIPS[tip])}

- -
-
- ); -} diff --git a/components/homework/HomeworkLoading.tsx b/components/homework/HomeworkLoading.tsx index 6566192..3cdc269 100644 --- a/components/homework/HomeworkLoading.tsx +++ b/components/homework/HomeworkLoading.tsx @@ -1,13 +1,13 @@ "use client"; -import { useEffect, useState } from "react"; - -import { Button, ProgressBar, Spinner } from "@/components/ui"; +import { LoadingState, type LoadingStage } from "@/components/LoadingState"; import type { HomeworkProgress } from "@/lib/client-api"; -import { useI18n } from "@/lib/i18n"; import type { HomeworkMode } from "@/lib/schemas"; -/** Staged loading state while the assignment is read and answered. */ +/** + * Loading state while the assignment is read and answered — the same screen + * as the deck wizard (components/LoadingState), with the homework wording. + */ export function HomeworkLoading({ progress, mode, @@ -17,35 +17,27 @@ export function HomeworkLoading({ mode: HomeworkMode; onCancel: () => void; }) { - const { t } = useI18n(); - const vision = progress.stage === "vision"; - const keys = vision - ? ["homework.loading.reading", "homework.loading.vision2"] - : mode === "make" - ? ["homework.loading.writing", "homework.loading.gen2", "homework.loading.gen3"] - : ["homework.loading.planning", "homework.loading.gen2", "homework.loading.gen3"]; - const [tick, setTick] = useState(0); - - useEffect(() => { - const id = window.setInterval(() => setTick((value) => value + 1), 2400); - return () => window.clearInterval(id); - }, []); - - const message = progress.message && tick === 0 ? progress.message : t(keys[tick % keys.length]); + const generationKey = + mode === "make" ? "homework.loading.writing" : mode === "check" ? "homework.loading.checking" : "homework.loading.planning"; + const stages: LoadingStage[] = [ + { + id: "vision", + labelKey: "homework.loading.reading", + messageKeys: ["homework.loading.reading", "homework.loading.vision2"], + }, + { + id: "generation", + labelKey: generationKey, + messageKeys: [generationKey, "homework.loading.gen2", "homework.loading.gen3"], + }, + ]; return ( -
- -

{t("homework.loading.title")}

-
- -

- {message} -

-
- -
+ ); } diff --git a/lib/i18n-dict.ts b/lib/i18n-dict.ts index 57aec85..bf4844f 100644 --- a/lib/i18n-dict.ts +++ b/lib/i18n-dict.ts @@ -299,7 +299,6 @@ const en: Record = { "homework.mode.guide.subtitle": "A step-by-step guide with hints and quick checks.", "homework.start.make": "Make my homework", "homework.start.guide": "Guide me through it", - "homework.loading.title": "Working on the assignment…", "homework.loading.reading": "Reading the assignment…", "homework.loading.readingSolution": "Reading your solution…", "homework.loading.vision2": "Transcribing the questions…", @@ -1239,7 +1238,6 @@ const cs: Record = { "homework.mode.guide.subtitle": "Návod krok za krokem s nápovědami a rychlými kontrolami.", "homework.start.make": "Udělej mi úkol", "homework.start.guide": "Proveď mě tím", - "homework.loading.title": "Pracuji na zadání…", "homework.loading.reading": "Čtu zadání…", "homework.loading.readingSolution": "Čtu tvoje řešení…", "homework.loading.vision2": "Přepisuji otázky…", diff --git a/public/sw.js b/public/sw.js index 83822cb..289a99f 100644 --- a/public/sw.js +++ b/public/sw.js @@ -9,7 +9,7 @@ * * Bumping CACHE_VERSION is how a new deploy invalidates old assets. */ -const CACHE_VERSION = "freezmo-v46"; +const CACHE_VERSION = "freezmo-v47"; const SHELL_CACHE = `${CACHE_VERSION}-shell`; const ASSET_CACHE = `${CACHE_VERSION}-assets`;