diff --git a/src/assets/onboarding/sunset2.webp b/src/assets/onboarding/sunset2.webp new file mode 100644 index 0000000..927828c Binary files /dev/null and b/src/assets/onboarding/sunset2.webp differ diff --git a/src/components/onboarding/StepGalleryPreview.tsx b/src/components/onboarding/StepGalleryPreview.tsx index 611cb61..16118f0 100644 --- a/src/components/onboarding/StepGalleryPreview.tsx +++ b/src/components/onboarding/StepGalleryPreview.tsx @@ -1,8 +1,7 @@ import { useEffect, useState } from "react"; -import { FakeTile } from "./fakes"; +import { FakeTile, ReplayButton } from "./fakes"; -const TILE_COUNT = 12; -const REVEAL_MS = 350; +const REVEAL_MS = 280; const TILE_PROPS: { favorite?: boolean; rating?: number; duration?: string }[] = [ {}, @@ -19,17 +18,20 @@ const TILE_PROPS: { favorite?: boolean; rating?: number; duration?: string }[] = {}, ]; -/// Placeholder tiles "loading in" one by one, looping, to show how the grid -/// fills while thumbnails are generated. +const TILE_COUNT = TILE_PROPS.length; + +/// Placeholder tiles "loading in" once (skeleton → image), then stopping fully +/// revealed with a replay control. export function StepGalleryPreview() { const [revealed, setRevealed] = useState(0); useEffect(() => { - const timer = setInterval(() => { - setRevealed((current) => (current >= TILE_COUNT + 4 ? 0 : current + 1)); - }, REVEAL_MS); - return () => clearInterval(timer); - }, []); + if (revealed >= TILE_COUNT) return; + const timer = setTimeout(() => setRevealed((n) => n + 1), REVEAL_MS); + return () => clearTimeout(timer); + }, [revealed]); + + const finished = revealed >= TILE_COUNT; return (
@@ -44,15 +46,18 @@ export function StepGalleryPreview() { ))}
-
-

- Click any tile to open the lightbox — keyboard navigation, - zoom, inline tag editing, ratings, and a full video player. -

-

- The toolbar filters by type, favorites, or rating, sorts by - date/name/size, and switches grid density. -

+
+
+

+ Click any tile to open the lightbox — keyboard navigation, + zoom, inline tag editing, ratings, and a full video player. +

+

+ The toolbar filters by type, favorites, or rating, sorts by + date/name/size, and switches grid density. +

