zero-knowledge file sharing
0

Configure Feed

Select the types of activity you want to include in your feed.

new view design

+250 -219
+1 -1
web/src/App.tsx
··· 3 3 export default function App(props: ParentProps) { 4 4 return ( 5 5 <div class="text-text flex min-h-screen flex-col items-center justify-center px-4 font-sans"> 6 - <div class="mt-6 w-full max-w-md">{props.children}</div> 6 + <div class="mt-6 w-full max-w-2xl">{props.children}</div> 7 7 <a 8 8 class="text-muted hover:text-accent-hover mt-auto p-4 text-[10px]" 9 9 href="https://github.com/notjuliet/drop"
+6
web/src/lib/ui.ts
··· 1 + export const fadeIn = { animation: "fade-in 0.4s" }; 2 + 3 + export const btnClass = 4 + "bg-transparent hover:bg-accent/10 active:scale-95 border-accent text-accent rounded-md border px-4 py-1.5 font-medium transition"; 5 + 6 + export const btnStyle = { "font-size": "clamp(1rem, 3vw, 1.5rem)" };
+20 -2
web/src/lib/utils.ts
··· 7 7 "svg", 8 8 "ico", 9 9 "bmp", 10 - "tiff", 11 10 "avif", 12 11 ]); 13 12 ··· 42 41 "env", 43 42 ]); 44 43 44 + export const VIDEO_EXTS = new Set(["mp4", "webm", "ogv", "mov"]); 45 + 46 + export const AUDIO_EXTS = new Set(["mp3", "wav", "ogg", "flac", "aac", "m4a"]); 47 + 48 + export const VIDEO_MIME: Record<string, string> = { 49 + mp4: "video/mp4", 50 + webm: "video/webm", 51 + ogv: "video/ogg", 52 + mov: "video/mp4", 53 + }; 54 + 55 + export const AUDIO_MIME: Record<string, string> = { 56 + mp3: "audio/mpeg", 57 + wav: "audio/wav", 58 + ogg: "audio/ogg", 59 + flac: "audio/flac", 60 + aac: "audio/aac", 61 + m4a: "audio/mp4", 62 + }; 63 + 45 64 export const IMAGE_MIME: Record<string, string> = { 46 65 png: "image/png", 47 66 jpg: "image/jpeg", ··· 51 70 svg: "image/svg+xml", 52 71 ico: "image/x-icon", 53 72 bmp: "image/bmp", 54 - tiff: "image/tiff", 55 73 avif: "image/avif", 56 74 }; 57 75
+34 -64
web/src/pages/Upload.tsx
··· 8 8 } from "solid-js"; 9 9 10 10 import { generateKey } from "../lib/crypto"; 11 + import { btnClass, btnStyle, fadeIn } from "../lib/ui"; 11 12 import { formatBytes } from "../lib/utils"; 12 13 13 - const viewClass = "flex flex-col items-center gap-2 sm:gap-3"; 14 - const fadeIn = { animation: "fade-in 0.4s" }; 15 - 16 - const btnClass = 17 - "bg-transparent hover:bg-accent/10 active:scale-95 border-accent text-accent rounded-md border px-4 py-1.5 font-medium transition"; 18 - const btnStyle = { "font-size": "clamp(1rem, 3vw, 1.5rem)" }; 19 14 const ghostClass = 20 - "text-muted hover:text-accent-hover border-none bg-transparent p-0 text-[10px] sm:text-xs"; 15 + "text-muted hover:text-accent-hover border-none bg-transparent min-w-16 py-1.5 -my-1.5 text-[10px] sm:text-xs"; 21 16 22 17 const DURATION_UNITS: Record<string, number> = { 23 18 s: 1, ··· 56 51 const RADIUS = 130; 57 52 const CENTER = RADIUS + 10; 58 53 const SIZE = CENTER * 2; 59 - const CIRCUMFERENCE = 2 * Math.PI * RADIUS; 60 54 const WAVE_AMP = 3; 61 55 const WAVE_LEN = RADIUS; // one full wave period 62 56 ··· 64 58 const topY = CENTER - RADIUS + 2 * RADIUS * (1 - progress() / 100); 65 59 const left = CENTER - RADIUS - WAVE_LEN; 66 60 const right = CENTER + RADIUS + WAVE_LEN; 67 - const bottom = CENTER + RADIUS; 61 + const bottom = CENTER + RADIUS + 2; 68 62 let d = `M ${left} ${bottom} V ${topY}`; 69 63 for (let x = left; x < right; x += WAVE_LEN / 2) { 70 64 const cx = x + WAVE_LEN / 4; ··· 76 70 d += ` V ${bottom} Z`; 77 71 return d; 78 72 }; 79 - 80 - let prevRatio = 0; 81 - const sizeRatio = createMemo(() => { 82 - const f = file(); 83 - const max = maxFileSize(); 84 - if (!f || !max) return 0; 85 - return Math.min(f.size / max, 1); 86 - }); 87 - 88 - const animDuration = createMemo(() => { 89 - const cur = sizeRatio(); 90 - const dur = Math.max(Math.max(cur, prevRatio) * 800, 200); 91 - prevRatio = cur; 92 - return dur; 93 - }); 94 73 95 74 const tooLarge = createMemo(() => { 96 75 const f = file(); ··· 285 264 </clipPath> 286 265 </defs> 287 266 <style>{`@keyframes wave { from { transform: translateX(0) } to { transform: translateX(-${WAVE_LEN}px) } }`}</style> 288 - <style>{`@keyframes fade-in { 0% { opacity: 0; transform: translateY(4px) } 100% { opacity: 1; transform: translateY(0) } }`}</style> 289 267 <circle 290 268 cx={CENTER} 291 269 cy={CENTER} 292 270 r={RADIUS} 293 271 fill="none" 294 - stroke={dragging() ? "var(--color-accent)" : "var(--color-border)"} 272 + stroke={ 273 + tooLarge() 274 + ? "var(--color-danger)" 275 + : dragging() || view() === "result" 276 + ? "var(--color-accent)" 277 + : "var(--color-border)" 278 + } 295 279 stroke-width="7" 296 280 pathLength={dragging() ? "100" : undefined} 297 281 stroke-dasharray={dragging() ? "3 2" : "none"} 298 282 class="transition-all duration-200" 299 283 /> 300 - <circle 301 - cx={CENTER} 302 - cy={CENTER} 303 - r={RADIUS} 304 - fill="none" 305 - stroke={tooLarge() ? "var(--color-danger)" : "var(--color-accent)"} 306 - stroke-width="7" 307 - stroke-dasharray={`${CIRCUMFERENCE}`} 308 - stroke-dashoffset={`${CIRCUMFERENCE * (1 - (status() !== "idle" || dragging() ? 0 : sizeRatio()))}`} 309 - stroke-linecap="round" 310 - transform={`rotate(-90 ${CENTER} ${CENTER})`} 311 - style={{ 312 - transition: dragging() 313 - ? "none" 314 - : `all ${animDuration()}ms ease-out`, 315 - }} 316 - /> 317 284 {/* Upload liquid fill */} 318 - <g clip-path="url(#circle-clip)"> 319 - <path 320 - d={wavePath()} 321 - fill="var(--color-border)" 322 - style={{ 323 - transition: "d 300ms ease-out", 324 - animation: 325 - status() === "uploading" ? `wave 2s linear infinite` : "none", 326 - }} 327 - /> 328 - </g> 285 + <Show when={status() === "uploading"}> 286 + <g clip-path="url(#circle-clip)"> 287 + <path 288 + d={wavePath()} 289 + fill="var(--color-border)" 290 + style={{ 291 + transition: "d 300ms ease-out", 292 + animation: `wave 2s linear infinite`, 293 + }} 294 + /> 295 + </g> 296 + </Show> 329 297 </svg> 330 298 331 299 <div class="z-10 flex flex-col items-center text-center"> ··· 334 302 </Show> 335 303 <Show when={!loading()}> 336 304 <Show when={view() === "result"}> 337 - <div class={viewClass} style={fadeIn}> 305 + <div class="flex flex-col items-center gap-3" style={fadeIn}> 338 306 <span 339 307 class="text-muted" 340 308 style={{ "font-size": "clamp(0.75rem, 2vw, 1rem)" }} ··· 358 326 </Show> 359 327 360 328 <Show when={view() === "uploading"}> 361 - <div class={viewClass} style={fadeIn}> 362 - <span 363 - class="text-accent font-medium tabular-nums" 364 - style={{ "font-size": "clamp(1.5rem, 5vw, 2.5rem)" }} 365 - > 366 - {progress()}% 367 - </span> 329 + <div class="flex flex-col items-center gap-3" style={fadeIn}> 330 + <Show when={status() === "uploading"}> 331 + <span 332 + class="text-accent font-medium tabular-nums" 333 + style={{ "font-size": "clamp(1.5rem, 5vw, 2.5rem)" }} 334 + > 335 + {progress()}% 336 + </span> 337 + </Show> 368 338 <span class="text-muted text-[10px] sm:text-xs"> 369 339 {status() === "encrypting" 370 340 ? "encrypting\u2026" ··· 383 353 </Show> 384 354 385 355 <Show when={view() === "file"}> 386 - <div class={viewClass} style={fadeIn}> 356 + <div class="flex flex-col items-center gap-3" style={fadeIn}> 387 357 <span 388 358 class="text-text flex gap-1.5 truncate" 389 359 style={{ "max-width": "clamp(120px, 40vw, 300px)" }} ··· 427 397 </Show> 428 398 429 399 <Show when={view() === "empty"}> 430 - <div class={viewClass} style={fadeIn}> 400 + <div class="flex flex-col items-center gap-3" style={fadeIn}> 431 401 <span 432 402 class="text-muted font-medium" 433 403 style={{ "font-size": "clamp(0.75rem, 2vw, 1rem)" }}
+171 -145
web/src/pages/View.tsx
··· 1 1 import { useParams } from "@solidjs/router"; 2 - import { 3 - createSignal, 4 - Show, 5 - Switch, 6 - Match, 7 - onMount, 8 - onCleanup, 9 - } from "solid-js"; 2 + import { createSignal, Show, onMount, onCleanup } from "solid-js"; 10 3 11 4 import { importKey } from "../lib/crypto"; 5 + import { btnClass, btnStyle, fadeIn } from "../lib/ui"; 12 6 import { 13 7 formatBytes, 14 8 formatExpiry, ··· 16 10 triggerDownload, 17 11 IMAGE_EXTS, 18 12 TEXT_EXTS, 13 + VIDEO_EXTS, 19 14 IMAGE_MIME, 15 + VIDEO_MIME, 16 + AUDIO_MIME, 20 17 } from "../lib/utils"; 21 18 22 - type Stage = "loading" | "meta" | "content" | "error"; 23 - type ContentType = "text" | "image" | "binary"; 19 + const ghostClass = 20 + "text-muted hover:text-accent hover:border-accent border border-border bg-transparent rounded px-2 py-1 text-sm transition-colors"; 21 + 22 + type Stage = "loading" | "meta" | "decrypting" | "content" | "error"; 23 + type ContentType = "text" | "image" | "video" | "audio" | "binary"; 24 24 25 25 export default function View() { 26 26 const params = useParams(); ··· 31 31 const [expiresAt, setExpiresAt] = createSignal(0); 32 32 const [burnAfterRead, setBurnAfterRead] = createSignal(false); 33 33 const [burned, setBurned] = createSignal(false); 34 - const [loadBtnText, setLoadBtnText] = createSignal("View"); 35 - const [loadBtnDisabled, setLoadBtnDisabled] = createSignal(false); 34 + const [progress, setProgress] = createSignal(0); 35 + const [decrypting, setDecrypting] = createSignal(false); 36 36 37 37 const [contentType, setContentType] = createSignal<ContentType>("binary"); 38 38 const [textContent, setTextContent] = createSignal(""); 39 39 const [imageSrc, setImageSrc] = createSignal(""); 40 - const [imageAlt, setImageAlt] = createSignal(""); 40 + const [mediaSrc, setMediaSrc] = createSignal(""); 41 41 const [fileName, setFileName] = createSignal(""); 42 + const [copied, setCopied] = createSignal(false); 42 43 43 44 let decryptedBlob: Blob | null = null; 44 45 const worker = new Worker( ··· 47 48 type: "module", 48 49 }, 49 50 ); 51 + 50 52 onCleanup(() => worker.terminate()); 51 53 52 54 onMount(async () => { ··· 78 80 setSize(info.size); 79 81 setExpiresAt(info.expiresAt); 80 82 setBurnAfterRead(info.burnAfterRead); 81 - setStage("meta"); 83 + 84 + if (info.burnAfterRead) { 85 + setStage("meta"); 86 + } else { 87 + handleView(); 88 + } 82 89 }); 83 90 84 91 const handleView = async () => { 85 - setLoadBtnDisabled(true); 86 - setLoadBtnText("Decrypting\u2026"); 92 + setStage("decrypting"); 93 + setProgress(0); 94 + setDecrypting(false); 87 95 88 96 try { 89 - const res = await fetch(`/api/file/${params.id}`); 90 - if (!res.ok) { 91 - setError("File not found or expired."); 92 - setStage("error"); 93 - return; 94 - } 97 + const xhr = new XMLHttpRequest(); 98 + xhr.open("GET", `/api/file/${params.id}`); 99 + xhr.responseType = "arraybuffer"; 100 + 101 + const buf = await new Promise<ArrayBuffer>((resolve, reject) => { 102 + xhr.onprogress = (e) => { 103 + if (e.lengthComputable) { 104 + setProgress(Math.round((e.loaded / e.total) * 100)); 105 + } 106 + }; 107 + xhr.onload = () => { 108 + if (xhr.status >= 200 && xhr.status < 300) { 109 + setProgress(100); 110 + setDecrypting(true); 111 + resolve(xhr.response); 112 + } else { 113 + reject(new Error("File not found or expired.")); 114 + } 115 + }; 116 + xhr.onerror = () => reject(new Error("Download failed.")); 117 + xhr.send(); 118 + }); 95 119 96 - const buf = await res.arrayBuffer(); 97 120 const { fileName: name, fileData } = await new Promise<{ 98 121 fileName: string; 99 122 fileData: Uint8Array<ArrayBuffer>; ··· 111 134 [buf], 112 135 ); 113 136 }); 137 + 114 138 const ext = getExt(name); 115 139 setFileName(name); 116 140 117 141 if (burnAfterRead()) setBurned(true); 118 142 119 - if (IMAGE_EXTS.has(ext)) { 120 - const mime = IMAGE_MIME[ext] || "image/png"; 143 + const mime = 144 + IMAGE_MIME[ext] || VIDEO_MIME[ext] || AUDIO_MIME[ext] || undefined; 145 + if (mime) { 121 146 const blob = new Blob([fileData], { type: mime }); 122 147 decryptedBlob = blob; 123 - setImageSrc(URL.createObjectURL(blob)); 124 - setImageAlt(name); 125 - setContentType("image"); 148 + const url = URL.createObjectURL(blob); 149 + if (IMAGE_EXTS.has(ext)) { 150 + setImageSrc(url); 151 + setContentType("image"); 152 + } else if (VIDEO_EXTS.has(ext)) { 153 + setMediaSrc(url); 154 + setContentType("video"); 155 + } else { 156 + setMediaSrc(url); 157 + setContentType("audio"); 158 + } 126 159 } else if (TEXT_EXTS.has(ext)) { 127 160 setTextContent(new TextDecoder().decode(fileData)); 128 161 setContentType("text"); ··· 132 165 } 133 166 134 167 setStage("content"); 135 - } catch { 136 - setError("Failed to decrypt. The key may be wrong."); 137 - setLoadBtnDisabled(false); 138 - setLoadBtnText("Retry"); 168 + } catch (e: any) { 169 + setError(e.message || "Failed to decrypt."); 170 + setStage("error"); 139 171 } 140 172 }; 141 173 142 174 const copyText = () => { 143 175 navigator.clipboard.writeText(textContent()); 144 - }; 145 - 146 - const viewRaw = () => { 147 - const blob = new Blob([textContent()], { type: "text/plain" }); 148 - window.open(URL.createObjectURL(blob)); 176 + setCopied(true); 177 + setTimeout(() => setCopied(false), 1500); 149 178 }; 150 179 151 180 const saveFile = () => { 152 - if (decryptedBlob) triggerDownload(decryptedBlob, fileName()); 181 + if (contentType() === "text") { 182 + const blob = new Blob([textContent()], { type: "text/plain" }); 183 + triggerDownload(blob, fileName()); 184 + } else if (decryptedBlob) { 185 + triggerDownload(decryptedBlob, fileName()); 186 + } 153 187 }; 154 188 155 189 return ( 156 190 <> 157 - <Show when={stage() === "meta"}> 158 - <div> 159 - <div class="mb-1 text-base">Encrypted file</div> 160 - <div class="text-muted mb-1 flex gap-3 text-xs"> 161 - <span>{formatBytes(size())}</span> 162 - <span>{formatExpiry(expiresAt())}</span> 163 - </div> 164 - <Show when={burnAfterRead()}> 165 - <div class="mb-3"> 166 - <span 167 - class="text-danger inline-block rounded-full px-2 py-0.5 text-[10px] font-medium" 168 - style={{ 169 - background: 170 - "color-mix(in srgb, var(--color-danger) 10%, transparent)", 171 - }} 172 - > 173 - Burns after viewing 174 - </span> 175 - </div> 191 + <Show when={stage() === "loading"}> 192 + <div class="flex justify-center"> 193 + <span class="text-muted text-xs">loading…</span> 194 + </div> 195 + </Show> 196 + 197 + <Show when={stage() === "decrypting"}> 198 + <div class="flex flex-col items-center gap-2" style={fadeIn}> 199 + <Show 200 + when={!decrypting()} 201 + fallback={<span class="text-muted text-xs">decrypting…</span>} 202 + > 203 + <span 204 + class="text-accent font-medium tabular-nums" 205 + style={{ "font-size": "clamp(1.5rem, 5vw, 2.5rem)" }} 206 + > 207 + {progress()}% 208 + </span> 209 + <span class="text-muted text-xs">downloading…</span> 176 210 </Show> 177 - <div class="mt-4"> 178 - <button 179 - class="bg-accent hover:bg-accent-hover w-full rounded-md border-none py-2.5 text-sm font-medium text-white transition-colors disabled:cursor-not-allowed disabled:opacity-40" 180 - disabled={loadBtnDisabled()} 181 - onClick={handleView} 182 - > 183 - {loadBtnText()} 184 - </button> 185 - </div> 211 + </div> 212 + </Show> 213 + 214 + <Show when={stage() === "meta"}> 215 + <div class="flex flex-col items-center gap-3" style={fadeIn}> 216 + <span 217 + class="text-muted" 218 + style={{ "font-size": "clamp(0.75rem, 2vw, 1rem)" }} 219 + > 220 + {formatBytes(size())} 221 + </span> 222 + <button class={btnClass} style={btnStyle} onClick={handleView}> 223 + view 224 + </button> 225 + <span class="text-muted text-xs"> 226 + {formatExpiry(expiresAt())} · burns after viewing 227 + </span> 228 + </div> 229 + </Show> 230 + 231 + <Show when={stage() === "error"}> 232 + <div class="flex justify-center" style={fadeIn}> 233 + <span class="text-danger text-sm">{error()}</span> 186 234 </div> 187 235 </Show> 188 236 189 237 <Show when={stage() === "content"}> 190 - <Switch> 191 - <Match when={contentType() === "text"}> 192 - <div class="relative"> 193 - <div class="absolute top-2 right-2 z-10 flex gap-1"> 194 - <button 195 - class="bg-surface text-muted hover:text-text border-border rounded-md border p-1.5 transition-colors" 196 - title="Copy" 197 - onClick={copyText} 198 - > 199 - <svg 200 - xmlns="http://www.w3.org/2000/svg" 201 - width="14" 202 - height="14" 203 - viewBox="0 0 24 24" 204 - fill="none" 205 - stroke="currentColor" 206 - stroke-width="2" 207 - stroke-linecap="round" 208 - stroke-linejoin="round" 209 - > 210 - <rect width="14" height="14" x="8" y="8" rx="2" ry="2" /> 211 - <path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" /> 212 - </svg> 238 + <div class="mx-auto flex w-full flex-col gap-4" style={fadeIn}> 239 + <div 240 + class="flex items-center justify-between gap-4" 241 + style={{ "font-size": "clamp(0.75rem, 2vw, 1rem)" }} 242 + > 243 + <span class="text-text flex min-w-0 gap-1.5"> 244 + <span class="truncate">{fileName()}</span> 245 + <span class="text-muted shrink-0 font-medium"> 246 + {formatBytes(size())} 247 + </span> 248 + </span> 249 + <div class="flex shrink-0 items-center gap-2"> 250 + <Show when={contentType() === "text"}> 251 + <button class={ghostClass} onClick={copyText}> 252 + {copied() ? "copied!" : "copy"} 213 253 </button> 214 - <button 215 - class="bg-surface text-muted hover:text-text border-border rounded-md border p-1.5 transition-colors" 216 - title="Raw" 217 - onClick={viewRaw} 218 - > 219 - <svg 220 - xmlns="http://www.w3.org/2000/svg" 221 - width="14" 222 - height="14" 223 - viewBox="0 0 24 24" 224 - fill="none" 225 - stroke="currentColor" 226 - stroke-width="2" 227 - stroke-linecap="round" 228 - stroke-linejoin="round" 229 - > 230 - <polyline points="4 7 4 4 20 4 20 7" /> 231 - <line x1="9" x2="15" y1="20" y2="20" /> 232 - <line x1="12" x2="12" y1="4" y2="20" /> 233 - </svg> 254 + </Show> 255 + <Show when={contentType() !== "binary"}> 256 + <button class={ghostClass} onClick={saveFile}> 257 + save 234 258 </button> 235 - </div> 236 - <div class="bg-surface border-border max-h-[60vh] w-full overflow-auto rounded-lg border p-4 pt-10 font-mono text-xs leading-relaxed wrap-break-word whitespace-pre-wrap"> 237 - {textContent()} 238 - </div> 259 + </Show> 239 260 </div> 240 - </Match> 261 + </div> 241 262 242 - <Match when={contentType() === "image"}> 243 - <div class="w-full"> 263 + <Show when={contentType() === "image"}> 264 + <div class="bg-surface border-border flex items-center justify-center rounded-lg border p-4"> 244 265 <img 245 266 src={imageSrc()} 246 - alt={imageAlt()} 247 - class="max-w-full rounded-lg shadow-sm" 267 + alt={fileName()} 268 + class="max-h-[70vh] w-fit max-w-full rounded" 248 269 /> 249 270 </div> 250 - <div class="mt-3"> 251 - <button 252 - class="bg-surface text-text border-border hover:bg-surface w-full rounded-md border py-2 text-sm font-medium transition-colors" 253 - onClick={saveFile} 254 - > 255 - Save 256 - </button> 271 + </Show> 272 + <Show when={contentType() === "video"}> 273 + <div class="bg-surface border-border flex items-center justify-center rounded-lg border p-4"> 274 + <video 275 + src={mediaSrc()} 276 + controls 277 + class="max-h-[70vh] w-fit max-w-full rounded" 278 + /> 279 + </div> 280 + </Show> 281 + <Show when={contentType() === "audio"}> 282 + <div class="bg-surface border-border rounded-lg border p-4"> 283 + <audio src={mediaSrc()} controls class="w-full" /> 284 + </div> 285 + </Show> 286 + <Show when={contentType() === "text"}> 287 + <div class="bg-surface border-border max-h-[70vh] w-full overflow-auto rounded-lg border p-4 font-mono text-xs leading-relaxed wrap-break-word whitespace-pre-wrap"> 288 + {textContent()} 257 289 </div> 258 - </Match> 259 - 260 - <Match when={contentType() === "binary"}> 261 - <div class="bg-surface border-border rounded-lg border p-4 text-center"> 262 - <p class="mb-3 text-sm">{fileName()}</p> 263 - <button 264 - class="bg-accent hover:bg-accent-hover w-full rounded-md border-none py-2.5 text-sm font-medium text-white transition-colors" 265 - onClick={saveFile} 266 - > 267 - Download 290 + </Show> 291 + <Show when={contentType() === "binary"}> 292 + <div class="flex justify-center"> 293 + <button class={btnClass} style={btnStyle} onClick={saveFile}> 294 + download 268 295 </button> 269 296 </div> 270 - </Match> 271 - </Switch> 297 + </Show> 272 298 273 - <Show when={burned()}> 274 - <div class="text-danger mt-3 text-xs"> 275 - This file has been burned and can no longer be viewed. 299 + <div class="flex items-center justify-between"> 300 + <span class={`text-xs ${burned() ? "text-danger" : "text-muted"}`}> 301 + {burned() ? "burned" : formatExpiry(expiresAt())} 302 + </span> 303 + <a href="/" class={ghostClass}> 304 + new drop 305 + </a> 276 306 </div> 277 - </Show> 278 - </Show> 279 - 280 - <Show when={stage() === "error"}> 281 - <div class="text-danger mt-4 text-xs">{error()}</div> 307 + </div> 282 308 </Show> 283 309 </> 284 310 );
+18 -7
web/src/styles.css
··· 16 16 } 17 17 18 18 :root { 19 - --c-bg: #faf7f4; 20 - --c-surface: #f0ebe4; 21 - --c-border: #ddd3c8; 19 + --c-bg: #fdf8f2; 20 + --c-surface: #f5ede0; 21 + --c-border: #e0d0c0; 22 22 --c-text: #1c1410; 23 - --c-muted: #8c7b6e; 24 - --c-accent: #9b5e23; 25 - --c-accent-hover: #7c4a1a; 26 - --c-accent-subtle: #f5ece3; 23 + --c-muted: #9a8070; 24 + --c-accent: #b08855; 25 + --c-accent-hover: #8f6e3f; 26 + --c-accent-subtle: #f5e8d5; 27 27 --c-danger: #b91c1c; 28 + } 29 + 30 + @keyframes fade-in { 31 + 0% { 32 + opacity: 0; 33 + transform: translateY(4px); 34 + } 35 + 100% { 36 + opacity: 1; 37 + transform: translateY(0); 38 + } 28 39 } 29 40 30 41 @media (prefers-color-scheme: dark) {