From 050f296fa06e442d0fe785c19de6554d715f36f1 Mon Sep 17 00:00:00 2001 From: flamboh Date: Fri, 10 Jul 2026 17:36:34 -0700 Subject: [PATCH 01/12] feat: prototype local listening guide --- components/audio/ListeningGuidePrototype.tsx | 447 +++++++++++++++++++ components/audio/audioTagger.tsx | 5 +- components/dev/PrototypeSwitcher.tsx | 76 ++++ 3 files changed, 527 insertions(+), 1 deletion(-) create mode 100644 components/audio/ListeningGuidePrototype.tsx create mode 100644 components/dev/PrototypeSwitcher.tsx diff --git a/components/audio/ListeningGuidePrototype.tsx b/components/audio/ListeningGuidePrototype.tsx new file mode 100644 index 00000000..090e8e22 --- /dev/null +++ b/components/audio/ListeningGuidePrototype.tsx @@ -0,0 +1,447 @@ +"use client"; + +// PROTOTYPE — Three listening-guide layouts, switchable via ?variant=, mounted in the existing app shell. +import { useEffect, useState } from "react"; +import { + ArrowLeft, + ArrowRight, + BookOpen, + Check, + ChevronRight, + CirclePlay, + Download, + FileArchive, + Headphones, + Image, + Laptop, + Monitor, + Music2, + Play, + Smartphone, + Sparkles, + Tablet, +} from "lucide-react"; +import PrototypeSwitcher from "@/components/dev/PrototypeSwitcher"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +const variants = [ + { key: "A", name: "guided picker" }, + { key: "B", name: "visual handbook" }, + { key: "C", name: "journey map" }, +]; + +const destinations = [ + { key: "spotify-desktop", service: "Spotify", device: "desktop", icon: Monitor }, + { key: "spotify-ios", service: "Spotify", device: "iPhone / iPad", icon: Smartphone }, + { key: "spotify-android", service: "Spotify", device: "Android", icon: Smartphone }, + { key: "apple-desktop", service: "Apple Music", device: "Mac / Windows", icon: Laptop }, + { key: "apple-ios", service: "Apple Music", device: "iPhone / iPad", icon: Tablet }, +] as const; + +type DestinationKey = (typeof destinations)[number]["key"]; + +function PlaceholderMedia({ + kind = "image", + label = "media placeholder", + className, +}: { + kind?: "image" | "video"; + label?: string; + className?: string; +}) { + return ( +
+
+
+ {kind === "video" ? : } + {label} +
+
+ ); +} + +function PrototypeHeader({ eyebrow }: { eyebrow: string }) { + const leavePrototype = () => { + const url = new URL(window.location.href); + url.searchParams.delete("prototype"); + url.searchParams.delete("variant"); + window.location.assign(url.toString()); + }; + + return ( +
+
+ +
+

+ {eyebrow} +

+

Listening guide placeholder

+
+
+ + prototype only + +
+ ); +} + +function DestinationPicker({ + selected, + onSelect, + compact = false, +}: { + selected: DestinationKey; + onSelect: (destination: DestinationKey) => void; + compact?: boolean; +}) { + return ( +
+ {destinations.map((destination) => { + const Icon = destination.icon; + const isSelected = selected === destination.key; + return ( + + ); + })} +
+ ); +} + +function VariantA() { + const [selected, setSelected] = useState("spotify-ios"); + + return ( +
+ +
+
+
+
+

Choose where you listen

+

+ Guide headline placeholder +

+

+ Short orientation placeholder. One sentence about selecting a destination. +

+
+ +
+ +
+
+ {["Prepare the files", "Move the files", "Find your music"].map((title, index) => ( +
+
+ {index + 1} +
+
+
+

{title} placeholder

+

+ Two or three lines of instructional copy will go here. Keep the action + focused and easy to scan. +

+
+ +
+
+ ))} +
+ + +
+
+
+
+ ); +} + +function VariantB() { + const [selected, setSelected] = useState("spotify-desktop"); + + return ( +
+ +
+ + +
+
+
+ +
+
+ Visual handbook placeholder +
+

+ Article title placeholder for the selected path +

+

+ Intro paragraph placeholder. This layout treats the guide like calm, familiar product + documentation with visual proof close to each instruction. +

+ + + + {["First section", "Second section", "Final section"].map((section, index) => ( +
+
+ + {index + 1} + +
+

{section} heading placeholder

+

+ Instructional paragraph placeholder with enough vertical space to judge + reading rhythm and media placement. +

+
+
+ {index !== 1 && ( + + )} +
+ ))} +
+
+
+
+ ); +} + +function VariantC() { + const [selected, setSelected] = useState("apple-ios"); + const journey = [ + { icon: Download, label: "Download" }, + { icon: FileArchive, label: "Prepare" }, + { icon: Smartphone, label: "Transfer" }, + { icon: Headphones, label: "Listen" }, + ]; + + return ( +
+ +
+
+
+ + + +

+ From Tagium to your headphones +

+

+ Short journey-level introduction placeholder. +

+
+ +
+ {journey.map((step, index) => { + const Icon = step.icon; + return ( +
+ {index < journey.length - 1 && ( + + )} +
+ + 0{index + 1} +
+

{step.label}

+

Step summary placeholder

+
+ ); + })} +
+ +
+
+
+

Choose your destination

+

+ The journey adapts after this choice. +

+
+
+ 3 visual steps placeholder +
+
+ +
+ +
+ +
+ {["Open the destination app", "Enable the local library", "Confirm the track"].map( + (step, index) => ( + + ), + )} +
+
+
+
+
+ ); +} + +export default function ListeningGuidePrototype() { + const [access, setAccess] = useState<"checking" | "allowed" | "denied">( + import.meta.env.DEV ? "allowed" : "checking", + ); + const requestedVariant = new URLSearchParams(window.location.search).get("variant") ?? "A"; + const initialVariant = variants.some((variant) => variant.key === requestedVariant) + ? requestedVariant + : "A"; + const [variant, setVariant] = useState(initialVariant); + + useEffect(() => { + if (import.meta.env.DEV) return; + + fetch("/api/dev/config", { headers: { Accept: "application/json" } }) + .then((response) => (response.ok ? response.json() : null)) + .then((config) => setAccess(config?.deployEnv === "preview" ? "allowed" : "denied")) + .catch(() => setAccess("denied")); + }, []); + + const changeVariant = (nextVariant: string) => { + const url = new URL(window.location.href); + url.searchParams.set("variant", nextVariant); + window.history.replaceState({}, "", url); + setVariant(nextVariant); + }; + + if (access !== "allowed") { + return ( +
+ {access === "checking" ? "loading prototype…" : "prototype unavailable"} +
+ ); + } + + return ( + <> + {variant === "A" && } + {variant === "B" && } + {variant === "C" && } + + + ); +} diff --git a/components/audio/audioTagger.tsx b/components/audio/audioTagger.tsx index e8329f22..8cbaf7e1 100644 --- a/components/audio/audioTagger.tsx +++ b/components/audio/audioTagger.tsx @@ -54,6 +54,7 @@ import { type PlaylistDownloadControllerSnapshot, } from "./playlistDownloadController"; import LandingScreen from "./LandingScreen"; +import ListeningGuidePrototype from "./ListeningGuidePrototype"; import TrackMetadataEditor from "./TrackMetadataEditor"; import SettingsPage from "./SettingsPage"; import AudioDownloader from "./AudioDownloader"; @@ -1904,7 +1905,9 @@ export default function AudioTagger() { />
- {activeView === "settings" ? ( + {new URLSearchParams(window.location.search).get("prototype") === "listening-guide" ? ( + + ) : activeView === "settings" ? ( void; +} + +export default function PrototypeSwitcher({ variants, current, onChange }: PrototypeSwitcherProps) { + const currentIndex = Math.max( + 0, + variants.findIndex((variant) => variant.key === current), + ); + + const cycle = (direction: -1 | 1) => { + const nextIndex = (currentIndex + direction + variants.length) % variants.length; + const nextVariant = variants[nextIndex]; + if (nextVariant) onChange(nextVariant.key); + }; + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + const target = event.target; + if ( + target instanceof HTMLInputElement || + target instanceof HTMLTextAreaElement || + (target instanceof HTMLElement && target.isContentEditable) + ) { + return; + } + + if (event.key === "ArrowLeft") cycle(-1); + if (event.key === "ArrowRight") cycle(1); + }; + + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }); + + const activeVariant = variants[currentIndex]; + + return ( +
+ +
+ {activeVariant?.key} — {activeVariant?.name} +
+ +
+ ); +} From a1a8485b04bcb05ced2e021e3f7bdefc703c1894 Mon Sep 17 00:00:00 2001 From: flamboh Date: Fri, 10 Jul 2026 18:06:49 -0700 Subject: [PATCH 02/12] feat: add listening guide wizard --- components/audio/ListeningGuidePrototype.tsx | 412 +++++++++++++++---- 1 file changed, 339 insertions(+), 73 deletions(-) diff --git a/components/audio/ListeningGuidePrototype.tsx b/components/audio/ListeningGuidePrototype.tsx index 090e8e22..2b6cb7b9 100644 --- a/components/audio/ListeningGuidePrototype.tsx +++ b/components/audio/ListeningGuidePrototype.tsx @@ -9,18 +9,22 @@ import { Check, ChevronRight, CirclePlay, + Cloud, Download, FileArchive, + FolderOpen, + Globe2, Headphones, Image, - Laptop, Monitor, Music2, Play, Smartphone, Sparkles, Tablet, + Video, } from "lucide-react"; +import type { LucideIcon } from "lucide-react"; import PrototypeSwitcher from "@/components/dev/PrototypeSwitcher"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; @@ -31,15 +35,39 @@ const variants = [ { key: "C", name: "journey map" }, ]; -const destinations = [ - { key: "spotify-desktop", service: "Spotify", device: "desktop", icon: Monitor }, - { key: "spotify-ios", service: "Spotify", device: "iPhone / iPad", icon: Smartphone }, - { key: "spotify-android", service: "Spotify", device: "Android", icon: Smartphone }, - { key: "apple-desktop", service: "Apple Music", device: "Mac / Windows", icon: Laptop }, - { key: "apple-ios", service: "Apple Music", device: "iPhone / iPad", icon: Tablet }, +const appOptions = [ + { key: "spotify", label: "Spotify", detail: "App detail placeholder", icon: Headphones }, + { key: "apple-music", label: "Apple Music", detail: "App detail placeholder", icon: Music2 }, + { key: "other", label: "Something else", detail: "App detail placeholder", icon: Sparkles }, ] as const; -type DestinationKey = (typeof destinations)[number]["key"]; +const deviceOptions = [ + { key: "computer", label: "Computer", detail: "Mac or Windows", icon: Monitor }, + { key: "iphone-ipad", label: "iPhone / iPad", detail: "iOS or iPadOS", icon: Tablet }, + { key: "android", label: "Android", detail: "Phone or tablet", icon: Smartphone }, +] as const; + +const sourceOptions = [ + { key: "soundcloud", label: "SoundCloud", detail: "Source detail placeholder", icon: Cloud }, + { key: "youtube", label: "YouTube", detail: "Source detail placeholder", icon: Video }, + { + key: "local-files", + label: "Files I already have", + detail: "Source detail placeholder", + icon: FolderOpen, + }, + { key: "other", label: "Somewhere else", detail: "Source detail placeholder", icon: Globe2 }, +] as const; + +type AppKey = (typeof appOptions)[number]["key"]; +type DeviceKey = (typeof deviceOptions)[number]["key"]; +type SourceKey = (typeof sourceOptions)[number]["key"]; + +interface GuideSetup { + app: AppKey; + devices: DeviceKey[]; + source: SourceKey; +} function PlaceholderMedia({ kind = "image", @@ -99,74 +127,318 @@ function PrototypeHeader({ eyebrow }: { eyebrow: string }) { ); } -function DestinationPicker({ +function OptionCard({ + icon: Icon, + label, + detail, selected, - onSelect, + onClick, + multiple = false, +}: { + icon: LucideIcon; + label: string; + detail: string; + selected: boolean; + onClick: () => void; + multiple?: boolean; +}) { + return ( + + ); +} + +function SetupSummary({ + setup, + onEdit, compact = false, }: { - selected: DestinationKey; - onSelect: (destination: DestinationKey) => void; + setup: GuideSetup; + onEdit: () => void; compact?: boolean; }) { - return ( -
- {destinations.map((destination) => { - const Icon = destination.icon; - const isSelected = selected === destination.key; - return ( + const app = appOptions.find((option) => option.key === setup.app); + const source = sourceOptions.find((option) => option.key === setup.source); + const devices = setup.devices + .map((key) => deviceOptions.find((option) => option.key === key)?.label) + .filter(Boolean) + .join(", "); + + if (compact) { + return ( +
+
+

+ Your setup +

- ); - })} +
+
+

{app?.label}

+

{devices}

+

From {source?.label}

+
+
+ ); + } + + return ( +
+
+ {[ + ["Listen with", app?.label], + ["On", devices], + ["Music from", source?.label], + ].map(([label, value]) => ( +
+

{label}

+

{value}

+
+ ))} +
+ +
+ ); +} + +function ListeningGuideWizard({ onComplete }: { onComplete: (setup: GuideSetup) => void }) { + const [step, setStep] = useState(0); + const [app, setApp] = useState(null); + const [devices, setDevices] = useState([]); + const [source, setSource] = useState(null); + const steps = ["Listening app", "Devices", "Music source"]; + + const toggleDevice = (device: DeviceKey) => { + setDevices((current) => + current.includes(device) ? current.filter((item) => item !== device) : [...current, device], + ); + }; + + const canContinue = (step === 0 && app !== null) || (step === 1 && devices.length > 0); + const canFinish = step === 2 && app !== null && devices.length > 0 && source !== null; + + return ( +
+ +
+
+
+
+ {steps.map((label, index) => ( +
+ + {index < step ? : index + 1} + + + {label} + + {index < steps.length - 1 && } +
+ ))} +
+ + {step === 0 && ( +
+

Question 1 of 3

+

+ What app do you use? +

+

Helper text placeholder.

+
+ {appOptions.map((option) => ( + setApp(option.key)} + /> + ))} +
+
+ )} + + {step === 1 && ( +
+

Question 2 of 3

+

+ Where do you want to listen? +

+

Choose every device that applies.

+
+ {deviceOptions.map((option) => ( + toggleDevice(option.key)} + /> + ))} +
+
+ )} + + {step === 2 && ( +
+

Question 3 of 3

+

+ Where is your music now? +

+

Choose the main source for this guide.

+
+ {sourceOptions.map((option) => ( + setSource(option.key)} + /> + ))} +
+
+ )} + +
+ + {step < 2 ? ( + + ) : ( + + )} +
+
+ + +
+
); } -function VariantA() { - const [selected, setSelected] = useState("spotify-ios"); +interface GuideVariantProps { + setup: GuideSetup; + onEditSetup: () => void; +} + +function VariantA({ setup, onEditSetup }: GuideVariantProps) { + const app = appOptions.find((option) => option.key === setup.app); return (
- +
-
+
+
-

Choose where you listen

+

Your tailored path

- Guide headline placeholder + {app?.label} guide headline placeholder

- Short orientation placeholder. One sentence about selecting a destination. + Short orientation placeholder based on the wizard answers.

-
-
{["Prepare the files", "Move the files", "Find your music"].map((title, index) => ( @@ -220,18 +492,13 @@ function VariantA() { ); } -function VariantB() { - const [selected, setSelected] = useState("spotify-desktop"); - +function VariantB({ setup, onEditSetup }: GuideVariantProps) { return (
-
-
-
-

Choose your destination

-

- The journey adapts after this choice. -

-
+
+
+

Your tailored journey

3 visual steps placeholder
- +
@@ -406,6 +667,7 @@ export default function ListeningGuidePrototype() { const [access, setAccess] = useState<"checking" | "allowed" | "denied">( import.meta.env.DEV ? "allowed" : "checking", ); + const [setup, setSetup] = useState(null); const requestedVariant = new URLSearchParams(window.location.search).get("variant") ?? "A"; const initialVariant = variants.some((variant) => variant.key === requestedVariant) ? requestedVariant @@ -436,11 +698,15 @@ export default function ListeningGuidePrototype() { ); } + if (!setup) { + return ; + } + return ( <> - {variant === "A" && } - {variant === "B" && } - {variant === "C" && } + {variant === "A" && setSetup(null)} />} + {variant === "B" && setSetup(null)} />} + {variant === "C" && setSetup(null)} />} ); From f30aee9800ceb762eee6c3d837c60aa407e505fe Mon Sep 17 00:00:00 2001 From: flamboh Date: Fri, 10 Jul 2026 22:35:44 -0700 Subject: [PATCH 03/12] feat: refine listening guide access flow --- components/audio/ListeningGuidePrototype.tsx | 283 ++----------------- components/audio/TagSidebarPanel.tsx | 68 ++++- components/audio/audioTagger.tsx | 26 +- components/dev/PrototypeSwitcher.tsx | 76 ----- 4 files changed, 119 insertions(+), 334 deletions(-) delete mode 100644 components/dev/PrototypeSwitcher.tsx diff --git a/components/audio/ListeningGuidePrototype.tsx b/components/audio/ListeningGuidePrototype.tsx index 2b6cb7b9..05501ba2 100644 --- a/components/audio/ListeningGuidePrototype.tsx +++ b/components/audio/ListeningGuidePrototype.tsx @@ -1,40 +1,29 @@ "use client"; -// PROTOTYPE — Three listening-guide layouts, switchable via ?variant=, mounted in the existing app shell. +// PROTOTYPE — Entry wizard feeding the selected right-rail handbook layout in the existing app shell. import { useEffect, useState } from "react"; import { ArrowLeft, ArrowRight, BookOpen, Check, - ChevronRight, CirclePlay, Cloud, - Download, - FileArchive, FolderOpen, Globe2, Headphones, Image, Monitor, Music2, - Play, Smartphone, Sparkles, Tablet, Video, } from "lucide-react"; import type { LucideIcon } from "lucide-react"; -import PrototypeSwitcher from "@/components/dev/PrototypeSwitcher"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; -const variants = [ - { key: "A", name: "guided picker" }, - { key: "B", name: "visual handbook" }, - { key: "C", name: "journey map" }, -]; - const appOptions = [ { key: "spotify", label: "Spotify", detail: "App detail placeholder", icon: Headphones }, { key: "apple-music", label: "Apple Music", detail: "App detail placeholder", icon: Music2 }, @@ -177,15 +166,7 @@ function OptionCard({ ); } -function SetupSummary({ - setup, - onEdit, - compact = false, -}: { - setup: GuideSetup; - onEdit: () => void; - compact?: boolean; -}) { +function SetupSummary({ setup, onEdit }: { setup: GuideSetup; onEdit: () => void }) { const app = appOptions.find((option) => option.key === setup.app); const source = sourceOptions.find((option) => option.key === setup.source); const devices = setup.devices @@ -193,30 +174,6 @@ function SetupSummary({ .filter(Boolean) .join(", "); - if (compact) { - return ( -
-
-

- Your setup -

- -
-
-

{app?.label}

-

{devices}

-

From {source?.label}

-
-
- ); - } - return (
@@ -419,111 +376,14 @@ interface GuideVariantProps { onEditSetup: () => void; } -function VariantA({ setup, onEditSetup }: GuideVariantProps) { - const app = appOptions.find((option) => option.key === setup.app); - - return ( -
- -
-
- -
-
-

Your tailored path

-

- {app?.label} guide headline placeholder -

-

- Short orientation placeholder based on the wizard answers. -

-
-
-
-
- {["Prepare the files", "Move the files", "Find your music"].map((title, index) => ( -
-
- {index + 1} -
-
-
-

{title} placeholder

-

- Two or three lines of instructional copy will go here. Keep the action - focused and easy to scan. -

-
- -
-
- ))} -
- - -
-
-
-
- ); -} - -function VariantB({ setup, onEditSetup }: GuideVariantProps) { +function VisualHandbookGuide({ setup, onEditSetup }: GuideVariantProps) { return (
- -
- - + +
-
+
@@ -567,98 +427,28 @@ function VariantB({ setup, onEditSetup }: GuideVariantProps) { ))}
-
-
- ); -} - -function VariantC({ setup, onEditSetup }: GuideVariantProps) { - const journey = [ - { icon: Download, label: "Download" }, - { icon: FileArchive, label: "Prepare" }, - { icon: Smartphone, label: "Transfer" }, - { icon: Headphones, label: "Listen" }, - ]; - - return ( -
- -
-
-
- - - -

- From Tagium to your headphones -

-

- Short journey-level introduction placeholder. -

-
-
- {journey.map((step, index) => { - const Icon = step.icon; - return ( -
- {index < journey.length - 1 && ( - +
-

{step.label}

-

Step summary placeholder

-
- ); - })} -
- -
-
-

Your tailored journey

-
- 3 visual steps placeholder -
-
- -
- -
- -
- {["Open the destination app", "Enable the local library", "Confirm the track"].map( - (step, index) => ( - - ), - )} -
-
-
-
+ > + {item} placeholder + + ), + )} + + +
); } @@ -668,11 +458,6 @@ export default function ListeningGuidePrototype() { import.meta.env.DEV ? "allowed" : "checking", ); const [setup, setSetup] = useState(null); - const requestedVariant = new URLSearchParams(window.location.search).get("variant") ?? "A"; - const initialVariant = variants.some((variant) => variant.key === requestedVariant) - ? requestedVariant - : "A"; - const [variant, setVariant] = useState(initialVariant); useEffect(() => { if (import.meta.env.DEV) return; @@ -683,13 +468,6 @@ export default function ListeningGuidePrototype() { .catch(() => setAccess("denied")); }, []); - const changeVariant = (nextVariant: string) => { - const url = new URL(window.location.href); - url.searchParams.set("variant", nextVariant); - window.history.replaceState({}, "", url); - setVariant(nextVariant); - }; - if (access !== "allowed") { return (
@@ -702,12 +480,5 @@ export default function ListeningGuidePrototype() { return ; } - return ( - <> - {variant === "A" && setSetup(null)} />} - {variant === "B" && setSetup(null)} />} - {variant === "C" && setSetup(null)} />} - - - ); + return setSetup(null)} />; } diff --git a/components/audio/TagSidebarPanel.tsx b/components/audio/TagSidebarPanel.tsx index 0c824a9e..7c425d18 100644 --- a/components/audio/TagSidebarPanel.tsx +++ b/components/audio/TagSidebarPanel.tsx @@ -1,7 +1,7 @@ "use client"; import type { MouseEvent as ReactMouseEvent } from "react"; -import { useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { Settings } from "lucide-react"; import { cn } from "@/lib/utils"; import AlbumSidebar from "./AlbumSidebar"; @@ -21,6 +21,7 @@ interface TagSidebarPanelProps { selectedFileId: string | null; selectedFileIds: Set; settingsOpen: boolean; + listeningGuideOpen?: boolean; onAudioUpload: (files: File[]) => void; onSelectAlbum: (albumId: string, event?: ReactMouseEvent) => void; onSelectFile: (albumId: string, fileId: string, event?: ReactMouseEvent) => void; @@ -48,10 +49,70 @@ interface TagSidebarPanelProps { playlistDownloadQueue?: PlaylistDownloadQueuePanelState | null; onDownloadAll: () => void; onOpenSettings: () => void; + onOpenListeningGuide?: () => void; onCancelPlaylistDownloadQueue?: () => void; onRetryPlaylistDownloadQueue?: () => void; } +function ListeningGuideEntryButton({ active, onClick }: { active: boolean; onClick: () => void }) { + const services = ["spotify?", "apple music?"]; + const [serviceIndex, setServiceIndex] = useState(0); + const [animating, setAnimating] = useState(false); + + useEffect(() => { + let swapTimeout: ReturnType | undefined; + const interval = setInterval(() => { + setAnimating(true); + swapTimeout = setTimeout(() => { + setServiceIndex((current) => (current + 1) % services.length); + setAnimating(false); + }, 350); + }, 3_000); + + return () => { + clearInterval(interval); + if (swapTimeout) clearTimeout(swapTimeout); + }; + }, [services.length]); + + const nextServiceIndex = (serviceIndex + 1) % services.length; + + return ( + + ); +} + export default function TagSidebarPanel({ loading, files, @@ -61,6 +122,7 @@ export default function TagSidebarPanel({ selectedFileId, selectedFileIds, settingsOpen, + listeningGuideOpen = false, onAudioUpload, onSelectAlbum, onSelectFile, @@ -79,6 +141,7 @@ export default function TagSidebarPanel({ playlistDownloadQueue = null, onDownloadAll, onOpenSettings, + onOpenListeningGuide, onCancelPlaylistDownloadQueue, onRetryPlaylistDownloadQueue, }: TagSidebarPanelProps) { @@ -210,6 +273,9 @@ export default function TagSidebarPanel({ settings + {onOpenListeningGuide && ( + + )}
); diff --git a/components/audio/audioTagger.tsx b/components/audio/audioTagger.tsx index 8cbaf7e1..beac2565 100644 --- a/components/audio/audioTagger.tsx +++ b/components/audio/audioTagger.tsx @@ -285,6 +285,9 @@ export default function AudioTagger() { const [editingAlbumId, setEditingAlbumId] = useState(null); const [createSeedTrackIds, setCreateSeedTrackIds] = useState([]); const [activeView, setActiveView] = useState("editor"); + const [listeningGuidePrototypeAvailable, setListeningGuidePrototypeAvailable] = useState( + import.meta.env.DEV, + ); const [settings, setSettings] = useState(loadAppSettings); const [playlistDownloadQueue, setPlaylistDownloadQueue] = useState(null); @@ -293,6 +296,8 @@ export default function AudioTagger() { albumCount: albums.length, importing: loading || urlImporting, }); + const listeningGuidePrototypeOpen = + new URLSearchParams(window.location.search).get("prototype") === "listening-guide"; useBeforeUnloadProtection(hasRecoverableWork); const filesRef = useRef(files); const albumsRef = useRef(albums); @@ -343,6 +348,14 @@ export default function AudioTagger() { reset(selectedFile.metadata); } }, [selectedFile, reset]); + useEffect(() => { + if (import.meta.env.DEV) return; + + fetch("/api/dev/config", { headers: { Accept: "application/json" } }) + .then((response) => (response.ok ? response.json() : null)) + .then((config) => setListeningGuidePrototypeAvailable(config?.deployEnv === "preview")) + .catch(() => setListeningGuidePrototypeAvailable(false)); + }, []); useEffect(() => { const fileIdSet = new Set(files.map((file) => file.id)); setLooseTrackIds((prevLooseTrackIds) => @@ -1879,6 +1892,7 @@ export default function AudioTagger() { selectedFileId={selectedFileId} selectedFileIds={selectedFileIds} settingsOpen={activeView === "settings"} + listeningGuideOpen={listeningGuidePrototypeOpen} onAudioUpload={handleAudioUpload} onSelectAlbum={handleSelectAlbum} onSelectFile={handleSelectFile} @@ -1900,12 +1914,22 @@ export default function AudioTagger() { if (isTrackCoverProcessing) return; setActiveView((currentView) => (currentView === "settings" ? "editor" : "settings")); }} + onOpenListeningGuide={ + listeningGuidePrototypeAvailable + ? () => { + const url = new URL(window.location.href); + url.searchParams.set("prototype", "listening-guide"); + url.searchParams.delete("variant"); + window.location.assign(url.toString()); + } + : undefined + } onCancelPlaylistDownloadQueue={handleCancelPlaylistDownloads} onRetryPlaylistDownloadQueue={handleRetryPlaylistDownloads} />
- {new URLSearchParams(window.location.search).get("prototype") === "listening-guide" ? ( + {listeningGuidePrototypeOpen ? ( ) : activeView === "settings" ? ( void; -} - -export default function PrototypeSwitcher({ variants, current, onChange }: PrototypeSwitcherProps) { - const currentIndex = Math.max( - 0, - variants.findIndex((variant) => variant.key === current), - ); - - const cycle = (direction: -1 | 1) => { - const nextIndex = (currentIndex + direction + variants.length) % variants.length; - const nextVariant = variants[nextIndex]; - if (nextVariant) onChange(nextVariant.key); - }; - - useEffect(() => { - const onKeyDown = (event: KeyboardEvent) => { - const target = event.target; - if ( - target instanceof HTMLInputElement || - target instanceof HTMLTextAreaElement || - (target instanceof HTMLElement && target.isContentEditable) - ) { - return; - } - - if (event.key === "ArrowLeft") cycle(-1); - if (event.key === "ArrowRight") cycle(1); - }; - - window.addEventListener("keydown", onKeyDown); - return () => window.removeEventListener("keydown", onKeyDown); - }); - - const activeVariant = variants[currentIndex]; - - return ( -
- -
- {activeVariant?.key} — {activeVariant?.name} -
- -
- ); -} From 175ab4695d7e03138aea8b73451558329aaea6e9 Mon Sep 17 00:00:00 2001 From: flamboh Date: Fri, 10 Jul 2026 22:44:23 -0700 Subject: [PATCH 04/12] fix: stabilize listening guide transition Keep labels in a continuous vertical track so the incoming label is not reassigned to an opacity-zero slot. Open the guide through active view state instead of URL navigation. --- components/audio/ListeningGuidePrototype.tsx | 54 ++++++------------- components/audio/TagSidebarPanel.tsx | 55 ++++++++++---------- components/audio/audioTagger.tsx | 18 +++---- 3 files changed, 53 insertions(+), 74 deletions(-) diff --git a/components/audio/ListeningGuidePrototype.tsx b/components/audio/ListeningGuidePrototype.tsx index 05501ba2..830d4b99 100644 --- a/components/audio/ListeningGuidePrototype.tsx +++ b/components/audio/ListeningGuidePrototype.tsx @@ -1,7 +1,7 @@ "use client"; // PROTOTYPE — Entry wizard feeding the selected right-rail handbook layout in the existing app shell. -import { useEffect, useState } from "react"; +import { useState } from "react"; import { ArrowLeft, ArrowRight, @@ -83,21 +83,14 @@ function PlaceholderMedia({ ); } -function PrototypeHeader({ eyebrow }: { eyebrow: string }) { - const leavePrototype = () => { - const url = new URL(window.location.href); - url.searchParams.delete("prototype"); - url.searchParams.delete("variant"); - window.location.assign(url.toString()); - }; - +function PrototypeHeader({ eyebrow, onBack }: { eyebrow: string; onBack: () => void }) { return (
diff --git a/components/audio/audioTagger.tsx b/components/audio/audioTagger.tsx index beac2565..9c78b130 100644 --- a/components/audio/audioTagger.tsx +++ b/components/audio/audioTagger.tsx @@ -81,7 +81,7 @@ import { TagiumFile, } from "./types"; -type ActiveView = "editor" | "settings"; +type ActiveView = "editor" | "settings" | "listening-guide"; type ManagedDownloadTrack = QueuedDownloadTrack & { importOperationId?: string }; type PlaylistDownloadQueueState = PlaylistDownloadControllerSnapshot; @@ -296,8 +296,6 @@ export default function AudioTagger() { albumCount: albums.length, importing: loading || urlImporting, }); - const listeningGuidePrototypeOpen = - new URLSearchParams(window.location.search).get("prototype") === "listening-guide"; useBeforeUnloadProtection(hasRecoverableWork); const filesRef = useRef(files); const albumsRef = useRef(albums); @@ -1892,7 +1890,7 @@ export default function AudioTagger() { selectedFileId={selectedFileId} selectedFileIds={selectedFileIds} settingsOpen={activeView === "settings"} - listeningGuideOpen={listeningGuidePrototypeOpen} + listeningGuideOpen={activeView === "listening-guide"} onAudioUpload={handleAudioUpload} onSelectAlbum={handleSelectAlbum} onSelectFile={handleSelectFile} @@ -1917,10 +1915,10 @@ export default function AudioTagger() { onOpenListeningGuide={ listeningGuidePrototypeAvailable ? () => { - const url = new URL(window.location.href); - url.searchParams.set("prototype", "listening-guide"); - url.searchParams.delete("variant"); - window.location.assign(url.toString()); + if (isTrackCoverProcessing) return; + setActiveView((currentView) => + currentView === "listening-guide" ? "editor" : "listening-guide", + ); } : undefined } @@ -1929,8 +1927,8 @@ export default function AudioTagger() { />
- {listeningGuidePrototypeOpen ? ( - + {activeView === "listening-guide" ? ( + setActiveView("editor")} /> ) : activeView === "settings" ? ( Date: Fri, 10 Jul 2026 23:12:25 -0700 Subject: [PATCH 05/12] feat: simplify listening guide wizard --- components/audio/ListeningGuidePrototype.tsx | 489 ++++++++----------- components/audio/TagSidebarPanel.tsx | 25 +- components/audio/audioTagger.tsx | 1 + public/brands/applemusic.svg | 1 + public/brands/soundcloud.svg | 1 + public/brands/spotify.svg | 1 + public/brands/youtube.svg | 1 + 7 files changed, 226 insertions(+), 293 deletions(-) create mode 100644 public/brands/applemusic.svg create mode 100644 public/brands/soundcloud.svg create mode 100644 public/brands/spotify.svg create mode 100644 public/brands/youtube.svg diff --git a/components/audio/ListeningGuidePrototype.tsx b/components/audio/ListeningGuidePrototype.tsx index 830d4b99..afc00ca3 100644 --- a/components/audio/ListeningGuidePrototype.tsx +++ b/components/audio/ListeningGuidePrototype.tsx @@ -1,51 +1,43 @@ "use client"; // PROTOTYPE — Entry wizard feeding the selected right-rail handbook layout in the existing app shell. -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { ArrowLeft, ArrowRight, - BookOpen, - Check, CirclePlay, - Cloud, FolderOpen, - Globe2, - Headphones, Image, Monitor, - Music2, + MonitorSmartphone, Smartphone, - Sparkles, Tablet, - Video, + X, } from "lucide-react"; import type { LucideIcon } from "lucide-react"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; const appOptions = [ - { key: "spotify", label: "Spotify", detail: "App detail placeholder", icon: Headphones }, - { key: "apple-music", label: "Apple Music", detail: "App detail placeholder", icon: Music2 }, - { key: "other", label: "Something else", detail: "App detail placeholder", icon: Sparkles }, + { key: "spotify", label: "spotify", logo: "/brands/spotify.svg" }, + { key: "apple-music", label: "apple music", logo: "/brands/applemusic.svg" }, ] as const; const deviceOptions = [ - { key: "computer", label: "Computer", detail: "Mac or Windows", icon: Monitor }, - { key: "iphone-ipad", label: "iPhone / iPad", detail: "iOS or iPadOS", icon: Tablet }, - { key: "android", label: "Android", detail: "Phone or tablet", icon: Smartphone }, + { key: "computer", label: "computer", icon: Monitor }, + { key: "iphone-ipad", label: "iphone / ipad", icon: Tablet }, + { key: "android", label: "android", icon: Smartphone }, + { key: "computer-mobile", label: "computer + phone / tablet", icon: MonitorSmartphone }, ] as const; const sourceOptions = [ - { key: "soundcloud", label: "SoundCloud", detail: "Source detail placeholder", icon: Cloud }, - { key: "youtube", label: "YouTube", detail: "Source detail placeholder", icon: Video }, + { key: "soundcloud", label: "soundcloud", logo: "/brands/soundcloud.svg" }, + { key: "youtube", label: "youtube", logo: "/brands/youtube.svg" }, { key: "local-files", - label: "Files I already have", - detail: "Source detail placeholder", + label: "local files", icon: FolderOpen, }, - { key: "other", label: "Somewhere else", detail: "Source detail placeholder", icon: Globe2 }, ] as const; type AppKey = (typeof appOptions)[number]["key"]; @@ -54,13 +46,13 @@ type SourceKey = (typeof sourceOptions)[number]["key"]; interface GuideSetup { app: AppKey; - devices: DeviceKey[]; + device: DeviceKey; source: SourceKey; } function PlaceholderMedia({ kind = "image", - label = "media placeholder", + label = "image", className, }: { kind?: "image" | "video"; @@ -77,13 +69,13 @@ function PlaceholderMedia({
{kind === "video" ? : } - {label} + {label}
); } -function PrototypeHeader({ eyebrow, onBack }: { eyebrow: string; onBack: () => void }) { +function PrototypeHeader({ onBack }: { onBack: () => void }) { return (
@@ -91,103 +83,55 @@ function PrototypeHeader({ eyebrow, onBack }: { eyebrow: string; onBack: () => v type="button" className="grid size-9 shrink-0 cursor-pointer place-items-center rounded-md text-primary/80 hover:bg-accent hover:text-primary" onClick={onBack} - aria-label="leave listening guide prototype" + aria-label="close listening guide" > - + -
-

- {eyebrow} -

-

Listening guide placeholder

-
+

how do i listen?

- - prototype only -
); } function OptionCard({ icon: Icon, + logo, label, - detail, selected, onClick, - multiple = false, }: { - icon: LucideIcon; + icon?: LucideIcon; + logo?: string; label: string; - detail: string; selected: boolean; onClick: () => void; - multiple?: boolean; }) { return ( ); } -function SetupSummary({ setup, onEdit }: { setup: GuideSetup; onEdit: () => void }) { - const app = appOptions.find((option) => option.key === setup.app); - const source = sourceOptions.find((option) => option.key === setup.source); - const devices = setup.devices - .map((key) => deviceOptions.find((option) => option.key === key)?.label) - .filter(Boolean) - .join(", "); - - return ( -
-
- {[ - ["Listen with", app?.label], - ["On", devices], - ["Music from", source?.label], - ].map(([label, value]) => ( -
-

{label}

-

{value}

-
- ))} -
- -
- ); -} - function ListeningGuideWizard({ onComplete, onBack, @@ -197,232 +141,197 @@ function ListeningGuideWizard({ }) { const [step, setStep] = useState(0); const [app, setApp] = useState(null); - const [devices, setDevices] = useState([]); + const [device, setDevice] = useState(null); const [source, setSource] = useState(null); - const steps = ["Listening app", "Devices", "Music source"]; + const questionHeadingRef = useRef(null); + const canAdvance = + (step === 0 && app !== null) || + (step === 1 && device !== null) || + (step === 2 && source !== null); - const toggleDevice = (device: DeviceKey) => { - setDevices((current) => - current.includes(device) ? current.filter((item) => item !== device) : [...current, device], - ); - }; + useEffect(() => { + questionHeadingRef.current?.focus(); + }, [step]); - const canContinue = (step === 0 && app !== null) || (step === 1 && devices.length > 0); - const canFinish = step === 2 && app !== null && devices.length > 0 && source !== null; + const advance = () => { + if (step < 2) { + if (canAdvance) setStep((current) => current + 1); + return; + } + + if (app && device && source) onComplete({ app, device, source }); + }; return (
- +
-
-
-
- {steps.map((label, index) => ( -
- - {index < step ? : index + 1} - - - {label} - - {index < steps.length - 1 && } -
- ))} -
+
+

+ step {step + 1} of 3 +

+ - {step === 0 && ( -
-

Question 1 of 3

-

- What app do you use? -

-

Helper text placeholder.

-
- {appOptions.map((option) => ( - setApp(option.key)} - /> - ))} -
+ {step === 0 && ( +
+

+ what app do you use? +

+
+ {appOptions.map((option) => ( + { + setApp(option.key); + setStep(1); + }} + /> + ))}
- )} +
+ )} - {step === 1 && ( -
-

Question 2 of 3

-

- Where do you want to listen? -

-

Choose every device that applies.

-
- {deviceOptions.map((option) => ( - toggleDevice(option.key)} - /> - ))} -
+ {step === 1 && ( +
+

+ where do you want to listen? +

+
+ {deviceOptions.map((option) => ( + { + setDevice(option.key); + setStep(2); + }} + /> + ))}
- )} +
+ )} - {step === 2 && ( -
-

Question 3 of 3

-

- Where is your music now? -

-

Choose the main source for this guide.

-
- {sourceOptions.map((option) => ( - setSource(option.key)} - /> - ))} -
+ {step === 2 && ( +
+

+ where is your music? +

+
+ {sourceOptions.map((option) => ( + { + setSource(option.key); + if (app && device) onComplete({ app, device, source: option.key }); + }} + /> + ))}
- )} +
+ )} -
+
+ + {canAdvance ? ( - {step < 2 ? ( - - ) : ( - - )} -
-
- - -
+ ) : ( +
+
); } -interface GuideVariantProps { - setup: GuideSetup; - onEditSetup: () => void; - onBack: () => void; -} +function VisualHandbookGuide({ setup, onBack }: { setup: GuideSetup; onBack: () => void }) { + const app = appOptions.find((option) => option.key === setup.app)?.label; + const device = deviceOptions.find((option) => option.key === setup.device)?.label; + const source = sourceOptions.find((option) => option.key === setup.source)?.label; + const sections = ["overview", "first section", "second section", "final section"]; + const guideHeadingRef = useRef(null); + + useEffect(() => { + guideHeadingRef.current?.focus(); + }, []); -function VisualHandbookGuide({ setup, onEditSetup, onBack }: GuideVariantProps) { return (
- +
-
-
- -
-
- Visual handbook placeholder -
-

- Article title placeholder for the selected path +
+

+ {source} to {app} on {device}

-

- Intro paragraph placeholder. This layout treats the guide like calm, familiar product - documentation with visual proof close to each instruction. -

- + - {["First section", "Second section", "Final section"].map((section, index) => ( -
+ {["first section", "second section", "final section"].map((section, index) => ( +
{index + 1} -
-

{section} heading placeholder

-

- Instructional paragraph placeholder with enough vertical space to judge - reading rhythm and media placement. -

-
+

{section}

- {index !== 1 && ( - - )} + {index !== 1 && }
))}
@@ -430,22 +339,20 @@ function VisualHandbookGuide({ setup, onEditSetup, onBack }: GuideVariantProps)

@@ -460,5 +367,5 @@ export default function ListeningGuidePrototype({ onBack }: { onBack: () => void return ; } - return setSetup(null)} onBack={onBack} />; + return ; } diff --git a/components/audio/TagSidebarPanel.tsx b/components/audio/TagSidebarPanel.tsx index 4456c6b2..de1550f6 100644 --- a/components/audio/TagSidebarPanel.tsx +++ b/components/audio/TagSidebarPanel.tsx @@ -59,8 +59,27 @@ interface TagSidebarPanelProps { function ListeningGuideEntryButton({ active, onClick }: { active: boolean; onClick: () => void }) { const [serviceIndex, setServiceIndex] = useState(0); const [transitionEnabled, setTransitionEnabled] = useState(true); + const [prefersReducedMotion, setPrefersReducedMotion] = useState(false); useEffect(() => { + const mediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)"); + const updateMotionPreference = () => { + setPrefersReducedMotion(mediaQuery.matches); + setTransitionEnabled(!mediaQuery.matches); + if (mediaQuery.matches) { + setServiceIndex(0); + } + }; + + updateMotionPreference(); + mediaQuery.addEventListener("change", updateMotionPreference); + + return () => mediaQuery.removeEventListener("change", updateMotionPreference); + }, []); + + useEffect(() => { + if (prefersReducedMotion) return; + const interval = setInterval(() => { setServiceIndex((current) => current >= listeningGuideServices.length - 1 ? 1 : current + 1, @@ -68,7 +87,7 @@ function ListeningGuideEntryButton({ active, onClick }: { active: boolean; onCli }, 3_000); return () => clearInterval(interval); - }, []); + }, [prefersReducedMotion]); return ( -

how do i listen?

+

+ how do i listen? +

); @@ -182,84 +183,86 @@ function ListeningGuideWizard({ ))}
- {step === 0 && ( -
-

- what app do you use? -

-
- {appOptions.map((option) => ( - { - setApp(option.key); - setStep(1); - }} - /> - ))} +
+ {step === 0 && ( +
+

+ what app do you use? +

+
+ {appOptions.map((option) => ( + { + setApp(option.key); + setStep(1); + }} + /> + ))} +
-
- )} + )} - {step === 1 && ( -
-

- where do you want to listen? -

-
- {deviceOptions.map((option) => ( - { - setDevice(option.key); - setStep(2); - }} - /> - ))} + {step === 1 && ( +
+

+ where do you want to listen? +

+
+ {deviceOptions.map((option) => ( + { + setDevice(option.key); + setStep(2); + }} + /> + ))} +
-
- )} + )} - {step === 2 && ( -
-

- where is your music? -

-
- {sourceOptions.map((option) => ( - { - setSource(option.key); - if (app && device) onComplete({ app, device, source: option.key }); - }} - /> - ))} + {step === 2 && ( +
+

+ where is your music? +

+
+ {sourceOptions.map((option) => ( + { + setSource(option.key); + if (app && device) onComplete({ app, device, source: option.key }); + }} + /> + ))} +
-
- )} + )} +
- {canAdvance ? ( - - ) : ( -
From ad3d731cb0a99981b015333f735c3304a0eec05f Mon Sep 17 00:00:00 2001 From: flamboh Date: Fri, 10 Jul 2026 23:35:42 -0700 Subject: [PATCH 08/12] feat: preserve listening guide selections --- components/audio/ListeningGuidePrototype.tsx | 63 +++++++++++++++----- 1 file changed, 49 insertions(+), 14 deletions(-) diff --git a/components/audio/ListeningGuidePrototype.tsx b/components/audio/ListeningGuidePrototype.tsx index c8b11958..772098f0 100644 --- a/components/audio/ListeningGuidePrototype.tsx +++ b/components/audio/ListeningGuidePrototype.tsx @@ -100,12 +100,14 @@ function OptionCard({ label, selected, onClick, + className, }: { icon?: LucideIcon; logo?: string; label: string; selected: boolean; onClick: () => void; + className?: string; }) { return ( +
@@ -362,10 +385,22 @@ function VisualHandbookGuide({ setup, onBack }: { setup: GuideSetup; onBack: () export default function ListeningGuidePrototype({ onBack }: { onBack: () => void }) { const [setup, setSetup] = useState(null); + const [showGuide, setShowGuide] = useState(false); - if (!setup) { - return ; + if (!showGuide || !setup) { + return ( + { + setSetup(nextSetup); + setShowGuide(true); + }} + onBack={onBack} + /> + ); } - return ; + return ( + setShowGuide(false)} /> + ); } From 799882c9277dee0f8d9ea7370f307fbb67b1bcd1 Mon Sep 17 00:00:00 2001 From: flamboh Date: Fri, 10 Jul 2026 23:38:07 -0700 Subject: [PATCH 09/12] style: theme application scrollbars --- src/index.css | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/index.css b/src/index.css index 3f813c8a..85d3920e 100644 --- a/src/index.css +++ b/src/index.css @@ -197,6 +197,28 @@ @layer base { * { @apply border-border outline-ring/50; + scrollbar-color: var(--muted) transparent; + scrollbar-width: thin; + } + + *::-webkit-scrollbar { + width: 10px; + height: 10px; + } + + *::-webkit-scrollbar-track { + background: transparent; + } + + *::-webkit-scrollbar-thumb { + border: 2px solid transparent; + border-radius: 9999px; + background-color: var(--muted); + background-clip: padding-box; + } + + *::-webkit-scrollbar-thumb:hover { + background-color: var(--muted-foreground); } html, From 616c5ca4ec02cf1ad63bbcc9cede9f13b98997f4 Mon Sep 17 00:00:00 2001 From: flamboh Date: Fri, 10 Jul 2026 23:44:53 -0700 Subject: [PATCH 10/12] style: stack listening service options --- components/audio/ListeningGuidePrototype.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/audio/ListeningGuidePrototype.tsx b/components/audio/ListeningGuidePrototype.tsx index 772098f0..253f839b 100644 --- a/components/audio/ListeningGuidePrototype.tsx +++ b/components/audio/ListeningGuidePrototype.tsx @@ -205,7 +205,7 @@ function ListeningGuideWizard({ logo={option.logo} label={option.label} selected={app === option.key} - className="min-h-48" + className="h-[204px] flex-col justify-center gap-1 text-center" onClick={() => { setApp(option.key); setStep(1); From c24808f5606aa4349275a7969e3c6ef638c4be90 Mon Sep 17 00:00:00 2001 From: flamboh Date: Sat, 11 Jul 2026 00:01:37 -0700 Subject: [PATCH 11/12] feat: persist listening guide progress --- components/audio/ListeningGuidePrototype.tsx | 124 +++++++++++++------ 1 file changed, 85 insertions(+), 39 deletions(-) diff --git a/components/audio/ListeningGuidePrototype.tsx b/components/audio/ListeningGuidePrototype.tsx index 253f839b..1c5f2d20 100644 --- a/components/audio/ListeningGuidePrototype.tsx +++ b/components/audio/ListeningGuidePrototype.tsx @@ -49,6 +49,42 @@ interface GuideSetup { source: SourceKey; } +interface ListeningGuideState { + view: "wizard" | "guide"; + step: number; + app: AppKey | null; + device: DeviceKey | null; + source: SourceKey | null; +} + +const listeningGuideStorageKey = "tagium:listening-guide-prototype"; +const initialListeningGuideState: ListeningGuideState = { + view: "wizard", + step: 0, + app: null, + device: null, + source: null, +}; + +function loadListeningGuideState(): ListeningGuideState { + if (typeof window === "undefined") return initialListeningGuideState; + + try { + const storedState = JSON.parse( + window.localStorage.getItem(listeningGuideStorageKey) ?? "null", + ) as Partial | null; + const app = appOptions.find((option) => option.key === storedState?.app)?.key ?? null; + const device = deviceOptions.find((option) => option.key === storedState?.device)?.key ?? null; + const source = sourceOptions.find((option) => option.key === storedState?.source)?.key ?? null; + const step = storedState?.step === 1 || storedState?.step === 2 ? storedState.step : 0; + const view = storedState?.view === "guide" && app && device && source ? "guide" : "wizard"; + + return { view, step, app, device, source }; + } catch { + return initialListeningGuideState; + } +} + function PlaceholderMedia({ kind = "image", label = "image", @@ -137,35 +173,35 @@ function OptionCard({ } function ListeningGuideWizard({ - initialSetup, + state, + onChange, onComplete, onBack, }: { - initialSetup: GuideSetup | null; + state: ListeningGuideState; + onChange: (state: ListeningGuideState) => void; onComplete: (setup: GuideSetup) => void; onBack: () => void; }) { - const [step, setStep] = useState(0); - const [app, setApp] = useState(initialSetup?.app ?? null); - const [device, setDevice] = useState(initialSetup?.device ?? null); - const [source, setSource] = useState(initialSetup?.source ?? null); const questionHeadingRef = useRef(null); const canAdvance = - (step === 0 && app !== null) || - (step === 1 && device !== null) || - (step === 2 && source !== null); + (state.step === 0 && state.app !== null) || + (state.step === 1 && state.device !== null) || + (state.step === 2 && state.source !== null); useEffect(() => { questionHeadingRef.current?.focus(); - }, [step]); + }, [state.step]); const advance = () => { - if (step < 2) { - if (canAdvance) setStep((current) => current + 1); + if (state.step < 2) { + if (canAdvance) onChange({ ...state, step: state.step + 1 }); return; } - if (app && device && source) onComplete({ app, device, source }); + if (state.app && state.device && state.source) { + onComplete({ app: state.app, device: state.device, source: state.source }); + } }; return ( @@ -174,7 +210,7 @@ function ListeningGuideWizard({

- step {step + 1} of 3 + step {state.step + 1} of 3

- {step === 0 && ( + {state.step === 0 && (

{ - setApp(option.key); - setStep(1); + onChange({ ...state, app: option.key, step: 1 }); }} /> ))} @@ -216,7 +251,7 @@ function ListeningGuideWizard({

)} - {step === 1 && ( + {state.step === 1 && (

{ - setDevice(option.key); - setStep(2); + onChange({ ...state, device: option.key, step: 2 }); }} /> ))} @@ -242,7 +276,7 @@ function ListeningGuideWizard({

)} - {step === 2 && ( + {state.step === 2 && (

{ - setSource(option.key); - if (app && device) onComplete({ app, device, source: option.key }); + if (state.app && state.device) { + onComplete({ + app: state.app, + device: state.device, + source: option.key, + }); + } }} /> ))} @@ -275,8 +314,8 @@ function ListeningGuideWizard({ type="button" variant="ghost" size="icon" - disabled={step === 0} - onClick={() => setStep((current) => current - 1)} + disabled={state.step === 0} + onClick={() => onChange({ ...state, step: state.step - 1 })} aria-label="previous question" > @@ -323,17 +362,17 @@ function VisualHandbookGuide({
-
+

{source} to {app} on {device}