🪻 distributed transcription service thistle.dunkirk.sh
1
fork

Configure Feed

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

feat: add login/auth and settings

+2350 -24
+1
.gitignore
··· 1 1 node_modules 2 + thistle.db
+62 -8
CRUSH.md
··· 10 10 - Language: TypeScript with strict mode 11 11 - Frontend: Vanilla HTML/CSS/JS with lightweight helpers on top of web components 12 12 13 + ## Design System 14 + 15 + ALWAYS use the project's CSS variables for colors: 16 + 17 + ```css 18 + :root { 19 + /* Color palette */ 20 + --gunmetal: #2d3142ff; /* dark blue-gray */ 21 + --paynes-gray: #4f5d75ff; /* medium blue-gray */ 22 + --silver: #bfc0c0ff; /* light gray */ 23 + --white: #ffffffff; /* white */ 24 + --coral: #ef8354ff; /* warm orange */ 25 + 26 + /* Semantic color assignments */ 27 + --text: var(--gunmetal); 28 + --background: var(--white); 29 + --primary: var(--paynes-gray); 30 + --secondary: var(--silver); 31 + --accent: var(--coral); 32 + } 33 + ``` 34 + 35 + **Color usage:** 36 + - NEVER hardcode colors like `#4f46e5`, `white`, `red`, etc. 37 + - Always use semantic variables (`var(--primary)`, `var(--background)`, `var(--accent)`, etc.) or named color variables (`var(--gunmetal)`, `var(--coral)`, etc.) 38 + 39 + **Dimensions:** 40 + - Use `rem` for all sizes, spacing, and widths (not `px`) 41 + - Base font size is 16px (1rem = 16px) 42 + - Common values: `0.5rem` (8px), `1rem` (16px), `2rem` (32px), `3rem` (48px) 43 + - Max widths: `48rem` (768px) for content, `56rem` (896px) for forms/data 44 + - Spacing scale: `0.25rem`, `0.5rem`, `0.75rem`, `1rem`, `1.5rem`, `2rem`, `3rem` 45 + 13 46 ## NO FRAMEWORKS 14 47 15 48 NEVER use React, Vue, Svelte, or any heavy framework. ··· 118 151 119 152 ```html 120 153 <!DOCTYPE html> 121 - <html> 122 - <head> 123 - <link rel="stylesheet" href="./styles.css"> 124 - </head> 125 - <body> 126 - <h1>Hello, world!</h1> 154 + <html lang="en"> 155 + 156 + <head> 157 + <meta charset="UTF-8"> 158 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 159 + <title>Page Title - Thistle</title> 160 + <link rel="icon" 161 + href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='0.9em' font-size='90'>🪻</text></svg>"> 162 + <link rel="stylesheet" href="../styles/main.css"> 163 + </head> 164 + 165 + <body> 166 + <auth-component></auth-component> 167 + 168 + <main> 169 + <h1>Page Title</h1> 127 170 <my-component></my-component> 128 - <script type="module" src="./frontend.ts"></script> 129 - </body> 171 + </main> 172 + 173 + <script type="module" src="../components/auth.ts"></script> 174 + <script type="module" src="../components/my-component.ts"></script> 175 + </body> 176 + 130 177 </html> 131 178 ``` 179 + 180 + **Standard HTML template:** 181 + - Always include the `<auth-component>` element for consistent login/logout UI 182 + - Always include the thistle emoji favicon 183 + - Always include proper meta tags (charset, viewport) 184 + - Structure: auth component, then main content, then scripts 185 + - Import `auth.ts` on every page for authentication UI 132 186 133 187 Bun's bundler will transpile and bundle automatically. `<link>` tags pointing to stylesheets work with Bun's CSS bundler. 134 188
+9
bun.lock
··· 5 5 "name": "inky", 6 6 "dependencies": { 7 7 "lit": "^3.3.1", 8 + "ua-parser-js": "^2.0.6", 8 9 }, 9 10 "devDependencies": { 10 11 "@biomejs/biome": "^2.3.2", ··· 50 51 51 52 "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], 52 53 54 + "detect-europe-js": ["detect-europe-js@0.1.2", "", {}, "sha512-lgdERlL3u0aUdHocoouzT10d9I89VVhk0qNRmll7mXdGfJT1/wqZ2ZLA4oJAjeACPY5fT1wsbq2AT+GkuInsow=="], 55 + 56 + "is-standalone-pwa": ["is-standalone-pwa@0.1.1", "", {}, "sha512-9Cbovsa52vNQCjdXOzeQq5CnCbAcRk05aU62K20WO372NrTv0NxibLFCK6lQ4/iZEFdEA3p3t2VNOn8AJ53F5g=="], 57 + 53 58 "lit": ["lit@3.3.1", "", { "dependencies": { "@lit/reactive-element": "^2.1.0", "lit-element": "^4.2.0", "lit-html": "^3.3.0" } }, "sha512-Ksr/8L3PTapbdXJCk+EJVB78jDodUMaP54gD24W186zGRARvwrsPfS60wae/SSCTCNZVPd1chXqio1qHQmu4NA=="], 54 59 55 60 "lit-element": ["lit-element@4.2.1", "", { "dependencies": { "@lit-labs/ssr-dom-shim": "^1.4.0", "@lit/reactive-element": "^2.1.0", "lit-html": "^3.3.0" } }, "sha512-WGAWRGzirAgyphK2urmYOV72tlvnxw7YfyLDgQ+OZnM9vQQBQnumQ7jUJe6unEzwGU3ahFOjuz1iz1jjrpCPuw=="], ··· 57 62 "lit-html": ["lit-html@3.3.1", "", { "dependencies": { "@types/trusted-types": "^2.0.2" } }, "sha512-S9hbyDu/vs1qNrithiNyeyv64c9yqiW9l+DBgI18fL+MTvOtWoFR0FWiyq1TxaYef5wNlpEmzlXoBlZEO+WjoA=="], 58 63 59 64 "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], 65 + 66 + "ua-is-frozen": ["ua-is-frozen@0.1.2", "", {}, "sha512-RwKDW2p3iyWn4UbaxpP2+VxwqXh0jpvdxsYpZ5j/MLLiQOfbsV5shpgQiw93+KMYQPcteeMQ289MaAFzs3G9pw=="], 67 + 68 + "ua-parser-js": ["ua-parser-js@2.0.6", "", { "dependencies": { "detect-europe-js": "^0.1.2", "is-standalone-pwa": "^0.1.1", "ua-is-frozen": "^0.1.2" }, "bin": { "ua-parser-js": "script/cli.js" } }, "sha512-EmaxXfltJaDW75SokrY4/lXMrVyXomE/0FpIIqP2Ctic93gK7rlme55Cwkz8l3YZ6gqf94fCU7AnIkidd/KXPg=="], 60 69 61 70 "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], 62 71 }
+2 -1
package.json
··· 14 14 "typescript": "^5" 15 15 }, 16 16 "dependencies": { 17 - "lit": "^3.3.1" 17 + "lit": "^3.3.1", 18 + "ua-parser-js": "^2.0.6" 18 19 } 19 20 }
+491
src/components/auth.ts
··· 1 + import { css, html, LitElement } from "lit"; 2 + import { customElement, state } from "lit/decorators.js"; 3 + 4 + interface User { 5 + email: string; 6 + name: string | null; 7 + avatar: string; 8 + } 9 + 10 + @customElement("auth-component") 11 + export class AuthComponent extends LitElement { 12 + @state() user: User | null = null; 13 + @state() loading = true; 14 + @state() showModal = false; 15 + @state() email = ""; 16 + @state() password = ""; 17 + @state() name = ""; 18 + @state() error = ""; 19 + @state() isSubmitting = false; 20 + @state() needsRegistration = false; 21 + 22 + static override styles = css` 23 + :host { 24 + display: block; 25 + position: fixed; 26 + top: 2rem; 27 + right: 2rem; 28 + z-index: 1000; 29 + } 30 + 31 + .auth-button { 32 + display: flex; 33 + align-items: center; 34 + gap: 0.5rem; 35 + padding: 0.5rem 1rem; 36 + background: var(--primary); 37 + color: white; 38 + border: 2px solid var(--primary); 39 + border-radius: 8px; 40 + cursor: pointer; 41 + font-size: 1rem; 42 + font-weight: 500; 43 + transition: all 0.2s; 44 + font-family: inherit; 45 + } 46 + 47 + .auth-button:hover { 48 + background: transparent; 49 + color: var(--primary); 50 + } 51 + 52 + .auth-button:hover .email { 53 + color: var(--primary); 54 + } 55 + 56 + .auth-button img { 57 + transition: all 0.2s; 58 + } 59 + 60 + .auth-button:hover img { 61 + opacity: 0.8; 62 + } 63 + 64 + .user-info { 65 + display: flex; 66 + align-items: center; 67 + gap: 0.75rem; 68 + } 69 + 70 + .email { 71 + font-weight: 500; 72 + color: white; 73 + font-size: 0.875rem; 74 + transition: all 0.2s; 75 + } 76 + 77 + .modal-overlay { 78 + position: fixed; 79 + top: 0; 80 + left: 0; 81 + right: 0; 82 + bottom: 0; 83 + background: rgba(0, 0, 0, 0.5); 84 + display: flex; 85 + align-items: center; 86 + justify-content: center; 87 + z-index: 2000; 88 + padding: 1rem; 89 + } 90 + 91 + .modal { 92 + background: var(--background); 93 + border: 2px solid var(--secondary); 94 + border-radius: 12px; 95 + padding: 2rem; 96 + max-width: 400px; 97 + width: 100%; 98 + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2); 99 + } 100 + 101 + .modal h2 { 102 + margin-top: 0; 103 + color: var(--text); 104 + font-size: 1.777rem; 105 + } 106 + 107 + .modal form { 108 + display: flex; 109 + flex-direction: column; 110 + gap: 1rem; 111 + } 112 + 113 + .field { 114 + display: flex; 115 + flex-direction: column; 116 + gap: 0.5rem; 117 + } 118 + 119 + .field label { 120 + font-weight: 500; 121 + color: var(--text); 122 + } 123 + 124 + .field input { 125 + padding: 0.75rem; 126 + border: 2px solid var(--secondary); 127 + border-radius: 6px; 128 + font-size: 1rem; 129 + font-family: inherit; 130 + background: var(--background); 131 + color: var(--text); 132 + } 133 + 134 + .field input:focus { 135 + outline: none; 136 + border-color: var(--primary); 137 + } 138 + 139 + .error { 140 + color: var(--accent); 141 + font-size: 0.875rem; 142 + margin: 0; 143 + } 144 + 145 + .btn { 146 + padding: 0.75rem 1.5rem; 147 + border-radius: 6px; 148 + font-size: 1rem; 149 + font-weight: 500; 150 + cursor: pointer; 151 + transition: all 0.2s; 152 + font-family: inherit; 153 + border: 2px solid; 154 + } 155 + 156 + .btn:disabled { 157 + opacity: 0.5; 158 + cursor: not-allowed; 159 + } 160 + 161 + .btn-affirmative { 162 + background: var(--primary); 163 + color: white; 164 + border-color: var(--primary); 165 + } 166 + 167 + .btn-affirmative:hover:not(:disabled) { 168 + background: transparent; 169 + color: var(--primary); 170 + } 171 + 172 + .btn-neutral { 173 + background: transparent; 174 + color: var(--text); 175 + border-color: var(--secondary); 176 + } 177 + 178 + .btn-neutral:hover:not(:disabled) { 179 + border-color: var(--primary); 180 + color: var(--primary); 181 + } 182 + 183 + .btn-rejection { 184 + background: transparent; 185 + color: var(--accent); 186 + border-color: var(--accent); 187 + } 188 + 189 + .btn-rejection:hover:not(:disabled) { 190 + background: var(--accent); 191 + color: white; 192 + } 193 + 194 + .modal-actions { 195 + display: flex; 196 + gap: 0.5rem; 197 + margin-top: 1rem; 198 + } 199 + 200 + .user-menu { 201 + position: absolute; 202 + top: calc(100% + 0.5rem); 203 + right: 0; 204 + background: var(--background); 205 + border: 2px solid var(--secondary); 206 + border-radius: 8px; 207 + padding: 0.5rem; 208 + min-width: 200px; 209 + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); 210 + display: flex; 211 + flex-direction: column; 212 + gap: 0.5rem; 213 + } 214 + 215 + .user-menu a, 216 + .user-menu button { 217 + padding: 0.75rem 1rem; 218 + background: transparent; 219 + color: var(--text); 220 + text-decoration: none; 221 + border: none; 222 + border-radius: 6px; 223 + font-weight: 500; 224 + text-align: left; 225 + transition: all 0.2s; 226 + font-family: inherit; 227 + font-size: 1rem; 228 + cursor: pointer; 229 + } 230 + 231 + .user-menu a:hover, 232 + .user-menu button:hover { 233 + background: var(--secondary); 234 + } 235 + 236 + .loading { 237 + font-size: 0.875rem; 238 + color: var(--text); 239 + } 240 + 241 + .info-text { 242 + color: var(--text); 243 + font-size: 0.875rem; 244 + margin: 0; 245 + } 246 + `; 247 + 248 + override async connectedCallback() { 249 + super.connectedCallback(); 250 + await this.checkAuth(); 251 + } 252 + 253 + async checkAuth() { 254 + try { 255 + const response = await fetch("/api/auth/me"); 256 + 257 + if (response.ok) { 258 + this.user = await response.json(); 259 + } 260 + } finally { 261 + this.loading = false; 262 + } 263 + } 264 + 265 + private openModal() { 266 + this.showModal = true; 267 + this.needsRegistration = false; 268 + this.email = ""; 269 + this.password = ""; 270 + this.name = ""; 271 + this.error = ""; 272 + } 273 + 274 + private closeModal() { 275 + this.showModal = false; 276 + this.email = ""; 277 + this.password = ""; 278 + this.name = ""; 279 + this.error = ""; 280 + this.needsRegistration = false; 281 + } 282 + 283 + private async handleSubmit(e: Event) { 284 + e.preventDefault(); 285 + this.error = ""; 286 + this.isSubmitting = true; 287 + 288 + try { 289 + if (this.needsRegistration) { 290 + const response = await fetch("/api/auth/register", { 291 + method: "POST", 292 + headers: { "Content-Type": "application/json" }, 293 + body: JSON.stringify({ 294 + email: this.email, 295 + password: this.password, 296 + name: this.name, 297 + }), 298 + }); 299 + 300 + if (!response.ok) { 301 + const data = await response.json(); 302 + this.error = data.error || "Registration failed"; 303 + return; 304 + } 305 + 306 + this.user = await response.json(); 307 + this.closeModal(); 308 + await this.checkAuth(); 309 + } else { 310 + const response = await fetch("/api/auth/login", { 311 + method: "POST", 312 + headers: { "Content-Type": "application/json" }, 313 + body: JSON.stringify({ 314 + email: this.email, 315 + password: this.password, 316 + }), 317 + }); 318 + 319 + if (!response.ok) { 320 + const data = await response.json(); 321 + 322 + if ( 323 + response.status === 401 && 324 + data.error?.includes("Invalid email") 325 + ) { 326 + this.needsRegistration = true; 327 + this.error = ""; 328 + return; 329 + } 330 + 331 + this.error = data.error || "Login failed"; 332 + return; 333 + } 334 + 335 + this.user = await response.json(); 336 + this.closeModal(); 337 + await this.checkAuth(); 338 + } 339 + } finally { 340 + this.isSubmitting = false; 341 + } 342 + } 343 + 344 + async handleLogout() { 345 + try { 346 + await fetch("/api/auth/logout", { method: "POST" }); 347 + this.user = null; 348 + } catch { 349 + // Silent fail 350 + } 351 + } 352 + 353 + private toggleUserMenu() { 354 + this.showModal = !this.showModal; 355 + } 356 + 357 + override render() { 358 + if (this.loading) { 359 + return html`<div class="loading">Loading...</div>`; 360 + } 361 + 362 + if (this.user) { 363 + return html` 364 + <div> 365 + <button class="auth-button" @click=${this.toggleUserMenu}> 366 + <img 367 + src="https://hostedboringavatars.vercel.app/api/marble?size=24&name=${this.user.avatar}&colors=2d3142ff,4f5d75ff,bfc0c0ff,ef8354ff" 368 + alt="Avatar" 369 + style="border-radius: 50%; width: 24px; height: 24px;" 370 + /> 371 + <span class="email">${this.user.name ?? this.user.email}</span> 372 + <span>▼</span> 373 + </button> 374 + ${ 375 + this.showModal 376 + ? html` 377 + <div class="user-menu"> 378 + <a href="/settings" @click=${this.closeModal}>Settings</a> 379 + <button @click=${this.handleLogout}>Logout</button> 380 + </div> 381 + ` 382 + : "" 383 + } 384 + </div> 385 + `; 386 + } 387 + 388 + return html` 389 + <div> 390 + <button class="auth-button" @click=${this.openModal}>Login</button> 391 + ${ 392 + this.showModal 393 + ? html` 394 + <div class="modal-overlay" @click=${this.closeModal}> 395 + <div class="modal" @click=${(e: Event) => e.stopPropagation()}> 396 + <h2>${this.needsRegistration ? "Complete Registration" : "Login"}</h2> 397 + ${ 398 + this.needsRegistration 399 + ? html` 400 + <p class="info-text"> 401 + Welcome! We'll create an account for <strong>${this.email}</strong> 402 + </p> 403 + ` 404 + : "" 405 + } 406 + <form @submit=${this.handleSubmit}> 407 + <div class="field"> 408 + <label for="email">Email</label> 409 + <input 410 + type="email" 411 + id="email" 412 + .value=${this.email} 413 + @input=${(e: InputEvent) => { 414 + this.email = (e.target as HTMLInputElement).value; 415 + }} 416 + required 417 + ?disabled=${this.needsRegistration} 418 + /> 419 + </div> 420 + 421 + ${ 422 + this.needsRegistration 423 + ? html` 424 + <div class="field"> 425 + <label for="name">Name</label> 426 + <input 427 + type="text" 428 + id="name" 429 + .value=${this.name} 430 + @input=${(e: InputEvent) => { 431 + this.name = ( 432 + e.target as HTMLInputElement 433 + ).value; 434 + }} 435 + required 436 + placeholder="What should we call you?" 437 + /> 438 + </div> 439 + ` 440 + : "" 441 + } 442 + 443 + <div class="field"> 444 + <label for="password">${this.needsRegistration ? "Create Password" : "Password"}</label> 445 + <input 446 + type="password" 447 + id="password" 448 + .value=${this.password} 449 + @input=${(e: InputEvent) => { 450 + this.password = (e.target as HTMLInputElement).value; 451 + }} 452 + required 453 + minlength="8" 454 + placeholder=${this.needsRegistration ? "At least 8 characters plz" : ""} 455 + /> 456 + </div> 457 + 458 + ${this.error ? html`<p class="error">${this.error}</p>` : ""} 459 + 460 + <div class="modal-actions"> 461 + <button 462 + type="submit" 463 + class="btn btn-affirmative" 464 + ?disabled=${this.isSubmitting} 465 + > 466 + ${ 467 + this.isSubmitting 468 + ? "Loading..." 469 + : this.needsRegistration 470 + ? "Create Account" 471 + : "Login" 472 + } 473 + </button> 474 + <button 475 + type="button" 476 + class="btn btn-neutral" 477 + @click=${this.closeModal} 478 + > 479 + Cancel 480 + </button> 481 + </div> 482 + </form> 483 + </div> 484 + </div> 485 + ` 486 + : "" 487 + } 488 + </div> 489 + `; 490 + } 491 + }
+1015
src/components/user-settings.ts
··· 1 + import { css, html, LitElement } from "lit"; 2 + import { customElement, state } from "lit/decorators.js"; 3 + import { UAParser } from "ua-parser-js"; 4 + 5 + interface User { 6 + email: string; 7 + name: string | null; 8 + avatar: string; 9 + created_at: number; 10 + } 11 + 12 + interface Session { 13 + id: string; 14 + ip_address: string | null; 15 + user_agent: string | null; 16 + created_at: number; 17 + expires_at: number; 18 + is_current: boolean; 19 + } 20 + 21 + type SettingsPage = "account" | "sessions" | "danger"; 22 + 23 + @customElement("user-settings") 24 + export class UserSettings extends LitElement { 25 + @state() user: User | null = null; 26 + @state() sessions: Session[] = []; 27 + @state() loading = true; 28 + @state() loadingSessions = true; 29 + @state() error = ""; 30 + @state() showDeleteConfirm = false; 31 + @state() currentPage: SettingsPage = "account"; 32 + @state() editingEmail = false; 33 + @state() editingPassword = false; 34 + @state() newEmail = ""; 35 + @state() newPassword = ""; 36 + @state() newName = ""; 37 + @state() newAvatar = ""; 38 + 39 + static override styles = css` 40 + :host { 41 + display: block; 42 + } 43 + 44 + .settings-container { 45 + display: flex; 46 + gap: 3rem; 47 + } 48 + 49 + .sidebar { 50 + width: 250px; 51 + background: var(--background); 52 + padding: 2rem 0; 53 + display: flex; 54 + flex-direction: column; 55 + } 56 + 57 + .sidebar-item { 58 + padding: 0.75rem 1.5rem; 59 + background: transparent; 60 + color: var(--text); 61 + border-radius: 6px; 62 + border: 2px solid rgba(191, 192, 192, 0.3); 63 + cursor: pointer; 64 + font-family: inherit; 65 + font-size: 1rem; 66 + font-weight: 500; 67 + text-align: left; 68 + transition: all 0.2s; 69 + margin: 0.25rem 1rem; 70 + } 71 + 72 + .sidebar-item:hover { 73 + background: rgba(79, 93, 117, 0.1); 74 + border-color: var(--secondary); 75 + color: var(--primary); 76 + } 77 + 78 + .sidebar-item.active { 79 + background: var(--primary); 80 + color: white; 81 + border-color: var(--primary); 82 + } 83 + 84 + .content { 85 + flex: 1; 86 + background: var(--background); 87 + } 88 + 89 + .content-inner { 90 + max-width: 900px; 91 + padding: 3rem 2rem 0rem 0; 92 + } 93 + 94 + .section { 95 + background: var(--background); 96 + border: 1px solid var(--secondary); 97 + border-radius: 12px; 98 + padding: 2rem; 99 + margin-bottom: 2rem; 100 + } 101 + 102 + .section-title { 103 + font-size: 1.25rem; 104 + font-weight: 600; 105 + color: var(--text); 106 + margin: 0 0 1.5rem 0; 107 + } 108 + 109 + .field-group { 110 + margin-bottom: 1.5rem; 111 + } 112 + 113 + .field-group:last-child { 114 + margin-bottom: 0; 115 + } 116 + 117 + .field-row { 118 + display: flex; 119 + justify-content: space-between; 120 + align-items: center; 121 + gap: 1rem; 122 + } 123 + 124 + .field-label { 125 + font-weight: 500; 126 + color: var(--text); 127 + font-size: 0.875rem; 128 + margin-bottom: 0.5rem; 129 + display: block; 130 + } 131 + 132 + .field-value { 133 + font-size: 1rem; 134 + color: var(--text); 135 + opacity: 0.8; 136 + } 137 + 138 + .change-link { 139 + background: none; 140 + border: 1px solid var(--secondary); 141 + color: var(--text); 142 + font-size: 0.875rem; 143 + font-weight: 500; 144 + cursor: pointer; 145 + padding: 0.25rem 0.75rem; 146 + border-radius: 6px; 147 + font-family: inherit; 148 + transition: all 0.2s; 149 + } 150 + 151 + .change-link:hover { 152 + border-color: var(--primary); 153 + color: var(--primary); 154 + } 155 + 156 + .btn { 157 + padding: 0.75rem 1.5rem; 158 + border-radius: 6px; 159 + font-size: 1rem; 160 + font-weight: 500; 161 + cursor: pointer; 162 + transition: all 0.2s; 163 + font-family: inherit; 164 + border: 2px solid transparent; 165 + } 166 + 167 + .btn-rejection { 168 + background: transparent; 169 + color: var(--accent); 170 + border-color: var(--accent); 171 + } 172 + 173 + .btn-rejection:hover { 174 + background: var(--accent); 175 + color: white; 176 + } 177 + 178 + .btn-small { 179 + padding: 0.5rem 1rem; 180 + font-size: 0.875rem; 181 + } 182 + 183 + .avatar-container:hover .avatar-overlay { 184 + opacity: 1; 185 + } 186 + 187 + .avatar-overlay { 188 + position: absolute; 189 + top: 0; 190 + left: 0; 191 + width: 48px; 192 + height: 48px; 193 + background: rgba(0, 0, 0, 0.2); 194 + border-radius: 50%; 195 + border: 2px solid transparent; 196 + display: flex; 197 + align-items: center; 198 + justify-content: center; 199 + opacity: 0; 200 + cursor: pointer; 201 + } 202 + 203 + .reload-symbol { 204 + font-size: 18px; 205 + color: white; 206 + transform: rotate(79deg) translate(0px, -2px); 207 + } 208 + 209 + .profile-row { 210 + display: flex; 211 + align-items: center; 212 + gap: 1rem; 213 + } 214 + 215 + .avatar-container { 216 + position: relative; 217 + } 218 + 219 + 220 + 221 + .danger-section { 222 + border-color: var(--accent); 223 + } 224 + 225 + .danger-section .section-title { 226 + color: var(--accent); 227 + } 228 + 229 + .danger-text { 230 + color: var(--text); 231 + opacity: 0.7; 232 + margin-bottom: 1.5rem; 233 + line-height: 1.5; 234 + } 235 + 236 + .session-list { 237 + display: flex; 238 + flex-direction: column; 239 + gap: 1rem; 240 + } 241 + 242 + .session-card { 243 + background: var(--background); 244 + border: 1px solid var(--secondary); 245 + border-radius: 8px; 246 + padding: 1.25rem; 247 + } 248 + 249 + .session-card.current { 250 + border-color: var(--accent); 251 + background: rgba(239, 131, 84, 0.03); 252 + } 253 + 254 + .session-header { 255 + display: flex; 256 + align-items: center; 257 + gap: 0.5rem; 258 + margin-bottom: 1rem; 259 + } 260 + 261 + .session-title { 262 + font-weight: 600; 263 + color: var(--text); 264 + } 265 + 266 + .current-badge { 267 + display: inline-block; 268 + background: var(--accent); 269 + color: white; 270 + padding: 0.25rem 0.5rem; 271 + border-radius: 4px; 272 + font-size: 0.75rem; 273 + font-weight: 600; 274 + } 275 + 276 + .session-details { 277 + display: grid; 278 + gap: 0.75rem; 279 + } 280 + 281 + .session-row { 282 + display: grid; 283 + grid-template-columns: 100px 1fr; 284 + gap: 1rem; 285 + } 286 + 287 + .session-label { 288 + font-weight: 500; 289 + color: var(--text); 290 + opacity: 0.6; 291 + font-size: 0.875rem; 292 + } 293 + 294 + .session-value { 295 + color: var(--text); 296 + font-size: 0.875rem; 297 + } 298 + 299 + .user-agent { 300 + font-family: monospace; 301 + word-break: break-all; 302 + } 303 + 304 + .field-input { 305 + padding: 0.5rem; 306 + border: 1px solid var(--secondary); 307 + border-radius: 6px; 308 + font-family: inherit; 309 + font-size: 1rem; 310 + color: var(--text); 311 + background: var(--background); 312 + flex: 1; 313 + } 314 + 315 + .field-input:focus { 316 + outline: none; 317 + border-color: var(--primary); 318 + } 319 + 320 + .modal-overlay { 321 + position: fixed; 322 + top: 0; 323 + left: 0; 324 + right: 0; 325 + bottom: 0; 326 + background: rgba(0, 0, 0, 0.5); 327 + display: flex; 328 + align-items: center; 329 + justify-content: center; 330 + z-index: 2000; 331 + } 332 + 333 + .modal { 334 + background: var(--background); 335 + border: 2px solid var(--accent); 336 + border-radius: 12px; 337 + padding: 2rem; 338 + max-width: 400px; 339 + width: 90%; 340 + } 341 + 342 + .modal h3 { 343 + margin-top: 0; 344 + color: var(--accent); 345 + } 346 + 347 + .modal-actions { 348 + display: flex; 349 + gap: 0.5rem; 350 + margin-top: 1.5rem; 351 + } 352 + 353 + .btn-neutral { 354 + background: transparent; 355 + color: var(--text); 356 + border-color: var(--secondary); 357 + } 358 + 359 + .btn-neutral:hover { 360 + border-color: var(--primary); 361 + color: var(--primary); 362 + } 363 + 364 + .error { 365 + color: var(--accent); 366 + } 367 + 368 + .loading { 369 + text-align: center; 370 + color: var(--text); 371 + padding: 2rem; 372 + } 373 + 374 + @media (max-width: 768px) { 375 + .settings-container { 376 + flex-direction: column; 377 + } 378 + 379 + .sidebar { 380 + width: 100%; 381 + flex-direction: row; 382 + overflow-x: auto; 383 + padding: 1rem 0; 384 + } 385 + 386 + .sidebar-item { 387 + white-space: nowrap; 388 + border-left: none; 389 + border-bottom: 3px solid transparent; 390 + } 391 + 392 + .sidebar-item.active { 393 + border-left-color: transparent; 394 + border-bottom-color: var(--accent); 395 + } 396 + 397 + .content-inner { 398 + padding: 2rem 1rem; 399 + } 400 + } 401 + `; 402 + 403 + override async connectedCallback() { 404 + super.connectedCallback(); 405 + await this.loadUser(); 406 + await this.loadSessions(); 407 + } 408 + 409 + async loadUser() { 410 + try { 411 + const response = await fetch("/api/auth/me"); 412 + 413 + if (!response.ok) { 414 + window.location.href = "/"; 415 + return; 416 + } 417 + 418 + this.user = await response.json(); 419 + } finally { 420 + this.loading = false; 421 + } 422 + } 423 + 424 + async loadSessions() { 425 + try { 426 + const response = await fetch("/api/sessions"); 427 + 428 + if (response.ok) { 429 + const data = await response.json(); 430 + this.sessions = data.sessions; 431 + } 432 + } finally { 433 + this.loadingSessions = false; 434 + } 435 + } 436 + 437 + async handleLogout() { 438 + try { 439 + await fetch("/api/auth/logout", { method: "POST" }); 440 + window.location.href = "/"; 441 + } catch { 442 + this.error = "Failed to logout"; 443 + } 444 + } 445 + 446 + async handleDeleteAccount() { 447 + try { 448 + const response = await fetch("/api/auth/delete-account", { 449 + method: "DELETE", 450 + }); 451 + 452 + if (!response.ok) { 453 + this.error = "Failed to delete account"; 454 + return; 455 + } 456 + 457 + window.location.href = "/"; 458 + } catch { 459 + this.error = "Failed to delete account"; 460 + } finally { 461 + this.showDeleteConfirm = false; 462 + } 463 + } 464 + 465 + async handleUpdateEmail() { 466 + if (!this.newEmail) { 467 + this.error = "Email required"; 468 + return; 469 + } 470 + 471 + try { 472 + const response = await fetch("/api/user/email", { 473 + method: "PUT", 474 + headers: { "Content-Type": "application/json" }, 475 + body: JSON.stringify({ email: this.newEmail }), 476 + }); 477 + 478 + if (!response.ok) { 479 + const data = await response.json(); 480 + this.error = data.error || "Failed to update email"; 481 + return; 482 + } 483 + 484 + // Reload user data 485 + await this.loadUser(); 486 + this.editingEmail = false; 487 + this.newEmail = ""; 488 + } catch { 489 + this.error = "Failed to update email"; 490 + } 491 + } 492 + 493 + async handleUpdatePassword() { 494 + if (!this.newPassword) { 495 + this.error = "Password required"; 496 + return; 497 + } 498 + 499 + if (this.newPassword.length < 8) { 500 + this.error = "Password must be at least 8 characters"; 501 + return; 502 + } 503 + 504 + try { 505 + const response = await fetch("/api/user/password", { 506 + method: "PUT", 507 + headers: { "Content-Type": "application/json" }, 508 + body: JSON.stringify({ password: this.newPassword }), 509 + }); 510 + 511 + if (!response.ok) { 512 + const data = await response.json(); 513 + this.error = data.error || "Failed to update password"; 514 + return; 515 + } 516 + 517 + this.editingPassword = false; 518 + this.newPassword = ""; 519 + } catch { 520 + this.error = "Failed to update password"; 521 + } 522 + } 523 + 524 + async handleUpdateName() { 525 + if (!this.newName) { 526 + this.error = "Name required"; 527 + return; 528 + } 529 + 530 + try { 531 + const response = await fetch("/api/user/name", { 532 + method: "PUT", 533 + headers: { "Content-Type": "application/json" }, 534 + body: JSON.stringify({ name: this.newName }), 535 + }); 536 + 537 + if (!response.ok) { 538 + const data = await response.json(); 539 + this.error = data.error || "Failed to update name"; 540 + return; 541 + } 542 + 543 + // Reload user data 544 + await this.loadUser(); 545 + this.newName = ""; 546 + } catch { 547 + this.error = "Failed to update name"; 548 + } 549 + } 550 + 551 + async handleUpdateAvatar() { 552 + if (!this.newAvatar) { 553 + this.error = "Avatar required"; 554 + return; 555 + } 556 + 557 + try { 558 + const response = await fetch("/api/user/avatar", { 559 + method: "PUT", 560 + headers: { "Content-Type": "application/json" }, 561 + body: JSON.stringify({ avatar: this.newAvatar }), 562 + }); 563 + 564 + if (!response.ok) { 565 + const data = await response.json(); 566 + this.error = data.error || "Failed to update avatar"; 567 + return; 568 + } 569 + 570 + // Reload user data 571 + await this.loadUser(); 572 + this.newAvatar = ""; 573 + } catch { 574 + this.error = "Failed to update avatar"; 575 + } 576 + } 577 + 578 + generateRandomAvatar() { 579 + // Generate a random string for the avatar 580 + const chars = "abcdefghijklmnopqrstuvwxyz0123456789"; 581 + let result = ""; 582 + for (let i = 0; i < 8; i++) { 583 + result += chars.charAt(Math.floor(Math.random() * chars.length)); 584 + } 585 + this.newAvatar = result; 586 + this.handleUpdateAvatar(); 587 + } 588 + 589 + formatDate(timestamp: number, future = false): string { 590 + const date = new Date(timestamp * 1000); 591 + const now = new Date(); 592 + const diff = Math.abs(now.getTime() - date.getTime()); 593 + 594 + // For future dates (like expiration) 595 + if (future || date > now) { 596 + // Less than a day 597 + if (diff < 24 * 60 * 60 * 1000) { 598 + const hours = Math.floor(diff / (60 * 60 * 1000)); 599 + return `in ${hours} hour${hours === 1 ? "" : "s"}`; 600 + } 601 + 602 + // Less than a week 603 + if (diff < 7 * 24 * 60 * 60 * 1000) { 604 + const days = Math.floor(diff / (24 * 60 * 60 * 1000)); 605 + return `in ${days} day${days === 1 ? "" : "s"}`; 606 + } 607 + 608 + // Show full date 609 + return date.toLocaleDateString(undefined, { 610 + month: "short", 611 + day: "numeric", 612 + year: date.getFullYear() !== now.getFullYear() ? "numeric" : undefined, 613 + }); 614 + } 615 + 616 + // For past dates 617 + // Less than a minute 618 + if (diff < 60 * 1000) { 619 + return "Just now"; 620 + } 621 + 622 + // Less than an hour 623 + if (diff < 60 * 60 * 1000) { 624 + const minutes = Math.floor(diff / (60 * 1000)); 625 + return `${minutes} minute${minutes === 1 ? "" : "s"} ago`; 626 + } 627 + 628 + // Less than a day 629 + if (diff < 24 * 60 * 60 * 1000) { 630 + const hours = Math.floor(diff / (60 * 60 * 1000)); 631 + return `${hours} hour${hours === 1 ? "" : "s"} ago`; 632 + } 633 + 634 + // Less than a week 635 + if (diff < 7 * 24 * 60 * 60 * 1000) { 636 + const days = Math.floor(diff / (24 * 60 * 60 * 1000)); 637 + return `${days} day${days === 1 ? "" : "s"} ago`; 638 + } 639 + 640 + // Show full date 641 + return date.toLocaleDateString(undefined, { 642 + month: "short", 643 + day: "numeric", 644 + year: date.getFullYear() !== now.getFullYear() ? "numeric" : undefined, 645 + }); 646 + } 647 + 648 + async handleKillSession(sessionId: string) { 649 + try { 650 + const response = await fetch(`/api/sessions`, { 651 + method: "DELETE", 652 + headers: { "Content-Type": "application/json" }, 653 + body: JSON.stringify({ sessionId }), 654 + }); 655 + 656 + if (!response.ok) { 657 + this.error = "Failed to kill session"; 658 + return; 659 + } 660 + 661 + // Reload sessions 662 + await this.loadSessions(); 663 + } catch { 664 + this.error = "Failed to kill session"; 665 + } 666 + } 667 + 668 + parseUserAgent(userAgent: string | null): string { 669 + if (!userAgent) return "Unknown"; 670 + 671 + const parser = new UAParser(userAgent); 672 + const result = parser.getResult(); 673 + 674 + const browser = result.browser.name 675 + ? `${result.browser.name}${result.browser.version ? ` ${result.browser.version}` : ""}` 676 + : ""; 677 + const os = result.os.name 678 + ? `${result.os.name}${result.os.version ? ` ${result.os.version}` : ""}` 679 + : ""; 680 + 681 + if (browser && os) { 682 + return `${browser} on ${os}`; 683 + } 684 + if (browser) return browser; 685 + if (os) return os; 686 + 687 + return userAgent; 688 + } 689 + 690 + renderAccountPage() { 691 + if (!this.user) return html``; 692 + 693 + const createdDate = new Date( 694 + this.user.created_at * 1000, 695 + ).toLocaleDateString(); 696 + 697 + return html` 698 + <div class="section"> 699 + <h2 class="section-title">Profile Information</h2> 700 + 701 + <div class="field-group"> 702 + <div class="profile-row"> 703 + <div class="avatar-container"> 704 + <img 705 + src="https://hostedboringavatars.vercel.app/api/marble?size=48&name=${this.user.avatar}&colors=2d3142ff,4f5d75ff,bfc0c0ff,ef8354ff" 706 + alt="Avatar" 707 + style="border-radius: 50%; width: 48px; height: 48px; border: 2px solid var(--secondary); cursor: pointer;" 708 + @click=${this.generateRandomAvatar} 709 + /> 710 + <div class="avatar-overlay" @click=${this.generateRandomAvatar}> 711 + <span class="reload-symbol">↻</span> 712 + </div> 713 + </div> 714 + <input 715 + type="text" 716 + class="field-input" 717 + style="flex: 1;" 718 + .value=${this.user.name ?? ""} 719 + @input=${(e: Event) => { 720 + this.newName = (e.target as HTMLInputElement).value; 721 + }} 722 + @blur=${() => { 723 + if (this.newName && this.newName !== (this.user?.name ?? "")) { 724 + this.handleUpdateName(); 725 + } 726 + }} 727 + placeholder="Your name" 728 + /> 729 + </div> 730 + </div> 731 + 732 + <div class="field-group"> 733 + <label class="field-label">Email</label> 734 + ${ 735 + this.editingEmail 736 + ? html` 737 + <div style="display: flex; gap: 0.5rem; align-items: center;"> 738 + <input 739 + type="email" 740 + class="field-input" 741 + .value=${this.newEmail} 742 + @input=${(e: Event) => { 743 + this.newEmail = (e.target as HTMLInputElement).value; 744 + }} 745 + placeholder=${this.user.email} 746 + /> 747 + <button 748 + class="btn btn-affirmative btn-small" 749 + @click=${this.handleUpdateEmail} 750 + > 751 + Save 752 + </button> 753 + <button 754 + class="btn btn-neutral btn-small" 755 + @click=${() => { 756 + this.editingEmail = false; 757 + this.newEmail = ""; 758 + }} 759 + > 760 + Cancel 761 + </button> 762 + </div> 763 + ` 764 + : html` 765 + <div class="field-row"> 766 + <div class="field-value">${this.user.email}</div> 767 + <button 768 + class="change-link" 769 + @click=${() => { 770 + this.editingEmail = true; 771 + this.newEmail = this.user?.email ?? ""; 772 + }} 773 + > 774 + Change 775 + </button> 776 + </div> 777 + ` 778 + } 779 + </div> 780 + 781 + <div class="field-group"> 782 + <label class="field-label">Password</label> 783 + ${ 784 + this.editingPassword 785 + ? html` 786 + <div style="display: flex; gap: 0.5rem; align-items: center;"> 787 + <input 788 + type="password" 789 + class="field-input" 790 + .value=${this.newPassword} 791 + @input=${(e: Event) => { 792 + this.newPassword = (e.target as HTMLInputElement).value; 793 + }} 794 + placeholder="New password" 795 + /> 796 + <button 797 + class="btn btn-affirmative btn-small" 798 + @click=${this.handleUpdatePassword} 799 + > 800 + Save 801 + </button> 802 + <button 803 + class="btn btn-neutral btn-small" 804 + @click=${() => { 805 + this.editingPassword = false; 806 + this.newPassword = ""; 807 + }} 808 + > 809 + Cancel 810 + </button> 811 + </div> 812 + ` 813 + : html` 814 + <div class="field-row"> 815 + <div class="field-value">••••••••</div> 816 + <button 817 + class="change-link" 818 + @click=${() => { 819 + this.editingPassword = true; 820 + }} 821 + > 822 + Change 823 + </button> 824 + </div> 825 + ` 826 + } 827 + </div> 828 + 829 + <div class="field-group"> 830 + <label class="field-label">Member Since</label> 831 + <div class="field-value">${createdDate}</div> 832 + </div> 833 + </div> 834 + 835 + `; 836 + } 837 + 838 + renderSessionsPage() { 839 + return html` 840 + <div class="section"> 841 + <h2 class="section-title">Active Sessions</h2> 842 + ${ 843 + this.loadingSessions 844 + ? html`<div class="loading">Loading sessions...</div>` 845 + : this.sessions.length === 0 846 + ? html`<p>No active sessions</p>` 847 + : html` 848 + <div class="session-list"> 849 + ${this.sessions.map( 850 + (session) => html` 851 + <div class="session-card ${session.is_current ? "current" : ""}"> 852 + <div class="session-header"> 853 + <span class="session-title">Session</span> 854 + ${session.is_current ? html`<span class="current-badge">Current</span>` : ""} 855 + </div> 856 + <div class="session-details"> 857 + <div class="session-row"> 858 + <span class="session-label">IP Address</span> 859 + <span class="session-value">${session.ip_address ?? "Unknown"}</span> 860 + </div> 861 + <div class="session-row"> 862 + <span class="session-label">Device</span> 863 + <span class="session-value">${this.parseUserAgent(session.user_agent)}</span> 864 + </div> 865 + <div class="session-row"> 866 + <span class="session-label">Created</span> 867 + <span class="session-value">${this.formatDate(session.created_at)}</span> 868 + </div> 869 + <div class="session-row"> 870 + <span class="session-label">Expires</span> 871 + <span class="session-value">${this.formatDate(session.expires_at, true)}</span> 872 + </div> 873 + </div> 874 + <div style="margin-top: 1rem;"> 875 + ${ 876 + session.is_current 877 + ? html` 878 + <button 879 + class="btn btn-rejection" 880 + @click=${this.handleLogout} 881 + > 882 + Logout 883 + </button> 884 + ` 885 + : html` 886 + <button 887 + class="btn btn-rejection" 888 + @click=${() => this.handleKillSession(session.id)} 889 + > 890 + Kill Session 891 + </button> 892 + ` 893 + } 894 + </div> 895 + </div> 896 + `, 897 + )} 898 + </div> 899 + ` 900 + } 901 + </div> 902 + `; 903 + } 904 + 905 + renderDangerPage() { 906 + return html` 907 + <div class="section danger-section"> 908 + <h2 class="section-title">Delete Account</h2> 909 + <p class="danger-text"> 910 + Once you delete your account, there is no going back. This will 911 + permanently delete your account and all associated data. 912 + </p> 913 + <button 914 + class="btn btn-rejection" 915 + @click=${() => { 916 + this.showDeleteConfirm = true; 917 + }} 918 + > 919 + Delete Account 920 + </button> 921 + </div> 922 + `; 923 + } 924 + 925 + override render() { 926 + if (this.loading) { 927 + return html`<div class="loading">Loading...</div>`; 928 + } 929 + 930 + if (this.error) { 931 + return html`<div class="error">${this.error}</div>`; 932 + } 933 + 934 + if (!this.user) { 935 + return html`<div class="error">No user data available</div>`; 936 + } 937 + 938 + return html` 939 + <div class="settings-container"> 940 + <div class="sidebar"> 941 + <button 942 + class="sidebar-item ${this.currentPage === "account" ? "active" : ""}" 943 + @click=${() => { 944 + this.currentPage = "account"; 945 + }} 946 + > 947 + Account 948 + </button> 949 + <button 950 + class="sidebar-item ${this.currentPage === "sessions" ? "active" : ""}" 951 + @click=${() => { 952 + this.currentPage = "sessions"; 953 + }} 954 + > 955 + Sessions 956 + </button> 957 + <button 958 + class="sidebar-item ${this.currentPage === "danger" ? "active" : ""}" 959 + @click=${() => { 960 + this.currentPage = "danger"; 961 + }} 962 + > 963 + Danger Zone 964 + </button> 965 + </div> 966 + 967 + <div class="content"> 968 + <div class="content-inner"> 969 + ${ 970 + this.currentPage === "account" 971 + ? this.renderAccountPage() 972 + : this.currentPage === "sessions" 973 + ? this.renderSessionsPage() 974 + : this.renderDangerPage() 975 + } 976 + </div> 977 + </div> 978 + </div> 979 + 980 + ${ 981 + this.showDeleteConfirm 982 + ? html` 983 + <div 984 + class="modal-overlay" 985 + @click=${() => { 986 + this.showDeleteConfirm = false; 987 + }} 988 + > 989 + <div class="modal" @click=${(e: Event) => e.stopPropagation()}> 990 + <h3>Delete Account</h3> 991 + <p> 992 + Are you absolutely sure? This action cannot be undone. All your data will be 993 + permanently deleted. 994 + </p> 995 + <div class="modal-actions"> 996 + <button class="btn btn-rejection" @click=${this.handleDeleteAccount}> 997 + Yes, Delete My Account 998 + </button> 999 + <button 1000 + class="btn btn-neutral" 1001 + @click=${() => { 1002 + this.showDeleteConfirm = false; 1003 + }} 1004 + > 1005 + Cancel 1006 + </button> 1007 + </div> 1008 + </div> 1009 + </div> 1010 + ` 1011 + : "" 1012 + } 1013 + `; 1014 + } 1015 + }
+86
src/db/schema.ts
··· 1 + import { Database } from "bun:sqlite"; 2 + 3 + export const db = new Database("thistle.db"); 4 + 5 + // Schema version tracking 6 + db.run(` 7 + CREATE TABLE IF NOT EXISTS schema_migrations ( 8 + version INTEGER PRIMARY KEY, 9 + applied_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) 10 + ) 11 + `); 12 + 13 + const migrations = [ 14 + { 15 + version: 1, 16 + name: "Complete schema", 17 + sql: ` 18 + CREATE TABLE IF NOT EXISTS users ( 19 + id INTEGER PRIMARY KEY AUTOINCREMENT, 20 + email TEXT UNIQUE NOT NULL, 21 + password_hash TEXT NOT NULL, 22 + name TEXT, 23 + avatar TEXT DEFAULT 'd', 24 + created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) 25 + ); 26 + 27 + CREATE TABLE IF NOT EXISTS sessions ( 28 + id TEXT PRIMARY KEY, 29 + user_id INTEGER NOT NULL, 30 + ip_address TEXT, 31 + user_agent TEXT, 32 + created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), 33 + expires_at INTEGER NOT NULL, 34 + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE 35 + ); 36 + 37 + CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id); 38 + CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at); 39 + `, 40 + }, 41 + ]; 42 + 43 + function getCurrentVersion(): number { 44 + const result = db 45 + .query<{ version: number }, []>( 46 + "SELECT MAX(version) as version FROM schema_migrations", 47 + ) 48 + .get(); 49 + return result?.version ?? 0; 50 + } 51 + 52 + function applyMigration( 53 + version: number, 54 + sql: string, 55 + index: number, 56 + total: number, 57 + ) { 58 + const current = getCurrentVersion(); 59 + if (current >= version) return; 60 + 61 + const isTTY = typeof process !== "undefined" && process.stdout?.isTTY; 62 + const startMsg = `Applying migration ${index + 1} of ${total}`; 63 + 64 + if (isTTY) { 65 + process.stdout.write(`${startMsg}...`); 66 + const start = performance.now(); 67 + db.run(sql); 68 + db.run("INSERT INTO schema_migrations (version) VALUES (?)", [version]); 69 + const duration = Math.round(performance.now() - start); 70 + process.stdout.write(`\r${startMsg} (${duration}ms)\n`); 71 + } else { 72 + console.log(startMsg); 73 + db.run(sql); 74 + db.run("INSERT INTO schema_migrations (version) VALUES (?)", [version]); 75 + } 76 + } 77 + 78 + // Apply all migrations 79 + const current = getCurrentVersion(); 80 + const pending = migrations.filter((m) => m.version > current); 81 + 82 + for (const [index, migration] of pending.entries()) { 83 + applyMigration(migration.version, migration.sql, index, pending.length); 84 + } 85 + 86 + export default db;
+367
src/index.ts
··· 1 + import { 2 + authenticateUser, 3 + cleanupExpiredSessions, 4 + createSession, 5 + createUser, 6 + deleteSession, 7 + deleteUser, 8 + getSession, 9 + getSessionFromRequest, 10 + getUserBySession, 11 + getUserSessionsForUser, 12 + updateUserAvatar, 13 + updateUserEmail, 14 + updateUserName, 15 + updateUserPassword, 16 + } from "./lib/auth"; 1 17 import indexHTML from "./pages/index.html"; 18 + import settingsHTML from "./pages/settings.html"; 19 + 20 + // Clean up expired sessions every hour 21 + setInterval(cleanupExpiredSessions, 60 * 60 * 1000); 2 22 3 23 const server = Bun.serve({ 4 24 port: 3000, 5 25 routes: { 6 26 "/": indexHTML, 27 + "/settings": settingsHTML, 28 + "/api/auth/register": { 29 + POST: async (req) => { 30 + try { 31 + const body = await req.json(); 32 + const { email, password, name } = body; 33 + 34 + if (!email || !password) { 35 + return Response.json( 36 + { error: "Email and password required" }, 37 + { status: 400 }, 38 + ); 39 + } 40 + 41 + if (password.length < 8) { 42 + return Response.json( 43 + { error: "Password must be at least 8 characters" }, 44 + { status: 400 }, 45 + ); 46 + } 47 + 48 + const user = await createUser(email, password, name); 49 + const ipAddress = 50 + req.headers.get("x-forwarded-for") ?? 51 + req.headers.get("x-real-ip") ?? 52 + "unknown"; 53 + const userAgent = req.headers.get("user-agent") ?? "unknown"; 54 + const sessionId = createSession(user.id, ipAddress, userAgent); 55 + 56 + return Response.json( 57 + { user: { id: user.id, email: user.email } }, 58 + { 59 + headers: { 60 + "Set-Cookie": `session=${sessionId}; HttpOnly; Path=/; Max-Age=${7 * 24 * 60 * 60}; SameSite=Lax`, 61 + }, 62 + }, 63 + ); 64 + } catch (err: unknown) { 65 + const error = err as { message?: string }; 66 + if (error.message?.includes("UNIQUE constraint failed")) { 67 + return Response.json( 68 + { error: "Email already registered" }, 69 + { status: 400 }, 70 + ); 71 + } 72 + return Response.json( 73 + { error: "Registration failed" }, 74 + { status: 500 }, 75 + ); 76 + } 77 + }, 78 + }, 79 + "/api/auth/login": { 80 + POST: async (req) => { 81 + try { 82 + const body = await req.json(); 83 + const { email, password } = body; 84 + 85 + if (!email || !password) { 86 + return Response.json( 87 + { error: "Email and password required" }, 88 + { status: 400 }, 89 + ); 90 + } 91 + 92 + const user = await authenticateUser(email, password); 93 + 94 + if (!user) { 95 + return Response.json( 96 + { error: "Invalid email or password" }, 97 + { status: 401 }, 98 + ); 99 + } 100 + 101 + const ipAddress = 102 + req.headers.get("x-forwarded-for") ?? 103 + req.headers.get("x-real-ip") ?? 104 + "unknown"; 105 + const userAgent = req.headers.get("user-agent") ?? "unknown"; 106 + const sessionId = createSession(user.id, ipAddress, userAgent); 107 + 108 + return Response.json( 109 + { user: { id: user.id, email: user.email } }, 110 + { 111 + headers: { 112 + "Set-Cookie": `session=${sessionId}; HttpOnly; Path=/; Max-Age=${7 * 24 * 60 * 60}; SameSite=Lax`, 113 + }, 114 + }, 115 + ); 116 + } catch (_) { 117 + return Response.json({ error: "Login failed" }, { status: 500 }); 118 + } 119 + }, 120 + }, 121 + "/api/auth/logout": { 122 + POST: (req) => { 123 + const sessionId = getSessionFromRequest(req); 124 + if (sessionId) { 125 + deleteSession(sessionId); 126 + } 127 + 128 + return Response.json( 129 + { success: true }, 130 + { 131 + headers: { 132 + "Set-Cookie": 133 + "session=; HttpOnly; Path=/; Max-Age=0; SameSite=Lax", 134 + }, 135 + }, 136 + ); 137 + }, 138 + }, 139 + "/api/auth/me": { 140 + GET: (req) => { 141 + const sessionId = getSessionFromRequest(req); 142 + if (!sessionId) { 143 + return Response.json({ error: "Not authenticated" }, { status: 401 }); 144 + } 145 + 146 + const user = getUserBySession(sessionId); 147 + if (!user) { 148 + return Response.json({ error: "Invalid session" }, { status: 401 }); 149 + } 150 + 151 + return Response.json({ 152 + email: user.email, 153 + name: user.name, 154 + avatar: user.avatar, 155 + created_at: user.created_at, 156 + }); 157 + }, 158 + }, 159 + "/api/sessions": { 160 + GET: (req) => { 161 + const sessionId = getSessionFromRequest(req); 162 + if (!sessionId) { 163 + return Response.json({ error: "Not authenticated" }, { status: 401 }); 164 + } 165 + 166 + const user = getUserBySession(sessionId); 167 + if (!user) { 168 + return Response.json({ error: "Invalid session" }, { status: 401 }); 169 + } 170 + 171 + const sessions = getUserSessionsForUser(user.id); 172 + return Response.json({ 173 + sessions: sessions.map((s) => ({ 174 + id: s.id, 175 + ip_address: s.ip_address, 176 + user_agent: s.user_agent, 177 + created_at: s.created_at, 178 + expires_at: s.expires_at, 179 + is_current: s.id === sessionId, 180 + })), 181 + }); 182 + }, 183 + DELETE: async (req) => { 184 + const currentSessionId = getSessionFromRequest(req); 185 + if (!currentSessionId) { 186 + return Response.json({ error: "Not authenticated" }, { status: 401 }); 187 + } 188 + 189 + const user = getUserBySession(currentSessionId); 190 + if (!user) { 191 + return Response.json({ error: "Invalid session" }, { status: 401 }); 192 + } 193 + 194 + const body = await req.json(); 195 + const targetSessionId = body.sessionId; 196 + 197 + if (!targetSessionId) { 198 + return Response.json( 199 + { error: "Session ID required" }, 200 + { status: 400 }, 201 + ); 202 + } 203 + 204 + // Verify the session belongs to the user 205 + const targetSession = getSession(targetSessionId); 206 + if (!targetSession || targetSession.user_id !== user.id) { 207 + return Response.json({ error: "Session not found" }, { status: 404 }); 208 + } 209 + 210 + deleteSession(targetSessionId); 211 + 212 + return Response.json({ success: true }); 213 + }, 214 + }, 215 + "/api/auth/delete-account": { 216 + DELETE: (req) => { 217 + const sessionId = getSessionFromRequest(req); 218 + if (!sessionId) { 219 + return Response.json({ error: "Not authenticated" }, { status: 401 }); 220 + } 221 + 222 + const user = getUserBySession(sessionId); 223 + if (!user) { 224 + return Response.json({ error: "Invalid session" }, { status: 401 }); 225 + } 226 + 227 + deleteUser(user.id); 228 + 229 + return Response.json( 230 + { success: true }, 231 + { 232 + headers: { 233 + "Set-Cookie": 234 + "session=; HttpOnly; Path=/; Max-Age=0; SameSite=Lax", 235 + }, 236 + }, 237 + ); 238 + }, 239 + }, 240 + "/api/user/email": { 241 + PUT: async (req) => { 242 + const sessionId = getSessionFromRequest(req); 243 + if (!sessionId) { 244 + return Response.json({ error: "Not authenticated" }, { status: 401 }); 245 + } 246 + 247 + const user = getUserBySession(sessionId); 248 + if (!user) { 249 + return Response.json({ error: "Invalid session" }, { status: 401 }); 250 + } 251 + 252 + const body = await req.json(); 253 + const { email } = body; 254 + 255 + if (!email) { 256 + return Response.json({ error: "Email required" }, { status: 400 }); 257 + } 258 + 259 + try { 260 + updateUserEmail(user.id, email); 261 + return Response.json({ success: true }); 262 + } catch (err: unknown) { 263 + const error = err as { message?: string }; 264 + if (error.message?.includes("UNIQUE constraint failed")) { 265 + return Response.json( 266 + { error: "Email already in use" }, 267 + { status: 400 }, 268 + ); 269 + } 270 + return Response.json( 271 + { error: "Failed to update email" }, 272 + { status: 500 }, 273 + ); 274 + } 275 + }, 276 + }, 277 + "/api/user/password": { 278 + PUT: async (req) => { 279 + const sessionId = getSessionFromRequest(req); 280 + if (!sessionId) { 281 + return Response.json({ error: "Not authenticated" }, { status: 401 }); 282 + } 283 + 284 + const user = getUserBySession(sessionId); 285 + if (!user) { 286 + return Response.json({ error: "Invalid session" }, { status: 401 }); 287 + } 288 + 289 + const body = await req.json(); 290 + const { password } = body; 291 + 292 + if (!password) { 293 + return Response.json({ error: "Password required" }, { status: 400 }); 294 + } 295 + 296 + if (password.length < 8) { 297 + return Response.json( 298 + { error: "Password must be at least 8 characters" }, 299 + { status: 400 }, 300 + ); 301 + } 302 + 303 + try { 304 + await updateUserPassword(user.id, password); 305 + return Response.json({ success: true }); 306 + } catch { 307 + return Response.json( 308 + { error: "Failed to update password" }, 309 + { status: 500 }, 310 + ); 311 + } 312 + }, 313 + }, 314 + "/api/user/name": { 315 + PUT: async (req) => { 316 + const sessionId = getSessionFromRequest(req); 317 + if (!sessionId) { 318 + return Response.json({ error: "Not authenticated" }, { status: 401 }); 319 + } 320 + 321 + const user = getUserBySession(sessionId); 322 + if (!user) { 323 + return Response.json({ error: "Invalid session" }, { status: 401 }); 324 + } 325 + 326 + const body = await req.json(); 327 + const { name } = body; 328 + 329 + if (!name) { 330 + return Response.json({ error: "Name required" }, { status: 400 }); 331 + } 332 + 333 + try { 334 + updateUserName(user.id, name); 335 + return Response.json({ success: true }); 336 + } catch { 337 + return Response.json( 338 + { error: "Failed to update name" }, 339 + { status: 500 }, 340 + ); 341 + } 342 + }, 343 + }, 344 + "/api/user/avatar": { 345 + PUT: async (req) => { 346 + const sessionId = getSessionFromRequest(req); 347 + if (!sessionId) { 348 + return Response.json({ error: "Not authenticated" }, { status: 401 }); 349 + } 350 + 351 + const user = getUserBySession(sessionId); 352 + if (!user) { 353 + return Response.json({ error: "Invalid session" }, { status: 401 }); 354 + } 355 + 356 + const body = await req.json(); 357 + const { avatar } = body; 358 + 359 + if (!avatar) { 360 + return Response.json({ error: "Avatar required" }, { status: 400 }); 361 + } 362 + 363 + try { 364 + updateUserAvatar(user.id, avatar); 365 + return Response.json({ success: true }); 366 + } catch { 367 + return Response.json( 368 + { error: "Failed to update avatar" }, 369 + { status: 500 }, 370 + ); 371 + } 372 + }, 373 + }, 7 374 }, 8 375 development: { 9 376 hmr: true,
+176
src/lib/auth.ts
··· 1 + import db from "../db/schema"; 2 + 3 + const SESSION_DURATION = 7 * 24 * 60 * 60; // 7 days in seconds 4 + 5 + export interface User { 6 + id: number; 7 + email: string; 8 + name: string | null; 9 + avatar: string; 10 + created_at: number; 11 + } 12 + 13 + export interface Session { 14 + id: string; 15 + user_id: number; 16 + ip_address: string | null; 17 + user_agent: string | null; 18 + created_at: number; 19 + expires_at: number; 20 + } 21 + 22 + export async function hashPassword(password: string): Promise<string> { 23 + return await Bun.password.hash(password, { 24 + algorithm: "argon2id", 25 + memoryCost: 19456, 26 + timeCost: 2, 27 + }); 28 + } 29 + 30 + export async function verifyPassword( 31 + password: string, 32 + hash: string, 33 + ): Promise<boolean> { 34 + return await Bun.password.verify(password, hash, "argon2id"); 35 + } 36 + 37 + export function createSession( 38 + userId: number, 39 + ipAddress?: string, 40 + userAgent?: string, 41 + ): string { 42 + const sessionId = crypto.randomUUID(); 43 + const expiresAt = Math.floor(Date.now() / 1000) + SESSION_DURATION; 44 + 45 + db.run( 46 + "INSERT INTO sessions (id, user_id, ip_address, user_agent, expires_at) VALUES (?, ?, ?, ?, ?)", 47 + [sessionId, userId, ipAddress ?? null, userAgent ?? null, expiresAt], 48 + ); 49 + 50 + return sessionId; 51 + } 52 + 53 + export function getSession(sessionId: string): Session | null { 54 + const now = Math.floor(Date.now() / 1000); 55 + 56 + const session = db 57 + .query<Session, [string, number]>( 58 + "SELECT id, user_id, ip_address, user_agent, created_at, expires_at FROM sessions WHERE id = ? AND expires_at > ?", 59 + ) 60 + .get(sessionId, now); 61 + 62 + return session ?? null; 63 + } 64 + 65 + export function getUserBySession(sessionId: string): User | null { 66 + const session = getSession(sessionId); 67 + if (!session) return null; 68 + 69 + const user = db 70 + .query<User, [number]>( 71 + "SELECT id, email, name, avatar, created_at FROM users WHERE id = ?", 72 + ) 73 + .get(session.user_id); 74 + 75 + return user ?? null; 76 + } 77 + 78 + export function deleteSession(sessionId: string): void { 79 + db.run("DELETE FROM sessions WHERE id = ?", [sessionId]); 80 + } 81 + 82 + export function cleanupExpiredSessions(): void { 83 + const now = Math.floor(Date.now() / 1000); 84 + db.run("DELETE FROM sessions WHERE expires_at <= ?", [now]); 85 + } 86 + 87 + export async function createUser( 88 + email: string, 89 + password: string, 90 + name?: string, 91 + ): Promise<User> { 92 + const passwordHash = await hashPassword(password); 93 + 94 + const result = db.run( 95 + "INSERT INTO users (email, password_hash, name) VALUES (?, ?, ?)", 96 + [email, passwordHash, name ?? null], 97 + ); 98 + 99 + const user = db 100 + .query<User, [number]>("SELECT id, email, name, avatar, created_at FROM users WHERE id = ?") 101 + .get(Number(result.lastInsertRowid)); 102 + 103 + if (!user) { 104 + throw new Error("Failed to create user"); 105 + } 106 + 107 + return user; 108 + } 109 + 110 + export async function authenticateUser( 111 + email: string, 112 + password: string, 113 + ): Promise<User | null> { 114 + const result = db 115 + .query<{ id: number; email: string; name: string | null; password_hash: string; created_at: number }, [string]>( 116 + "SELECT id, email, name, avatar, password_hash, created_at FROM users WHERE email = ?", 117 + ) 118 + .get(email); 119 + 120 + if (!result) return null; 121 + 122 + const isValid = await verifyPassword(password, result.password_hash); 123 + if (!isValid) return null; 124 + 125 + return { 126 + id: result.id, 127 + email: result.email, 128 + name: result.name, 129 + avatar: result.avatar, 130 + created_at: result.created_at, 131 + }; 132 + } 133 + 134 + export function getUserSessionsForUser(userId: number): Session[] { 135 + const now = Math.floor(Date.now() / 1000); 136 + 137 + const sessions = db 138 + .query<Session, [number, number]>( 139 + "SELECT id, user_id, ip_address, user_agent, created_at, expires_at FROM sessions WHERE user_id = ? AND expires_at > ? ORDER BY created_at DESC", 140 + ) 141 + .all(userId, now); 142 + 143 + return sessions; 144 + } 145 + 146 + export function getSessionFromRequest(req: Request): string | null { 147 + const cookie = req.headers.get("cookie"); 148 + if (!cookie) return null; 149 + 150 + const match = cookie.match(/session=([^;]+)/); 151 + return match?.[1] ?? null; 152 + } 153 + 154 + export function deleteUser(userId: number): void { 155 + db.run("DELETE FROM users WHERE id = ?", [userId]); 156 + } 157 + 158 + export function updateUserEmail(userId: number, newEmail: string): void { 159 + db.run("UPDATE users SET email = ? WHERE id = ?", [newEmail, userId]); 160 + } 161 + 162 + export function updateUserName(userId: number, newName: string): void { 163 + db.run("UPDATE users SET name = ? WHERE id = ?", [newName, userId]); 164 + } 165 + 166 + export function updateUserAvatar(userId: number, avatar: string): void { 167 + db.run("UPDATE users SET avatar = ? WHERE id = ?", [avatar, userId]); 168 + } 169 + 170 + export async function updateUserPassword( 171 + userId: number, 172 + newPassword: string, 173 + ): Promise<void> { 174 + const hash = await hashPassword(newPassword); 175 + db.run("UPDATE users SET password_hash = ? WHERE id = ?", [hash, userId]); 176 + }
+9
src/pages/index.html
··· 8 8 <link rel="icon" 9 9 href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='0.9em' font-size='90'>🪻</text></svg>"> 10 10 <link rel="stylesheet" href="../styles/main.css"> 11 + <style> 12 + main { 13 + max-width: 48rem; 14 + } 15 + </style> 11 16 </head> 12 17 13 18 <body> 19 + <auth-component></auth-component> 20 + 14 21 <main> 15 22 <h1>Thistle</h1> 23 + 16 24 <p>Here is a basic counter to figure out the basics of web components</p> 17 25 18 26 <counter-component></counter-component> 19 27 </main> 20 28 21 29 <script type="module" src="../components/counter.ts"></script> 30 + <script type="module" src="../components/auth.ts"></script> 22 31 </body> 23 32 24 33 </html>
+25
src/pages/settings.html
··· 1 + <!DOCTYPE html> 2 + <html lang="en"> 3 + 4 + <head> 5 + <meta charset="UTF-8"> 6 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 7 + <title>Settings - Thistle</title> 8 + <link rel="icon" 9 + href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='0.9em' font-size='90'>🪻</text></svg>"> 10 + <link rel="stylesheet" href="../styles/main.css"> 11 + </head> 12 + 13 + <body> 14 + <auth-component></auth-component> 15 + 16 + <main> 17 + <h1>Settings</h1> 18 + <user-settings></user-settings> 19 + </main> 20 + 21 + <script type="module" src="../components/auth.ts"></script> 22 + <script type="module" src="../components/user-settings.ts"></script> 23 + </body> 24 + 25 + </html>
+59
src/styles/buttons.css
··· 1 + /* Shared button styles for consistent UI across components */ 2 + 3 + .btn { 4 + padding: 0.75rem 1.5rem; 5 + border-radius: 6px; 6 + font-size: 1rem; 7 + font-weight: 500; 8 + cursor: pointer; 9 + transition: all 0.2s; 10 + font-family: inherit; 11 + border: 2px solid; 12 + } 13 + 14 + .btn:disabled { 15 + opacity: 0.5; 16 + cursor: not-allowed; 17 + } 18 + 19 + /* Affirmative actions (submit, save, confirm) */ 20 + .btn-affirmative { 21 + background: var(--primary); 22 + color: white; 23 + border-color: var(--primary); 24 + } 25 + 26 + .btn-affirmative:hover:not(:disabled) { 27 + background: transparent; 28 + color: var(--primary); 29 + } 30 + 31 + /* Neutral actions (cancel, close) */ 32 + .btn-neutral { 33 + background: transparent; 34 + color: var(--text); 35 + border-color: var(--secondary); 36 + } 37 + 38 + .btn-neutral:hover:not(:disabled) { 39 + border-color: var(--primary); 40 + color: var(--primary); 41 + } 42 + 43 + /* Rejection/destructive actions (delete, logout) */ 44 + .btn-rejection { 45 + background: transparent; 46 + color: var(--accent); 47 + border-color: var(--accent); 48 + } 49 + 50 + .btn-rejection:hover:not(:disabled) { 51 + background: var(--accent); 52 + color: white; 53 + } 54 + 55 + /* Small button variant */ 56 + .btn-small { 57 + padding: 0.5rem 1rem; 58 + font-size: 0.875rem; 59 + }
+48 -15
src/styles/main.css
··· 1 + @import url('./buttons.css'); 2 + 1 3 :root { 2 - --text: #5b6971; 3 - --background: #fefbf1; 4 - --primary: #8fa668; 5 - --secondary: #d0cdf9; 6 - --accent: #e59976; 4 + /* Color palette */ 5 + --gunmetal: #2d3142ff; 6 + --paynes-gray: #4f5d75ff; 7 + --silver: #bfc0c0ff; 8 + --off-white: #fcf6f1; 9 + --coral: #ef8354ff; 10 + 11 + /* Semantic color assignments */ 12 + --text: var(--gunmetal); 13 + --background: var(--off-white); 14 + --primary: var(--paynes-gray); 15 + --secondary: var(--silver); 16 + --accent: var(--coral); 7 17 } 8 18 9 19 body { ··· 16 26 color: var(--text); 17 27 } 18 28 19 - h1, h2, h3, h4, h5 { 29 + h1, 30 + h2, 31 + h3, 32 + h4, 33 + h5 { 20 34 font-family: 'Charter', 'Bitstream Charter', 'Sitka Text', Cambria, serif; 21 35 font-weight: 600; 22 36 line-height: 1.2; 23 37 color: var(--text); 24 38 } 25 39 26 - html {font-size: 100%;} /* 16px */ 40 + html { 41 + font-size: 100%; 42 + } 43 + 44 + /* 16px */ 27 45 28 46 h1 { 29 - font-size: 4.210rem; /* 67.36px */ 47 + font-size: 4.210rem; 48 + /* 67.36px */ 30 49 margin-top: 0; 31 50 } 32 51 33 - h2 {font-size: 3.158rem; /* 50.56px */} 52 + h2 { 53 + font-size: 3.158rem; 54 + /* 50.56px */ 55 + } 34 56 35 - h3 {font-size: 2.369rem; /* 37.92px */} 57 + h3 { 58 + font-size: 2.369rem; 59 + /* 37.92px */ 60 + } 36 61 37 - h4 {font-size: 1.777rem; /* 28.48px */} 62 + h4 { 63 + font-size: 1.777rem; 64 + /* 28.48px */ 65 + } 38 66 39 - h5 {font-size: 1.333rem; /* 21.28px */} 67 + h5 { 68 + font-size: 1.333rem; 69 + /* 21.28px */ 70 + } 40 71 41 - small {font-size: 0.750rem; /* 12px */} 72 + small { 73 + font-size: 0.750rem; 74 + /* 12px */ 75 + } 42 76 43 77 main { 44 - max-width: 800px; 45 78 margin: 0 auto; 46 - } 79 + }