learn and share notes on atproto (wip) 馃 malfestio.stormlightlabs.org/
readability solid axum atproto srs
at main 178 lines 5.2 kB view raw
1import { api } from "$lib/api"; 2import { prefStore } from "$lib/store"; 3import type { Accessor, JSX } from "solid-js"; 4import { createContext, createEffect, createSignal, onCleanup, useContext } from "solid-js"; 5 6type TutorialPlacement = "top" | "bottom" | "left" | "right"; 7 8export type TutorialStep = { id: string; title: string; desc: string; placement: TutorialPlacement }; 9 10export const DECK_CREATION_STEPS: TutorialStep[] = [{ 11 id: "title", 12 title: "Name Your Deck", 13 desc: "Start by giving your deck a memorable title. This helps you find it later!", 14 placement: "bottom", 15}, { 16 id: "description", 17 title: "Add a Description", 18 desc: "Describe what this deck covers. Great for sharing with others!", 19 placement: "bottom", 20}, { 21 id: "tags", 22 title: "Add Tags", 23 desc: "Tags help categorize your deck. Use topics like 'spanish', 'vocabulary', 'history'.", 24 placement: "bottom", 25}, { 26 id: "visibility", 27 title: "Set Visibility", 28 desc: "Keep it Private for yourself, or make it Public to share with the community.", 29 placement: "bottom", 30}, { 31 id: "add-card", 32 title: "Add Your First Card", 33 desc: "Click here to create a flashcard. Add a question on the front and answer on the back!", 34 placement: "top", 35}]; 36 37type TutorialContextValue = { 38 active: Accessor<boolean>; 39 currentStep: Accessor<TutorialStep | undefined>; 40 currentStepIndex: Accessor<number>; 41 steps: Accessor<TutorialStep[]>; 42 targets: Accessor<Map<string, HTMLElement>>; 43 isFirstStep: Accessor<boolean>; 44 isLastStep: Accessor<boolean>; 45 progress: Accessor<number>; 46 shouldShowTutorial: () => boolean; 47 startTutorial: () => void; 48 nextStep: () => void; 49 prevStep: () => void; 50 skipTutorial: () => void; 51 completeTutorial: () => Promise<void>; 52 registerTarget: (id: string, element: HTMLElement) => void; 53 unregisterTarget: (id: string) => void; 54}; 55 56const TutorialContext = createContext<TutorialContextValue>(); 57 58export function TutorialProvider(props: { children: JSX.Element; steps?: TutorialStep[] }) { 59 const [active, setActive] = createSignal(false); 60 const [currentStepIndex, setCurrentStepIndex] = createSignal(0); 61 const [targets, setTargets] = createSignal<Map<string, HTMLElement>>(new Map()); 62 const steps = () => props.steps ?? DECK_CREATION_STEPS; 63 64 const shouldShowTutorial = () => { 65 const prefs = prefStore.prefs(); 66 return prefs !== null && !prefs.tutorial_deck_completed; 67 }; 68 69 const registerTarget = (id: string, element: HTMLElement) => { 70 setTargets((prev) => { 71 const next = new Map(prev); 72 next.set(id, element); 73 return next; 74 }); 75 }; 76 77 const unregisterTarget = (id: string) => { 78 setTargets((prev) => { 79 const next = new Map(prev); 80 next.delete(id); 81 return next; 82 }); 83 }; 84 85 const startTutorial = () => { 86 setCurrentStepIndex(0); 87 setActive(true); 88 }; 89 90 const nextStep = () => { 91 if (currentStepIndex() < steps().length - 1) { 92 setCurrentStepIndex(currentStepIndex() + 1); 93 } else { 94 completeTutorial(); 95 } 96 }; 97 98 const prevStep = () => { 99 if (currentStepIndex() > 0) { 100 setCurrentStepIndex(currentStepIndex() - 1); 101 } 102 }; 103 104 const skipTutorial = () => completeTutorial(); 105 106 const completeTutorial = async () => { 107 setActive(false); 108 try { 109 await api.updatePreferences({ tutorial_deck_completed: true }); 110 prefStore.fetchPrefs(); 111 } catch (e) { 112 console.error("Failed to mark tutorial complete:", e); 113 } 114 }; 115 116 const currentStep = () => steps()[currentStepIndex()]; 117 const isFirstStep = () => currentStepIndex() === 0; 118 const isLastStep = () => currentStepIndex() === steps().length - 1; 119 const progress = () => ((currentStepIndex() + 1) / steps().length) * 100; 120 121 // Keyboard navigation 122 createEffect(() => { 123 if (!active()) return; 124 125 const handleKeyDown = (e: KeyboardEvent) => { 126 if (e.key === "Escape") skipTutorial(); 127 if (e.key === "ArrowRight" || e.key === "Enter") nextStep(); 128 if (e.key === "ArrowLeft") prevStep(); 129 }; 130 131 window.addEventListener("keydown", handleKeyDown); 132 onCleanup(() => window.removeEventListener("keydown", handleKeyDown)); 133 }); 134 135 const value: TutorialContextValue = { 136 active, 137 currentStep, 138 currentStepIndex, 139 steps, 140 targets, 141 isFirstStep, 142 isLastStep, 143 progress, 144 shouldShowTutorial, 145 startTutorial, 146 nextStep, 147 prevStep, 148 skipTutorial, 149 completeTutorial, 150 registerTarget, 151 unregisterTarget, 152 }; 153 154 return <TutorialContext.Provider value={value}>{props.children}</TutorialContext.Provider>; 155} 156 157export function useTutorial() { 158 const context = useContext(TutorialContext); 159 if (!context) { 160 throw new Error("useTutorial must be used within a TutorialProvider"); 161 } 162 return context; 163} 164 165/** 166 * Register an element as a tutorial target. Use as a ref directive. 167 */ 168export function useTutorialTarget(stepId: string) { 169 const context = useContext(TutorialContext); 170 171 return (element: HTMLElement) => { 172 if (!context) return; 173 context.registerTarget(stepId, element); 174 onCleanup(() => context.unregisterTarget(stepId)); 175 }; 176} 177 178export type { TutorialContextValue };