feat: brain service — self-contained second brain knowledge manager

Full backend service with:
- FastAPI REST API with CRUD, search, reprocess endpoints
- PostgreSQL + pgvector for items and semantic search
- Redis + RQ for background job processing
- Meilisearch for fast keyword/filter search
- Browserless/Chrome for JS rendering and screenshots
- OpenAI structured output for AI classification
- Local file storage with S3-ready abstraction
- Gateway auth via X-Gateway-User-Id header
- Own docker-compose stack (6 containers)

Classification: fixed folders (Home/Family/Work/Travel/Knowledge/Faith/Projects)
and fixed tags (28 predefined). AI assigns exactly 1 folder, 2-3 tags, title,
summary, and confidence score per item.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yusuf Suleman
2026-04-01 11:48:29 -05:00
parent 51a8157fd4
commit 8275f3a71b
73 changed files with 24081 additions and 4209 deletions

View File

@@ -0,0 +1,982 @@
<script lang="ts">
import { invalidateAll } from '$app/navigation';
import { tick } from 'svelte';
import { Bot, Dumbbell, ImagePlus, SendHorizonal, Sparkles, X } from '@lucide/svelte';
type ChatRole = 'user' | 'assistant';
type MealType = 'breakfast' | 'lunch' | 'dinner' | 'snack';
type Message = {
role: ChatRole;
content: string;
image?: string;
imageName?: string;
};
type Draft = {
food_name?: string;
meal_type?: MealType;
entry_date?: string;
quantity?: number;
unit?: string;
calories?: number;
protein?: number;
carbs?: number;
fat?: number;
sugar?: number;
fiber?: number;
note?: string;
};
type DraftBundle = Draft[];
interface Props {
open: boolean;
onclose: () => void;
entryDate?: string | null;
}
let { open = $bindable(), onclose, entryDate = null }: Props = $props();
const intro = `Tell me what you ate, including multiple items or a nutrition label photo, then correct me naturally until it looks right.`;
let messages = $state<Message[]>([
{ role: 'assistant', content: intro }
]);
let draft = $state<Draft | null>(null);
let drafts = $state<DraftBundle>([]);
let input = $state('');
let sending = $state(false);
let error = $state('');
let composerEl: HTMLInputElement | null = $state(null);
let fileInputEl: HTMLInputElement | null = $state(null);
let threadEl: HTMLElement | null = $state(null);
let threadEndEl: HTMLDivElement | null = $state(null);
let photoPreview = $state<string | null>(null);
let photoName = $state('');
let attachedPhoto = $state<string | null>(null);
let attachedPhotoName = $state('');
async function scrollThreadToBottom(behavior: ScrollBehavior = 'smooth') {
await tick();
requestAnimationFrame(() => {
threadEndEl?.scrollIntoView({ block: 'end', behavior });
});
}
function resetThread() {
messages = [{ role: 'assistant', content: intro }];
draft = null;
drafts = [];
input = '';
error = '';
photoPreview = null;
photoName = '';
attachedPhoto = null;
attachedPhotoName = '';
if (fileInputEl) fileInputEl.value = '';
}
function beginRevision() {
if (!input.trim()) {
input = 'Actually ';
}
requestAnimationFrame(() => composerEl?.focus());
}
function retryCurrentGuess() {
void sendMessage("That's not right. Try again.", 'chat');
}
async function sendMessage(content: string, action: 'chat' | 'apply' = 'chat') {
const clean = content.trim();
if (!clean && !photoPreview && action === 'chat') return;
error = '';
sending = true;
const outgoingPhoto = action === 'chat' ? photoPreview : null;
const outgoingPhotoName = action === 'chat' ? photoName : '';
const nextMessages =
action === 'chat'
? [
...messages,
{
role: 'user' as const,
content: clean,
image: outgoingPhoto || undefined,
imageName: outgoingPhotoName || undefined
}
]
: messages;
if (action === 'chat') {
messages = nextMessages;
input = '';
if (outgoingPhoto) {
attachedPhoto = outgoingPhoto;
attachedPhotoName = outgoingPhotoName;
photoPreview = null;
photoName = '';
if (fileInputEl) fileInputEl.value = '';
}
void scrollThreadToBottom('auto');
}
try {
const response = await fetch('/assistant/fitness', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
action,
messages: nextMessages,
draft,
drafts,
entryDate,
imageDataUrl: action === 'chat' ? outgoingPhoto || attachedPhoto : null
})
});
const data = await response.json();
if (!response.ok) {
throw new Error(data?.error || 'Assistant request failed');
}
if (data.draft) {
draft = data.draft;
}
if (Array.isArray(data.drafts)) {
drafts = data.drafts;
if (data.drafts.length > 0) {
draft = null;
}
} else if (data.draft) {
drafts = [];
}
if (data.reply) {
messages = [...nextMessages, { role: 'assistant', content: data.reply }];
}
if (data.applied) {
photoPreview = null;
photoName = '';
attachedPhoto = null;
attachedPhotoName = '';
if (fileInputEl) fileInputEl.value = '';
draft = null;
drafts = [];
window.dispatchEvent(
new CustomEvent('fitnessassistantapplied', {
detail: { entryDate: data.draft?.entry_date || data.drafts?.[0]?.entry_date || entryDate || null }
})
);
await invalidateAll();
}
await scrollThreadToBottom();
} catch (err) {
error = err instanceof Error ? err.message : 'Assistant request failed';
} finally {
sending = false;
requestAnimationFrame(() => composerEl?.focus());
}
}
function handleSubmit() {
void sendMessage(input, 'chat');
}
function openPhotoPicker() {
fileInputEl?.click();
}
function loadPhoto(file: File | null) {
if (!file) return;
photoName = file.name;
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result === 'string') {
photoPreview = reader.result;
}
};
reader.readAsDataURL(file);
}
function clearPhoto() {
photoPreview = null;
photoName = '';
if (fileInputEl) fileInputEl.value = '';
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
onclose();
return;
}
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
handleSubmit();
}
}
$effect(() => {
if (open) {
requestAnimationFrame(() => composerEl?.focus());
}
});
$effect(() => {
if (!open) return;
document.body.classList.add('assistant-open');
return () => {
document.body.classList.remove('assistant-open');
};
});
</script>
{#if open}
<button class="assistant-backdrop" aria-label="Close assistant" onclick={onclose}></button>
<div class="assistant-drawer" role="dialog" aria-label="Fitness assistant">
<header class="assistant-head">
<div class="assistant-title-wrap">
<div class="assistant-mark">
<Dumbbell size={16} strokeWidth={1.9} />
</div>
<div>
<div class="assistant-kicker">Assistant</div>
<div class="assistant-title">Fitness chat</div>
</div>
</div>
<div class="assistant-head-actions">
<button class="assistant-ghost" onclick={resetThread}>Reset</button>
<button class="assistant-close" onclick={onclose} aria-label="Close assistant">
<X size={17} strokeWidth={1.9} />
</button>
</div>
</header>
<div class="assistant-body">
<section class="thread" bind:this={threadEl}>
<div class="thread-spacer" aria-hidden="true"></div>
{#each messages as message}
<div class="bubble-row" class:user={message.role === 'user'}>
{#if message.role === 'assistant'}
<div class="bubble-icon">
<Bot size={14} strokeWidth={1.8} />
</div>
{/if}
<div class="bubble-stack" class:user={message.role === 'user'}>
{#if message.image}
<div class="image-bubble" class:user={message.role === 'user'}>
<img src={message.image} alt={message.imageName || 'Sent food photo'} />
{#if message.imageName}
<div class="image-bubble-name">{message.imageName}</div>
{/if}
</div>
{/if}
{#if message.content}
<div class="bubble" class:user={message.role === 'user'}>
{message.content}
</div>
{/if}
</div>
</div>
{/each}
{#if drafts.length > 1}
<div class="bubble-row draft-row">
<div class="bubble-icon">
<Sparkles size={14} strokeWidth={1.8} />
</div>
<div class="draft-card bundle-card">
<div class="draft-card-top">
<div>
<div class="draft-card-kicker">Ready to add</div>
<div class="draft-card-title">{drafts.length} items</div>
</div>
<div class="draft-meal">{drafts[0]?.meal_type || 'meal'}</div>
</div>
<div class="bundle-list">
{#each drafts as item}
<div class="bundle-row">
<div class="bundle-name">{item.food_name}</div>
<div class="bundle-calories">{Math.round(item.calories || 0)} cal</div>
</div>
{/each}
</div>
<div class="draft-inline-metrics">
<span>{drafts.reduce((sum, item) => sum + Math.round(item.calories || 0), 0)} cal total</span>
</div>
<div class="draft-card-actions">
<button class="draft-apply subtle" onclick={beginRevision} disabled={sending}>
Edit
</button>
<button class="draft-apply" onclick={() => void sendMessage('', 'apply')} disabled={sending}>
{sending ? 'Adding...' : 'Add all'}
</button>
</div>
</div>
</div>
{:else if draft?.food_name}
<div class="bubble-row draft-row">
<div class="bubble-icon">
<Sparkles size={14} strokeWidth={1.8} />
</div>
<div class="draft-card">
<div class="draft-card-top">
<div>
<div class="draft-card-kicker">Ready to add</div>
<div class="draft-card-title">{draft.food_name}</div>
</div>
{#if draft.meal_type}
<div class="draft-meal">{draft.meal_type}</div>
{/if}
</div>
<div class="draft-inline-metrics">
<span>{Math.round(draft.calories || 0)} cal</span>
<span>{Math.round(draft.protein || 0)}p</span>
<span>{Math.round(draft.carbs || 0)}c</span>
<span>{Math.round(draft.fat || 0)}f</span>
<span>{Math.round(draft.sugar || 0)} sugar</span>
<span>{Math.round(draft.fiber || 0)} fiber</span>
</div>
<div class="draft-card-actions">
<button class="draft-apply subtle" onclick={beginRevision} disabled={sending}>
Edit
</button>
<button class="draft-apply subtle" onclick={retryCurrentGuess} disabled={sending}>
Try again
</button>
<button class="draft-apply" onclick={() => void sendMessage('', 'apply')} disabled={sending}>
{sending ? 'Adding...' : 'Add it'}
</button>
</div>
</div>
</div>
{/if}
<div class="thread-end" bind:this={threadEndEl} aria-hidden="true"></div>
</section>
</div>
{#if error}
<div class="assistant-error">{error}</div>
{/if}
<footer class="assistant-compose">
<div class="compose-shell">
<input
class="sr-only-file"
type="file"
accept="image/*"
bind:this={fileInputEl}
onchange={(event) => loadPhoto((event.currentTarget as HTMLInputElement).files?.[0] || null)}
/>
<div class="compose-tools">
<button class="compose-tool" type="button" onclick={openPhotoPicker}>
<ImagePlus size={14} strokeWidth={1.9} />
<span>Photo</span>
</button>
</div>
{#if photoPreview}
<div class="photo-staged">
<img src={photoPreview} alt="Selected food" />
<div class="photo-staged-copy">
<div class="photo-staged-title">{photoName || 'Food photo'}</div>
<div class="photo-staged-note">Ill draft the entry from this photo.</div>
</div>
<button class="photo-staged-clear" type="button" onclick={clearPhoto}>Remove</button>
</div>
{:else if attachedPhoto}
<div class="photo-staged attached">
<img src={attachedPhoto} alt="Attached food context" />
<div class="photo-staged-copy">
<div class="photo-staged-title">{attachedPhotoName || 'Attached photo'}</div>
<div class="photo-staged-note">Still attached for follow-up corrections.</div>
</div>
<button class="photo-staged-clear" type="button" onclick={() => { attachedPhoto = null; attachedPhotoName = ''; }}>
Clear
</button>
</div>
{/if}
<input
class="compose-input"
bind:value={input}
bind:this={composerEl}
placeholder="Add 2 boiled eggs for breakfast..."
onkeydown={handleKeydown}
type="text"
/>
<button
class="compose-send"
onclick={handleSubmit}
disabled={sending || (!input.trim() && !photoPreview)}
aria-label="Send message"
>
<SendHorizonal size={16} strokeWidth={2} />
</button>
</div>
</footer>
</div>
{/if}
<style>
.assistant-backdrop {
position: fixed;
inset: 0;
border: none;
background: linear-gradient(180deg, rgba(239, 231, 220, 0.52), rgba(225, 215, 202, 0.34));
backdrop-filter: blur(6px) saturate(1.02);
z-index: 88;
overscroll-behavior: none;
touch-action: none;
}
:global(body.assistant-open) {
overflow: hidden;
overscroll-behavior: none;
}
.assistant-drawer {
position: fixed;
top: 18px;
right: 18px;
bottom: 18px;
width: min(430px, calc(100vw - 24px));
border-radius: 30px;
border: 1px solid rgba(36, 26, 18, 0.1);
background:
radial-gradient(circle at top right, rgba(54, 108, 86, 0.08), transparent 24%),
linear-gradient(180deg, rgba(252, 248, 242, 0.96), rgba(244, 236, 227, 0.94));
box-shadow: 0 28px 80px rgba(24, 16, 11, 0.2);
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto auto;
overflow: hidden;
z-index: 89;
}
.assistant-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 18px 18px 14px;
border-bottom: 1px solid rgba(36, 26, 18, 0.08);
}
.assistant-title-wrap {
display: flex;
align-items: center;
gap: 12px;
}
.assistant-mark {
width: 38px;
height: 38px;
border-radius: 14px;
display: grid;
place-items: center;
background: linear-gradient(135deg, #215043, #3f7862);
color: #fff9f2;
}
.assistant-kicker {
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
color: #857362;
}
.assistant-title {
font-size: 1.05rem;
font-weight: 700;
letter-spacing: -0.03em;
color: #1f1710;
}
.assistant-head-actions {
display: flex;
align-items: center;
gap: 8px;
}
.assistant-ghost,
.assistant-close {
border: 1px solid rgba(36, 26, 18, 0.08);
background: rgba(255, 255, 255, 0.62);
color: #65584c;
}
.assistant-ghost {
height: 34px;
padding: 0 12px;
border-radius: 999px;
font-size: 0.84rem;
font-weight: 600;
font-family: inherit;
cursor: pointer;
}
.assistant-close {
width: 34px;
height: 34px;
border-radius: 999px;
display: grid;
place-items: center;
cursor: pointer;
}
.assistant-body {
min-height: 0;
overflow: hidden;
}
.thread {
min-height: 0;
overflow: auto;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
touch-action: pan-y;
padding: 14px 18px 10px;
display: flex;
flex-direction: column;
gap: 10px;
}
.bundle-card {
gap: 12px;
}
.bundle-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.bundle-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
border-radius: 14px;
background: rgba(255, 255, 255, 0.56);
border: 1px solid rgba(36, 26, 18, 0.08);
}
.bundle-name {
font-size: 0.92rem;
font-weight: 600;
color: #201811;
}
.bundle-calories {
font-size: 0.82rem;
font-weight: 700;
color: #6d5b4f;
white-space: nowrap;
}
.thread-spacer {
display: none;
}
.thread-end {
width: 100%;
height: 1px;
}
.bubble-row {
display: flex;
align-items: flex-end;
gap: 8px;
}
.bubble-row.user {
justify-content: flex-end;
}
.bubble-stack {
display: grid;
gap: 8px;
max-width: 84%;
}
.bubble-stack.user {
justify-items: end;
}
.bubble-icon {
width: 28px;
height: 28px;
border-radius: 999px;
display: grid;
place-items: center;
background: rgba(47, 106, 82, 0.08);
color: #2f6a52;
flex-shrink: 0;
}
.bubble {
padding: 11px 13px;
border-radius: 18px 18px 18px 8px;
background: rgba(255, 255, 255, 0.78);
border: 1px solid rgba(36, 26, 18, 0.08);
color: #241c14;
font-size: 0.95rem;
line-height: 1.45;
}
.bubble.user {
border-radius: 18px 18px 8px 18px;
background: linear-gradient(180deg, #2d6450, #245242);
border-color: transparent;
color: #f8f4ee;
}
.image-bubble {
width: min(240px, 100%);
padding: 8px;
border-radius: 20px 20px 20px 10px;
background: rgba(255, 255, 255, 0.82);
border: 1px solid rgba(36, 26, 18, 0.08);
display: grid;
gap: 8px;
}
.image-bubble.user {
border-radius: 20px 20px 10px 20px;
background: linear-gradient(180deg, rgba(53, 102, 83, 0.16), rgba(39, 83, 66, 0.12)), rgba(255, 251, 246, 0.96);
}
.image-bubble img {
width: 100%;
aspect-ratio: 1 / 1;
object-fit: cover;
border-radius: 14px;
display: block;
}
.image-bubble-name {
padding: 0 4px 2px;
font-size: 0.74rem;
font-weight: 700;
color: #6b5c4f;
line-height: 1.25;
word-break: break-word;
}
.draft-meal {
padding: 6px 10px;
border-radius: 999px;
background: rgba(47, 106, 82, 0.1);
color: #2f6a52;
font-size: 0.76rem;
font-weight: 700;
text-transform: capitalize;
}
.draft-apply {
height: 40px;
padding: 0 14px;
border: none;
border-radius: 14px;
background: linear-gradient(180deg, #366d57, #245141);
color: #f8f4ee;
font-size: 0.88rem;
font-weight: 700;
font-family: inherit;
cursor: pointer;
}
.draft-apply.subtle {
background: rgba(255, 255, 255, 0.82);
color: #2c2016;
border: 1px solid rgba(36, 26, 18, 0.08);
}
.draft-apply:disabled {
opacity: 0.45;
cursor: default;
}
.draft-card {
max-width: 100%;
padding: 14px;
border-radius: 20px 20px 20px 10px;
border: 1px solid rgba(36, 26, 18, 0.08);
background:
radial-gradient(circle at top right, rgba(199, 137, 50, 0.08), transparent 30%),
rgba(255, 251, 246, 0.88);
display: grid;
gap: 12px;
}
.draft-card-top {
display: flex;
align-items: start;
justify-content: space-between;
gap: 12px;
}
.draft-card-kicker {
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
color: #8c7a68;
}
.draft-card-title {
margin-top: 4px;
font-size: 1rem;
font-weight: 700;
letter-spacing: -0.03em;
color: #201811;
}
.draft-inline-metrics {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.draft-inline-metrics span {
padding: 6px 9px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.8);
border: 1px solid rgba(36, 26, 18, 0.06);
font-size: 0.75rem;
font-weight: 600;
color: #5e5246;
}
.draft-card-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
}
.draft-row {
align-items: stretch;
}
.assistant-error {
padding: 0 18px 10px;
color: #9b4337;
font-size: 0.86rem;
}
.assistant-compose {
padding: 14px 18px 18px;
border-top: 1px solid rgba(36, 26, 18, 0.08);
}
.compose-shell {
padding: 10px;
border-radius: 24px;
border: 1px solid rgba(36, 26, 18, 0.08);
background: rgba(255, 255, 255, 0.7);
display: grid;
gap: 10px;
}
.sr-only-file {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
}
.compose-tools {
display: flex;
gap: 8px;
}
.compose-tool {
height: 30px;
padding: 0 10px;
border-radius: 999px;
border: 1px solid rgba(36, 26, 18, 0.08);
background: rgba(249, 245, 239, 0.78);
color: #6f6254;
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 0.76rem;
font-weight: 600;
font-family: inherit;
}
.compose-input {
width: 100%;
border: none;
background: transparent;
outline: none;
color: #201810;
font-size: 1rem;
line-height: 1.5;
font-family: inherit;
height: 42px;
padding: 0 2px;
}
.compose-input::placeholder {
color: #8f8071;
}
.compose-send {
justify-self: end;
width: 42px;
height: 42px;
border-radius: 14px;
border: none;
background: #2e6851;
color: #fff7f0;
display: grid;
place-items: center;
cursor: pointer;
}
.compose-send:disabled {
opacity: 0.45;
cursor: default;
}
.photo-staged {
display: grid;
grid-template-columns: 54px minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
padding: 10px;
border-radius: 18px;
background: rgba(255, 251, 246, 0.86);
border: 1px solid rgba(36, 26, 18, 0.08);
}
.photo-staged.attached {
background: rgba(246, 240, 231, 0.8);
}
.photo-staged img {
width: 54px;
height: 54px;
object-fit: cover;
border-radius: 12px;
display: block;
}
.photo-staged-title {
font-size: 0.84rem;
font-weight: 700;
color: #221912;
}
.photo-staged-note {
font-size: 0.74rem;
color: #7a6858;
}
.photo-staged-clear {
border: none;
background: none;
color: #7d4d3e;
font-size: 0.76rem;
font-weight: 700;
font-family: inherit;
cursor: pointer;
}
@media (max-width: 768px) {
.assistant-drawer {
top: auto;
right: 0;
left: 0;
bottom: 0;
width: auto;
height: min(86dvh, 760px);
max-height: min(86dvh, 760px);
border-radius: 28px 28px 0 0;
border-left: none;
border-right: none;
border-bottom: none;
box-shadow: 0 -18px 42px rgba(24, 16, 11, 0.18);
}
.assistant-backdrop {
backdrop-filter: blur(6px);
}
.assistant-head {
padding-top: 14px;
}
.assistant-head::before {
content: '';
position: absolute;
top: 8px;
left: 50%;
transform: translateX(-50%);
width: 42px;
height: 4px;
border-radius: 999px;
background: rgba(44, 31, 19, 0.16);
}
.bubble {
max-width: 90%;
}
.bubble-stack {
max-width: 90%;
}
.image-bubble {
width: min(220px, 100%);
}
.thread {
padding-top: 8px;
padding-bottom: 16px;
align-content: start;
}
.thread-spacer {
display: block;
height: clamp(120px, 22vh, 220px);
}
.assistant-compose {
padding-bottom: calc(14px + env(safe-area-inset-bottom));
background: linear-gradient(180deg, rgba(247, 240, 231, 0.88), rgba(243, 235, 225, 0.96));
}
.compose-shell {
border-radius: 22px;
gap: 8px;
}
.compose-input {
font-size: 16px;
height: 40px;
}
.draft-card-actions {
flex-direction: column;
}
.draft-apply,
.draft-apply.subtle {
width: 100%;
}
}
</style>

View File

@@ -0,0 +1,406 @@
<script lang="ts">
import { page } from '$app/state';
import {
BookOpen,
CalendarDays,
CircleDot,
Compass,
Dumbbell,
Landmark,
LibraryBig,
Menu,
Package2,
Search,
Settings2,
SquareCheckBig,
X
} from '@lucide/svelte';
interface Props {
children: any;
userName?: string;
visibleApps?: string[];
onOpenCommand?: () => void;
}
let {
children,
userName = '',
visibleApps = ['tasks', 'trips', 'fitness', 'inventory', 'budget', 'reader', 'media'],
onOpenCommand
}: Props = $props();
const baseItems = [
{ id: 'dashboard', href: '/', label: 'Overview', icon: CircleDot },
{ id: 'tasks', href: '/tasks', label: 'Tasks', icon: SquareCheckBig },
{ id: 'trips', href: '/trips', label: 'Trips', icon: Compass },
{ id: 'fitness', href: '/fitness', label: 'Fitness', icon: Dumbbell },
{ id: 'budget', href: '/budget', label: 'Budget', icon: Landmark },
{ id: 'inventory', href: '/inventory', label: 'Inventory', icon: Package2 },
{ id: 'reader', href: '/reader', label: 'Reader', icon: BookOpen },
{ id: 'media', href: '/media', label: 'Media', icon: LibraryBig },
{ id: 'settings', href: '/settings', label: 'Settings', icon: Settings2 }
];
const navItems = $derived(
baseItems.filter((item) => item.id === 'dashboard' || item.id === 'settings' || visibleApps.includes(item.id))
);
let mobileNavOpen = $state(false);
function isActive(href: string): boolean {
if (href === '/') return page.url.pathname === '/';
return page.url.pathname === href || page.url.pathname.startsWith(href + '/');
}
</script>
<div class="shell">
<div class="shell-glow shell-glow-a"></div>
<div class="shell-glow shell-glow-b"></div>
<aside class="shell-rail reveal">
<div class="rail-top">
<a class="brand" href="/">
<div class="brand-mark">P</div>
<div>
<div class="brand-name">Platform</div>
<div class="brand-sub">ops workspace</div>
</div>
</a>
<button class="command-trigger" onclick={onOpenCommand}>
<Search size={15} strokeWidth={1.8} />
<span>Assistant</span>
<kbd>Ctrl K</kbd>
</button>
<nav class="rail-nav">
{#each navItems as item}
<a href={item.href} class:active={isActive(item.href)}>
<item.icon size={16} strokeWidth={1.8} />
<span>{item.label}</span>
</a>
{/each}
</nav>
</div>
<div class="rail-bottom">
<div class="rail-date">
<CalendarDays size={14} strokeWidth={1.8} />
<span>{new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric' }).format(new Date())}</span>
</div>
<div class="rail-user">{userName || 'Platform user'}</div>
</div>
</aside>
<div class="shell-main">
<header class="mobile-bar">
<button class="mobile-menu-btn" onclick={() => (mobileNavOpen = true)} aria-label="Open navigation">
<Menu size={18} strokeWidth={1.9} />
</button>
<a class="mobile-brand" href="/">
<div class="brand-mark">P</div>
<div>
<div class="brand-name">Platform</div>
<div class="brand-sub">ops workspace</div>
</div>
</a>
<button class="command-trigger mobile" onclick={onOpenCommand}>
<Search size={15} strokeWidth={1.8} />
</button>
</header>
{#if mobileNavOpen}
<button class="mobile-nav-overlay" aria-label="Close navigation" onclick={() => (mobileNavOpen = false)}></button>
<aside class="mobile-nav-sheet">
<div class="mobile-nav-head">
<div>
<div class="brand-name">Platform</div>
<div class="brand-sub">ops workspace</div>
</div>
<button class="mobile-menu-btn" onclick={() => (mobileNavOpen = false)} aria-label="Close navigation">
<X size={18} strokeWidth={1.9} />
</button>
</div>
<nav class="mobile-nav-list">
{#each navItems as item}
<a href={item.href} class:active={isActive(item.href)} onclick={() => (mobileNavOpen = false)}>
<item.icon size={16} strokeWidth={1.8} />
<span>{item.label}</span>
</a>
{/each}
</nav>
</aside>
{/if}
<main class="shell-content">
{@render children()}
</main>
</div>
</div>
<style>
:global(body) {
background:
radial-gradient(circle at top left, rgba(214, 120, 58, 0.08), transparent 22%),
radial-gradient(circle at 85% 10%, rgba(65, 91, 82, 0.12), transparent 20%),
linear-gradient(180deg, #f5efe6 0%, #efe6da 48%, #ede7de 100%);
}
.shell {
--shell-ink: #1e1812;
--shell-muted: #6b6256;
--shell-line: rgba(35, 26, 17, 0.11);
min-height: 100vh;
display: grid;
grid-template-columns: 270px minmax(0, 1fr);
position: relative;
}
.shell-glow {
position: fixed;
border-radius: 999px;
filter: blur(80px);
pointer-events: none;
opacity: 0.4;
}
.shell-glow-a {
width: 340px;
height: 340px;
top: -120px;
right: 12%;
background: rgba(179, 92, 50, 0.16);
}
.shell-glow-b {
width: 260px;
height: 260px;
bottom: 10%;
left: 4%;
background: rgba(68, 95, 86, 0.14);
}
.shell-rail {
position: sticky;
top: 0;
height: 100vh;
padding: 28px 20px 24px;
display: flex;
flex-direction: column;
justify-content: space-between;
background: linear-gradient(180deg, rgba(250, 246, 239, 0.82), rgba(244, 237, 228, 0.66));
border-right: 1px solid var(--shell-line);
backdrop-filter: blur(20px);
}
.rail-top {
display: grid;
gap: 18px;
}
.brand,
.mobile-brand {
display: flex;
align-items: center;
gap: 14px;
}
.brand-mark {
width: 42px;
height: 42px;
display: grid;
place-items: center;
border-radius: 14px;
background: linear-gradient(135deg, #211912, #5d4c3f);
color: white;
font-size: 18px;
font-weight: 700;
letter-spacing: 0.06em;
}
.brand-name {
font-size: 14px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--shell-ink);
}
.brand-sub,
.rail-user {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--shell-muted);
}
.command-trigger {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
border-radius: 999px;
border: 1px solid rgba(35, 26, 17, 0.09);
background: rgba(255, 255, 255, 0.56);
color: var(--shell-muted);
font-size: 13px;
}
.command-trigger kbd {
margin-left: auto;
font-family: var(--mono);
font-size: 11px;
color: var(--shell-muted);
}
.rail-nav {
display: grid;
gap: 8px;
}
.rail-nav a {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 14px;
border-radius: 999px;
color: var(--shell-muted);
transition: background 160ms ease, color 160ms ease, transform 160ms ease;
}
.rail-nav a:hover,
.rail-nav a.active {
background: rgba(255, 255, 255, 0.62);
color: var(--shell-ink);
transform: translateX(3px);
}
.rail-bottom {
display: grid;
gap: 10px;
padding-top: 18px;
border-top: 1px solid var(--shell-line);
}
.rail-date {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--shell-muted);
}
.shell-main {
min-width: 0;
}
.mobile-bar {
display: none;
align-items: center;
justify-content: space-between;
padding: 16px;
border-bottom: 1px solid var(--shell-line);
background: rgba(250, 246, 239, 0.78);
backdrop-filter: blur(20px);
position: sticky;
top: 0;
z-index: 20;
}
.command-trigger.mobile {
width: 40px;
height: 40px;
justify-content: center;
padding: 0;
}
.mobile-menu-btn {
display: none;
width: 40px;
height: 40px;
border-radius: 12px;
align-items: center;
justify-content: center;
border: 1px solid rgba(35, 26, 17, 0.09);
background: rgba(255, 255, 255, 0.56);
color: var(--shell-muted);
}
.mobile-nav-overlay {
position: fixed;
inset: 0;
border: 0;
background: rgba(17, 13, 10, 0.28);
backdrop-filter: blur(4px);
z-index: 39;
}
.mobile-nav-sheet {
position: fixed;
top: 0;
left: 0;
bottom: 0;
width: min(320px, 86vw);
padding: 18px 16px;
background: linear-gradient(180deg, rgba(250, 246, 239, 0.96), rgba(244, 237, 228, 0.94));
border-right: 1px solid var(--shell-line);
backdrop-filter: blur(20px);
z-index: 40;
display: grid;
align-content: start;
gap: 18px;
}
.mobile-nav-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.mobile-nav-list {
display: grid;
gap: 8px;
}
.mobile-nav-list a {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 14px;
border-radius: 16px;
color: var(--shell-muted);
background: transparent;
transition: background 160ms ease, color 160ms ease;
}
.mobile-nav-list a.active {
background: rgba(255, 255, 255, 0.62);
color: var(--shell-ink);
}
.shell-content {
min-width: 0;
}
@media (max-width: 900px) {
.shell {
grid-template-columns: 1fr;
}
.shell-rail {
display: none;
}
.mobile-bar {
display: flex;
}
.mobile-menu-btn {
display: inline-flex;
}
}
</style>

View File

@@ -0,0 +1,87 @@
<script lang="ts">
interface Props {
eyebrow: string;
title: string;
description?: string;
meta?: string;
actions?: any;
}
let { eyebrow, title, description = '', meta = '', actions }: Props = $props();
</script>
<section class="page-intro reveal">
<div class="copy">
<div class="eyebrow">{eyebrow}</div>
<h1>{title}</h1>
{#if description}
<p>{description}</p>
{/if}
</div>
<div class="side">
{#if meta}
<div class="meta">{meta}</div>
{/if}
{#if actions}
<div class="actions">{@render actions()}</div>
{/if}
</div>
</section>
<style>
.page-intro {
display: flex;
align-items: end;
justify-content: space-between;
gap: 18px;
margin-bottom: 20px;
}
.eyebrow,
.meta {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.12em;
color: #8b7b6a;
}
h1 {
margin: 8px 0 10px;
max-width: 10ch;
font-size: clamp(2.2rem, 4vw, 3.8rem);
line-height: 0.95;
letter-spacing: -0.05em;
color: #1e1812;
}
p {
max-width: 46rem;
margin: 0;
color: #6b6256;
line-height: 1.65;
}
.side {
display: grid;
gap: 10px;
justify-items: end;
}
.actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
justify-content: flex-end;
}
@media (max-width: 900px) {
.page-intro {
flex-direction: column;
align-items: flex-start;
}
.side {
justify-items: start;
}
}
</style>