feat(onboarding): guided first-run tour with background FFmpeg provisioning
Phase 5 of the 0.1.0 release prep: - FFmpeg no longer blocks startup (or crashes the app offline): provisioning runs in a background thread emitting throttled ffmpeg-progress events, with installed/downloading/failed state queryable via get_ffmpeg_status and a retry command - video jobs are invisible to claiming and tier-priority checks until FFmpeg is ready, so they wait as pending without failing — and without starving image embeddings/tagging (include_videos gating in db.rs) - 7-step show-don't-tell onboarding wizard: welcome + live FFmpeg progress, real first-folder picker, faked animating pipeline bar, placeholder gallery tiles, cycling search-syntax demo, views overview, and an AI features step with a real opt-in tagger download - skippable at every point (Escape included), persisted via settings/onboarding_completed.txt, re-runnable from Settings > General, which also gains an FFmpeg status/retry row
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
import { useEffect } from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { useGalleryStore } from "../../store";
|
||||
import { StepWelcome } from "./StepWelcome";
|
||||
import { StepAddLibrary } from "./StepAddLibrary";
|
||||
import { StepPipeline } from "./StepPipeline";
|
||||
import { StepGalleryPreview } from "./StepGalleryPreview";
|
||||
import { StepSearchDemo } from "./StepSearchDemo";
|
||||
import { StepViews } from "./StepViews";
|
||||
import { StepAiFeatures } from "./StepAiFeatures";
|
||||
|
||||
const STEPS: { id: string; title: string; component: () => React.ReactNode }[] = [
|
||||
{ id: "welcome", title: "Welcome", component: () => <StepWelcome /> },
|
||||
{ id: "library", title: "Your library", component: () => <StepAddLibrary /> },
|
||||
{ id: "pipeline", title: "Background work", component: () => <StepPipeline /> },
|
||||
{ id: "gallery", title: "The gallery", component: () => <StepGalleryPreview /> },
|
||||
{ id: "search", title: "Search", component: () => <StepSearchDemo /> },
|
||||
{ id: "views", title: "Views", component: () => <StepViews /> },
|
||||
{ id: "ai", title: "AI features", component: () => <StepAiFeatures /> },
|
||||
];
|
||||
|
||||
export function OnboardingOverlay() {
|
||||
const onboardingOpen = useGalleryStore((s) => s.onboardingOpen);
|
||||
const onboardingStep = useGalleryStore((s) => s.onboardingStep);
|
||||
const setOnboardingStep = useGalleryStore((s) => s.setOnboardingStep);
|
||||
const completeOnboarding = useGalleryStore((s) => s.completeOnboarding);
|
||||
|
||||
useEffect(() => {
|
||||
if (!onboardingOpen) return;
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") completeOnboarding();
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [onboardingOpen, completeOnboarding]);
|
||||
|
||||
if (!onboardingOpen) return null;
|
||||
|
||||
const step = STEPS[Math.min(onboardingStep, STEPS.length - 1)];
|
||||
const isFirst = onboardingStep === 0;
|
||||
const isLast = onboardingStep >= STEPS.length - 1;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[70] flex items-center justify-center bg-black/65 backdrop-blur-sm">
|
||||
<div className="flex max-h-[min(85vh,760px)] w-[min(90vw,720px)] flex-col rounded-lg border border-white/10 bg-[#07080f] shadow-2xl shadow-black/60">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-white/[0.07] px-7 py-4">
|
||||
<div>
|
||||
<p className="text-[11px] uppercase tracking-[0.14em] text-gray-600">
|
||||
Step {onboardingStep + 1} of {STEPS.length}
|
||||
</p>
|
||||
<h2 className="mt-0.5 text-base font-semibold text-white">{step.title}</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{STEPS.map((s, i) => (
|
||||
<button
|
||||
key={s.id}
|
||||
aria-label={s.title}
|
||||
className={`h-1.5 rounded-full transition-all ${
|
||||
i === onboardingStep ? "w-5 bg-white/70" : i < onboardingStep ? "w-1.5 bg-white/35" : "w-1.5 bg-white/15"
|
||||
}`}
|
||||
onClick={() => setOnboardingStep(i)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step body */}
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-7 py-6">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={step.id}
|
||||
initial={{ opacity: 0, x: 14 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -14 }}
|
||||
transition={{ duration: 0.16 }}
|
||||
>
|
||||
{step.component()}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between border-t border-white/[0.07] px-7 py-4">
|
||||
<button
|
||||
className="rounded-md border border-transparent px-3 py-1.5 text-xs text-gray-500 transition-colors hover:bg-white/[0.06] hover:text-gray-200"
|
||||
onClick={completeOnboarding}
|
||||
>
|
||||
Skip tour
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
className="rounded-md border border-white/10 bg-white/[0.055] px-3 py-1.5 text-xs text-gray-300 transition-colors hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-45"
|
||||
onClick={() => setOnboardingStep(onboardingStep - 1)}
|
||||
disabled={isFirst}
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
<button
|
||||
className="rounded-md border border-emerald-400/35 bg-emerald-500/15 px-4 py-1.5 text-xs text-emerald-200 transition-colors hover:bg-emerald-500/25"
|
||||
onClick={() => (isLast ? completeOnboarding() : setOnboardingStep(onboardingStep + 1))}
|
||||
>
|
||||
{isLast ? "Finish" : "Next"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user