learn and share notes on atproto (wip) 🦉 malfestio.stormlightlabs.org/
readability solid axum atproto srs
at main 195 lines 7.1 kB view raw
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;