🪻 distributed transcription service thistle.dunkirk.sh

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

dunkirk.sh c2a259ab fc917bb0

verified
Changed files
+252 -22
src
+211 -22
src/components/user-settings.ts
···
canceled_at: number | null;
}
-
type SettingsPage = "account" | "sessions" | "passkeys" | "billing" | "danger";
+
type SettingsPage = "account" | "sessions" | "passkeys" | "billing" | "notifications" | "danger";
@customElement("user-settings")
export class UserSettings extends LitElement {
···
@state() newAvatar = "";
@state() passkeySupported = false;
@state() addingPasskey = false;
+
@state() emailNotificationsEnabled = true;
+
@state() deletingAccount = false;
static override styles = css`
:host {
···
color: var(--accent);
}
+
.error-banner {
+
background: #fecaca;
+
border: 2px solid rgba(220, 38, 38, 0.8);
+
border-radius: 6px;
+
padding: 1rem;
+
margin-bottom: 1.5rem;
+
color: #dc2626;
+
font-weight: 500;
+
}
+
.loading {
text-align: center;
color: var(--text);
padding: 2rem;
}
+
.setting-row {
+
display: flex;
+
align-items: center;
+
justify-content: space-between;
+
padding: 1rem;
+
border: 1px solid var(--secondary);
+
border-radius: 6px;
+
gap: 1rem;
+
}
+
+
.setting-info {
+
flex: 1;
+
}
+
+
.toggle {
+
position: relative;
+
display: inline-block;
+
width: 48px;
+
height: 24px;
+
}
+
+
.toggle input {
+
opacity: 0;
+
width: 0;
+
height: 0;
+
}
+
+
.toggle-slider {
+
position: absolute;
+
cursor: pointer;
+
top: 0;
+
left: 0;
+
right: 0;
+
bottom: 0;
+
background-color: var(--secondary);
+
transition: 0.2s;
+
border-radius: 24px;
+
}
+
+
.toggle-slider:before {
+
position: absolute;
+
content: "";
+
height: 18px;
+
width: 18px;
+
left: 3px;
+
bottom: 3px;
+
background-color: white;
+
transition: 0.2s;
+
border-radius: 50%;
+
}
+
+
.toggle input:checked + .toggle-slider {
+
background-color: var(--primary);
+
}
+
+
.toggle input:checked + .toggle-slider:before {
+
transform: translateX(24px);
+
}
+
@media (max-width: 768px) {
.settings-container {
padding: 1rem;
···
}
private isValidTab(tab: string): boolean {
-
return ["account", "sessions", "passkeys", "billing", "danger"].includes(tab);
+
return ["account", "sessions", "passkeys", "billing", "notifications", "danger"].includes(tab);
}
private setTab(tab: SettingsPage) {
this.currentPage = tab;
+
this.error = ""; // Clear errors when switching tabs
// Update URL without reloading page
const url = new URL(window.location.href);
url.searchParams.set("tab", tab);
···
return;
}
-
this.user = await response.json();
+
const data = await response.json();
+
this.user = data;
+
this.emailNotificationsEnabled = data.email_notifications_enabled ?? true;
} finally {
this.loading = false;
}
···
return;
}
+
this.error = "";
try {
const response = await fetch(`/api/passkeys/${passkeyId}`, {
method: "DELETE",
});
if (!response.ok) {
-
const error = await response.json();
-
this.error = error.error || "Failed to delete passkey";
+
const data = await response.json();
+
this.error = data.error || "Failed to delete passkey";
return;
}
// Reload passkeys
await this.loadPasskeys();
-
} catch {
-
this.error = "Failed to delete passkey";
+
} catch (err) {
+
this.error = err instanceof Error ? err.message : "Failed to delete passkey";
}
}
async handleLogout() {
+
this.error = "";
try {
-
await fetch("/api/auth/logout", { method: "POST" });
+
const response = await fetch("/api/auth/logout", { method: "POST" });
+
+
if (!response.ok) {
+
const data = await response.json();
+
this.error = data.error || "Failed to logout";
+
return;
+
}
+
window.location.href = "/";
-
} catch {
-
this.error = "Failed to logout";
+
} catch (err) {
+
this.error = err instanceof Error ? err.message : "Failed to logout";
}
}
async handleDeleteAccount() {
+
this.deletingAccount = true;
+
this.error = "";
+
document.body.style.cursor = "wait";
+
try {
const response = await fetch("/api/user", {
method: "DELETE",
});
if (!response.ok) {
-
this.error = "Failed to delete account";
+
const data = await response.json();
+
this.error = data.error || "Failed to delete account";
return;
}
···
} catch {
this.error = "Failed to delete account";
} finally {
+
this.deletingAccount = false;
this.showDeleteConfirm = false;
+
document.body.style.cursor = "";
}
}
async handleUpdateEmail() {
+
this.error = "";
if (!this.newEmail) {
this.error = "Email required";
return;
···
}
async handleUpdatePassword() {
+
this.error = "";
if (!this.newPassword) {
this.error = "Password required";
return;
···
}
async handleUpdateName() {
+
this.error = "";
if (!this.newName) {
this.error = "Name required";
return;
···
}
async handleUpdateAvatar() {
+
this.error = "";
if (!this.newAvatar) {
this.error = "Avatar required";
return;
···
}
async handleKillSession(sessionId: string) {
+
this.error = "";
try {
const response = await fetch(`/api/sessions`, {
method: "DELETE",
···
return html`
<div class="content-inner">
+
${this.error ? html`
+
<div class="error-banner">
+
${this.error}
+
</div>
+
` : ""}
<div class="section">
<h2 class="section-title">Profile Information</h2>
···
renderSessionsPage() {
return html`
<div class="content-inner">
+
${this.error ? html`
+
<div class="error-banner">
+
${this.error}
+
</div>
+
` : ""}
<div class="section">
<h2 class="section-title">Active Sessions</h2>
${
···
return html`
<div class="content-inner">
+
${this.error ? html`
+
<div class="error-banner">
+
${this.error}
+
</div>
+
` : ""}
<div class="section">
<h2 class="section-title">Subscription</h2>
···
Reactivate your subscription to unlock unlimited transcriptions.
</p>
</div>
-
-
${this.error ? html`<p class="error" style="margin-top: 1rem;">${this.error}</p>` : ""}
</div>
</div>
`;
···
if (hasActiveSubscription) {
return html`
<div class="content-inner">
+
${this.error ? html`
+
<div class="error-banner">
+
${this.error}
+
</div>
+
` : ""}
<div class="section">
<h2 class="section-title">Subscription</h2>
···
Opens the customer portal where you can update payment methods, view invoices, and manage your subscription.
</p>
</div>
-
-
${this.error ? html`<p class="error" style="margin-top: 1rem;">${this.error}</p>` : ""}
</div>
</div>
`;
···
return html`
<div class="content-inner">
+
${this.error ? html`
+
<div class="error-banner">
+
${this.error}
+
</div>
+
` : ""}
<div class="section">
<h2 class="section-title">Billing & Subscription</h2>
<p class="field-description" style="margin-bottom: 1.5rem;">
···
${this.loading ? "Loading..." : "Activate Your Subscription"}
</button>
-
${this.error ? html`<p class="error" style="margin-top: 1rem;">${this.error}</p>` : ""}
</div>
</div>
`;
···
renderDangerPage() {
return html`
<div class="content-inner">
+
${this.error ? html`
+
<div class="error-banner">
+
${this.error}
+
</div>
+
` : ""}
<div class="section danger-section">
<h2 class="section-title">Delete Account</h2>
<p class="danger-text">
···
`;
+
renderNotificationsPage() {
+
return html`
+
<div class="content-inner">
+
${this.error ? html`
+
<div class="error-banner">
+
${this.error}
+
</div>
+
` : ""}
+
<div class="section">
+
<h2 class="section-title">Email Notifications</h2>
+
<p style="color: var(--text); margin-bottom: 1rem;">
+
Control which emails you receive from Thistle.
+
</p>
+
+
<div class="setting-row">
+
<div class="setting-info">
+
<strong>Transcription Complete</strong>
+
<p style="color: var(--paynes-gray); font-size: 0.875rem; margin: 0.25rem 0 0 0;">
+
Get notified when your transcription is ready
+
</p>
+
</div>
+
<label class="toggle">
+
<input
+
type="checkbox"
+
.checked=${this.emailNotificationsEnabled}
+
@change=${async (e: Event) => {
+
const target = e.target as HTMLInputElement;
+
this.emailNotificationsEnabled = target.checked;
+
this.error = "";
+
+
try {
+
const response = await fetch("/api/user/notifications", {
+
method: "PUT",
+
headers: { "Content-Type": "application/json" },
+
body: JSON.stringify({
+
email_notifications_enabled: this.emailNotificationsEnabled,
+
}),
+
});
+
+
if (!response.ok) {
+
const data = await response.json();
+
throw new Error(data.error || "Failed to update notification settings");
+
}
+
} catch (err) {
+
// Revert on error
+
this.emailNotificationsEnabled = !target.checked;
+
target.checked = !target.checked;
+
this.error = err instanceof Error ? err.message : "Failed to update notification settings";
+
}
+
}}
+
/>
+
<span class="toggle-slider"></span>
+
</label>
+
</div>
+
</div>
+
</div>
+
`;
+
}
+
override render() {
if (this.loading) {
return html`<div class="loading">Loading...</div>`;
-
}
-
-
if (this.error) {
-
return html`<div class="error">${this.error}</div>`;
if (!this.user) {
···
Billing
</button>
<button
+
class="tab ${this.currentPage === "notifications" ? "active" : ""}"
+
@click=${() => {
+
this.setTab("notifications");
+
}}
+
>
+
Notifications
+
</button>
+
<button
class="tab ${this.currentPage === "danger" ? "active" : ""}"
@click=${() => {
this.setTab("danger");
···
${this.currentPage === "account" ? this.renderAccountPage() : ""}
${this.currentPage === "sessions" ? this.renderSessionsPage() : ""}
${this.currentPage === "billing" ? this.renderBillingPage() : ""}
+
${this.currentPage === "notifications" ? this.renderNotificationsPage() : ""}
${this.currentPage === "danger" ? this.renderDangerPage() : ""}
</div>
···
permanently deleted.
</p>
<div class="modal-actions">
-
<button class="btn btn-rejection" @click=${this.handleDeleteAccount}>
-
Yes, Delete My Account
+
<button
+
class="btn btn-rejection"
+
@click=${this.handleDeleteAccount}
+
?disabled=${this.deletingAccount}
+
>
+
${this.deletingAccount ? "Deleting..." : "Yes, Delete My Account"}
</button>
<button
class="btn btn-neutral"
@click=${() => {
this.showDeleteConfirm = false;
}}
+
?disabled=${this.deletingAccount}
Cancel
</button>
+7
src/db/schema.ts
···
CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_token ON password_reset_tokens(token);
`,
},
+
{
+
version: 4,
+
name: "Add email notification preferences",
+
sql: `
+
ALTER TABLE users ADD COLUMN email_notifications_enabled BOOLEAN DEFAULT 1;
+
`,
+
},
];
function getCurrentVersion(): number {
+34
src/index.ts
···
)
.get(user.id);
+
// Get notification preferences
+
const prefs = db
+
.query<{ email_notifications_enabled: number }, [number]>(
+
"SELECT email_notifications_enabled FROM users WHERE id = ?",
+
)
+
.get(user.id);
+
return Response.json({
email: user.email,
name: user.name,
···
role: user.role,
has_subscription: !!subscription,
email_verified: isEmailVerified(user.id),
+
email_notifications_enabled: prefs?.email_notifications_enabled === 1,
});
},
},
···
} catch {
return Response.json(
{ error: "Failed to update avatar" },
+
{ status: 500 },
+
);
+
}
+
},
+
},
+
"/api/user/notifications": {
+
PUT: async (req) => {
+
const sessionId = getSessionFromRequest(req);
+
if (!sessionId) {
+
return Response.json({ error: "Not authenticated" }, { status: 401 });
+
}
+
const user = getUserBySession(sessionId);
+
if (!user) {
+
return Response.json({ error: "Invalid session" }, { status: 401 });
+
}
+
const body = await req.json();
+
const { email_notifications_enabled } = body;
+
if (typeof email_notifications_enabled !== "boolean") {
+
return Response.json({ error: "email_notifications_enabled must be a boolean" }, { status: 400 });
+
}
+
try {
+
db.run("UPDATE users SET email_notifications_enabled = ? WHERE id = ?", [email_notifications_enabled ? 1 : 0, user.id]);
+
return Response.json({ success: true });
+
} catch {
+
return Response.json(
+
{ error: "Failed to update notification settings" },
{ status: 500 },
);