learn and share notes on atproto (wip) 🦉
malfestio.stormlightlabs.org/
readability
solid
axum
atproto
srs
1import { useTutorial } from "$lib/TutorialProvider";
2import { Button } from "$ui/Button";
3import type { Component } from "solid-js";
4import { createEffect, createMemo, createSignal, Index, onCleanup, Show } from "solid-js";
5import { Motion, Presence } from "solid-motionone";
6
7type Position = { top: number; left: number; width: number; height: number };
8
9const getTooltipPosition = (target: Position, placement: "top" | "bottom" | "left" | "right") => {
10 const padding = 12;
11 const tooltipWidth = 320;
12
13 switch (placement) {
14 case "top": {
15 const tooltipHeight = 240;
16 return {
17 top: target.top - padding - 16 - tooltipHeight,
18 left: target.left + target.width / 2 - tooltipWidth / 2,
19 };
20 }
21 case "bottom":
22 return { top: target.top + target.height + padding, left: target.left + target.width / 2 - tooltipWidth / 2 };
23 case "left":
24 return {
25 top: target.top + target.height / 2,
26 left: target.left - padding - tooltipWidth,
27 transform: "translateY(-50%)",
28 };
29 case "right":
30 return {
31 top: target.top + target.height / 2,
32 left: target.left + target.width + padding,
33 transform: "translateY(-50%)",
34 };
35 default:
36 return { top: target.top + target.height + padding, left: target.left };
37 }
38};
39
40export const TutorialOverlay: Component = () => {
41 const tutorial = useTutorial();
42 const [targetPos, setTargetPos] = createSignal<Position | null>(null);
43
44 const currentTarget = createMemo(() => {
45 const step = tutorial.currentStep();
46 if (!step) return null;
47 return tutorial.targets().get(step.id) ?? null;
48 });
49
50 createEffect(() => {
51 if (!tutorial.active()) return;
52
53 const element = currentTarget();
54 if (!element) {
55 setTargetPos(null);
56 return;
57 }
58
59 const updatePosition = () => {
60 const rect = element.getBoundingClientRect();
61 setTargetPos({
62 top: rect.top + window.scrollY,
63 left: rect.left + window.scrollX,
64 width: rect.width,
65 height: rect.height,
66 });
67 };
68
69 updatePosition();
70 window.addEventListener("resize", updatePosition);
71 window.addEventListener("scroll", updatePosition);
72 onCleanup(() => {
73 window.removeEventListener("resize", updatePosition);
74 window.removeEventListener("scroll", updatePosition);
75 });
76 });
77
78 createEffect(() => {
79 if (tutorial.active()) {
80 document.body.style.overflow = "hidden";
81 } else {
82 document.body.style.overflow = "";
83 }
84 onCleanup(() => {
85 document.body.style.overflow = "";
86 });
87 });
88
89 return (
90 <Presence>
91 <Show when={tutorial.active()}>
92 <Motion.div
93 initial={{ opacity: 0 }}
94 animate={{ opacity: 1 }}
95 exit={{ opacity: 0 }}
96 transition={{ duration: 0.2 }}
97 class="fixed inset-0 z-50 pointer-events-none">
98 <Show when={targetPos()} keyed>
99 {(pos) => (
100 <>
101 <svg
102 class="absolute inset-0 w-full h-full pointer-events-auto"
103 style={{ height: `${document.documentElement.scrollHeight}px` }}>
104 <defs>
105 <mask id="spotlight-mask">
106 <rect width="100%" height="100%" fill="white" />
107 <rect
108 x={pos.left - 8}
109 y={pos.top - 8}
110 width={pos.width + 16}
111 height={pos.height + 16}
112 rx="8"
113 fill="black" />
114 </mask>
115 </defs>
116 <rect
117 width="100%"
118 height="100%"
119 fill="rgba(0,0,0,0.75)"
120 mask="url(#spotlight-mask)"
121 onClick={() => tutorial.skipTutorial()} />
122 </svg>
123
124 <Motion.div
125 class="absolute border-2 border-[#0F62FE] rounded-lg pointer-events-none"
126 style={{
127 top: `${pos.top - 8}px`,
128 left: `${pos.left - 8}px`,
129 width: `${pos.width + 16}px`,
130 height: `${pos.height + 16}px`,
131 "box-shadow": "0 0 0 4px rgba(15, 98, 254, 0.3)",
132 }} />
133
134 <Motion.div
135 initial={{ opacity: 0, scale: 0.95 }}
136 animate={{ opacity: 1, scale: 1 }}
137 transition={{ duration: 0.2 }}
138 class="absolute w-80 bg-[#262626] border border-[#393939] rounded-lg shadow-xl p-4 pointer-events-auto"
139 style={{
140 top: `${getTooltipPosition(pos, tutorial.currentStep()!.placement).top}px`,
141 left: `${getTooltipPosition(pos, tutorial.currentStep()!.placement).left}px`,
142 transform: getTooltipPosition(pos, tutorial.currentStep()!.placement).transform,
143 }}>
144 <div class="h-1 bg-[#393939] rounded-full mb-4 overflow-hidden">
145 <div
146 class="h-full bg-[#0F62FE] transition-all duration-300"
147 style={{ width: `${tutorial.progress()}%` }} />
148 </div>
149
150 <h3 class="text-lg font-medium text-[#F4F4F4] mb-2">{tutorial.currentStep()?.title}</h3>
151 <p class="text-sm text-[#C6C6C6] mb-4">{tutorial.currentStep()?.desc}</p>
152
153 <div class="flex items-center justify-between">
154 <button
155 onClick={() => tutorial.skipTutorial()}
156 class="text-sm text-[#8D8D8D] hover:text-[#C6C6C6] transition-colors">
157 Skip tutorial
158 </button>
159 <div class="flex gap-2">
160 <Show when={!tutorial.isFirstStep()}>
161 <Button
162 variant="secondary"
163 size="sm"
164 onClick={() => tutorial.prevStep()}>
165 Back
166 </Button>
167 </Show>
168 <Button size="sm" onClick={() => tutorial.nextStep()}>
169 {tutorial.isLastStep() ? "Finish" : "Next"}
170 </Button>
171 </div>
172 </div>
173
174 <div class="flex justify-center gap-1.5 mt-4">
175 <Index each={tutorial.steps()}>
176 {(_, i) => (
177 <div
178 class={`w-2 h-2 rounded-full transition-colors ${
179 i === tutorial.currentStepIndex() ? "bg-[#0F62FE]" : "bg-[#525252]"
180 }`} />
181 )}
182 </Index>
183 </div>
184 <p class="text-xs text-[#525252] text-center mt-3">Use ← → arrow keys or Esc to skip</p>
185 </Motion.div>
186 </>
187 )}
188 </Show>
189 </Motion.div>
190 </Show>
191 </Presence>
192 );
193};
194
195export default TutorialOverlay;