zero-knowledge file sharing
13
fork

Configure Feed

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

new upload UI

Juliet ded58a59 99a44873

+225 -112
+4 -3
package.json
··· 3 3 "private": true, 4 4 "type": "module", 5 5 "scripts": { 6 - "dev": "cd web && bun run dev & bun run --hot src/index.ts", 7 - "build": "cd web && bun install && bun run build", 8 - "start": "bun run build && bun run src/index.ts" 6 + "install:all": "bun install && bun install --cwd web", 7 + "dev": "bun run install:all && bun run --cwd web dev & bun run --hot src/index.ts", 8 + "build": "bun run --cwd web build", 9 + "start": "bun run install:all && bun run build && bun run src/index.ts" 9 10 }, 10 11 "dependencies": { 11 12 "hono": "^4.12.3"
+1 -2
web/index.html
··· 6 6 <title>drop</title> 7 7 <link rel="icon" href="/favicon.svg" type="image/svg+xml" /> 8 8 </head> 9 - <body> 10 - <div id="root"></div> 9 + <body id="root" class="bg-bg"> 11 10 <script type="module" src="/src/index.tsx"></script> 12 11 </body> 13 12 </html>
+1 -1
web/public/favicon.svg
··· 1 1 <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"> 2 - <path d="M16 2 C15 6 9 12 7 17 C5 22 8 30 16 30 C24 30 27 22 25 17 C23 12 17 6 16 2Z" fill="#c4956a"/> 2 + <path d="M16 2 C16 2 7 13 7 19 C7 25.5 11 30 16 30 C21 30 25 25.5 25 19 C25 13 16 2 16 2Z" fill="#c8a882"/> 3 3 </svg>
+2 -2
web/src/App.tsx
··· 2 2 3 3 export default function App(props: ParentProps) { 4 4 return ( 5 - <div class="bg-bg text-text flex min-h-screen flex-col items-center justify-center px-4 font-sans"> 5 + <div class="text-text flex min-h-screen flex-col items-center justify-center px-4 font-sans"> 6 6 <div class="w-full max-w-md"> 7 - <h1 class="mb-6 text-lg font-medium"> 7 + <h1 class="mt-2 mb-6 text-lg font-medium"> 8 8 <a 9 9 href="/" 10 10 class="text-text hover:text-accent no-underline transition-colors"
+4 -1
web/src/lib/crypto.ts
··· 64 64 } 65 65 66 66 // Decrypt and unpack — returns { fileName, fileData } 67 - export async function decrypt(ciphertext: Uint8Array, key: CryptoKey) { 67 + export async function decrypt( 68 + ciphertext: Uint8Array<ArrayBuffer>, 69 + key: CryptoKey, 70 + ) { 68 71 const plain = await crypto.subtle.decrypt( 69 72 { name: "AES-GCM", iv: IV }, 70 73 key,
+4 -4
web/src/lib/utils.ts
··· 61 61 } 62 62 63 63 export function formatBytes(n: number): string { 64 - if (n < 1024) return `${n} B`; 65 - if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; 66 - if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`; 67 - return `${(n / 1024 / 1024 / 1024).toFixed(2)} GB`; 64 + if (n < 1000) return `${n} B`; 65 + if (n < 1e6) return `${parseFloat((n / 1e3).toFixed(1))} KB`; 66 + if (n < 1e9) return `${parseFloat((n / 1e6).toFixed(1))} MB`; 67 + return `${parseFloat((n / 1e9).toFixed(2))} GB`; 68 68 } 69 69 70 70 export function formatExpiry(unixSec: number): string {
+199 -83
web/src/pages/Upload.tsx
··· 1 - import { createSignal, Show, onMount, onCleanup } from "solid-js"; 1 + import { createSignal, Show, onMount, onCleanup, createMemo } from "solid-js"; 2 2 3 3 import { generateKey, encrypt } from "../lib/crypto"; 4 4 import { formatBytes } from "../lib/utils"; ··· 6 6 export default function Upload() { 7 7 const [file, setFile] = createSignal<File | null>(null); 8 8 const [uploading, setUploading] = createSignal(false); 9 + const [encrypting, setEncrypting] = createSignal(false); 9 10 const [progress, setProgress] = createSignal(0); 10 11 const [error, setError] = createSignal(""); 11 12 const [resultUrl, setResultUrl] = createSignal(""); 12 - const [buttonText, setButtonText] = createSignal("Upload"); 13 13 const [dragging, setDragging] = createSignal(false); 14 + const [maxFileSize, setMaxFileSize] = createSignal(0); 14 15 15 16 let fileInput!: HTMLInputElement; 16 17 let linkInput!: HTMLInputElement; 18 + let expiryInput!: HTMLInputElement; 19 + let burnInput!: HTMLInputElement; 20 + let copyBtn!: HTMLButtonElement; 21 + let activeXhr: XMLHttpRequest | null = null; 22 + 23 + const RADIUS = 130; 24 + const CENTER = RADIUS + 10; 25 + const SIZE = CENTER * 2; 26 + const CIRCUMFERENCE = 2 * Math.PI * RADIUS; 27 + 28 + let prevRatio = 0; 29 + const sizeRatio = createMemo(() => { 30 + const f = file(); 31 + const max = maxFileSize(); 32 + if (!f || !max) return 0; 33 + return Math.min(f.size / max, 1); 34 + }); 35 + 36 + const animDuration = createMemo(() => { 37 + const cur = sizeRatio(); 38 + const dur = Math.max(Math.max(cur, prevRatio) * 800, 200); 39 + prevRatio = cur; 40 + return dur; 41 + }); 42 + 43 + const tooLarge = createMemo(() => { 44 + const f = file(); 45 + const max = maxFileSize(); 46 + return !!f && !!max && f.size > max; 47 + }); 48 + 49 + const statusText = () => { 50 + if (encrypting()) return "Encrypting\u2026"; 51 + if (uploading()) return "Uploading\u2026"; 52 + return ""; 53 + }; 17 54 18 55 const handleDragOver = (e: DragEvent) => { 19 56 e.preventDefault(); ··· 35 72 if (e.dataTransfer?.files[0]) setFile(e.dataTransfer.files[0]); 36 73 }; 37 74 38 - onMount(() => { 75 + onMount(async () => { 39 76 document.addEventListener("dragover", handleDragOver); 40 77 document.addEventListener("dragleave", handleDragLeave); 41 78 document.addEventListener("drop", handleDrop); 79 + 80 + try { 81 + const res = await fetch("/api/info"); 82 + if (res.ok) { 83 + const info = await res.json(); 84 + setMaxFileSize(info.maxFileSize); 85 + } 86 + } catch {} 42 87 }); 43 88 44 89 onCleanup(() => { ··· 47 92 document.removeEventListener("drop", handleDrop); 48 93 }); 49 94 95 + const cancelUpload = () => { 96 + if (activeXhr) { 97 + activeXhr.abort(); 98 + activeXhr = null; 99 + } 100 + setUploading(false); 101 + setEncrypting(false); 102 + setProgress(0); 103 + removeFile(); 104 + }; 105 + 50 106 const removeFile = () => { 51 107 setFile(null); 108 + setError(""); 52 109 fileInput.value = ""; 53 110 }; 54 111 ··· 56 113 const f = file(); 57 114 if (!f) return; 58 115 116 + setEncrypting(true); 59 117 setUploading(true); 60 - setButtonText("Encrypting\u2026"); 61 118 setError(""); 62 119 setResultUrl(""); 63 120 setProgress(0); ··· 69 126 70 127 const formData = new FormData(); 71 128 formData.append("file", new Blob([ciphertext])); 72 - formData.append( 73 - "expiresIn", 74 - (document.getElementById("expiry") as HTMLInputElement).value.trim(), 75 - ); 76 - formData.append( 77 - "burnAfterRead", 78 - (document.getElementById("burn") as HTMLInputElement).checked 79 - ? "true" 80 - : "false", 81 - ); 129 + formData.append("expiresIn", expiryInput.value.trim()); 130 + formData.append("burnAfterRead", burnInput.checked ? "true" : "false"); 82 131 83 - setButtonText("Uploading\u2026"); 132 + setEncrypting(false); 84 133 85 134 const res = await new Promise<{ id: string }>((resolve, reject) => { 86 135 const xhr = new XMLHttpRequest(); 136 + activeXhr = xhr; 87 137 xhr.open("POST", "/api/file"); 88 138 89 139 xhr.upload.onprogress = (e) => { 90 140 if (e.lengthComputable) { 91 - const pct = Math.round((e.loaded / e.total) * 100); 92 - setProgress(pct); 93 - setButtonText(`Uploading\u2026 ${pct}%`); 141 + setProgress(Math.round((e.loaded / e.total) * 100)); 94 142 } 95 143 }; 96 144 ··· 113 161 114 162 const url = `${location.origin}/p/${res.id}#${encoded}`; 115 163 setResultUrl(url); 164 + removeFile(); 116 165 } catch (e: any) { 117 166 setError(e.message); 118 167 } finally { 168 + activeXhr = null; 119 169 setUploading(false); 120 - setButtonText("Upload"); 170 + setEncrypting(false); 171 + setProgress(0); 121 172 } 122 173 }; 123 174 124 175 const copyLink = () => { 125 176 navigator.clipboard.writeText(linkInput.value); 126 - const btn = document.getElementById("copy-btn")!; 127 - btn.textContent = "Copied!"; 128 - setTimeout(() => (btn.textContent = "Copy"), 1500); 177 + copyBtn.textContent = "Copied!"; 178 + setTimeout(() => (copyBtn.textContent = "Copy"), 1500); 129 179 }; 130 180 131 181 return ( 132 182 <> 133 183 <div 134 - class="bg-surface border-border hover:border-accent flex h-[200px] w-full cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border border-dashed transition-colors" 135 - classList={{ "border-accent bg-accent-subtle": dragging() }} 184 + class="group relative mx-auto flex items-center justify-center" 185 + style={{ width: `${SIZE}px`, height: `${SIZE}px` }} 136 186 onClick={() => fileInput.click()} 137 187 > 138 188 <svg 139 - xmlns="http://www.w3.org/2000/svg" 140 - width="32" 141 - height="32" 142 - class="text-muted" 143 - fill="none" 144 - viewBox="0 0 24 24" 145 - stroke="currentColor" 146 - stroke-width="1.5" 189 + width={SIZE} 190 + height={SIZE} 191 + viewBox={`0 0 ${SIZE} ${SIZE}`} 192 + class="absolute inset-0" 147 193 > 148 - <path 194 + <defs> 195 + <clipPath id="circle-clip"> 196 + <circle cx={CENTER} cy={CENTER} r={RADIUS - 2.5} /> 197 + </clipPath> 198 + </defs> 199 + <circle 200 + cx={CENTER} 201 + cy={CENTER} 202 + r={RADIUS} 203 + fill="none" 204 + stroke={dragging() ? "var(--color-accent)" : "var(--color-border)"} 205 + stroke-width="5" 206 + stroke-dasharray={dragging() ? "6 4" : "none"} 207 + class="transition-all duration-200" 208 + /> 209 + <circle 210 + cx={CENTER} 211 + cy={CENTER} 212 + r={RADIUS} 213 + fill="none" 214 + stroke={tooLarge() ? "var(--color-danger)" : "var(--color-accent)"} 215 + stroke-width="5" 216 + stroke-dasharray={`${CIRCUMFERENCE}`} 217 + stroke-dashoffset={`${CIRCUMFERENCE * (1 - (uploading() ? 0 : sizeRatio()))}`} 149 218 stroke-linecap="round" 150 - stroke-linejoin="round" 151 - d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" 219 + transform={`rotate(-90 ${CENTER} ${CENTER})`} 220 + style={{ 221 + transition: `all ${animDuration()}ms ease-out`, 222 + }} 223 + /> 224 + {/* Upload liquid fill */} 225 + <rect 226 + x={CENTER - RADIUS} 227 + y={CENTER - RADIUS + 2 * RADIUS * (1 - progress() / 100)} 228 + width={2 * RADIUS} 229 + height={2 * RADIUS * (progress() / 100)} 230 + fill="var(--color-accent)" 231 + opacity="0.15" 232 + clip-path="url(#circle-clip)" 233 + class="transition-all duration-300 ease-out" 152 234 /> 153 235 </svg> 154 - <p class="text-muted text-sm"> 155 - Drop a file or{" "} 156 - <button 157 - class="text-accent cursor-pointer border-none bg-transparent p-0 text-sm underline" 158 - onClick={(e) => { 159 - e.stopPropagation(); 160 - fileInput.click(); 161 - }} 162 - > 163 - browse 164 - </button> 165 - </p> 236 + 237 + <div class="z-10 flex flex-col items-center gap-1.5 text-center"> 238 + <Show when={uploading()}> 239 + <span class="text-accent text-2xl font-medium tabular-nums"> 240 + {progress()}% 241 + </span> 242 + <span class="text-muted text-[10px]">{statusText()}</span> 243 + <button 244 + class="text-muted hover:text-text mt-1 border-none bg-transparent p-0 text-[10px]" 245 + onClick={(e) => { 246 + e.stopPropagation(); 247 + cancelUpload(); 248 + }} 249 + > 250 + cancel 251 + </button> 252 + </Show> 253 + <Show when={!uploading()}> 254 + <Show 255 + when={!file()} 256 + fallback={ 257 + <> 258 + <span class="text-text max-w-[160px] truncate font-mono text-xs"> 259 + {file()!.name} 260 + </span> 261 + <span 262 + class={ 263 + tooLarge() 264 + ? "text-danger text-xs font-medium" 265 + : "text-muted text-xs" 266 + } 267 + > 268 + {formatBytes(file()!.size)} 269 + {tooLarge() ? ` / ${formatBytes(maxFileSize())} limit` : ""} 270 + </span> 271 + <button 272 + class="bg-accent hover:bg-accent-hover mt-2 rounded-md border-none px-4 py-1.5 text-sm font-medium text-white transition-colors disabled:cursor-not-allowed disabled:opacity-40" 273 + disabled={tooLarge()} 274 + onClick={(e) => { 275 + e.stopPropagation(); 276 + handleUpload(); 277 + }} 278 + > 279 + Upload 280 + </button> 281 + <button 282 + class="text-muted hover:text-text mt-1 border-none bg-transparent p-0 text-[10px]" 283 + onClick={(e) => { 284 + e.stopPropagation(); 285 + removeFile(); 286 + }} 287 + > 288 + remove 289 + </button> 290 + </> 291 + } 292 + > 293 + <p class="text-muted text-sm font-medium">Drop a file</p> 294 + <span class="text-muted text-[10px]">or</span> 295 + <button 296 + class="bg-accent hover:bg-accent-hover rounded-md border-none px-4 py-1.5 text-sm font-medium text-white transition-colors" 297 + onClick={(e) => { 298 + e.stopPropagation(); 299 + fileInput.click(); 300 + }} 301 + > 302 + Browse 303 + </button> 304 + <Show when={maxFileSize()}> 305 + <span class="text-muted text-[10px]"> 306 + up to {formatBytes(maxFileSize())} 307 + </span> 308 + </Show> 309 + </Show> 310 + </Show> 311 + </div> 312 + 166 313 <input 167 314 type="file" 168 315 ref={fileInput!} ··· 173 320 /> 174 321 </div> 175 322 176 - <Show when={file()}> 177 - <div class="mt-3 flex items-center gap-2"> 178 - <span class="bg-surface border-border text-text inline-flex items-center gap-1.5 rounded-full border px-3 py-1 font-mono text-xs"> 179 - {file()!.name} ({formatBytes(file()!.size)}) 180 - </span> 181 - <button 182 - class="text-muted hover:text-text cursor-pointer border-none bg-transparent p-0 text-sm leading-none" 183 - onClick={removeFile} 184 - > 185 - &times; 186 - </button> 187 - </div> 188 - </Show> 189 - 190 323 <div class="mt-4 flex items-center gap-4"> 191 324 <input 192 - id="expiry" 325 + ref={expiryInput!} 193 326 type="text" 194 327 value="24h" 195 328 placeholder="e.g. 30m, 24h, 7d" 196 329 class="bg-surface border-border text-text focus:border-accent w-24 rounded-md border px-2.5 py-1.5 text-xs transition-colors outline-none" 197 330 /> 198 - <label class="text-muted flex cursor-pointer items-center gap-1.5 text-xs select-none"> 199 - <input type="checkbox" id="burn" class="accent-accent" /> 331 + <label class="text-muted flex items-center gap-1.5 text-xs select-none"> 332 + <input type="checkbox" ref={burnInput!} class="accent-accent" /> 200 333 Burn after read 201 334 </label> 202 335 </div> 203 336 204 - <button 205 - class="bg-accent hover:bg-accent-hover relative mt-4 w-full cursor-pointer overflow-hidden rounded-md border-none py-2.5 text-sm font-medium text-white transition-colors disabled:cursor-not-allowed disabled:opacity-40" 206 - disabled={!file() || uploading()} 207 - onClick={handleUpload} 208 - > 209 - {buttonText()} 210 - </button> 211 - 212 - <Show when={uploading()}> 213 - <div class="bg-surface mt-2 h-1 w-full overflow-hidden rounded-full"> 214 - <div 215 - class="bg-accent h-full rounded-full transition-all duration-150" 216 - style={{ width: `${progress()}%` }} 217 - /> 218 - </div> 219 - </Show> 220 - 221 337 <Show when={error()}> 222 338 <div class="text-danger mt-4 text-xs">{error()}</div> 223 339 </Show> ··· 234 350 class="bg-bg border-border text-text min-w-0 flex-1 rounded-md border px-2.5 py-1.5 font-mono text-xs outline-none" 235 351 /> 236 352 <button 237 - id="copy-btn" 238 - class="bg-accent hover:bg-accent-hover shrink-0 cursor-pointer rounded-md border-none px-3 py-1.5 text-xs font-medium text-white transition-colors" 353 + ref={copyBtn!} 354 + class="bg-accent hover:bg-accent-hover shrink-0 rounded-md border-none px-3 py-1.5 text-xs font-medium text-white transition-colors" 239 355 onClick={copyLink} 240 356 > 241 357 Copy
+8 -9
web/src/pages/View.tsx
··· 118 118 }; 119 119 120 120 const viewRaw = () => { 121 - const w = window.open(); 122 - if (w) 123 - w.document.write(`<pre>${textContent().replace(/</g, "&lt;")}</pre>`); 121 + const blob = new Blob([textContent()], { type: "text/plain" }); 122 + window.open(URL.createObjectURL(blob)); 124 123 }; 125 124 126 125 const saveFile = () => { ··· 131 130 <> 132 131 <Show when={stage() === "meta"}> 133 132 <div> 134 - <div class="mb-1 font-mono text-base">Encrypted file</div> 133 + <div class="mb-1 text-base">Encrypted file</div> 135 134 <div class="text-muted mb-1 flex gap-3 text-xs"> 136 135 <span>{formatBytes(size())}</span> 137 136 <span>{formatExpiry(expiresAt())}</span> ··· 151 150 </Show> 152 151 <div class="mt-4"> 153 152 <button 154 - class="bg-accent hover:bg-accent-hover w-full cursor-pointer rounded-md border-none py-2.5 text-sm font-medium text-white transition-colors disabled:cursor-not-allowed disabled:opacity-40" 153 + 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" 155 154 disabled={loadBtnDisabled()} 156 155 onClick={handleView} 157 156 > ··· 167 166 <div class="relative"> 168 167 <div class="absolute top-2 right-2 z-10 flex gap-1"> 169 168 <button 170 - class="bg-surface text-muted hover:text-text border-border cursor-pointer rounded-md border p-1.5 transition-colors" 169 + class="bg-surface text-muted hover:text-text border-border rounded-md border p-1.5 transition-colors" 171 170 title="Copy" 172 171 onClick={copyText} 173 172 > ··· 187 186 </svg> 188 187 </button> 189 188 <button 190 - class="bg-surface text-muted hover:text-text border-border cursor-pointer rounded-md border p-1.5 transition-colors" 189 + class="bg-surface text-muted hover:text-text border-border rounded-md border p-1.5 transition-colors" 191 190 title="Raw" 192 191 onClick={viewRaw} 193 192 > ··· 224 223 </div> 225 224 <div class="mt-3"> 226 225 <button 227 - class="bg-surface text-text border-border hover:bg-surface w-full cursor-pointer rounded-md border py-2 text-sm font-medium transition-colors" 226 + class="bg-surface text-text border-border hover:bg-surface w-full rounded-md border py-2 text-sm font-medium transition-colors" 228 227 onClick={saveFile} 229 228 > 230 229 Save ··· 236 235 <div class="bg-surface border-border rounded-lg border p-4 text-center"> 237 236 <p class="mb-3 font-mono text-sm">{fileName()}</p> 238 237 <button 239 - class="bg-accent hover:bg-accent-hover w-full cursor-pointer rounded-md border-none py-2.5 text-sm font-medium text-white transition-colors" 238 + class="bg-accent hover:bg-accent-hover w-full rounded-md border-none py-2.5 text-sm font-medium text-white transition-colors" 240 239 onClick={saveFile} 241 240 > 242 241 Download
+2 -7
web/src/styles.css
··· 1 1 @import "tailwindcss"; 2 2 3 - @source "../src"; 4 - 5 3 @theme { 4 + --font-mono: "SF Mono", "Cascadia Code", "Fira Code", Consolas, monospace; 5 + 6 6 --color-bg: var(--c-bg); 7 7 --color-surface: var(--c-surface); 8 8 --color-border: var(--c-border); ··· 12 12 --color-accent-hover: var(--c-accent-hover); 13 13 --color-accent-subtle: var(--c-accent-subtle); 14 14 --color-danger: var(--c-danger); 15 - --font-mono: "SF Mono", "Cascadia Code", "Fira Code", Consolas, monospace; 16 - } 17 - 18 - html { 19 - color-scheme: light dark; 20 15 } 21 16 22 17 :root {