Add Prettier and ESLint tooling (Refs #77)

This commit is contained in:
2026-10-09 15:56:13 -04:00
parent 8ed059b8d3
commit 16469e2d43
711 changed files with 99737 additions and 28286 deletions
+279 -46
View File
@@ -4,73 +4,306 @@
import { availableTimeZones, DEFAULT_TIME_ZONE } from '$lib/time-zone.js';
let exampleDate = $state<Date | null>(null);
const zones = $derived([...new Set([...availableTimeZones(), $timeZone])].sort());
onMount(() => { exampleDate = new Date(); });
onMount(() => {
exampleDate = new Date();
});
import { page } from '$app/stores';
import { invalidateAll } from '$app/navigation';
import Button from '$ui/components/primitives/Button.svelte';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
let currentPassword = $state(''); let newPassword = $state(''); let confirmPassword = $state('');
let busy = $state(false); let signingOut = $state(false); let error = $state(''); let notice = $state('');
let currentPassword = $state('');
let newPassword = $state('');
let confirmPassword = $state('');
let busy = $state(false);
let signingOut = $state(false);
let error = $state('');
let notice = $state('');
const user = $derived($page.data.user);
async function changePassword(event: SubmitEvent) {
event.preventDefault(); if (busy || signingOut) return; error = ''; notice = '';
if (newPassword !== confirmPassword) { error = 'The new passwords do not match.'; return; }
event.preventDefault();
if (busy || signingOut) return;
error = '';
notice = '';
if (newPassword !== confirmPassword) {
error = 'The new passwords do not match.';
return;
}
const required = user?.mustChangePassword;
busy = true;
try {
const response = await fetch('/api/auth/password', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ currentPassword, newPassword }) });
const body = await response.json(); if (!response.ok) throw new Error(body.error ?? 'Could not change your password.');
currentPassword = ''; newPassword = ''; confirmPassword = '';
if (required) { window.location.assign('/'); return; }
await invalidateAll(); notice = 'Your password has been changed.';
} catch (cause) { error = (cause as Error).message; } finally { busy = false; }
const response = await fetch('/api/auth/password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ currentPassword, newPassword })
});
const body = await response.json();
if (!response.ok) throw new Error(body.error ?? 'Could not change your password.');
currentPassword = '';
newPassword = '';
confirmPassword = '';
if (required) {
window.location.assign('/');
return;
}
await invalidateAll();
notice = 'Your password has been changed.';
} catch (cause) {
error = (cause as Error).message;
} finally {
busy = false;
}
}
async function logout() {
if (busy || signingOut) return; signingOut = true; error = '';
if (busy || signingOut) return;
signingOut = true;
error = '';
try {
const response = await fetch('/api/auth/logout', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
if (!response.ok) { const body = await response.json(); throw new Error(body.error ?? 'Could not sign out.'); }
const response = await fetch('/api/auth/logout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{}'
});
if (!response.ok) {
const body = await response.json();
throw new Error(body.error ?? 'Could not sign out.');
}
window.location.assign('/login');
} catch (cause) { error = (cause as Error).message; signingOut = false; }
} catch (cause) {
error = (cause as Error).message;
signingOut = false;
}
}
</script>
<svelte:head><title>My account · Electric Cherry</title></svelte:head>
<section class="page"><SectionHeader title="My account" subtitle="Manage your password, display preferences, and current session." />
<section class="page">
<SectionHeader
title="My account"
subtitle="Manage your password, display preferences, and current session."
/>
{#if user}
<section class="panel identity"><div><h2>{user.displayName}</h2><p>@{user.username} · <span class="role">{user.role}</span></p></div><Button disabled={busy || signingOut} loading={signingOut} onclick={logout}>Sign out</Button></section>
{#if user.mustChangePassword}<p class="required" role="status">Your account uses a temporary password. Choose a new password before continuing to Electric Cherry.</p>{/if}
<section class="panel"><h2>{user.mustChangePassword ? 'Choose your password' : 'Change password'}</h2>
{#if error}<p class="error" role="alert">{error}</p>{/if}{#if notice}<p class="notice" role="status">{notice}</p>{/if}
<form onsubmit={changePassword} aria-busy={busy}><fieldset disabled={busy || signingOut}>
<input type="text" class="password-username" value={user.username} autocomplete="username" aria-label="Username" readonly tabindex="-1" />
<label for="current-password">{user.mustChangePassword ? 'Temporary password' : 'Current password'}<input id="current-password" name="currentPassword" type="password" autocomplete="current-password" required maxlength="128" bind:value={currentPassword} /></label>
<label for="new-password">New password<input id="new-password" name="newPassword" type="password" autocomplete="new-password" required minlength="8" maxlength="128" bind:value={newPassword} aria-describedby="new-password-help" /></label>
<p id="new-password-help" class="hint">Use 8–128 characters and a password you do not use elsewhere.</p>
<label for="confirm-password">Confirm new password<input id="confirm-password" name="confirmPassword" type="password" autocomplete="new-password" required minlength="8" maxlength="128" bind:value={confirmPassword} /></label>
<div><Button type="submit" variant="primary" loading={busy} disabled={busy || signingOut}>{user.mustChangePassword ? 'Save password and continue' : 'Change password'}</Button></div>
</fieldset></form>
<section class="panel identity">
<div>
<h2>{user.displayName}</h2>
<p>@{user.username} · <span class="role">{user.role}</span></p>
</div>
<Button disabled={busy || signingOut} loading={signingOut} onclick={logout}
>Sign out</Button
>
</section>
{#if user.mustChangePassword}<p class="required" role="status">
Your account uses a temporary password. Choose a new password before continuing to
Electric Cherry.
</p>{/if}
<section class="panel">
<h2>{user.mustChangePassword ? 'Choose your password' : 'Change password'}</h2>
{#if error}<p class="error" role="alert">{error}</p>{/if}{#if notice}<p
class="notice"
role="status"
>
{notice}
</p>{/if}
<form onsubmit={changePassword} aria-busy={busy}>
<fieldset disabled={busy || signingOut}>
<input
type="text"
class="password-username"
value={user.username}
autocomplete="username"
aria-label="Username"
readonly
tabindex="-1"
/>
<label for="current-password"
>{user.mustChangePassword ? 'Temporary password' : 'Current password'}<input
id="current-password"
name="currentPassword"
type="password"
autocomplete="current-password"
required
maxlength="128"
bind:value={currentPassword}
/></label
>
<label for="new-password"
>New password<input
id="new-password"
name="newPassword"
type="password"
autocomplete="new-password"
required
minlength="8"
maxlength="128"
bind:value={newPassword}
aria-describedby="new-password-help"
/></label
>
<p id="new-password-help" class="hint">
Use 8–128 characters and a password you do not use elsewhere.
</p>
<label for="confirm-password"
>Confirm new password<input
id="confirm-password"
name="confirmPassword"
type="password"
autocomplete="new-password"
required
minlength="8"
maxlength="128"
bind:value={confirmPassword}
/></label
>
<div>
<Button
type="submit"
variant="primary"
loading={busy}
disabled={busy || signingOut}
>{user.mustChangePassword
? 'Save password and continue'
: 'Change password'}</Button
>
</div>
</fieldset>
</form>
</section>
<section class="panel" aria-labelledby="display-heading">
<h2 id="display-heading">Display preferences</h2>
<label class="timezone"
>Timezone
<select bind:value={$timeZone} aria-describedby="timezone-help">
{#each zones as zone}<option value={zone}
>{zone.replaceAll('_', ' ')}{zone === DEFAULT_TIME_ZONE
? ' (default)'
: ''}</option
>{/each}
</select>
</label>
<p id="timezone-help">
Dates and times throughout the app use this timezone, including daylight saving
changes. Saved automatically in this browser.
</p>
{#if exampleDate}<p>Example: {$timeFormat.dateTime(exampleDate)}</p>{/if}
{#if $timeZonePersistenceError}<p role="status">
Browser storage is unavailable. Your timezone applies for this visit but cannot
be saved.
</p>{/if}
</section>
<section class="panel" aria-labelledby="display-heading">
<h2 id="display-heading">Display preferences</h2>
<label class="timezone">Timezone
<select bind:value={$timeZone} aria-describedby="timezone-help">
{#each zones as zone}<option value={zone}>{zone.replaceAll('_', ' ')}{zone === DEFAULT_TIME_ZONE ? ' (default)' : ''}</option>{/each}
</select>
</label>
<p id="timezone-help">Dates and times throughout the app use this timezone, including daylight saving changes. Saved automatically in this browser.</p>
{#if exampleDate}<p>Example: {$timeFormat.dateTime(exampleDate)}</p>{/if}
{#if $timeZonePersistenceError}<p role="status">Browser storage is unavailable. Your timezone applies for this visit but cannot be saved.</p>{/if}
</section>
{/if}
</section>
<style>
.timezone { display: grid; gap: 8px; margin-top: 16px; font-size: var(--text-sm); max-width: 420px; }
.timezone select { width: 100%; padding: 10px 12px; font: inherit; color: var(--text-primary); background: var(--surface-sunken); border: 1px solid var(--border-control); border-radius: var(--radius-sm); }
.timezone ~ p { font-size:var(--text-sm); color:var(--text-secondary); }
.page { max-width:760px; display:grid; gap:24px; } .panel { padding:24px; background:var(--surface); border:1px solid var(--border-strong); border-radius:var(--radius-md); } h2 { font-size:var(--text-md); margin:0; } .identity { display:flex; align-items:center; justify-content:space-between; gap:20px; } .identity p { margin:8px 0 0; color:var(--text-secondary); font-size:var(--text-sm); } .role { text-transform:capitalize; }
.required { padding:16px; margin:0; color:var(--text-primary); background:var(--surface-raised); border-left:3px solid var(--accent-primary); font-size:var(--text-sm); line-height:1.6; }
form { margin-top:24px; max-width:460px; } fieldset { border:0; margin:0; padding:0; display:grid; gap:18px; min-width:0; } label { display:grid; gap:8px; font-size:var(--text-sm); } input { width:100%; padding:12px; color:var(--text-primary); background:var(--surface-sunken); border:1px solid var(--border-control); border-radius:var(--radius-md); font:inherit; } .password-username { position:absolute; width:1px; height:1px; clip-path:inset(50%); overflow:hidden; padding:0; border:0; } .hint { margin:-8px 0 0; color:var(--text-secondary); font-size:var(--text-xs); line-height:1.6; } .error { color:var(--state-danger); font-size:var(--text-sm); } .notice { color:var(--state-online); font-size:var(--text-sm); }
@media(max-width:540px) { .identity { align-items:flex-start; flex-direction:column; } }
.timezone {
display: grid;
gap: 8px;
margin-top: 16px;
font-size: var(--text-sm);
max-width: 420px;
}
.timezone select {
width: 100%;
padding: 10px 12px;
font: inherit;
color: var(--text-primary);
background: var(--surface-sunken);
border: 1px solid var(--border-control);
border-radius: var(--radius-sm);
}
.timezone ~ p {
font-size: var(--text-sm);
color: var(--text-secondary);
}
.page {
max-width: 760px;
display: grid;
gap: 24px;
}
.panel {
padding: 24px;
background: var(--surface);
border: 1px solid var(--border-strong);
border-radius: var(--radius-md);
}
h2 {
font-size: var(--text-md);
margin: 0;
}
.identity {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
}
.identity p {
margin: 8px 0 0;
color: var(--text-secondary);
font-size: var(--text-sm);
}
.role {
text-transform: capitalize;
}
.required {
padding: 16px;
margin: 0;
color: var(--text-primary);
background: var(--surface-raised);
border-left: 3px solid var(--accent-primary);
font-size: var(--text-sm);
line-height: 1.6;
}
form {
margin-top: 24px;
max-width: 460px;
}
fieldset {
border: 0;
margin: 0;
padding: 0;
display: grid;
gap: 18px;
min-width: 0;
}
label {
display: grid;
gap: 8px;
font-size: var(--text-sm);
}
input {
width: 100%;
padding: 12px;
color: var(--text-primary);
background: var(--surface-sunken);
border: 1px solid var(--border-control);
border-radius: var(--radius-md);
font: inherit;
}
.password-username {
position: absolute;
width: 1px;
height: 1px;
clip-path: inset(50%);
overflow: hidden;
padding: 0;
border: 0;
}
.hint {
margin: -8px 0 0;
color: var(--text-secondary);
font-size: var(--text-xs);
line-height: 1.6;
}
.error {
color: var(--state-danger);
font-size: var(--text-sm);
}
.notice {
color: var(--state-online);
font-size: var(--text-sm);
}
@media (max-width: 540px) {
.identity {
align-items: flex-start;
flex-direction: column;
}
}
</style>