v1.4 - Login fixes, etc

This commit is contained in:
2026-04-27 21:53:36 +12:00
parent 8cf9bfb441
commit c9580ac2eb
33 changed files with 2283 additions and 202 deletions
+673 -63
View File
@@ -1,8 +1,10 @@
<script lang="ts">
import { invalidateAll } from '$app/navigation';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { clientSession } from '$lib/session';
import { clientSession, sessionHydrated } from '$lib/session';
import { onMount, tick } from 'svelte';
import packageInfo from '../../../package.json';
type SearchItem = {
href: string;
@@ -23,6 +25,7 @@
{ href: '/products', label: 'Delivered Pricing', shortLabel: 'DP' },
{ href: '/scenarios', label: 'Planning View', shortLabel: 'PV' }
];
const primaryBottomNavigation = navigation.slice(0, 4);
const searchItems: SearchItem[] = [
{
@@ -55,6 +58,12 @@
description: 'Review delivered product pricing and margins.',
keywords: 'products pricing margins delivered outputs'
},
{
href: '/settings',
label: 'Open Workspace Settings',
description: 'Review account details and workspace preferences.',
keywords: 'settings account preferences profile workspace'
},
{
href: '/scenarios',
label: 'Open Scenarios',
@@ -69,7 +78,14 @@
let paletteOpen = $state(false);
let paletteQuery = $state('');
let quickMenuOpen = $state(false);
let userMenuOpen = $state(false);
let navOpen = $state(false);
let showBottomNav = $state(false);
let isRestoringSession = $state(false);
let restoredToken = $state<string | null>(null);
let paletteInput: HTMLInputElement | null = $state(null);
const appVersion = `v${packageInfo.version}`;
const currentYear = new Date().getFullYear();
function matchesRoute(href: string, pathname: string) {
return href === '/' ? pathname === '/' : pathname.startsWith(href);
@@ -86,6 +102,7 @@
'/mixes': 'Browse saved mix worksheets and costing outputs',
'/mixes/new': 'Create a new mix worksheet for Hunter Premium Produce',
'/products': 'Track delivered product pricing and margin views',
'/settings': 'Review your workspace profile and application settings',
'/scenarios': 'Compare alternate pricing and production assumptions'
};
@@ -96,6 +113,16 @@
paletteQuery = query;
paletteOpen = true;
quickMenuOpen = false;
userMenuOpen = false;
navOpen = false;
}
function syncViewport() {
showBottomNav = window.innerWidth <= 1180;
if (!showBottomNav) {
navOpen = false;
}
}
async function runSearchItem(item: SearchItem) {
@@ -104,6 +131,13 @@
await goto(item.href);
}
async function openSettings() {
quickMenuOpen = false;
userMenuOpen = false;
navOpen = false;
await goto('/settings');
}
const filteredSearchItems = $derived(
searchItems.filter((item) => {
const haystack = `${item.label} ${item.description} ${item.keywords}`.toLowerCase();
@@ -114,8 +148,10 @@
$effect(() => {
page.url.pathname;
quickMenuOpen = false;
userMenuOpen = false;
paletteOpen = false;
paletteQuery = '';
navOpen = false;
});
$effect(() => {
@@ -124,7 +160,37 @@
}
});
$effect(() => {
const hydrated = $sessionHydrated;
const token = $clientSession?.token ?? null;
if (!hydrated) {
return;
}
if (!token) {
isRestoringSession = false;
restoredToken = null;
return;
}
if (restoredToken === token) {
return;
}
restoredToken = token;
isRestoringSession = true;
invalidateAll().finally(() => {
if (restoredToken === token) {
isRestoringSession = false;
}
});
});
onMount(() => {
syncViewport();
const handleKeydown = (event: KeyboardEvent) => {
const target = event.target as HTMLElement | null;
const isTypingField =
@@ -141,11 +207,18 @@
if (event.key === 'Escape') {
paletteOpen = false;
quickMenuOpen = false;
userMenuOpen = false;
navOpen = false;
}
};
window.addEventListener('keydown', handleKeydown);
return () => window.removeEventListener('keydown', handleKeydown);
window.addEventListener('resize', syncViewport);
return () => {
window.removeEventListener('keydown', handleKeydown);
window.removeEventListener('resize', syncViewport);
};
});
</script>
@@ -154,65 +227,79 @@
</svelte:head>
<div class="app-shell">
<aside class="sidebar">
<div class="brand-row">
<a class="brand" href="/">
<span class="brand-mark">HP</span>
<span>Hunter Premium Produce</span>
</a>
{#if showBottomNav && navOpen}
<button aria-label="Close navigation" class="nav-backdrop" type="button" onclick={() => (navOpen = false)}></button>
{/if}
<button class="nav-toggle" type="button" aria-label="Navigation options">
<span></span>
</button>
</div>
<button class="search-box" type="button" aria-label="Search the workspace" onclick={() => openPalette()}>
<span class="search-icon"></span>
<span class="search-placeholder">Search the workspace...</span>
<kbd>/</kbd>
</button>
<nav class="nav-list" aria-label="Client navigation">
{#each navigation as item}
<a class:active={matchesRoute(item.href, page.url.pathname)} href={item.href}>
<span class="nav-icon">{item.shortLabel}</span>
<span>{item.label}</span>
{#if !showBottomNav}
<aside class="sidebar">
<div class="brand-row">
<a class="brand" href="/">
<span class="brand-mark">HP</span>
<span>Hunter Premium Produce</span>
</a>
{/each}
</nav>
</div>
<div class="sidebar-footer">
{#each footerLinks as item}
<a href={item.href}>
<span class="nav-icon muted">{item.shortLabel}</span>
<span>{item.label}</span>
</a>
{/each}
</div>
</aside>
<div class="sidebar-body">
<button class="search-box" type="button" aria-label="Search the workspace" onclick={() => openPalette()}>
<span class="search-icon"></span>
<span class="search-placeholder">Search the workspace...</span>
<kbd>/</kbd>
</button>
<div class="main-shell">
<nav class="nav-list" aria-label="Client navigation">
{#each navigation as item}
<a class:active={matchesRoute(item.href, page.url.pathname)} href={item.href}>
<span class="nav-icon">{item.shortLabel}</span>
<span>{item.label}</span>
</a>
{/each}
</nav>
<div class="sidebar-footer">
{#each footerLinks as item}
<a href={item.href}>
<span class="nav-icon muted">{item.shortLabel}</span>
<span>{item.label}</span>
</a>
{/each}
</div>
<div class="sidebar-meta">
<span>{appVersion}</span>
<small>&copy; {currentYear} Hunter Premium Produce</small>
</div>
</div>
</aside>
{/if}
<div class:bottom-nav-layout={showBottomNav} class="main-shell">
<header class="topbar">
<div class="topbar-copy">
<h1>{pageTitle(page.url.pathname)}</h1>
<p>{pageDescription(page.url.pathname)}</p>
<div class="topbar-start">
<div class="topbar-copy">
<h1>{pageTitle(page.url.pathname)}</h1>
<p>{pageDescription(page.url.pathname)}</p>
</div>
</div>
<div class="topbar-middle">
<button class="search-box topbar-search" type="button" aria-label="Search the workspace" onclick={() => openPalette()}>
<span class="search-icon"></span>
<span class="search-placeholder">Search pages, mixes, products, and settings...</span>
<kbd>/</kbd>
</button>
</div>
<div class="topbar-actions">
{#if $clientSession}
<button class="workspace-chip session-chip" type="button" onclick={() => clientSession.clear()}>
<span class="workspace-label">Signed in</span>
<strong>{$clientSession.email}</strong>
</button>
{:else}
<div class="workspace-chip">
<span class="workspace-label">Client</span>
<strong>Sign in required</strong>
</div>
{/if}
<div class="menu-wrap">
<button class="action-button" type="button" onclick={() => (quickMenuOpen = !quickMenuOpen)}>
<button
class="action-button"
type="button"
onclick={() => {
quickMenuOpen = !quickMenuOpen;
userMenuOpen = false;
}}
>
Quick Actions
<span class:open={quickMenuOpen} class="chevron"></span>
</button>
@@ -226,11 +313,65 @@
</div>
{/if}
</div>
<div class="menu-wrap user-menu-wrap">
<button
aria-expanded={userMenuOpen}
class="user-trigger"
type="button"
onclick={() => {
userMenuOpen = !userMenuOpen;
quickMenuOpen = false;
}}
>
<span class={`user-status-dot ${$clientSession ? 'live' : 'idle'}`}></span>
<span class="user-trigger-copy">
<span class="workspace-label">{$sessionHydrated ? ($clientSession ? 'Signed in' : 'Signed out') : 'Checking session'}</span>
<strong>{$sessionHydrated ? ($clientSession ? $clientSession.email : 'Sign in required') : 'Restoring workspace access'}</strong>
</span>
<span class:open={userMenuOpen} class="chevron"></span>
</button>
{#if userMenuOpen}
<div class="menu-panel user-menu-panel">
<div class="user-menu-summary">
<strong>
{$sessionHydrated
? $clientSession
? $clientSession.name || 'Client account'
: 'Client session inactive'
: 'Checking saved client session'}
</strong>
<span>
{$sessionHydrated
? $clientSession
? $clientSession.email
: 'Return to the overview page to sign in.'
: 'Waiting for the browser session check to complete.'}
</span>
</div>
<button type="button" onclick={openSettings}>Change settings</button>
{#if $clientSession}
<button type="button" onclick={() => clientSession.clear()}>Log out</button>
{:else if !$sessionHydrated}
<button type="button" disabled>Checking session...</button>
{:else}
<a href="/">Go to sign-in</a>
{/if}
</div>
{/if}
</div>
</div>
</header>
<main class="content">
{#if !isRootRoute && !$clientSession}
{#if !isRootRoute && (!$sessionHydrated || isRestoringSession)}
<section class="locked-card loading-card">
<p class="workspace-label">Checking Session</p>
<h2>Restoring your client workspace.</h2>
<p>Refreshing the current page with the saved browser session before deciding whether sign-in is required.</p>
</section>
{:else if !isRootRoute && !$clientSession}
<section class="locked-card">
<p class="workspace-label">Client Sign-In Required</p>
<h2>Sign in on the Hunter Premium Produce home page to unlock workspace data.</h2>
@@ -244,6 +385,95 @@
</div>
</div>
{#if showBottomNav}
<nav class="bottom-nav" aria-label="Tablet navigation">
{#each primaryBottomNavigation as item}
<a class:active={matchesRoute(item.href, page.url.pathname)} href={item.href}>
<span class="bottom-nav-icon">{item.shortLabel}</span>
<span>{item.label}</span>
</a>
{/each}
<button aria-expanded={navOpen} class:active={navOpen} type="button" onclick={() => (navOpen = !navOpen)}>
<span class="bottom-nav-icon muted">+</span>
<span>More</span>
</button>
</nav>
{#if navOpen}
<section
aria-label="Tablet navigation drawer"
class="bottom-drawer"
role="dialog"
aria-modal="true"
onclick={(event) => event.stopPropagation()}
>
<div class="drawer-handle"></div>
<div class="drawer-header">
<div>
<p class="workspace-label">Workspace Drawer</p>
<strong>Hunter Premium Produce</strong>
</div>
<button aria-label="Close drawer" class="nav-toggle" type="button" onclick={() => (navOpen = false)}>
<span></span>
</button>
</div>
<button class="search-box drawer-search" type="button" aria-label="Search the workspace" onclick={() => openPalette()}>
<span class="search-icon"></span>
<span class="search-placeholder">Search the workspace...</span>
<kbd>/</kbd>
</button>
<div class="drawer-grid">
<nav class="drawer-section" aria-label="All workspace pages">
{#each navigation as item}
<a class:active={matchesRoute(item.href, page.url.pathname)} href={item.href} onclick={() => (navOpen = false)}>
<span class="nav-icon">{item.shortLabel}</span>
<span>{item.label}</span>
</a>
{/each}
</nav>
<div class="drawer-section drawer-actions">
<a href="/mixes/new" onclick={() => (navOpen = false)}>
<span class="nav-icon">NW</span>
<span>Create mix worksheet</span>
</a>
<button type="button" onclick={openSettings}>
<span class="nav-icon muted">ST</span>
<span>Change settings</span>
</button>
<a href="/products" onclick={() => (navOpen = false)}>
<span class="nav-icon muted">DP</span>
<span>Review delivered pricing</span>
</a>
<button type="button" onclick={() => openPalette('')}>
<span class="nav-icon muted">SR</span>
<span>Search the workspace</span>
</button>
{#if $clientSession}
<button type="button" onclick={() => clientSession.clear()}>
<span class="nav-icon muted">SO</span>
<span>Sign out</span>
</button>
{/if}
</div>
</div>
<div class="drawer-footer">
{#each footerLinks as item}
<a href={item.href} onclick={() => (navOpen = false)}>
<span>{item.label}</span>
<small>{item.shortLabel}</small>
</a>
{/each}
</div>
</section>
{/if}
{/if}
{#if paletteOpen}
<div class="palette-overlay" role="presentation" onclick={() => (paletteOpen = false)}>
<div
@@ -340,6 +570,10 @@
min-height: 100vh;
}
.nav-backdrop {
display: none;
}
.sidebar {
display: flex;
flex-direction: column;
@@ -349,6 +583,14 @@
border-right: 1px solid var(--line);
}
.sidebar-body {
min-height: 0;
display: flex;
flex: 1;
flex-direction: column;
gap: 1.1rem;
}
.brand-row {
display: flex;
align-items: center;
@@ -401,6 +643,10 @@
color: var(--muted);
}
.topbar-nav-button {
flex-shrink: 0;
}
.nav-toggle span,
.search-icon,
.chevron {
@@ -521,6 +767,18 @@
padding-top: 0.6rem;
}
.sidebar-meta {
display: grid;
gap: 0.2rem;
padding: 0.85rem 0.3rem 0;
color: var(--muted);
font-size: 0.78rem;
}
.sidebar-meta small {
font-size: 0.74rem;
}
.main-shell {
min-width: 0;
display: flex;
@@ -528,15 +786,22 @@
}
.topbar {
display: flex;
display: grid;
grid-template-columns: minmax(0, 0.95fr) minmax(20rem, 1.1fr) auto;
align-items: center;
justify-content: space-between;
gap: 0.9rem;
padding: 0.86rem 1.34rem;
background: var(--panel);
border-bottom: 1px solid var(--line);
}
.topbar-start {
min-width: 0;
display: flex;
align-items: flex-start;
gap: 0.82rem;
}
.topbar-copy h1,
.topbar-copy p {
margin: 0;
@@ -553,6 +818,15 @@
font-size: 0.92rem;
}
.topbar-middle {
min-width: 0;
}
.topbar-search {
min-height: 3rem;
background: #fff;
}
.topbar-actions {
display: flex;
align-items: center;
@@ -600,6 +874,73 @@
color: #304038;
}
.user-trigger {
min-width: 14rem;
display: inline-flex;
align-items: center;
gap: 0.72rem;
padding: 0.64rem 0.84rem;
border: 1px solid var(--line);
border-radius: 0.96rem;
background: var(--panel-soft);
color: #304038;
text-align: left;
cursor: pointer;
}
.user-status-dot {
width: 0.72rem;
height: 0.72rem;
flex-shrink: 0;
border-radius: 999px;
background: #b4c0ba;
box-shadow: 0 0 0 0.24rem rgba(180, 192, 186, 0.2);
}
.user-status-dot.live {
background: var(--green);
box-shadow: 0 0 0 0.24rem rgba(34, 169, 94, 0.14);
}
.user-status-dot.idle {
background: #c08b3d;
box-shadow: 0 0 0 0.24rem rgba(192, 139, 61, 0.14);
}
.user-trigger-copy {
min-width: 0;
display: grid;
flex: 1;
}
.user-trigger-copy strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.95rem;
}
.user-menu-wrap {
min-width: 0;
}
.user-menu-panel {
min-width: 16rem;
}
.user-menu-summary {
display: grid;
gap: 0.2rem;
padding: 0.72rem 0.78rem;
border-radius: 0.82rem;
background: var(--panel-soft);
}
.user-menu-summary span {
color: var(--muted);
font-size: 0.82rem;
}
.chevron {
width: 0.54rem;
height: 0.54rem;
@@ -658,6 +999,10 @@
box-shadow: var(--shadow);
}
.loading-card {
min-height: 10rem;
}
.locked-card h2,
.locked-card p {
margin: 0;
@@ -775,29 +1120,294 @@
justify-content: flex-start;
}
@media (max-width: 1080px) {
.bottom-nav,
.bottom-drawer {
display: none;
}
.main-shell.bottom-nav-layout .content {
padding-bottom: 7.25rem;
}
@media (max-width: 1180px) {
.app-shell {
grid-template-columns: 1fr;
}
.sidebar {
border-right: none;
border-bottom: 1px solid var(--line);
.nav-backdrop {
position: fixed;
inset: 0;
z-index: 48;
display: block;
border: none;
background: rgba(11, 18, 14, 0.28);
backdrop-filter: blur(4px);
}
.sidebar-footer {
margin-top: 0;
.bottom-nav {
position: fixed;
left: max(0.8rem, env(safe-area-inset-left));
right: max(0.8rem, env(safe-area-inset-right));
bottom: max(0.8rem, env(safe-area-inset-bottom));
z-index: 45;
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 0.5rem;
padding: 0.6rem;
border: 1px solid rgba(217, 228, 221, 0.92);
border-radius: 1.35rem;
background: rgba(255, 255, 255, 0.96);
box-shadow: 0 20px 40px rgba(15, 23, 17, 0.16);
backdrop-filter: blur(16px);
}
.bottom-nav a,
.bottom-nav button {
min-width: 0;
display: grid;
justify-items: center;
gap: 0.34rem;
padding: 0.62rem 0.38rem;
border: none;
border-radius: 1rem;
background: transparent;
color: #51635a;
text-align: center;
font-size: 0.74rem;
font-weight: 700;
cursor: pointer;
}
.bottom-nav a.active,
.bottom-nav button.active {
color: var(--green-deep);
background: linear-gradient(180deg, #f4fbf7 0%, #e8f6ee 100%);
}
.bottom-nav-icon {
width: 2.1rem;
height: 2.1rem;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 0.78rem;
color: #fff;
background: linear-gradient(135deg, var(--green) 0%, var(--green-deep) 100%);
font-size: 0.66rem;
letter-spacing: 0.04em;
}
.bottom-nav-icon.muted {
background: linear-gradient(135deg, #96a49c 0%, #718077 100%);
}
.bottom-drawer {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 50;
display: grid;
gap: 1rem;
padding: 0.85rem 1rem calc(6.6rem + env(safe-area-inset-bottom));
border-top: 1px solid var(--line);
border-radius: 1.6rem 1.6rem 0 0;
background:
linear-gradient(180deg, rgba(248, 251, 249, 0.98) 0%, rgba(255, 255, 255, 0.98) 100%);
box-shadow: 0 -20px 45px rgba(15, 23, 17, 0.16);
backdrop-filter: blur(16px);
}
.drawer-handle {
width: 3.5rem;
height: 0.34rem;
margin: 0 auto;
border-radius: 999px;
background: #c8d4ce;
}
.drawer-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.8rem;
}
.drawer-header strong {
font-size: 1rem;
}
.drawer-search {
background: #fff;
}
.drawer-grid {
display: grid;
gap: 0.9rem;
grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.9fr);
}
.drawer-section {
display: grid;
gap: 0.4rem;
}
.drawer-section a,
.drawer-section button {
display: flex;
align-items: center;
gap: 0.72rem;
padding: 0.82rem 0.86rem;
border: 1px solid var(--line);
border-radius: 0.96rem;
background: rgba(255, 255, 255, 0.88);
color: #304038;
text-align: left;
cursor: pointer;
}
.drawer-section a.active {
color: var(--green-deep);
background: var(--green-soft);
}
.drawer-footer {
display: grid;
gap: 0.5rem;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.drawer-footer a {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.82rem 0.9rem;
border: 1px solid var(--line);
border-radius: 0.96rem;
background: rgba(255, 255, 255, 0.88);
color: #304038;
font-weight: 600;
}
.drawer-footer small {
color: var(--muted);
font-size: 0.72rem;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.topbar {
position: sticky;
top: 0;
z-index: 30;
grid-template-columns: minmax(0, 1fr);
padding: 0.95rem 1rem;
background: rgba(255, 255, 255, 0.96);
backdrop-filter: blur(12px);
}
.topbar-middle {
order: 3;
}
.topbar-actions {
justify-content: flex-start;
}
.content {
padding: 1rem;
}
}
@media (min-width: 1181px) {
.bottom-nav-layout .content {
padding-bottom: 1.34rem;
}
}
@media (min-width: 1181px) {
.nav-toggle {
display: none;
}
}
@media (max-width: 900px) {
.drawer-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 720px) {
.topbar,
.topbar-actions,
.action-button {
.topbar-actions {
display: flex;
flex-direction: column;
align-items: flex-start;
}
.topbar-start {
width: 100%;
}
.topbar-copy {
min-width: 0;
}
.topbar-copy h1 {
font-size: 1.34rem;
}
.topbar-middle {
width: 100%;
}
.bottom-nav {
left: max(0.55rem, env(safe-area-inset-left));
right: max(0.55rem, env(safe-area-inset-right));
bottom: max(0.55rem, env(safe-area-inset-bottom));
gap: 0.32rem;
padding: 0.45rem;
}
.bottom-nav a,
.bottom-nav button {
padding: 0.55rem 0.2rem;
font-size: 0.68rem;
}
.bottom-nav-icon {
width: 1.9rem;
height: 1.9rem;
font-size: 0.6rem;
}
.bottom-drawer {
padding: 0.75rem 0.8rem calc(6.3rem + env(safe-area-inset-bottom));
}
.topbar-actions,
.workspace-chip,
.menu-wrap,
.action-button,
.user-trigger {
width: 100%;
}
.action-button {
justify-content: space-between;
}
.menu-panel {
left: 0;
right: 0;
min-width: 0;
}
.drawer-footer {
grid-template-columns: 1fr;
}
.content {
padding: 0.92rem;
}