This commit is contained in:
2026-05-08 09:06:14 +12:00
parent 1533b5aa9b
commit 9afc3170ff
22 changed files with 2710 additions and 549 deletions
+459 -89
View File
@@ -1,126 +1,496 @@
<script lang="ts">
import { api } from '$lib/api';
import { clientSession } from '$lib/session';
import { toast } from '$lib/toast';
import { CircleUserRound, LockKeyhole } from 'lucide-svelte';
const currentYear = new Date().getFullYear();
</script>
type Section = 'profile' | 'security';
let activeSection = $state<Section>('profile');
<section class="settings-grid">
<article class="surface-card">
<p class="eyebrow">Session</p>
<h3>Signed-in account</h3>
<div class="details-list">
<div>
<span>Name</span>
<strong>{$clientSession?.name ?? 'No active session'}</strong>
</div>
<div>
<span>Email</span>
<strong>{$clientSession?.email ?? 'Sign in required'}</strong>
</div>
<div>
<span>Role</span>
<strong>{$clientSession?.role ?? 'Client'}</strong>
</div>
</div>
</article>
let name = $state($clientSession?.name ?? '');
let email = $state($clientSession?.email ?? '');
<article class="surface-card">
<p class="eyebrow">Display</p>
<h3>Navigation behaviour</h3>
<div class="details-list">
<div>
<span>Desktop</span>
<strong>Left rail navigation</strong>
</div>
<div>
<span>iPad / Tablet</span>
<strong>Bottom navigation drawer</strong>
</div>
<div>
<span>Copyright</span>
<strong>&copy; {currentYear} Hunter Premium Produce</strong>
</div>
</div>
</article>
</section>
let currentPassword = $state('');
let newPassword = $state('');
let confirmPassword = $state('');
<style>
h2,
h3,
p {
margin: 0;
let profileSaving = $state(false);
let passwordSaving = $state(false);
let passwordError = $state('');
async function saveProfile() {
profileSaving = true;
const tid = toast.loading('Saving profile…');
try {
const updated = await api.updateMe({ name: name.trim(), email: email.trim().toLowerCase() });
clientSession.set({
...$clientSession!,
name: updated.name,
email: updated.email,
token: updated.token ?? $clientSession!.token,
});
toast.dismiss(tid);
toast.success('Profile updated');
} catch (err: unknown) {
toast.dismiss(tid);
toast.error(err instanceof Error ? err.message : 'An error occurred');
} finally {
profileSaving = false;
}
}
.eyebrow {
color: #7f8e85;
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.08em;
async function savePassword() {
passwordError = '';
if (newPassword !== confirmPassword) {
passwordError = 'New passwords do not match';
return;
}
if (newPassword.length < 8) {
passwordError = 'Password must be at least 8 characters';
return;
}
passwordSaving = true;
const tid = toast.loading('Updating password…');
try {
await api.updateMe({ current_password: currentPassword, new_password: newPassword });
currentPassword = '';
newPassword = '';
confirmPassword = '';
toast.dismiss(tid);
toast.success('Password updated');
} catch (err: unknown) {
toast.dismiss(tid);
const msg = err instanceof Error ? err.message : 'An error occurred';
passwordError = msg;
toast.error(msg);
} finally {
passwordSaving = false;
}
}
const initials = $derived(
($clientSession?.name ?? '')
.split(' ')
.slice(0, 2)
.map((w) => w[0])
.join('')
.toUpperCase() || '?'
);
const navItems: { id: Section; label: string; icon: typeof CircleUserRound }[] = [
{ id: 'profile', label: 'Profile', icon: CircleUserRound },
{ id: 'security', label: 'Security', icon: LockKeyhole },
];
</script>
<div class="settings-layout">
<nav class="settings-nav" aria-label="Settings sections">
<p class="nav-section-label">Settings</p>
<div class="nav-identity">
<div class="avatar" aria-hidden="true">{initials}</div>
<div class="identity-text">
<p class="identity-name">{$clientSession?.name ?? 'Unknown'}</p>
<p class="identity-role">{$clientSession?.role_name ?? $clientSession?.role ?? 'User'}</p>
</div>
</div>
<ul>
{#each navItems as item}
{@const Icon = item.icon}
<li>
<button
type="button"
class="nav-item"
class:active={activeSection === item.id}
onclick={() => (activeSection = item.id)}
>
<span class="nav-icon"><Icon size={18} strokeWidth={1.75} /></span>
<span>{item.label}</span>
</button>
</li>
{/each}
</ul>
</nav>
<div class="settings-panel">
{#if activeSection === 'profile'}
<div class="panel-section">
<header class="panel-header">
<h2>Profile</h2>
<p>Update your display name and email address.</p>
</header>
<form class="panel-form" onsubmit={(e) => { e.preventDefault(); saveProfile(); }}>
<div class="field-row">
<div class="field">
<label for="name">Full name</label>
<input id="name" type="text" bind:value={name} autocomplete="name" required />
</div>
<div class="field">
<label for="email">Email address</label>
<input id="email" type="email" bind:value={email} autocomplete="email" required />
</div>
</div>
<div class="form-footer">
<button class="btn-primary" type="submit" disabled={profileSaving}>
{profileSaving ? 'Saving…' : 'Save changes'}
</button>
</div>
</form>
</div>
{:else if activeSection === 'security'}
<div class="panel-section">
<header class="panel-header">
<h2>Security</h2>
<p>Choose a strong password with at least 8 characters.</p>
</header>
<form class="panel-form" onsubmit={(e) => { e.preventDefault(); savePassword(); }}>
<div class="field">
<label for="current-password">Current password</label>
<input id="current-password" type="password" bind:value={currentPassword} autocomplete="current-password" required />
</div>
<div class="divider" aria-hidden="true"></div>
<div class="field-row">
<div class="field">
<label for="new-password">New password</label>
<input id="new-password" type="password" bind:value={newPassword} autocomplete="new-password" required />
</div>
<div class="field">
<label for="confirm-password">Confirm new password</label>
<input id="confirm-password" type="password" bind:value={confirmPassword} autocomplete="new-password" required />
</div>
</div>
{#if passwordError}
<p class="form-error">{passwordError}</p>
{/if}
<div class="form-footer">
<button class="btn-primary" type="submit" disabled={passwordSaving}>
{passwordSaving ? 'Updating…' : 'Update password'}
</button>
</div>
</form>
</div>
{/if}
</div>
</div>
<style>
.settings-layout {
display: grid;
grid-template-columns: 15rem minmax(0, 1fr);
align-items: stretch;
min-height: calc(100vh - 8.5rem);
max-height: calc(100vh - 8.5rem);
background: var(--panel);
border: 1px solid var(--line);
border-radius: 1.15rem;
box-shadow: var(--shadow);
overflow: hidden;
}
.settings-nav {
position: sticky;
top: 0;
display: flex;
flex-direction: column;
gap: 0.4rem;
height: 100%;
min-height: calc(100vh - 8.5rem);
padding: 1.1rem 0.85rem 0.85rem;
background: var(--panel);
border-right: 1px solid var(--line);
overflow-y: auto;
}
.nav-section-label {
margin: 0 0.55rem 0.3rem;
color: var(--muted);
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.page-intro,
.settings-grid {
margin-bottom: 1.2rem;
.nav-identity {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0 0.25rem 0.9rem;
border-bottom: 1px solid var(--line);
}
.page-intro h2 {
margin: 0.35rem 0 0.45rem;
font-size: clamp(1.7rem, 3vw, 2.2rem);
.avatar {
flex-shrink: 0;
width: 2.5rem;
height: 2.5rem;
border-radius: 50%;
background: var(--green-deep);
color: #fff;
font-size: 0.9rem;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
letter-spacing: 0.02em;
}
.identity-text {
min-width: 0;
}
.identity-name {
margin: 0;
font-size: 0.85rem;
font-weight: 700;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.identity-role {
margin: 0;
font-size: 0.74rem;
color: var(--muted);
text-transform: capitalize;
}
.settings-nav ul {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.12rem;
}
.settings-nav li {
margin: 0;
}
.nav-item {
position: relative;
display: flex;
align-items: center;
gap: 0.7rem;
width: 100%;
padding: 0.6rem 0.6rem;
border: none;
border-radius: 0.7rem;
background: transparent;
color: #3a4a41;
font-size: 0.93rem;
text-align: left;
cursor: pointer;
transition: background-color 140ms ease, color 140ms ease;
}
.nav-icon {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 1.6rem;
height: 1.6rem;
color: #6d7d74;
border-radius: 0.55rem;
transition: color 140ms ease;
}
.nav-item:hover {
background: var(--panel-soft);
color: #304038;
}
.nav-item.active {
background: var(--color-brand);
color: #fff;
font-weight: 600;
}
.nav-item:hover .nav-icon {
color: #304038;
}
.nav-item.active .nav-icon {
color: #fff;
}
.nav-item.active::before {
content: '';
position: absolute;
left: -0.85rem;
top: 0.45rem;
bottom: 0.45rem;
width: 3px;
border-radius: 999px;
background: var(--color-brand);
}
.settings-panel {
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
background: var(--panel);
height: 100%;
overflow: hidden;
}
.panel-section {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
}
.panel-header {
padding: 1.5rem 1.75rem 1.25rem;
border-bottom: 1px solid var(--line);
}
.panel-header h2 {
margin: 0 0 0.3rem;
font-size: 1.1rem;
font-weight: 700;
}
.page-intro p:last-child,
.details-list span {
.panel-header p {
margin: 0;
font-size: 0.85rem;
color: var(--muted);
}
.settings-grid {
/* ── Form ───────────────────────────────────────────────────── */
.panel-form {
display: grid;
flex: 1;
gap: 1rem;
min-height: 0;
overflow-y: auto;
width: 100%;
padding: 1.5rem 1.75rem;
max-width: 42rem;
}
.field-row {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
}
.surface-card {
.field {
display: grid;
gap: 1rem;
padding: 1.2rem;
gap: 0.42rem;
}
.field label {
font-size: 0.82rem;
font-weight: 600;
color: var(--text);
}
.field input {
width: 100%;
padding: 0.62rem 0.85rem;
border: 1px solid var(--line);
border-radius: 1.25rem;
background: var(--panel);
box-shadow: var(--shadow);
}
.surface-card h3 {
font-size: 1.1rem;
font-weight: 700;
}
.details-list {
display: grid;
gap: 0.85rem;
}
.details-list div {
padding: 0.9rem 0.95rem;
border: 1px solid var(--line);
border-radius: 0.95rem;
border-radius: 0.6rem;
background: var(--panel-soft);
color: var(--text);
font-size: 0.9rem;
transition: border-color 140ms ease, box-shadow 140ms ease;
box-sizing: border-box;
}
.details-list span {
display: block;
margin-bottom: 0.28rem;
.field input:focus {
outline: none;
border-color: var(--green-deep);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-brand) 18%, transparent);
}
.divider {
height: 1px;
background: var(--line);
margin: 0.25rem 0;
}
.form-error {
margin: 0;
padding: 0.65rem 0.85rem;
background: color-mix(in srgb, #e53e3e 8%, transparent);
border: 1px solid color-mix(in srgb, #e53e3e 25%, transparent);
border-radius: 0.6rem;
color: #c53030;
font-size: 0.84rem;
}
.details-list strong {
font-size: 0.98rem;
font-weight: 700;
.form-footer {
display: flex;
justify-content: flex-end;
padding-top: 0.25rem;
border-top: 1px solid var(--line);
margin-top: 0.5rem;
}
@media (max-width: 900px) {
.settings-grid {
.btn-primary {
padding: 0.58rem 1.4rem;
background: var(--color-brand);
color: #fff;
border: none;
border-radius: 0.6rem;
font-size: 0.88rem;
font-weight: 600;
cursor: pointer;
transition: opacity 140ms ease;
}
.btn-primary:hover:not(:disabled) {
opacity: 0.88;
}
.btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* ── Responsive ─────────────────────────────────────────────── */
@media (max-width: 720px) {
.settings-layout {
grid-template-columns: 1fr;
min-height: auto;
max-height: none;
}
.settings-nav {
position: static;
min-height: auto;
height: auto;
overflow: visible;
border-right: none;
border-bottom: 1px solid var(--line);
}
.settings-nav ul {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
}
.nav-item {
width: auto;
padding-right: 0.9rem;
}
.field-row {
grid-template-columns: 1fr;
}
}