diff --git a/components/audio/ListeningGuidePrototype.tsx b/components/audio/ListeningGuidePrototype.tsx new file mode 100644 index 00000000..248fdc47 --- /dev/null +++ b/components/audio/ListeningGuidePrototype.tsx @@ -0,0 +1,452 @@ +"use client"; + +// PROTOTYPE — Entry wizard feeding the selected right-rail handbook layout in the existing app shell. +import { useEffect, useRef, useState } from "react"; +import { + ArrowLeft, + ArrowRight, + CirclePlay, + FolderOpen, + Image, + Monitor, + MonitorSmartphone, + Smartphone, + Tablet, +} 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", logo: "/brands/spotify.svg" }, + { key: "apple-music", label: "apple music", logo: "/brands/applemusic.svg" }, +] as const; + +const deviceOptions = [ + { 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", logo: "/brands/soundcloud.svg" }, + { key: "youtube", label: "youtube", logo: "/brands/youtube.svg" }, + { + key: "local-files", + label: "local files", + icon: FolderOpen, + }, +] as const; + +type AppKey = (typeof appOptions)[number]["key"]; +type DeviceKey = (typeof deviceOptions)[number]["key"]; +type SourceKey = (typeof sourceOptions)[number]["key"]; + +interface GuideSetup { + app: AppKey; + device: DeviceKey; + 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", + className, +}: { + kind?: "image" | "video"; + label?: string; + className?: string; +}) { + return ( +
+
+
+ {kind === "video" ? : } + {label} +
+
+ ); +} + +function PrototypeHeader({ onBack }: { onBack: () => void }) { + return ( +
+
+ +

+ how do i listen? +

+
+
+ ); +} + +function OptionCard({ + icon: Icon, + logo, + label, + selected, + onClick, + className, +}: { + icon?: LucideIcon; + logo?: string; + label: string; + selected: boolean; + onClick: () => void; + className?: string; +}) { + return ( + + ); +} + +function ListeningGuideWizard({ + state, + onChange, + onComplete, + onBack, +}: { + state: ListeningGuideState; + onChange: (state: ListeningGuideState) => void; + onComplete: (setup: GuideSetup) => void; + onBack: () => void; +}) { + const questionHeadingRef = useRef(null); + const canAdvance = + (state.step === 0 && state.app !== null) || + (state.step === 1 && state.device !== null) || + (state.step === 2 && state.source !== null); + + useEffect(() => { + questionHeadingRef.current?.focus(); + }, [state.step]); + + const advance = () => { + if (state.step < 2) { + if (canAdvance) onChange({ ...state, step: state.step + 1 }); + return; + } + + if (state.app && state.device && state.source) { + onComplete({ app: state.app, device: state.device, source: state.source }); + } + }; + + return ( +
+ +
+
+

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

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

+ what app do you use? +

+
+ {appOptions.map((option) => ( + { + onChange({ ...state, app: option.key, step: 1 }); + }} + /> + ))} +
+
+ )} + + {state.step === 1 && ( +
+

+ where do you want to listen? +

+
+ {deviceOptions.map((option) => ( + { + onChange({ ...state, device: option.key, step: 2 }); + }} + /> + ))} +
+
+ )} + + {state.step === 2 && ( +
+

+ where is your music? +

+
+ {sourceOptions.map((option) => ( + { + if (state.app && state.device) { + onComplete({ + app: state.app, + device: state.device, + source: option.key, + }); + } + }} + /> + ))} +
+
+ )} +
+ +
+ + +
+
+
+
+ ); +} + +function VisualHandbookGuide({ + setup, + onBack, + onNewSelection, +}: { + setup: GuideSetup; + onBack: () => void; + onNewSelection: () => 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(); + }, []); + + return ( +
+ +
+
+
+
+

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

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

{section}

+
+ {index !== 1 && } +
+ ))} +
+
+ + +
+
+ ); +} + +export default function ListeningGuidePrototype({ onBack }: { onBack: () => void }) { + const [state, setState] = useState(loadListeningGuideState); + + useEffect(() => { + window.localStorage.setItem(listeningGuideStorageKey, JSON.stringify(state)); + }, [state]); + + if (state.view === "wizard" || !state.app || !state.device || !state.source) { + return ( + { + setState({ view: "guide", step: 2, ...nextSetup }); + }} + onBack={onBack} + /> + ); + } + + return ( + setState(initialListeningGuideState)} + /> + ); +} diff --git a/components/audio/TagSidebarPanel.tsx b/components/audio/TagSidebarPanel.tsx index 0c824a9e..de1550f6 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"; @@ -12,6 +12,8 @@ import { AlbumGroup, TagiumFile } from "./types"; import { Button } from "../ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; +const listeningGuideServices = ["spotify?", "apple music?", "spotify?"]; + interface TagSidebarPanelProps { loading: boolean; files: TagiumFile[]; @@ -21,6 +23,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 +51,90 @@ interface TagSidebarPanelProps { playlistDownloadQueue?: PlaylistDownloadQueuePanelState | null; onDownloadAll: () => void; onOpenSettings: () => void; + onOpenListeningGuide?: () => void; onCancelPlaylistDownloadQueue?: () => void; onRetryPlaylistDownloadQueue?: () => void; } +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, + ); + }, 3_000); + + return () => clearInterval(interval); + }, [prefersReducedMotion]); + + return ( + + ); +} + export default function TagSidebarPanel({ loading, files, @@ -61,6 +144,7 @@ export default function TagSidebarPanel({ selectedFileId, selectedFileIds, settingsOpen, + listeningGuideOpen = false, onAudioUpload, onSelectAlbum, onSelectFile, @@ -79,6 +163,7 @@ export default function TagSidebarPanel({ playlistDownloadQueue = null, onDownloadAll, onOpenSettings, + onOpenListeningGuide, onCancelPlaylistDownloadQueue, onRetryPlaylistDownloadQueue, }: TagSidebarPanelProps) { @@ -210,6 +295,9 @@ export default function TagSidebarPanel({ settings + {onOpenListeningGuide && ( + + )}
); diff --git a/components/audio/audioTagger.tsx b/components/audio/audioTagger.tsx index e8329f22..d1b5373b 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"; @@ -80,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; @@ -284,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); @@ -342,6 +346,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) => @@ -1878,6 +1890,7 @@ export default function AudioTagger() { selectedFileId={selectedFileId} selectedFileIds={selectedFileIds} settingsOpen={activeView === "settings"} + listeningGuideOpen={activeView === "listening-guide"} onAudioUpload={handleAudioUpload} onSelectAlbum={handleSelectAlbum} onSelectFile={handleSelectFile} @@ -1899,12 +1912,25 @@ export default function AudioTagger() { if (isTrackCoverProcessing) return; setActiveView((currentView) => (currentView === "settings" ? "editor" : "settings")); }} + onOpenListeningGuide={ + listeningGuidePrototypeAvailable + ? () => { + if (isTrackCoverProcessing) return; + setActiveView((currentView) => + currentView === "listening-guide" ? "editor" : "listening-guide", + ); + window.scrollTo({ top: 0, left: 0 }); + } + : undefined + } onCancelPlaylistDownloadQueue={handleCancelPlaylistDownloads} onRetryPlaylistDownloadQueue={handleRetryPlaylistDownloads} />
- {activeView === "settings" ? ( + {activeView === "listening-guide" ? ( + setActiveView("editor")} /> + ) : activeView === "settings" ? ( Apple Music diff --git a/public/brands/soundcloud.svg b/public/brands/soundcloud.svg new file mode 100644 index 00000000..5d20b484 --- /dev/null +++ b/public/brands/soundcloud.svg @@ -0,0 +1 @@ +SoundCloud diff --git a/public/brands/spotify.svg b/public/brands/spotify.svg new file mode 100644 index 00000000..32e8387d --- /dev/null +++ b/public/brands/spotify.svg @@ -0,0 +1 @@ +Spotify diff --git a/public/brands/youtube.svg b/public/brands/youtube.svg new file mode 100644 index 00000000..9bf5436e --- /dev/null +++ b/public/brands/youtube.svg @@ -0,0 +1 @@ +YouTube 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,