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:
@@ -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">I’ll 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>
|
||||
406
frontend-v2/src/lib/components/layout/AppShell.svelte
Normal file
406
frontend-v2/src/lib/components/layout/AppShell.svelte
Normal 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>
|
||||
87
frontend-v2/src/lib/components/shared/PageIntro.svelte
Normal file
87
frontend-v2/src/lib/components/shared/PageIntro.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user