🪻 distributed transcription service thistle.dunkirk.sh
1
fork

Configure Feed

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

feat: add notification toggling to the settings page and improve error handling

+252 -22
+211 -22
src/components/user-settings.ts
··· 36 36 canceled_at: number | null; 37 37 } 38 38 39 - type SettingsPage = "account" | "sessions" | "passkeys" | "billing" | "danger"; 39 + type SettingsPage = "account" | "sessions" | "passkeys" | "billing" | "notifications" | "danger"; 40 40 41 41 @customElement("user-settings") 42 42 export class UserSettings extends LitElement { ··· 59 59 @state() newAvatar = ""; 60 60 @state() passkeySupported = false; 61 61 @state() addingPasskey = false; 62 + @state() emailNotificationsEnabled = true; 63 + @state() deletingAccount = false; 62 64 63 65 static override styles = css` 64 66 :host { ··· 418 420 color: var(--accent); 419 421 } 420 422 423 + .error-banner { 424 + background: #fecaca; 425 + border: 2px solid rgba(220, 38, 38, 0.8); 426 + border-radius: 6px; 427 + padding: 1rem; 428 + margin-bottom: 1.5rem; 429 + color: #dc2626; 430 + font-weight: 500; 431 + } 432 + 421 433 .loading { 422 434 text-align: center; 423 435 color: var(--text); 424 436 padding: 2rem; 425 437 } 426 438 439 + .setting-row { 440 + display: flex; 441 + align-items: center; 442 + justify-content: space-between; 443 + padding: 1rem; 444 + border: 1px solid var(--secondary); 445 + border-radius: 6px; 446 + gap: 1rem; 447 + } 448 + 449 + .setting-info { 450 + flex: 1; 451 + } 452 + 453 + .toggle { 454 + position: relative; 455 + display: inline-block; 456 + width: 48px; 457 + height: 24px; 458 + } 459 + 460 + .toggle input { 461 + opacity: 0; 462 + width: 0; 463 + height: 0; 464 + } 465 + 466 + .toggle-slider { 467 + position: absolute; 468 + cursor: pointer; 469 + top: 0; 470 + left: 0; 471 + right: 0; 472 + bottom: 0; 473 + background-color: var(--secondary); 474 + transition: 0.2s; 475 + border-radius: 24px; 476 + } 477 + 478 + .toggle-slider:before { 479 + position: absolute; 480 + content: ""; 481 + height: 18px; 482 + width: 18px; 483 + left: 3px; 484 + bottom: 3px; 485 + background-color: white; 486 + transition: 0.2s; 487 + border-radius: 50%; 488 + } 489 + 490 + .toggle input:checked + .toggle-slider { 491 + background-color: var(--primary); 492 + } 493 + 494 + .toggle input:checked + .toggle-slider:before { 495 + transform: translateX(24px); 496 + } 497 + 427 498 @media (max-width: 768px) { 428 499 .settings-container { 429 500 padding: 1rem; ··· 463 534 } 464 535 465 536 private isValidTab(tab: string): boolean { 466 - return ["account", "sessions", "passkeys", "billing", "danger"].includes(tab); 537 + return ["account", "sessions", "passkeys", "billing", "notifications", "danger"].includes(tab); 467 538 } 468 539 469 540 private setTab(tab: SettingsPage) { 470 541 this.currentPage = tab; 542 + this.error = ""; // Clear errors when switching tabs 471 543 // Update URL without reloading page 472 544 const url = new URL(window.location.href); 473 545 url.searchParams.set("tab", tab); ··· 483 555 return; 484 556 } 485 557 486 - this.user = await response.json(); 558 + const data = await response.json(); 559 + this.user = data; 560 + this.emailNotificationsEnabled = data.email_notifications_enabled ?? true; 487 561 } finally { 488 562 this.loading = false; 489 563 } ··· 558 632 return; 559 633 } 560 634 635 + this.error = ""; 561 636 try { 562 637 const response = await fetch(`/api/passkeys/${passkeyId}`, { 563 638 method: "DELETE", 564 639 }); 565 640 566 641 if (!response.ok) { 567 - const error = await response.json(); 568 - this.error = error.error || "Failed to delete passkey"; 642 + const data = await response.json(); 643 + this.error = data.error || "Failed to delete passkey"; 569 644 return; 570 645 } 571 646 572 647 // Reload passkeys 573 648 await this.loadPasskeys(); 574 - } catch { 575 - this.error = "Failed to delete passkey"; 649 + } catch (err) { 650 + this.error = err instanceof Error ? err.message : "Failed to delete passkey"; 576 651 } 577 652 } 578 653 579 654 async handleLogout() { 655 + this.error = ""; 580 656 try { 581 - await fetch("/api/auth/logout", { method: "POST" }); 657 + const response = await fetch("/api/auth/logout", { method: "POST" }); 658 + 659 + if (!response.ok) { 660 + const data = await response.json(); 661 + this.error = data.error || "Failed to logout"; 662 + return; 663 + } 664 + 582 665 window.location.href = "/"; 583 - } catch { 584 - this.error = "Failed to logout"; 666 + } catch (err) { 667 + this.error = err instanceof Error ? err.message : "Failed to logout"; 585 668 } 586 669 } 587 670 588 671 async handleDeleteAccount() { 672 + this.deletingAccount = true; 673 + this.error = ""; 674 + document.body.style.cursor = "wait"; 675 + 589 676 try { 590 677 const response = await fetch("/api/user", { 591 678 method: "DELETE", 592 679 }); 593 680 594 681 if (!response.ok) { 595 - this.error = "Failed to delete account"; 682 + const data = await response.json(); 683 + this.error = data.error || "Failed to delete account"; 596 684 return; 597 685 } 598 686 ··· 600 688 } catch { 601 689 this.error = "Failed to delete account"; 602 690 } finally { 691 + this.deletingAccount = false; 603 692 this.showDeleteConfirm = false; 693 + document.body.style.cursor = ""; 604 694 } 605 695 } 606 696 607 697 async handleUpdateEmail() { 698 + this.error = ""; 608 699 if (!this.newEmail) { 609 700 this.error = "Email required"; 610 701 return; ··· 633 724 } 634 725 635 726 async handleUpdatePassword() { 727 + this.error = ""; 636 728 if (!this.newPassword) { 637 729 this.error = "Password required"; 638 730 return; ··· 670 762 } 671 763 672 764 async handleUpdateName() { 765 + this.error = ""; 673 766 if (!this.newName) { 674 767 this.error = "Name required"; 675 768 return; ··· 697 790 } 698 791 699 792 async handleUpdateAvatar() { 793 + this.error = ""; 700 794 if (!this.newAvatar) { 701 795 this.error = "Avatar required"; 702 796 return; ··· 844 938 } 845 939 846 940 async handleKillSession(sessionId: string) { 941 + this.error = ""; 847 942 try { 848 943 const response = await fetch(`/api/sessions`, { 849 944 method: "DELETE", ··· 895 990 896 991 return html` 897 992 <div class="content-inner"> 993 + ${this.error ? html` 994 + <div class="error-banner"> 995 + ${this.error} 996 + </div> 997 + ` : ""} 898 998 <div class="section"> 899 999 <h2 class="section-title">Profile Information</h2> 900 1000 ··· 1103 1203 renderSessionsPage() { 1104 1204 return html` 1105 1205 <div class="content-inner"> 1206 + ${this.error ? html` 1207 + <div class="error-banner"> 1208 + ${this.error} 1209 + </div> 1210 + ` : ""} 1106 1211 <div class="section"> 1107 1212 <h2 class="section-title">Active Sessions</h2> 1108 1213 ${ ··· 1193 1298 1194 1299 return html` 1195 1300 <div class="content-inner"> 1301 + ${this.error ? html` 1302 + <div class="error-banner"> 1303 + ${this.error} 1304 + </div> 1305 + ` : ""} 1196 1306 <div class="section"> 1197 1307 <h2 class="section-title">Subscription</h2> 1198 1308 ··· 1235 1345 Reactivate your subscription to unlock unlimited transcriptions. 1236 1346 </p> 1237 1347 </div> 1238 - 1239 - ${this.error ? html`<p class="error" style="margin-top: 1rem;">${this.error}</p>` : ""} 1240 1348 </div> 1241 1349 </div> 1242 1350 `; ··· 1245 1353 if (hasActiveSubscription) { 1246 1354 return html` 1247 1355 <div class="content-inner"> 1356 + ${this.error ? html` 1357 + <div class="error-banner"> 1358 + ${this.error} 1359 + </div> 1360 + ` : ""} 1248 1361 <div class="section"> 1249 1362 <h2 class="section-title">Subscription</h2> 1250 1363 ··· 1293 1406 Opens the customer portal where you can update payment methods, view invoices, and manage your subscription. 1294 1407 </p> 1295 1408 </div> 1296 - 1297 - ${this.error ? html`<p class="error" style="margin-top: 1rem;">${this.error}</p>` : ""} 1298 1409 </div> 1299 1410 </div> 1300 1411 `; ··· 1302 1413 1303 1414 return html` 1304 1415 <div class="content-inner"> 1416 + ${this.error ? html` 1417 + <div class="error-banner"> 1418 + ${this.error} 1419 + </div> 1420 + ` : ""} 1305 1421 <div class="section"> 1306 1422 <h2 class="section-title">Billing & Subscription</h2> 1307 1423 <p class="field-description" style="margin-bottom: 1.5rem;"> ··· 1314 1430 > 1315 1431 ${this.loading ? "Loading..." : "Activate Your Subscription"} 1316 1432 </button> 1317 - ${this.error ? html`<p class="error" style="margin-top: 1rem;">${this.error}</p>` : ""} 1318 1433 </div> 1319 1434 </div> 1320 1435 `; ··· 1323 1438 renderDangerPage() { 1324 1439 return html` 1325 1440 <div class="content-inner"> 1441 + ${this.error ? html` 1442 + <div class="error-banner"> 1443 + ${this.error} 1444 + </div> 1445 + ` : ""} 1326 1446 <div class="section danger-section"> 1327 1447 <h2 class="section-title">Delete Account</h2> 1328 1448 <p class="danger-text"> ··· 1342 1462 `; 1343 1463 } 1344 1464 1465 + renderNotificationsPage() { 1466 + return html` 1467 + <div class="content-inner"> 1468 + ${this.error ? html` 1469 + <div class="error-banner"> 1470 + ${this.error} 1471 + </div> 1472 + ` : ""} 1473 + <div class="section"> 1474 + <h2 class="section-title">Email Notifications</h2> 1475 + <p style="color: var(--text); margin-bottom: 1rem;"> 1476 + Control which emails you receive from Thistle. 1477 + </p> 1478 + 1479 + <div class="setting-row"> 1480 + <div class="setting-info"> 1481 + <strong>Transcription Complete</strong> 1482 + <p style="color: var(--paynes-gray); font-size: 0.875rem; margin: 0.25rem 0 0 0;"> 1483 + Get notified when your transcription is ready 1484 + </p> 1485 + </div> 1486 + <label class="toggle"> 1487 + <input 1488 + type="checkbox" 1489 + .checked=${this.emailNotificationsEnabled} 1490 + @change=${async (e: Event) => { 1491 + const target = e.target as HTMLInputElement; 1492 + this.emailNotificationsEnabled = target.checked; 1493 + this.error = ""; 1494 + 1495 + try { 1496 + const response = await fetch("/api/user/notifications", { 1497 + method: "PUT", 1498 + headers: { "Content-Type": "application/json" }, 1499 + body: JSON.stringify({ 1500 + email_notifications_enabled: this.emailNotificationsEnabled, 1501 + }), 1502 + }); 1503 + 1504 + if (!response.ok) { 1505 + const data = await response.json(); 1506 + throw new Error(data.error || "Failed to update notification settings"); 1507 + } 1508 + } catch (err) { 1509 + // Revert on error 1510 + this.emailNotificationsEnabled = !target.checked; 1511 + target.checked = !target.checked; 1512 + this.error = err instanceof Error ? err.message : "Failed to update notification settings"; 1513 + } 1514 + }} 1515 + /> 1516 + <span class="toggle-slider"></span> 1517 + </label> 1518 + </div> 1519 + </div> 1520 + </div> 1521 + `; 1522 + } 1523 + 1345 1524 override render() { 1346 1525 if (this.loading) { 1347 1526 return html`<div class="loading">Loading...</div>`; 1348 - } 1349 - 1350 - if (this.error) { 1351 - return html`<div class="error">${this.error}</div>`; 1352 1527 } 1353 1528 1354 1529 if (!this.user) { ··· 1385 1560 Billing 1386 1561 </button> 1387 1562 <button 1563 + class="tab ${this.currentPage === "notifications" ? "active" : ""}" 1564 + @click=${() => { 1565 + this.setTab("notifications"); 1566 + }} 1567 + > 1568 + Notifications 1569 + </button> 1570 + <button 1388 1571 class="tab ${this.currentPage === "danger" ? "active" : ""}" 1389 1572 @click=${() => { 1390 1573 this.setTab("danger"); ··· 1397 1580 ${this.currentPage === "account" ? this.renderAccountPage() : ""} 1398 1581 ${this.currentPage === "sessions" ? this.renderSessionsPage() : ""} 1399 1582 ${this.currentPage === "billing" ? this.renderBillingPage() : ""} 1583 + ${this.currentPage === "notifications" ? this.renderNotificationsPage() : ""} 1400 1584 ${this.currentPage === "danger" ? this.renderDangerPage() : ""} 1401 1585 </div> 1402 1586 ··· 1416 1600 permanently deleted. 1417 1601 </p> 1418 1602 <div class="modal-actions"> 1419 - <button class="btn btn-rejection" @click=${this.handleDeleteAccount}> 1420 - Yes, Delete My Account 1603 + <button 1604 + class="btn btn-rejection" 1605 + @click=${this.handleDeleteAccount} 1606 + ?disabled=${this.deletingAccount} 1607 + > 1608 + ${this.deletingAccount ? "Deleting..." : "Yes, Delete My Account"} 1421 1609 </button> 1422 1610 <button 1423 1611 class="btn btn-neutral" 1424 1612 @click=${() => { 1425 1613 this.showDeleteConfirm = false; 1426 1614 }} 1615 + ?disabled=${this.deletingAccount} 1427 1616 > 1428 1617 Cancel 1429 1618 </button>
+7
src/db/schema.ts
··· 247 247 CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_token ON password_reset_tokens(token); 248 248 `, 249 249 }, 250 + { 251 + version: 4, 252 + name: "Add email notification preferences", 253 + sql: ` 254 + ALTER TABLE users ADD COLUMN email_notifications_enabled BOOLEAN DEFAULT 1; 255 + `, 256 + }, 250 257 ]; 251 258 252 259 function getCurrentVersion(): number {
+34
src/index.ts
··· 659 659 ) 660 660 .get(user.id); 661 661 662 + // Get notification preferences 663 + const prefs = db 664 + .query<{ email_notifications_enabled: number }, [number]>( 665 + "SELECT email_notifications_enabled FROM users WHERE id = ?", 666 + ) 667 + .get(user.id); 668 + 662 669 return Response.json({ 663 670 email: user.email, 664 671 name: user.name, ··· 667 674 role: user.role, 668 675 has_subscription: !!subscription, 669 676 email_verified: isEmailVerified(user.id), 677 + email_notifications_enabled: prefs?.email_notifications_enabled === 1, 670 678 }); 671 679 }, 672 680 }, ··· 1024 1032 } catch { 1025 1033 return Response.json( 1026 1034 { error: "Failed to update avatar" }, 1035 + { status: 500 }, 1036 + ); 1037 + } 1038 + }, 1039 + }, 1040 + "/api/user/notifications": { 1041 + PUT: async (req) => { 1042 + const sessionId = getSessionFromRequest(req); 1043 + if (!sessionId) { 1044 + return Response.json({ error: "Not authenticated" }, { status: 401 }); 1045 + } 1046 + const user = getUserBySession(sessionId); 1047 + if (!user) { 1048 + return Response.json({ error: "Invalid session" }, { status: 401 }); 1049 + } 1050 + const body = await req.json(); 1051 + const { email_notifications_enabled } = body; 1052 + if (typeof email_notifications_enabled !== "boolean") { 1053 + return Response.json({ error: "email_notifications_enabled must be a boolean" }, { status: 400 }); 1054 + } 1055 + try { 1056 + db.run("UPDATE users SET email_notifications_enabled = ? WHERE id = ?", [email_notifications_enabled ? 1 : 0, user.id]); 1057 + return Response.json({ success: true }); 1058 + } catch { 1059 + return Response.json( 1060 + { error: "Failed to update notification settings" }, 1027 1061 { status: 500 }, 1028 1062 ); 1029 1063 }