+
+ {finished ? setRevealed(0)} /> : null}
); diff --git a/src/components/onboarding/StepPipeline.tsx b/src/components/onboarding/StepPipeline.tsx index 5cee730..c6c3550 100644 --- a/src/components/onboarding/StepPipeline.tsx +++ b/src/components/onboarding/StepPipeline.tsx @@ -1,29 +1,40 @@ import { useEffect, useState } from "react"; -import { FakeProgressBar, FakeStageTag } from "./fakes"; +import { FakeProgressBar, FakeStageTag, ReplayButton } from "./fakes"; const STAGES = ["Thumbnails", "Metadata", "Embeddings", "Tags"] as const; const STAGE_TOTAL = 128; -const TICK_MS = 90; -const STEP_PER_TICK = 6; +const TICK_MS = 80; +const STEP_PER_TICK = 8; -/// A looping, entirely fake render of the background-tasks bar: each stage -/// drains in order, then the cycle restarts. +/// A one-shot fake of the background-tasks bar: each stage drains in order, +/// then it stops at "all done" with a replay control. export function StepPipeline() { const [stageIndex, setStageIndex] = useState(0); - const [done, setDone] = useState(0); + const [filled, setFilled] = useState(0); + + const finished = stageIndex >= STAGES.length; useEffect(() => { - const timer = setInterval(() => { - setDone((current) => { - if (current + STEP_PER_TICK < STAGE_TOTAL) return current + STEP_PER_TICK; - setStageIndex((stage) => (stage + 1) % STAGES.length); - return 0; - }); + if (finished) return; + const timer = setTimeout(() => { + // Read from the closure and call setters directly — nesting one setter + // inside another's updater double-advances under React StrictMode. + if (filled + STEP_PER_TICK < STAGE_TOTAL) { + setFilled(filled + STEP_PER_TICK); + } else { + setStageIndex(stageIndex + 1); + setFilled(0); + } }, TICK_MS); - return () => clearInterval(timer); - }, []); + return () => clearTimeout(timer); + }, [filled, stageIndex, finished]); - const remaining = STAGE_TOTAL - done; + const replay = () => { + setStageIndex(0); + setFilled(0); + }; + + const remaining = STAGE_TOTAL - filled; return (
@@ -41,32 +52,37 @@ export function StepPipeline() {
- - + {!finished ? ( + + ) : null} + Holiday Photos
{STAGES.map((stage, i) => ( ))}
- +
-
-

- It's all interruptible. Close the app whenever you like — - the queue picks up where it left off next launch. -

-

- Per-folder control. Right-click a folder in the sidebar to - pause its background work, and click the bar itself to expand a detailed per-folder view. -

+
+
+

+ It's all interruptible. Close the app whenever you like — + the queue picks up where it left off next launch. +

+

+ Per-folder control. Right-click a folder in the sidebar to + pause its background work, and click the bar itself to expand a detailed per-folder view. +

+
+ {finished ? : null}
); diff --git a/src/components/onboarding/StepSearchDemo.tsx b/src/components/onboarding/StepSearchDemo.tsx index 926ba54..e2a4545 100644 --- a/src/components/onboarding/StepSearchDemo.tsx +++ b/src/components/onboarding/StepSearchDemo.tsx @@ -1,11 +1,11 @@ import { useEffect, useState } from "react"; -import { SEARCH_RESULTS } from "./fakes"; +import { ReplayButton, SEARCH_RESULTS } from "./fakes"; const DEMOS = [ { query: "beach-day_042.jpg", mode: "Filename", - description: "Plain text matches file names — the default, instant search.", + description: "Plain text matches file names — the default, instant search. One name, one file.", results: SEARCH_RESULTS.filename, }, { @@ -25,25 +25,39 @@ const DEMOS = [ const TYPE_MS = 55; const HOLD_MS = 2600; -/// A mock search bar that "types" each demo query, shows fake results, and -/// cycles to the next mode. +/// Types each demo query, shows matching results, advances through all three +/// modes once, then stops on the last with a replay control. export function StepSearchDemo() { const [demoIndex, setDemoIndex] = useState(0); const [typed, setTyped] = useState(0); + const [finished, setFinished] = useState(false); const demo = DEMOS[demoIndex]; + const isLastDemo = demoIndex === DEMOS.length - 1; useEffect(() => { + if (finished) return; if (typed < demo.query.length) { const timer = setTimeout(() => setTyped((n) => n + 1), TYPE_MS); return () => clearTimeout(timer); } + // Fully typed: hold, then advance — or finish on the last demo. const timer = setTimeout(() => { - setDemoIndex((i) => (i + 1) % DEMOS.length); - setTyped(0); + if (isLastDemo) { + setFinished(true); + } else { + setDemoIndex((i) => i + 1); + setTyped(0); + } }, HOLD_MS); return () => clearTimeout(timer); - }, [typed, demo.query.length]); + }, [typed, demo.query.length, isLastDemo, finished]); + + const replay = () => { + setDemoIndex(0); + setTyped(0); + setFinished(false); + }; const fullyTyped = typed >= demo.query.length; @@ -61,23 +75,26 @@ export function StepSearchDemo() { {demo.query.slice(0, typed)} - | + {!finished ? | : null} {demo.mode}
- {/* Fake results */} -
+ {/* Results — fixed-width tiles, centered, so 1/2/3 results all look right */} +
{demo.results.map((src, i) => ( -
+
))}
-

{demo.description}

+
+

{demo.description}

+ {finished ? : null} +
); } diff --git a/src/components/onboarding/fakes.tsx b/src/components/onboarding/fakes.tsx index c3e2896..ad43dba 100644 --- a/src/components/onboarding/fakes.tsx +++ b/src/components/onboarding/fakes.tsx @@ -3,6 +3,7 @@ // demo progress — so new users see the real UI's shapes before their own // library exists. import sunset from "../../assets/onboarding/sunset.webp"; +import sunset2 from "../../assets/onboarding/sunset2.webp"; import beach from "../../assets/onboarding/beach.webp"; import landscape1 from "../../assets/onboarding/landscape1.webp"; import landscape2 from "../../assets/onboarding/landscape2.webp"; @@ -37,11 +38,14 @@ export function fakeImage(index: number): string { return FAKE_IMAGES[index % FAKE_IMAGES.length]; } -// Curated result sets so each search demo returns thematically right images. +// Result sets matched to what each query would genuinely return. +// - filename: one exact file (a filename search hits a specific name) +// - semantic "golden sunset over water": only the ocean-sunset stills +// - tags "landscape": mountain valleys + the alpine lake (all landscapes) export const SEARCH_RESULTS = { - filename: [beach, dunes, sunset], // "beach-day_042.jpg" - semantic: [sunset, alpinelake, beach], // "golden sunset over water" - tags: [landscape1, landscape2, forest], // "landscape" + filename: [beach], + semantic: [sunset, sunset2], + tags: [landscape1, landscape2, alpinelake], } as const; // Abstract gradient blobs/ticks for the Explore & Timeline mini-previews, @@ -133,6 +137,21 @@ export function FakeProgressBar({ fraction, className = "" }: { fraction: number ); } +export function ReplayButton({ onClick, label = "Replay" }: { onClick: () => void; label?: string }) { + return ( + + ); +} + export function formatBytes(bytes: number): string { if (bytes >= 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`; if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(0)} MB`;