v1.3 - client and admin scaffolding

This commit is contained in:
2026-04-25 22:51:36 +12:00
parent bc211ffcc8
commit 8cf9bfb441
54 changed files with 8882 additions and 1248 deletions
@@ -0,0 +1,805 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { clientSession } from '$lib/session';
import { onMount, tick } from 'svelte';
type SearchItem = {
href: string;
label: string;
description: string;
keywords: string;
};
const navigation = [
{ href: '/', label: 'Overview', shortLabel: 'OV' },
{ href: '/raw-materials', label: 'Raw Materials', shortLabel: 'RM' },
{ href: '/mixes', label: 'Mix Master', shortLabel: 'MM' },
{ href: '/products', label: 'Products', shortLabel: 'PR' },
{ href: '/scenarios', label: 'Scenarios', shortLabel: 'SC' }
];
const footerLinks = [
{ href: '/products', label: 'Delivered Pricing', shortLabel: 'DP' },
{ href: '/scenarios', label: 'Planning View', shortLabel: 'PV' }
];
const searchItems: SearchItem[] = [
{
href: '/',
label: 'Open Hunter Overview',
description: 'Jump to the Hunter Premium Produce workspace summary.',
keywords: 'hunter premium produce overview dashboard workspace'
},
{
href: '/raw-materials',
label: 'Open Raw Materials',
description: 'Review live input costs that feed the pricing model.',
keywords: 'raw materials pricing inputs costs supplier'
},
{
href: '/mixes',
label: 'Open Mix Master',
description: 'Browse saved mixes and their costing outputs.',
keywords: 'mix master mixes recipes spreadsheet'
},
{
href: '/mixes/new',
label: 'Create New Mix',
description: 'Start a new costing worksheet for Hunter Premium Produce.',
keywords: 'new mix create worksheet hunter premium produce formula'
},
{
href: '/products',
label: 'Open Products',
description: 'Review delivered product pricing and margins.',
keywords: 'products pricing margins delivered outputs'
},
{
href: '/scenarios',
label: 'Open Scenarios',
description: 'Inspect planning scenarios and overrides.',
keywords: 'scenarios sandbox overrides compare planning'
}
];
let { children } = $props();
const isRootRoute = $derived(page.url.pathname === '/');
let paletteOpen = $state(false);
let paletteQuery = $state('');
let quickMenuOpen = $state(false);
let paletteInput: HTMLInputElement | null = $state(null);
function matchesRoute(href: string, pathname: string) {
return href === '/' ? pathname === '/' : pathname.startsWith(href);
}
function pageTitle(pathname: string) {
return navigation.find((item) => matchesRoute(item.href, pathname))?.label ?? 'Overview';
}
function pageDescription(pathname: string) {
const descriptions: Record<string, string> = {
'/': 'Hunter Premium Produce client workspace',
'/raw-materials': 'Review source input costs and downstream exposure',
'/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',
'/scenarios': 'Compare alternate pricing and production assumptions'
};
return descriptions[pathname] ?? 'Hunter Premium Produce client workspace';
}
function openPalette(query = '') {
paletteQuery = query;
paletteOpen = true;
quickMenuOpen = false;
}
async function runSearchItem(item: SearchItem) {
paletteOpen = false;
paletteQuery = '';
await goto(item.href);
}
const filteredSearchItems = $derived(
searchItems.filter((item) => {
const haystack = `${item.label} ${item.description} ${item.keywords}`.toLowerCase();
return haystack.includes(paletteQuery.trim().toLowerCase());
})
);
$effect(() => {
page.url.pathname;
quickMenuOpen = false;
paletteOpen = false;
paletteQuery = '';
});
$effect(() => {
if (paletteOpen) {
tick().then(() => paletteInput?.focus());
}
});
onMount(() => {
const handleKeydown = (event: KeyboardEvent) => {
const target = event.target as HTMLElement | null;
const isTypingField =
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
target instanceof HTMLSelectElement ||
target?.isContentEditable;
if ((event.key === 'k' && (event.metaKey || event.ctrlKey)) || (!isTypingField && event.key === '/')) {
event.preventDefault();
openPalette();
}
if (event.key === 'Escape') {
paletteOpen = false;
quickMenuOpen = false;
}
};
window.addEventListener('keydown', handleKeydown);
return () => window.removeEventListener('keydown', handleKeydown);
});
</script>
<svelte:head>
<title>{pageTitle(page.url.pathname)} | Hunter Premium Produce</title>
</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>
<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>
</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>
</aside>
<div class="main-shell">
<header class="topbar">
<div class="topbar-copy">
<h1>{pageTitle(page.url.pathname)}</h1>
<p>{pageDescription(page.url.pathname)}</p>
</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)}>
Quick Actions
<span class:open={quickMenuOpen} class="chevron"></span>
</button>
{#if quickMenuOpen}
<div class="menu-panel">
<a href="/mixes">Open mix costing</a>
<a href="/mixes/new">Create mix worksheet</a>
<a href="/products">Review delivered pricing</a>
<button type="button" onclick={() => openPalette('')}>Search the workspace</button>
</div>
{/if}
</div>
</div>
</header>
<main class="content">
{#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>
<p>The client-facing routes stay empty until a valid client session is active.</p>
<a href="/">Return to sign-in</a>
</section>
{:else}
{@render children()}
{/if}
</main>
</div>
</div>
{#if paletteOpen}
<div class="palette-overlay" role="presentation" onclick={() => (paletteOpen = false)}>
<div
class="palette"
role="dialog"
aria-modal="true"
aria-label="Workspace search"
tabindex="-1"
onclick={(event) => event.stopPropagation()}
onkeydown={(event) => {
if (event.key === 'Escape') {
paletteOpen = false;
}
}}
>
<div class="palette-input-row">
<span class="search-icon"></span>
<input bind:this={paletteInput} bind:value={paletteQuery} placeholder="Search pages, workflows, and pricing views..." />
<kbd>Esc</kbd>
</div>
<div class="palette-results">
{#if filteredSearchItems.length}
{#each filteredSearchItems as item}
<button class="palette-item" type="button" onclick={() => runSearchItem(item)}>
<div>
<strong>{item.label}</strong>
<span>{item.description}</span>
</div>
<small>{item.href}</small>
</button>
{/each}
{:else}
<div class="palette-empty">
<strong>No results</strong>
<span>Try searching for mixes, products, scenarios, or pricing.</span>
</div>
{/if}
</div>
</div>
</div>
{/if}
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
:global(:root) {
--bg: #f4f7f5;
--panel: #ffffff;
--panel-soft: #f8fbf9;
--line: #e5ece7;
--line-strong: #d9e4dd;
--text: #18231d;
--muted: #6d7d74;
--green: #22a95e;
--green-deep: #148249;
--green-soft: #eaf8ef;
--blue-soft: #eef7ff;
--shadow: 0 10px 30px rgba(15, 23, 17, 0.06);
}
:global(html, body) {
margin: 0;
min-height: 100%;
background: var(--bg);
color: var(--text);
font-family: Inter, "Segoe UI", sans-serif;
}
:global(*) {
box-sizing: border-box;
}
:global(h1, h2, h3, h4, h5, h6) {
font-family: Inter, "Segoe UI", sans-serif;
letter-spacing: -0.03em;
}
:global(button),
:global(input),
:global(select),
:global(textarea) {
font: inherit;
}
:global(a) {
color: inherit;
text-decoration: none;
}
.app-shell {
display: grid;
grid-template-columns: 228px minmax(0, 1fr);
min-height: 100vh;
}
.sidebar {
display: flex;
flex-direction: column;
gap: 1.1rem;
padding: 0.9rem;
background: var(--panel);
border-right: 1px solid var(--line);
}
.brand-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.68rem;
}
.brand {
display: inline-flex;
align-items: center;
gap: 0.68rem;
font-size: 1.08rem;
font-weight: 700;
}
.brand-mark,
.nav-icon {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: #fff;
background: linear-gradient(135deg, var(--green) 0%, var(--green-deep) 100%);
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.04em;
}
.brand-mark {
width: 1.9rem;
height: 1.9rem;
border-radius: 0.68rem;
}
.nav-toggle,
.action-button,
.menu-panel button {
border: 1px solid var(--line);
background: var(--panel);
cursor: pointer;
}
.nav-toggle {
width: 2.05rem;
height: 2.05rem;
border-radius: 0.68rem;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--muted);
}
.nav-toggle span,
.search-icon,
.chevron {
position: relative;
display: inline-block;
}
.nav-toggle span,
.nav-toggle span::before,
.nav-toggle span::after {
width: 0.88rem;
height: 2px;
background: currentColor;
border-radius: 999px;
content: '';
}
.nav-toggle span::before,
.nav-toggle span::after {
position: absolute;
left: 0;
}
.nav-toggle span::before {
top: -0.28rem;
}
.nav-toggle span::after {
top: 0.28rem;
}
.search-box {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 0.64rem;
width: 100%;
padding: 0.72rem 0.82rem;
border: 1px solid var(--line);
border-radius: 0.82rem;
background: var(--panel-soft);
text-align: left;
cursor: pointer;
}
.search-placeholder {
color: #93a098;
}
.search-icon {
width: 0.82rem;
height: 0.82rem;
border: 2px solid #98a59d;
border-radius: 999px;
}
.search-icon::after {
content: '';
position: absolute;
right: -0.28rem;
bottom: -0.18rem;
width: 0.42rem;
height: 2px;
border-radius: 999px;
background: #98a59d;
transform: rotate(45deg);
}
kbd {
padding: 0.1rem 0.42rem;
border: 1px solid var(--line-strong);
border-radius: 0.42rem;
color: var(--muted);
background: #fff;
font-size: 0.76rem;
}
.nav-list,
.sidebar-footer {
display: grid;
gap: 0.3rem;
}
.nav-list a,
.sidebar-footer a {
display: flex;
align-items: center;
gap: 0.68rem;
padding: 0.72rem 0.68rem;
border-radius: 0.82rem;
color: #304038;
transition: background-color 160ms ease;
}
.nav-list a:hover,
.sidebar-footer a:hover,
.nav-list a.active {
background: var(--green-soft);
}
.nav-list a.active {
color: var(--green-deep);
font-weight: 600;
}
.nav-icon {
width: 1.56rem;
height: 1.56rem;
border-radius: 0.56rem;
}
.nav-icon.muted {
background: linear-gradient(135deg, #95a39b 0%, #6e7c73 100%);
}
.sidebar-footer {
margin-top: auto;
padding-top: 0.6rem;
}
.main-shell {
min-width: 0;
display: flex;
flex-direction: column;
}
.topbar {
display: flex;
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-copy h1,
.topbar-copy p {
margin: 0;
}
.topbar-copy h1 {
font-size: 1.62rem;
font-weight: 700;
}
.topbar-copy p {
margin-top: 0.22rem;
color: var(--muted);
font-size: 0.92rem;
}
.topbar-actions {
display: flex;
align-items: center;
gap: 0.68rem;
flex-wrap: wrap;
justify-content: flex-end;
}
.workspace-chip {
display: grid;
gap: 0.14rem;
padding: 0.65rem 0.85rem;
border: 1px solid var(--line);
border-radius: 0.92rem;
background: var(--panel-soft);
}
.session-chip {
cursor: pointer;
text-align: left;
}
.workspace-label {
color: var(--muted);
font-size: 0.76rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.workspace-chip strong {
font-size: 0.96rem;
}
.menu-wrap {
position: relative;
}
.action-button {
display: inline-flex;
align-items: center;
gap: 0.62rem;
border-radius: 0.88rem;
padding: 0.68rem 0.84rem;
color: #304038;
}
.chevron {
width: 0.54rem;
height: 0.54rem;
border-right: 2px solid #7a8c82;
border-bottom: 2px solid #7a8c82;
transform: rotate(45deg);
transition: transform 140ms ease;
}
.chevron.open {
transform: rotate(-135deg);
}
.menu-panel {
position: absolute;
top: calc(100% + 0.45rem);
right: 0;
z-index: 20;
min-width: 13rem;
display: grid;
gap: 0.18rem;
padding: 0.4rem;
border: 1px solid var(--line);
border-radius: 0.96rem;
background: rgba(255, 255, 255, 0.98);
box-shadow: 0 18px 40px rgba(15, 23, 17, 0.1);
backdrop-filter: blur(10px);
}
.menu-panel a,
.menu-panel button {
padding: 0.72rem 0.78rem;
border-radius: 0.78rem;
color: #304038;
text-align: left;
background: transparent;
border: none;
}
.menu-panel a:hover,
.menu-panel button:hover {
background: var(--panel-soft);
}
.content {
min-width: 0;
padding: 1.34rem;
}
.locked-card {
max-width: 42rem;
padding: 1.25rem;
border: 1px solid var(--line);
border-radius: 1.25rem;
background: var(--panel);
box-shadow: var(--shadow);
}
.locked-card h2,
.locked-card p {
margin: 0;
}
.locked-card h2 {
margin-top: 0.35rem;
font-size: clamp(1.7rem, 3vw, 2.2rem);
}
.locked-card p:last-of-type {
margin-top: 0.45rem;
color: var(--muted);
}
.locked-card a {
display: inline-flex;
margin-top: 1rem;
padding: 0.78rem 0.92rem;
border-radius: 0.88rem;
background: linear-gradient(135deg, var(--green) 0%, var(--green-deep) 100%);
color: #fff;
font-weight: 600;
}
.palette-overlay {
position: fixed;
inset: 0;
z-index: 40;
display: grid;
place-items: start center;
padding: 8vh 1rem 1rem;
background: rgba(11, 18, 14, 0.3);
backdrop-filter: blur(10px);
}
.palette {
width: min(44rem, 100%);
border: 1px solid rgba(217, 228, 221, 0.9);
border-radius: 1.2rem;
background: rgba(255, 255, 255, 0.98);
box-shadow: 0 24px 60px rgba(15, 23, 17, 0.16);
overflow: hidden;
}
.palette-input-row {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 0.8rem;
padding: 0.95rem 1rem;
border-bottom: 1px solid var(--line);
}
.palette-input-row input {
border: none;
outline: none;
background: transparent;
color: var(--text);
font-size: 0.98rem;
}
.palette-results {
max-height: 26rem;
overflow: auto;
padding: 0.5rem;
}
.palette-item,
.palette-empty {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.88rem 0.92rem;
border: none;
border-radius: 0.92rem;
text-align: left;
background: transparent;
}
.palette-item {
cursor: pointer;
}
.palette-item:hover {
background: var(--panel-soft);
}
.palette-item strong,
.palette-empty strong {
display: block;
font-size: 0.96rem;
}
.palette-item span,
.palette-empty span,
.palette-item small {
color: var(--muted);
}
.palette-item span {
display: block;
margin-top: 0.18rem;
font-size: 0.84rem;
}
.palette-item small {
flex-shrink: 0;
font-size: 0.76rem;
}
.palette-empty {
justify-content: flex-start;
}
@media (max-width: 1080px) {
.app-shell {
grid-template-columns: 1fr;
}
.sidebar {
border-right: none;
border-bottom: 1px solid var(--line);
}
.sidebar-footer {
margin-top: 0;
}
}
@media (max-width: 720px) {
.topbar,
.topbar-actions,
.action-button {
flex-direction: column;
align-items: flex-start;
}
.content {
padding: 0.92rem;
}
}
</style>