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

@@ -5,6 +5,8 @@ import { env } from '$env/dynamic/private';
const gatewayUrl = env.GATEWAY_URL || 'http://localhost:8100';
export const load: LayoutServerLoad = async ({ cookies, url }) => {
const host = url.host.toLowerCase();
const useAtelierShell = host.includes(':4174') || host.startsWith('test.');
const session = cookies.get('platform_session');
if (!session) {
throw redirect(302, `/login?redirect=${encodeURIComponent(url.pathname)}`);
@@ -28,7 +30,7 @@ export const load: LayoutServerLoad = async ({ cookies, url }) => {
};
const hidden = hiddenByUser[data.user.username] || [];
const visibleApps = allApps.filter(a => !hidden.includes(a));
return { user: data.user, visibleApps };
return { user: data.user, visibleApps, useAtelierShell };
}
}
} catch { /* gateway down — let client handle */ }

View File

@@ -1,12 +1,16 @@
<script lang="ts">
import AppShell from '$lib/components/layout/AppShell.svelte';
import Navbar from '$lib/components/layout/Navbar.svelte';
import MobileTabBar from '$lib/components/layout/MobileTabBar.svelte';
import CommandPalette from '$lib/components/layout/CommandPalette.svelte';
import FitnessAssistantDrawer from '$lib/components/assistant/FitnessAssistantDrawer.svelte';
let { children, data } = $props();
let commandOpen = $state(false);
let assistantEntryDate = $state<string | null>(null);
const visibleApps = data?.visibleApps || ['trips', 'fitness', 'inventory', 'budget', 'reader', 'media'];
const userName = data?.user?.display_name || '';
const useAtelierShell = data?.useAtelierShell || false;
function openCommand() {
commandOpen = true;
@@ -22,20 +26,33 @@
commandOpen = !commandOpen;
}
}
function handleAssistantOpen(event: Event) {
const detail = (event as CustomEvent<{ entryDate?: string }>).detail;
assistantEntryDate = detail?.entryDate || null;
commandOpen = true;
}
</script>
<svelte:window onkeydown={handleKeydown} />
<svelte:window onkeydown={handleKeydown} onopenassistant={handleAssistantOpen} />
<div class="app">
<Navbar onOpenCommand={openCommand} {visibleApps} />
<main>
{#if useAtelierShell}
<AppShell onOpenCommand={openCommand} {visibleApps} {userName}>
{@render children()}
</main>
</AppShell>
<FitnessAssistantDrawer bind:open={commandOpen} onclose={closeCommand} entryDate={assistantEntryDate} />
{:else}
<div class="app">
<Navbar onOpenCommand={openCommand} {visibleApps} />
<MobileTabBar {visibleApps} />
<CommandPalette bind:open={commandOpen} onclose={closeCommand} />
</div>
<main>
{@render children()}
</main>
<MobileTabBar {visibleApps} />
<CommandPalette bind:open={commandOpen} onclose={closeCommand} />
</div>
{/if}
<style>
.app {

View File

@@ -1,444 +1,12 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/state';
import BudgetModule from '$lib/components/dashboard/BudgetModule.svelte';
import FitnessModule from '$lib/components/dashboard/FitnessModule.svelte';
import IssuesModule from '$lib/components/dashboard/IssuesModule.svelte';
import TaskSlideOver from '$lib/components/dashboard/TaskSlideOver.svelte';
interface QuickTask {
id: string;
title: string;
startDate?: string;
dueDate?: string;
isAllDay?: boolean;
_projectName: string;
projectId: string;
_projectId: string;
}
const userName = $derived((page as any).data?.user?.display_name || 'there');
let inventoryIssueCount = $state(0);
let inventoryReviewCount = $state(0);
let budgetUncatCount = $state(0);
let budgetSpending = $state('');
let budgetIncome = $state('');
let fitnessCalRemaining = $state(0);
let fitnessCalLogged = $state(0);
let fitnessCalGoal = $state(2000);
let fitnessProtein = $state(0);
let fitnessCarbs = $state(0);
let fitnessProteinGoal = $state(150);
let fitnessCarbsGoal = $state(200);
let fitnessFat = $state(0);
let fitnessFatGoal = $state(65);
let headerTasks = $state<QuickTask[]>([]);
let headerOverdue = $state(0);
let headerTotalCount = $state(0);
let taskPanelOpen = $state(false);
function getGreeting(): string {
const h = new Date().getHours();
if (h < 12) return 'Good morning';
if (h < 17) return 'Good afternoon';
return 'Good evening';
}
function getDateString(): string {
return new Date().toLocaleDateString('en-US', {
weekday: 'long', month: 'long', day: 'numeric'
});
}
function formatTaskTime(t: QuickTask): string {
const d = t.startDate || t.dueDate;
if (!d || t.isAllDay) return '';
try {
const date = new Date(d);
const h = date.getHours(), m = date.getMinutes();
if (h === 0 && m === 0) return '';
const ampm = h >= 12 ? 'PM' : 'AM';
const hour = h % 12 || 12;
return m > 0 ? `${hour}:${String(m).padStart(2, '0')} ${ampm}` : `${hour} ${ampm}`;
} catch { return ''; }
}
const fitPercent = $derived(fitnessCalGoal > 0 ? Math.min(100, Math.round((fitnessCalLogged / fitnessCalGoal) * 100)) : 0);
async function loadTasks() {
try {
const res = await fetch('/api/tasks/today', { credentials: 'include' });
if (res.ok) {
const t = await res.json();
headerOverdue = t.overdueCount || 0;
const today = t.today || [];
const overdue = t.overdue || [];
headerTotalCount = today.length + overdue.length;
headerTasks = [...overdue, ...today].slice(0, 1);
}
} catch { /* silent */ }
}
onMount(async () => {
const n = new Date();
const today = `${n.getFullYear()}-${String(n.getMonth() + 1).padStart(2, '0')}-${String(n.getDate()).padStart(2, '0')}`;
try {
const [invRes, budgetRes, uncatRes, fitTotalsRes, fitGoalsRes] = await Promise.all([
fetch('/api/inventory/summary', { credentials: 'include' }),
fetch('/api/budget/summary', { credentials: 'include' }),
fetch('/api/budget/uncategorized-count', { credentials: 'include' }),
fetch(`/api/fitness/entries/totals?date=${today}`, { credentials: 'include' }),
fetch(`/api/fitness/goals/for-date?date=${today}`, { credentials: 'include' }),
]);
if (invRes.ok) {
const data = await invRes.json();
inventoryIssueCount = data.issueCount || 0;
inventoryReviewCount = data.reviewCount || 0;
}
if (budgetRes.ok) {
const data = await budgetRes.json();
budgetSpending = '$' + Math.abs(data.spendingDollars || 0).toLocaleString('en-US');
budgetIncome = '$' + Math.abs(data.incomeDollars || 0).toLocaleString('en-US');
}
if (uncatRes.ok) {
const data = await uncatRes.json();
budgetUncatCount = data.count || 0;
}
if (fitTotalsRes.ok) {
const t = await fitTotalsRes.json();
fitnessCalLogged = Math.round(t.total_calories || 0);
fitnessProtein = Math.round(t.total_protein || 0);
fitnessCarbs = Math.round(t.total_carbs || 0);
fitnessFat = Math.round(t.total_fat || 0);
}
if (fitGoalsRes.ok) {
const g = await fitGoalsRes.json();
fitnessCalGoal = g.calories || 2000;
fitnessProteinGoal = g.protein || 150;
fitnessCarbsGoal = g.carbs || 200;
fitnessFatGoal = g.fat || 65;
fitnessCalRemaining = Math.max(0, fitnessCalGoal - fitnessCalLogged);
}
} catch { /* silent */ }
await loadTasks();
});
import { page } from '$app/state';
import AtelierDashboardPage from '$lib/pages/dashboard/AtelierDashboardPage.svelte';
import LegacyDashboardPage from '$lib/pages/dashboard/LegacyDashboardPage.svelte';
const useAtelierShell = $derived((page as any).data?.useAtelierShell || false);
</script>
<div class="page">
<div class="db-surface">
<!-- ═══ HEADER ═══ -->
<div class="db-header">
<div>
<div class="db-date">{getDateString()}</div>
<h1 class="db-greeting">{getGreeting()}, <strong>{userName}</strong></h1>
</div>
<button class="task-pill" onclick={() => taskPanelOpen = true}>
<span class="tp-dot" class:tp-overdue={headerOverdue > 0}></span>
<span class="tp-text"><b>{headerTotalCount} task{headerTotalCount !== 1 ? 's' : ''}</b>
{#if headerTasks[0]} &middot; Next: {headerTasks[0].title}{/if}
</span>
<svg class="tp-arrow" width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M5 3l3.5 3.5L5 10"/></svg>
</button>
</div>
<TaskSlideOver bind:open={taskPanelOpen} onclose={() => { taskPanelOpen = false; loadTasks(); }} />
<!-- ═══ BENTO ROW 1: Budget (2fr) + Inventory (1fr) ═══ -->
<div class="bento stagger">
<a href="/budget" class="bc bc-emerald">
<div class="bc-top">
<span class="bc-label">Budget</span>
<div class="bc-icon emerald">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg>
</div>
</div>
<div class="bc-value">{budgetUncatCount}</div>
<div class="bc-desc">uncategorized transactions<br><strong>{budgetSpending} spent</strong> &middot; {budgetIncome} income</div>
<div class="bc-action">Review <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M4 2l3.5 3.5L4 9"/></svg></div>
</a>
<a href="/inventory" class="bc bc-rose">
<div class="bc-top">
<span class="bc-label">Inventory</span>
<div class="bc-icon rose">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/></svg>
</div>
</div>
<div class="bc-value-sm">{inventoryIssueCount} issues</div>
<div class="bc-desc">{inventoryReviewCount} needs review<br>{inventoryIssueCount + inventoryReviewCount} items need attention</div>
<div class="bc-action">View <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M4 2l3.5 3.5L4 9"/></svg></div>
</a>
</div>
<!-- ═══ BENTO ROW 2: Calories (1fr) + Fitness detail (1fr) ═══ -->
<div class="bento-row2 stagger">
<a href="/fitness" class="bc bc-emerald">
<div class="bc-top">
<span class="bc-label">Calories</span>
<div class="bc-icon emerald">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>
</div>
</div>
<div class="bc-value">{fitnessCalRemaining.toLocaleString()}</div>
<div class="bc-desc">remaining today<br><strong>{fitnessCalLogged.toLocaleString()} logged</strong> &middot; {fitnessProtein}g protein &middot; {fitnessCarbs}g carbs</div>
<div class="bc-action">Log food <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M4 2l3.5 3.5L4 9"/></svg></div>
</a>
<div class="bc">
<div class="fit-header">
<div class="fit-av">Y</div>
<div>
<div class="fit-name">{userName}</div>
<div class="fit-sub">{fitnessCalLogged.toLocaleString()} cal &middot; {fitnessCalRemaining.toLocaleString()} left</div>
</div>
</div>
<div class="fit-bar"><div class="fit-fill" style="width: {fitPercent}%"></div></div>
<div class="fit-macros">
<div><span class="fm-v">{fitnessProtein}<span class="fm-u">/{fitnessProteinGoal}g</span></span><div class="fm-l">protein</div></div>
<div><span class="fm-v">{fitnessCarbs}<span class="fm-u">/{fitnessCarbsGoal}g</span></span><div class="fm-l">carbs</div></div>
<div><span class="fm-v">{fitnessFat}<span class="fm-u">/{fitnessFatGoal}g</span></span><div class="fm-l">fat</div></div>
</div>
</div>
</div>
<!-- ═══ MODULES: Budget detail (7fr) + Issues (3fr) ═══ -->
<div class="db-modules">
<BudgetModule />
<IssuesModule />
</div>
</div>
</div>
<style>
/* ═══ Dashboard — matches React mockup exactly ═══ */
.db-surface {
max-width: 1280px;
width: 100%;
margin: 0 auto;
padding: 0 24px;
}
/* ── Header ── */
.db-header {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 32px;
margin-bottom: 28px;
}
.db-date {
font-size: 11px;
font-weight: 600;
color: var(--accent);
text-transform: uppercase;
letter-spacing: 0.1em;
margin-bottom: 4px;
}
.db-greeting {
font-size: 28px;
font-weight: 400;
letter-spacing: -0.03em;
line-height: 1.1;
color: var(--text-1);
margin: 0;
}
.db-greeting strong { font-weight: 800; }
/* ── Task pill ── */
.task-pill {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 14px 8px 10px;
border-radius: 999px;
background: var(--card);
border: 1px solid var(--border);
box-shadow: var(--shadow-sm);
cursor: pointer;
flex-shrink: 0;
white-space: nowrap;
transition: all 0.25s cubic-bezier(0.16,1,0.3,1);
font-family: var(--font);
}
.task-pill:hover { box-shadow: var(--shadow-md); transform: translateY(-1px); }
.task-pill:active { transform: scale(0.97); }
.tp-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--accent);
flex-shrink: 0;
animation: breathe 2.5s ease-in-out infinite;
}
.tp-dot.tp-overdue { background: var(--error); }
@keyframes breathe {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.4; transform: scale(0.85); }
}
.tp-text { font-size: 12px; font-weight: 500; color: var(--text-3); }
.tp-text b { color: var(--text-1); font-weight: 600; }
.tp-arrow { color: var(--text-4); transition: all 0.2s; }
.task-pill:hover .tp-arrow { color: var(--accent); transform: translateX(2px); }
/* ── Bento grids ── */
.bento {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 10px;
margin-bottom: 10px;
}
.bento-row2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
margin-bottom: 10px;
}
/* ── Bento card ── */
.bc {
background: var(--card);
border: 1px solid var(--border);
border-radius: 16px;
padding: 22px 24px;
box-shadow: var(--shadow-sm);
cursor: pointer;
position: relative;
overflow: hidden;
text-decoration: none;
color: inherit;
display: flex;
flex-direction: column;
border-top: 2px solid transparent;
transition: all 0.3s cubic-bezier(0.16,1,0.3,1);
}
.bc:hover {
transform: translateY(-3px);
box-shadow: var(--shadow-md);
}
.bc:active { transform: scale(0.985); }
.bc.bc-emerald:hover { border-top-color: var(--accent); }
.bc.bc-rose:hover { border-top-color: var(--error); }
.bc-top {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 14px;
}
.bc-label {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--text-4);
}
.bc-icon {
width: 28px;
height: 28px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
}
.bc-icon.emerald { background: var(--accent-dim); color: var(--accent); }
.bc-icon.rose { background: var(--error-dim); color: var(--error); }
.bc-value {
font-size: 32px;
font-weight: 700;
font-family: var(--mono);
letter-spacing: -0.04em;
line-height: 1;
color: var(--text-1);
margin-bottom: 6px;
}
.bc-value-sm {
font-size: 20px;
font-weight: 700;
font-family: var(--mono);
letter-spacing: -0.03em;
color: var(--text-1);
margin-bottom: 4px;
}
.bc-desc {
font-size: 13px;
color: var(--text-3);
line-height: 1.5;
}
.bc-desc :global(strong) { color: var(--text-2); font-weight: 500; }
.bc-action {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
font-weight: 600;
color: var(--accent);
margin-top: 10px;
letter-spacing: 0.02em;
transition: gap 0.2s;
}
.bc:hover .bc-action { gap: 7px; }
/* ── Fitness card (inline) ── */
.fit-header { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; }
.fit-av {
width: 34px; height: 34px; border-radius: 50%;
background: var(--accent-dim); color: var(--accent);
display: flex; align-items: center; justify-content: center;
font-size: 12px; font-weight: 700;
}
.fit-name { font-size: 13px; font-weight: 600; color: var(--text-1); }
.fit-sub { font-size: 11px; color: var(--text-4); margin-top: 1px; }
.fit-bar {
height: 4px; background: var(--card-hover); border-radius: 2px;
margin-bottom: 14px; overflow: hidden;
}
.fit-fill {
height: 100%; border-radius: 2px; background: var(--accent);
transition: width 1s cubic-bezier(0.16,1,0.3,1);
}
.fit-macros { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 6px; }
.fm-v { font-size: 14px; font-weight: 600; font-family: var(--mono); color: var(--text-1); }
.fm-u { font-size: 10px; color: var(--text-4); }
.fm-l { font-size: 10px; color: var(--text-4); margin-top: 1px; }
/* ── Modules ── */
.db-modules {
display: grid;
grid-template-columns: 7fr 3fr;
gap: 10px;
align-items: start;
}
.db-modules > :global(.module) {
padding: 22px 24px;
border-radius: 16px;
min-width: 0;
}
/* ── Mobile ── */
@media (max-width: 900px) {
.db-header { flex-direction: column; align-items: flex-start; gap: 12px; }
.db-greeting { font-size: 22px; }
.task-pill { width: 100%; }
}
@media (max-width: 768px) {
.db-surface { padding: 0 16px; }
.bento, .bento-row2, .db-modules { grid-template-columns: 1fr; }
.bento > *, .bento-row2 > *, .db-modules > :global(*) { min-width: 0; max-width: 100%; }
.bc-value { font-size: 24px; }
}
</style>
{#if useAtelierShell}
<AtelierDashboardPage />
{:else}
<LegacyDashboardPage />
{/if}

File diff suppressed because it is too large Load Diff

View File

@@ -5,6 +5,8 @@
let protein = $state(150);
let carbs = $state(200);
let fat = $state(65);
let sugar = $state(0);
let fiber = $state(0);
let startDate = $state('');
let loading = $state(true);
let editing = $state(false);
@@ -16,6 +18,8 @@
let editProtein = $state('150');
let editCarbs = $state('200');
let editFat = $state('65');
let editSugar = $state('0');
let editFiber = $state('0');
function today(): string {
const n = new Date();
@@ -31,6 +35,8 @@
protein = g.protein || 150;
carbs = g.carbs || 200;
fat = g.fat || 65;
sugar = g.sugar || 0;
fiber = g.fiber || 0;
hasGoal = true;
if (g.start_date) {
const d = new Date(g.start_date + 'T00:00:00');
@@ -46,6 +52,8 @@
editProtein = String(Math.round(protein));
editCarbs = String(Math.round(carbs));
editFat = String(Math.round(fat));
editSugar = String(Math.round(sugar));
editFiber = String(Math.round(fiber));
editing = true;
}
@@ -65,6 +73,8 @@
protein: parseFloat(editProtein) || 150,
carbs: parseFloat(editCarbs) || 200,
fat: parseFloat(editFat) || 65,
sugar: parseFloat(editSugar) || 0,
fiber: parseFloat(editFiber) || 0,
start_date: today(),
}),
});
@@ -112,6 +122,14 @@
<label class="edit-label">Fat (g/day)</label>
<input class="edit-input" type="number" bind:value={editFat} />
</div>
<div class="edit-field">
<label class="edit-label">Sugar (g/day)</label>
<input class="edit-input" type="number" bind:value={editSugar} />
</div>
<div class="edit-field">
<label class="edit-label">Fiber (g/day)</label>
<input class="edit-input" type="number" bind:value={editFiber} />
</div>
</div>
<div class="edit-actions">
<button class="btn-cancel" onclick={cancelEdit}>Cancel</button>
@@ -141,6 +159,16 @@
<div class="goal-value">{loading ? '...' : Math.round(fat)}</div>
<div class="goal-unit">grams/day</div>
</div>
<div class="goal-card">
<div class="goal-label">Sugar</div>
<div class="goal-value">{loading ? '...' : Math.round(sugar)}</div>
<div class="goal-unit">grams/day</div>
</div>
<div class="goal-card">
<div class="goal-label">Fiber</div>
<div class="goal-value">{loading ? '...' : Math.round(fiber)}</div>
<div class="goal-unit">grams/day</div>
</div>
</div>
{#if startDate}

View File

@@ -1,902 +1,12 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/state';
import ImmichPicker from '$lib/components/shared/ImmichPicker.svelte';
import AtelierInventoryPage from '$lib/pages/inventory/AtelierInventoryPage.svelte';
import LegacyInventoryPage from '$lib/pages/inventory/LegacyInventoryPage.svelte';
// ── Types matching NocoDB fields ──
interface InventoryItem {
id: number;
name: string;
order: string;
sku: string;
serial: string;
status: string;
price: number;
tax: number;
total: number;
qty: number;
tracking: string;
vendor: string;
buyerName: string;
date: string;
notes: string;
photos: number;
photoUrls: string[];
}
interface ItemDetailRaw {
Id: number;
Item: string;
'Order Number': string;
'Serial Numbers': string;
SKU: string;
Received: string;
'Price Per Item': number;
Tax: number;
Total: number;
QTY: number;
'Tracking Number': string;
Source: string;
Name: string;
Date: string;
Notes: string;
photos: any[];
[key: string]: any;
}
// ── State ──
let activeTab = $state<'issues' | 'review' | 'all'>('issues');
let searchQuery = $state('');
let detailOpen = $state(false);
let selectedItem = $state<InventoryItem | null>(null);
let selectedDetail = $state<ItemDetailRaw | null>(null);
let nocodbUrl = $state('');
let recentItems = $state<InventoryItem[]>([]);
let issueItems = $state<InventoryItem[]>([]);
let reviewItems = $state<InventoryItem[]>([]);
let recentLoaded = $state(false);
let loading = $state(true);
let searching = $state(false);
let searchResults = $state<InventoryItem[] | null>(null);
let saving = $state(false);
let debounceTimer: ReturnType<typeof setTimeout>;
// ── Derived ──
const issueCount = $derived(issueItems.length);
const reviewCount = $derived(reviewItems.length);
const displayedItems = $derived(() => {
if (searchResults !== null) return searchResults;
if (activeTab === 'issues') return issueItems;
if (activeTab === 'review') return reviewItems;
return recentItems;
});
// ── Map API response to our item shape ──
function mapIssue(raw: any): InventoryItem {
return {
id: raw.id,
name: raw.item || '',
order: raw.orderNumber || '',
sku: raw.sku || '',
serial: raw.serialNumbers || '',
status: normalizeStatus(raw.received || ''),
price: 0,
tax: 0,
total: 0,
qty: 1,
tracking: raw.trackingNumber || '',
vendor: '',
buyerName: '',
date: '',
notes: raw.notes || '',
photos: 0,
photoUrls: []
};
}
const NOCODB_BASE = 'https://noco.quadjourney.com';
function extractPhotoUrls(photos: any[]): string[] {
if (!Array.isArray(photos)) return [];
return photos
.filter((p: any) => p && p.signedPath)
.map((p: any) => `${NOCODB_BASE}/${p.signedPath}`);
}
function mapDetail(raw: ItemDetailRaw): InventoryItem {
const photoUrls = extractPhotoUrls(raw.photos);
return {
id: raw.Id,
name: raw.Item || '',
order: raw['Order Number'] || '',
sku: raw.SKU || '',
serial: raw['Serial Numbers'] || '',
status: normalizeStatus(raw.Received || ''),
price: raw['Price Per Item'] || 0,
tax: raw.Tax || 0,
total: raw.Total || 0,
qty: raw.QTY || 1,
tracking: raw['Tracking Number'] || '',
vendor: raw.Source || '',
buyerName: raw.Name || '',
date: raw.Date || '',
notes: raw.Notes || '',
photos: Array.isArray(raw.photos) ? raw.photos.length : 0,
photoUrls
};
}
function normalizeStatus(received: string): string {
if (!received) return 'Pending';
const lower = received.toLowerCase();
if (lower === 'issue' || lower === 'issues') return 'Issue';
if (lower === 'needs review') return 'Needs Review';
if (lower === 'pending') return 'Pending';
if (lower === 'closed') return 'Closed';
// Any date string or other value = Received
return 'Received';
}
// ── API calls ──
async function loadSummary() {
try {
const res = await fetch('/api/inventory/summary', { credentials: 'include' });
if (res.ok) {
const data = await res.json();
issueItems = (data.issues || []).map(mapIssue);
reviewItems = (data.needsReview || []).map(mapIssue);
}
} catch { /* silent */ }
}
function mapSearchResult(r: any): InventoryItem {
return {
id: r.id,
name: r.item || '',
order: '', sku: '', serial: '',
status: normalizeStatus(r.received || ''),
price: 0, tax: 0, total: 0, qty: 1,
tracking: '', vendor: '', buyerName: '', date: '',
notes: '', photos: 0, photoUrls: []
};
}
async function loadRecent() {
if (recentLoaded) return;
try {
const res = await fetch('/api/inventory/recent?limit=30', { credentials: 'include' });
if (res.ok) {
const data = await res.json();
recentItems = (data.items || []).map(mapSearchResult);
recentLoaded = true;
}
} catch { /* silent */ }
}
async function searchItems(query: string) {
if (!query.trim()) { searchResults = null; return; }
searching = true;
try {
const res = await fetch(`/api/inventory/search-records?q=${encodeURIComponent(query)}`, { credentials: 'include' });
if (res.ok) {
const data = await res.json();
searchResults = (data.results || []).map((r: any) => ({
id: r.id,
name: r.item || '',
order: '', sku: '', serial: '',
status: normalizeStatus(r.received || ''),
price: 0, tax: 0, total: 0, qty: 1,
tracking: '', vendor: '', buyerName: '', date: '',
notes: '', photos: 0
}));
}
} catch { searchResults = []; }
finally { searching = false; }
}
async function loadItemDetail(id: number) {
try {
const res = await fetch(`/api/inventory/item-details/${id}`, { credentials: 'include' });
if (res.ok) {
const data = await res.json();
selectedDetail = data.item;
selectedItem = mapDetail(data.item);
nocodbUrl = data.nocodb_url || '';
detailOpen = true;
}
} catch { /* silent */ }
}
async function updateField(field: string, value: string | number) {
if (!selectedItem) return;
saving = true;
try {
const res = await fetch(`/api/inventory/item/${selectedItem.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ [field]: value }),
credentials: 'include'
});
if (res.ok && selectedDetail) {
(selectedDetail as any)[field] = value;
selectedItem = mapDetail(selectedDetail);
// Update in lists
updateItemInLists(selectedItem);
}
} catch { /* silent */ }
finally { saving = false; }
}
function updateItemInLists(item: InventoryItem) {
const issueIdx = issueItems.findIndex(i => i.id === item.id);
const allIdx = allItems.findIndex(i => i.id === item.id);
if (allIdx >= 0) allItems[allIdx] = item;
// Add/remove from issues based on status
if (item.status === 'Issue') {
if (issueIdx < 0) issueItems = [...issueItems, item];
else issueItems[issueIdx] = item;
} else {
if (issueIdx >= 0) issueItems = issueItems.filter(i => i.id !== item.id);
}
}
// ── Event handlers ──
function onSearchInput() {
clearTimeout(debounceTimer);
if (!searchQuery.trim()) { searchResults = null; return; }
debounceTimer = setTimeout(() => searchItems(searchQuery), 300);
}
function openDetail(item: InventoryItem) {
loadItemDetail(item.id);
}
function closeDetail() {
detailOpen = false;
selectedItem = null;
selectedDetail = null;
}
const statusOptions = ['Issue', 'Needs Review', 'Pending', 'Received', 'Closed'];
// Maps our status to NocoDB "Received" field values
function statusToReceived(status: string): string {
if (status === 'Issue') return 'Issues';
if (status === 'Needs Review') return 'Needs Review';
if (status === 'Pending') return 'Pending';
if (status === 'Closed') return 'Closed';
// For "Received", we set today's date
return new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
}
async function changeStatus(newStatus: string) {
if (!selectedItem) return;
const receivedValue = statusToReceived(newStatus);
await updateField('Received', receivedValue);
}
function statusColor(status: string) {
if (status === 'Issue' || status === 'Issues') return 'error';
if (status === 'Needs Review') return 'warning';
if (status === 'Received') return 'success';
if (status === 'Pending') return 'warning';
if (status === 'Closed') return 'muted';
return 'muted';
}
function formatPrice(n: number) {
return '$' + n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
// ── Inline editing ──
let editingField = $state('');
let editValue = $state('');
// NocoDB field name → our display label + item property
const editableFields: Record<string, { nocoField: string; type: 'text' | 'number' }> = {
'Item': { nocoField: 'Item', type: 'text' },
'Price Per Item': { nocoField: 'Price Per Item', type: 'number' },
'Tax': { nocoField: 'Tax', type: 'number' },
'Total': { nocoField: 'Total', type: 'number' },
'QTY': { nocoField: 'QTY', type: 'number' },
'SKU': { nocoField: 'SKU', type: 'text' },
'Serial Numbers': { nocoField: 'Serial Numbers', type: 'text' },
'Order Number': { nocoField: 'Order Number', type: 'text' },
'Source': { nocoField: 'Source', type: 'text' },
'Name': { nocoField: 'Name', type: 'text' },
'Date': { nocoField: 'Date', type: 'text' },
'Tracking Number': { nocoField: 'Tracking Number', type: 'text' },
'Notes': { nocoField: 'Notes', type: 'text' },
};
function startEdit(nocoField: string, currentValue: any) {
editingField = nocoField;
editValue = currentValue != null ? String(currentValue) : '';
}
async function saveEdit() {
if (!editingField || !selectedItem) return;
const field = editableFields[editingField];
const value = field?.type === 'number' ? Number(editValue) || 0 : editValue;
await updateField(editingField, value);
editingField = '';
editValue = '';
}
function cancelEdit() {
editingField = '';
editValue = '';
}
function handleEditKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') saveEdit();
if (e.key === 'Escape') cancelEdit();
}
// Get raw NocoDB value for a field
function rawField(field: string): any {
return selectedDetail ? (selectedDetail as any)[field] : '';
}
// ── Action handlers ──
async function duplicateItem() {
if (!selectedItem) return;
saving = true;
try {
const res = await fetch(`/api/inventory/duplicate/${selectedItem.id}`, {
method: 'POST', credentials: 'include'
});
if (res.ok) {
const data = await res.json();
const newId = data.newId || data.id;
if (newId) loadItemDetail(newId);
}
} catch { /* silent */ }
finally { saving = false; }
}
async function sendToPhone() {
if (!selectedItem) return;
saving = true;
try {
await fetch('/api/inventory/send-to-phone', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ rowId: selectedItem.id }),
credentials: 'include'
});
} catch { /* silent */ }
finally { saving = false; }
}
// ── Photo upload ──
let fileInput: HTMLInputElement;
let uploading = $state(false);
let uploadMenuOpen = $state(false);
function triggerUpload() {
fileInput?.click();
}
async function handleFileSelect(e: Event) {
const input = e.target as HTMLInputElement;
if (!input.files?.length || !selectedItem) return;
uploading = true;
try {
const formData = new FormData();
formData.append('rowId', String(selectedItem.id));
for (const file of input.files) {
formData.append('photos', file);
}
const res = await fetch('/api/inventory/upload', {
method: 'POST',
body: formData,
credentials: 'include'
});
if (res.ok) {
// Reload item to get updated photos
loadItemDetail(selectedItem.id);
}
} catch { /* silent */ }
finally {
uploading = false;
input.value = '';
}
}
// ── Immich picker ──
let immichOpen = $state(false);
async function handleImmichSelect(assetIds: string[]) {
if (!selectedItem || !assetIds.length) return;
uploading = true;
try {
// Server-to-server: inventory service downloads from Immich and uploads to NocoDB
const res = await fetch('/api/inventory/upload-from-immich', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
rowId: selectedItem.id,
assetIds,
deleteAfter: true
})
});
if (res.ok) {
const data = await res.json();
console.log(`Uploaded ${data.uploadedCount} photos, deleted ${data.deletedCount} from Immich`);
}
// Reload to show new photos
loadItemDetail(selectedItem.id);
} catch { /* silent */ }
finally { uploading = false; }
}
async function createNewItem() {
saving = true;
try {
const res = await fetch('/api/inventory/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ Item: 'New Item' }),
credentials: 'include'
});
if (res.ok) {
const data = await res.json();
if (data.id) loadItemDetail(data.id);
}
} catch { /* silent */ }
finally { saving = false; }
}
// ── Init ──
onMount(async () => {
await loadSummary();
loading = false;
// Auto-open from query param
const itemId = page.url.searchParams.get('item');
if (itemId) loadItemDetail(Number(itemId));
});
let { data } = $props();
</script>
{#snippet editableRow(nocoField: string, displayValue: string, classes: string)}
{#if editingField === nocoField}
<div class="detail-row editing">
<span class="field-label">{nocoField}</span>
<input
class="edit-input {classes}"
type={editableFields[nocoField]?.type === 'number' ? 'number' : 'text'}
bind:value={editValue}
onkeydown={handleEditKeydown}
onblur={saveEdit}
autofocus
/>
</div>
{:else}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="detail-row editable" onclick={() => startEdit(nocoField, rawField(nocoField))}>
<span class="field-label">{nocoField}</span>
<span class="field-value {classes}">{displayValue}</span>
</div>
{/if}
{/snippet}
<div class="page">
<div class="app-surface">
<div class="page-header">
<div class="header-row">
<div>
<div class="page-title">INVENTORY</div>
<div class="page-subtitle">{loading ? 'Loading...' : ''}<strong>{issueCount} issues</strong> · {reviewCount} needs review</div>
</div>
<button class="btn-primary" onclick={createNewItem}>+ New Item</button>
</div>
</div>
<!-- Search -->
<div class="search-wrap">
<svg class="search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>
<input
type="text"
class="search-input"
placeholder="Search by item name, order number, serial number, SKU..."
bind:value={searchQuery}
oninput={onSearchInput}
/>
{#if searchQuery}
<button class="search-clear" onclick={() => { searchQuery = ''; searchResults = null; }}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
{/if}
</div>
<!-- Tabs (hidden during search) -->
{#if !searchQuery && searchResults === null}
<div class="tabs">
<button class="tab" class:active={activeTab === 'issues'} onclick={() => activeTab = 'issues'}>
Issues <span class="tab-badge">{issueCount}</span>
</button>
<button class="tab" class:active={activeTab === 'review'} onclick={() => activeTab = 'review'}>
Needs Review <span class="tab-badge review">{reviewCount}</span>
</button>
<button class="tab" class:active={activeTab === 'all'} onclick={() => { activeTab = 'all'; loadRecent(); }}>
Recent
</button>
</div>
{:else}
<div class="search-results-label">{displayedItems().length} result{displayedItems().length !== 1 ? 's' : ''} for "{searchQuery}"</div>
{/if}
<!-- Item rows -->
<div class="items-card">
{#each displayedItems() as item (item.id)}
<button class="item-row" class:has-issue={item.status === 'Issue'} class:has-review={item.status === 'Needs Review'} onclick={() => openDetail(item)}>
<div class="item-info">
<div class="item-name">{item.name}</div>
<div class="item-meta">Order #{item.order} · SKU: {item.sku}</div>
</div>
<span class="status-badge {statusColor(item.status)}">{item.status}</span>
<svg class="row-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 18l6-6-6-6"/></svg>
</button>
{/each}
{#if displayedItems().length === 0}
<div class="empty">No items found</div>
{/if}
</div>
</div>
</div>
<!-- Detail sheet/modal -->
{#if detailOpen && selectedItem}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="detail-overlay" onclick={closeDetail}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="detail-sheet" onclick={(e) => e.stopPropagation()}>
<!-- 1. Title (editable) -->
<div class="detail-header">
{#if editingField === 'Item'}
<input
class="edit-input detail-title-edit"
type="text"
bind:value={editValue}
onkeydown={handleEditKeydown}
onblur={saveEdit}
autofocus
/>
{:else}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="detail-title editable" onclick={() => startEdit('Item', rawField('Item'))}>{selectedItem.name}</div>
{/if}
<button class="detail-close" onclick={closeDetail}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<!-- 2. Status control (segmented) -->
<div class="status-control">
{#each statusOptions as status}
<button
class="status-seg"
class:active={selectedItem.status === status}
data-status={status}
onclick={() => changeStatus(status)}
>
{status}
</button>
{/each}
</div>
<!-- 3. Photos -->
<div class="detail-photos">
{#if selectedItem.photoUrls.length > 0}
{#each selectedItem.photoUrls as url}
<img class="photo-img" src={url} alt="Item photo" loading="lazy" />
{/each}
{:else}
<div class="photo-placeholder empty-photo">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/></svg>
<span>No photos yet</span>
</div>
{/if}
</div>
<!-- 4. Actions -->
<div class="actions-group">
<div class="actions-row">
<input type="file" accept="image/*" multiple class="hidden-input" bind:this={fileInput} onchange={handleFileSelect} />
<button class="action-btn primary" onclick={() => uploadMenuOpen = !uploadMenuOpen} disabled={uploading}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/><circle cx="12" cy="13" r="4"/></svg>
{uploading ? 'Uploading...' : 'Upload Photos'}
</button>
{#if uploadMenuOpen}
<div class="upload-menu">
<button class="upload-option" onclick={() => { uploadMenuOpen = false; triggerUpload(); }}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
From device
</button>
<button class="upload-option" onclick={() => { uploadMenuOpen = false; immichOpen = true; }}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/></svg>
From Immich
</button>
</div>
{/if}
<button class="action-btn" onclick={sendToPhone}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="5" y="2" width="14" height="20" rx="2"/><line x1="12" y1="18" x2="12.01" y2="18"/></svg>
Phone
</button>
</div>
<div class="actions-row secondary">
<button class="action-btn sm" onclick={duplicateItem}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
Duplicate
</button>
<a class="action-btn sm ghost" href={nocodbUrl || '#'} target="_blank" rel="noopener">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
NocoDB
</a>
</div>
</div>
<!-- Purchase -->
<div class="section-group">
<div class="section-label">Purchase</div>
<div class="detail-fields">
{@render editableRow('Price Per Item', formatPrice(selectedItem.price), 'mono')}
{@render editableRow('Tax', formatPrice(selectedItem.tax), 'mono')}
{@render editableRow('Total', formatPrice(selectedItem.total), 'mono strong')}
{@render editableRow('QTY', String(selectedItem.qty), '')}
</div>
</div>
<!-- Item Info -->
<div class="section-group">
<div class="section-label">Item Info</div>
<div class="detail-fields">
{@render editableRow('SKU', selectedItem.sku || '—', 'mono')}
{@render editableRow('Serial Numbers', selectedItem.serial || '—', 'mono')}
</div>
</div>
<!-- Order -->
<div class="section-group">
<div class="section-label">Order</div>
<div class="detail-fields">
{@render editableRow('Order Number', selectedItem.order || '—', 'mono')}
{@render editableRow('Source', selectedItem.vendor || '—', '')}
{@render editableRow('Name', selectedItem.buyerName || '—', '')}
{@render editableRow('Date', selectedItem.date || '—', '')}
</div>
</div>
<!-- Shipping -->
<div class="section-group">
<div class="section-label">Shipping</div>
<div class="detail-fields">
{@render editableRow('Tracking Number', selectedItem.tracking || '—', 'mono')}
</div>
</div>
<!-- Notes -->
<div class="section-group">
<div class="section-label">Notes</div>
<div class="detail-fields">
{#if editingField === 'Notes'}
<div class="detail-row">
<textarea
class="edit-input edit-textarea"
bind:value={editValue}
onkeydown={handleEditKeydown}
onblur={saveEdit}
rows="3"
></textarea>
</div>
{:else}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="detail-row editable" onclick={() => startEdit('Notes', rawField('Notes'))}>
<span class="field-value" style="text-align:left;font-weight:400;color:var(--text-2);width:100%">{selectedItem.notes || 'Add notes...'}</span>
</div>
{/if}
</div>
</div>
</div>
</div>
{#if data?.useAtelierShell}
<AtelierInventoryPage />
{:else}
<LegacyInventoryPage />
{/if}
{#if immichOpen}
<ImmichPicker bind:open={immichOpen} onselect={handleImmichSelect} />
{/if}
<style>
.header-row { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--sp-4); }
.page-subtitle { font-size: var(--text-2xl); font-weight: 300; color: var(--text-1); line-height: 1.2; }
.btn-primary { padding: var(--sp-2) var(--sp-4); border-radius: var(--radius-md); background: var(--accent); color: white; border: none; font-size: var(--text-sm); font-weight: 600; cursor: pointer; font-family: var(--font); transition: opacity var(--transition); }
.btn-primary:hover { opacity: 0.9; }
/* ── Search ── */
.search-wrap { position: relative; margin-bottom: var(--sp-4); }
.search-icon { position: absolute; left: 14px; top: 50%; transform: translateY(-50%); width: 18px; height: 18px; color: var(--text-4); pointer-events: none; transition: color var(--transition); }
.search-input { width: 100%; padding: var(--sp-3) var(--sp-10) var(--sp-3) 42px; border-radius: var(--radius); border: 1.5px solid var(--border); background: var(--card); color: var(--text-1); font-size: var(--text-md); font-family: var(--font); transition: all var(--transition); box-shadow: var(--shadow-xs); }
.search-input::placeholder { color: var(--text-4); }
.search-input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 4px var(--accent-dim), var(--shadow-sm); }
.search-input:focus ~ .search-icon { color: var(--accent); }
.search-clear { position: absolute; right: 12px; top: 50%; transform: translateY(-50%); background: none; border: none; cursor: pointer; color: var(--text-4); padding: var(--sp-1); border-radius: var(--radius-xs); }
.search-clear:hover { color: var(--text-2); background: var(--card-hover); }
.search-clear svg { width: 16px; height: 16px; }
.search-results-label { font-size: var(--text-sm); color: var(--text-3); margin-bottom: var(--sp-3); }
/* ── Tabs ── */
.tabs { display: flex; gap: var(--sp-1); margin-bottom: var(--sp-4); }
.tab { padding: var(--sp-2) 14px; border-radius: var(--radius-md); font-size: var(--text-base); font-weight: 500; color: var(--text-3); background: none; border: none; cursor: pointer; transition: all var(--transition); font-family: var(--font); display: flex; align-items: center; gap: var(--sp-1.5); }
.tab:hover { color: var(--text-1); background: var(--card-hover); }
.tab.active { color: var(--text-1); background: var(--card); box-shadow: var(--shadow-xs); }
.tab-badge { font-size: var(--text-xs); font-weight: 600; background: var(--error); color: white; padding: 1px 7px; border-radius: 10px; }
.tab-badge.review { background: var(--warning); }
.tab-count { font-size: var(--text-xs); color: var(--text-4); }
/* ── Item rows ── */
.items-card { background: var(--card); border-radius: var(--radius); border: 1px solid var(--border); box-shadow: var(--shadow-md); overflow: hidden; }
.item-row { display: flex; align-items: center; gap: 14px; padding: 15px 16px; width: 100%; background: none; border: none; cursor: pointer; transition: background var(--transition); text-align: left; font-family: var(--font); color: inherit; }
.item-row:hover { background: var(--card-hover); }
.item-row + .item-row { border-top: 1px solid var(--border); }
.item-row:nth-child(even) { background: color-mix(in srgb, var(--surface) 68%, var(--card)); }
.item-row:nth-child(even):hover { background: var(--card-hover); }
.item-row.has-issue { border-left: 4px solid var(--error); }
.item-row.has-review { border-left: 4px solid var(--warning); }
.item-info { flex: 1; min-width: 0; }
.item-name { font-size: var(--text-base); font-weight: 600; color: var(--text-1); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.item-meta { font-size: var(--text-sm); color: var(--text-2); margin-top: 3px; letter-spacing: 0.01em; }
.status-badge { font-size: var(--text-xs); font-weight: 500; padding: 3px 10px; border-radius: var(--radius-sm); flex-shrink: 0; }
.status-badge.error { background: var(--error-dim); color: var(--error); }
.status-badge.success { background: var(--success-dim); color: var(--success); }
.status-badge.warning { background: var(--warning-bg); color: var(--warning); }
.status-badge.muted { background: var(--card-hover); color: var(--text-4); }
.row-chevron { width: 14px; height: 14px; color: var(--text-4); flex-shrink: 0; opacity: 0.5; }
.item-row:hover .row-chevron { opacity: 1; }
.empty { padding: var(--sp-12); text-align: center; color: var(--text-3); font-size: var(--text-base); }
/* ── Detail sheet ── */
.detail-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.35); z-index: 60; display: flex; justify-content: flex-end; animation: fadeIn 150ms ease; }
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
.detail-sheet { width: 520px; max-width: 100%; height: 100%; background: var(--surface); overflow-y: auto; padding: var(--sp-7); box-shadow: -12px 0 40px rgba(0,0,0,0.12); animation: slideIn 200ms ease; }
@keyframes slideIn { from { transform: translateX(100%); } to { transform: translateX(0); } }
.detail-header { display: flex; justify-content: space-between; align-items: flex-start; gap: var(--sp-3); margin-bottom: 18px; }
.detail-title { font-size: var(--text-lg); font-weight: 600; color: var(--text-1); line-height: 1.35; flex: 1; min-width: 0; }
/* ── Status segmented control ── */
.status-control {
display: flex;
justify-content: center;
gap: 0;
margin: 0 8px 22px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 3px;
box-shadow: 0 1px 4px rgba(0,0,0,0.04);
}
.status-seg {
flex: 1;
padding: 8px 0;
font-size: var(--text-sm);
font-weight: 500;
color: var(--text-3);
background: none;
border: none;
border-radius: 9px;
cursor: pointer;
font-family: var(--font);
transition: all var(--transition);
text-align: center;
}
.status-seg:hover { color: var(--text-2); }
.status-seg.active { color: var(--text-1); box-shadow: 0 1px 4px rgba(0,0,0,0.08); }
.status-seg.active[data-status="Issue"] { background: var(--error-dim); color: var(--error); }
.status-seg.active[data-status="Pending"] { background: var(--warning-bg); color: var(--warning); }
.status-seg.active[data-status="Received"] { background: var(--success-dim); color: var(--success); }
.status-seg.active[data-status="Closed"] { background: var(--card); color: var(--text-2); }
.detail-close { background: none; border: none; cursor: pointer; color: var(--text-4); padding: var(--sp-1.5); border-radius: var(--radius-md); transition: all var(--transition); }
.detail-close:hover { color: var(--text-1); background: var(--card-hover); }
.detail-close svg { width: 18px; height: 18px; }
/* ── Photos ── */
.detail-photos { display: flex; gap: 10px; margin-bottom: var(--sp-5); overflow-x: auto; padding-bottom: var(--sp-1); }
.detail-photos::-webkit-scrollbar { height: 4px; }
.detail-photos::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
.photo-img { width: 140px; height: 105px; border-radius: 10px; object-fit: cover; flex-shrink: 0; background: var(--card-hover); }
.photo-placeholder { width: 140px; height: 105px; border-radius: 10px; background: color-mix(in srgb, var(--surface) 70%, var(--card)); border: 1px dashed var(--border); display: flex; flex-direction: column; align-items: center; justify-content: center; gap: var(--sp-1.5); flex-shrink: 0; color: var(--text-4); }
.photo-placeholder svg { width: 22px; height: 22px; opacity: 0.5; }
.empty-photo { width: 100%; height: 90px; border-radius: 10px; }
.empty-photo span { font-size: var(--text-sm); color: var(--text-4); }
/* ── Actions ── */
.actions-group { display: flex; flex-direction: column; gap: var(--sp-2); margin-bottom: var(--sp-7); }
.actions-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--sp-2); }
.actions-row.secondary { grid-template-columns: repeat(2, 1fr); }
.action-btn {
display: flex; align-items: center; justify-content: center; gap: 5px;
padding: 8px 14px; border-radius: var(--radius-md); height: 34px; width: 100%;
background: var(--card); border: 1px solid var(--border);
font-size: var(--text-sm); font-weight: 500; color: var(--text-3);
cursor: pointer; font-family: var(--font);
transition: all var(--transition);
}
.action-btn:hover { border-color: var(--text-4); color: var(--text-1); }
.action-btn:active { transform: scale(0.97); }
.action-btn.primary { background: var(--accent); border-color: var(--accent); color: white; }
.action-btn.primary:hover { opacity: 0.9; border-color: var(--accent); color: white; }
.action-btn.sm { padding: 6px 11px; font-size: var(--text-sm); height: 30px; }
.action-btn.ghost { border-color: transparent; color: var(--text-4); background: none; }
.action-btn.ghost:hover { color: var(--text-2); background: var(--card-hover); border-color: transparent; }
.action-btn svg { width: 14px; height: 14px; }
.action-btn.sm svg { width: 13px; height: 13px; }
/* ── Section groups ── */
.section-group { margin-bottom: var(--sp-7); }
.section-label { font-size: var(--text-xs); font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-2); margin-bottom: var(--sp-1.5); }
/* ── Detail fields ── */
.detail-fields { background: var(--card); border-radius: var(--radius); border: 1px solid var(--border); box-shadow: var(--shadow-sm); overflow: hidden; }
.detail-row { display: flex; justify-content: space-between; align-items: center; padding: 14px 16px; }
.detail-row + .detail-row { border-top: 1px solid var(--border); }
.detail-row:nth-child(even) { background: color-mix(in srgb, var(--surface) 70%, var(--card)); }
.field-label { font-size: var(--text-sm); color: var(--text-1); opacity: 0.65; letter-spacing: 0.01em; }
.field-value { font-size: var(--text-base); font-weight: 400; color: var(--text-1); text-align: right; letter-spacing: -0.01em; }
/* ── Inline editing ── */
.detail-row.editable { cursor: pointer; }
.detail-row.editable:hover { background: var(--card-hover); }
.detail-title.editable { cursor: pointer; border-radius: var(--radius-sm); padding: 2px 4px; margin: -2px -4px; }
.detail-title.editable:hover { background: var(--card-hover); }
.edit-input {
width: 100%; padding: 6px 10px; border-radius: var(--radius-sm);
border: 1px solid var(--accent); background: var(--surface-secondary);
color: var(--text-1); font-size: var(--text-base); font-family: var(--font);
text-align: right; outline: none;
box-shadow: 0 0 0 3px var(--accent-border);
}
.edit-input.mono { font-family: var(--mono); }
.edit-input[type="number"] { font-family: var(--mono); }
.detail-title-edit {
font-size: var(--text-lg); font-weight: 600; text-align: left;
flex: 1; min-width: 0;
}
.edit-textarea {
text-align: left; resize: vertical; min-height: 60px;
width: 100%; font-size: var(--text-sm); line-height: 1.5;
}
.detail-row.editing { background: var(--accent-dim); }
.hidden-input { display: none; }
.upload-menu {
position: absolute; left: 0; right: 0; top: 100%; margin-top: var(--sp-1); z-index: 10;
background: var(--surface); border: 1px solid var(--border); border-radius: 10px;
box-shadow: 0 8px 24px rgba(0,0,0,0.12); overflow: hidden;
}
.upload-option {
display: flex; align-items: center; gap: 10px; width: 100%;
padding: 12px 16px; background: none; border: none; border-bottom: 1px solid var(--border);
font-size: var(--text-base); font-weight: 500; color: var(--text-1); cursor: pointer;
font-family: var(--font); transition: background var(--transition); text-align: left;
}
.upload-option:last-child { border-bottom: none; }
.upload-option:hover { background: var(--card-hover); }
.upload-option svg { width: 18px; height: 18px; color: var(--text-3); }
.actions-row { position: relative; }
.field-value.mono { font-family: var(--mono); }
.field-value.strong { font-weight: 600; color: var(--text-1); }
/* ── Mobile ── */
@media (max-width: 768px) {
.page-subtitle { font-size: var(--text-xl); }
.detail-sheet { width: 100%; padding: var(--sp-5); }
.detail-photos { gap: var(--sp-2); }
.photo-img { width: 120px; height: 90px; }
.photo-placeholder { width: 120px; height: 90px; }
.detail-title { font-size: var(--text-lg); }
}
</style>

View File

@@ -1,880 +1,12 @@
<script lang="ts">
import { onMount } from 'svelte';
import AtelierReaderPage from '$lib/pages/reader/AtelierReaderPage.svelte';
import LegacyReaderPage from '$lib/pages/reader/LegacyReaderPage.svelte';
// ── Types ──
interface Feed { name: string; count: number; id?: number; }
interface FeedCategory { name: string; feeds: Feed[]; expanded: boolean; id?: number; }
interface Article {
id: number; title: string; feed: string; timeAgo: string; readingTime: string;
content: string; thumbnail?: string; starred: boolean; read: boolean;
author?: string; url?: string;
}
// ── State ──
let navItems = $state([
{ label: 'Today', count: 0, icon: 'calendar' },
{ label: 'Starred', count: 0, icon: 'star' },
{ label: 'History', count: 0, icon: 'clock' }
]);
let feedCategories = $state<FeedCategory[]>([]);
let articles = $state<Article[]>([]);
let activeNav = $state('Today');
let activeFeedId = $state<number | null>(null);
let selectedArticle = $state<Article | null>(null);
let filterMode = $state<'unread' | 'all'>('unread');
let sidebarOpen = $state(false);
let autoScrollActive = $state(false);
let autoScrollSpeed = $state(1.5);
let articleListEl: HTMLDivElement;
let scrollRAF: number | null = null;
let loading = $state(true);
let loadingMore = $state(false);
let hasMore = $state(true);
let totalUnread = $state(0);
const LIMIT = 50;
let feedCounters: Record<string, number> = {};
// ── Helpers ──
function timeAgo(dateStr: string): string {
const mins = Math.floor((Date.now() - new Date(dateStr).getTime()) / 60000);
if (mins < 1) return 'now';
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
const days = Math.floor(hrs / 24);
return `${days}d ago`;
}
function extractThumb(html: string): string | null {
const match = html?.match(/<img[^>]+src=["']([^"']+)["']/i);
if (!match) return null;
return match[1].replace(/&amp;/g, '&');
}
function stripHtml(html: string): string {
if (!html) return '';
return html.replace(/<[^>]*>/g, '').replace(/&nbsp;/g, ' ').replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/\s+/g, ' ').trim();
}
function mapEntry(e: any): Article {
return {
id: e.id, title: e.title, feed: e.feed?.title || '', url: e.url,
timeAgo: timeAgo(e.published_at), readingTime: `${e.reading_time || 1} min`,
content: e.content || '', thumbnail: extractThumb(e.content),
starred: e.starred || false, read: e.status === 'read',
author: e.author || ''
};
}
// ── API ──
async function api(path: string, opts: RequestInit = {}) {
const res = await fetch(`/api/reader${path}`, { credentials: 'include', ...opts });
if (!res.ok) throw new Error(`${res.status}`);
return res.json();
}
async function loadSidebar() {
try {
const [cats, feeds, counters] = await Promise.all([
api('/categories'), api('/feeds'), api('/feeds/counters')
]);
feedCounters = counters.unreads || {};
totalUnread = Object.values(feedCounters).reduce((s: number, c: any) => s + (c as number), 0);
feedCategories = cats.map((c: any) => ({
id: c.id, name: c.title,
expanded: false,
feeds: feeds
.filter((f: any) => f.category.id === c.id)
.map((f: any) => ({ id: f.id, name: f.title, count: feedCounters[String(f.id)] || 0 }))
})).filter((c: FeedCategory) => c.feeds.length > 0);
// Expand first category
if (feedCategories.length > 0) feedCategories[0].expanded = true;
// Update nav counts
navItems[0].count = totalUnread;
try {
const [starred, history] = await Promise.all([
api('/entries?starred=true&limit=1'),
api('/entries?status=read&limit=1')
]);
navItems[1].count = starred.total || 0;
navItems[2].count = history.total || 0;
} catch { /* silent */ }
} catch { /* silent */ }
}
async function loadEntries(append = false) {
if (loadingMore) return;
if (!append) { loading = true; articles = []; hasMore = true; }
else loadingMore = true;
try {
let params = `limit=${LIMIT}&direction=desc&order=published_at`;
if (!append) params += '&offset=0';
else params += `&offset=${articles.length}`;
if (activeFeedId) {
params += `&feed_id=${activeFeedId}`;
}
if (activeNav === 'Today') {
params += '&status=unread';
} else if (activeNav === 'Starred') {
params += '&starred=true';
} else if (activeNav === 'History') {
params += '&status=read';
}
const data = await api(`/entries?${params}`);
const mapped = (data.entries || []).map(mapEntry);
if (append) {
articles = [...articles, ...mapped];
} else {
articles = mapped;
}
hasMore = mapped.length === LIMIT;
} catch { /* silent */ }
finally { loading = false; loadingMore = false; }
}
async function markEntryRead(id: number) {
try {
await api('/entries', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ entry_ids: [id], status: 'read' })
});
} catch { /* silent */ }
}
async function markEntryUnread(id: number) {
try {
await api('/entries', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ entry_ids: [id], status: 'unread' })
});
} catch { /* silent */ }
}
async function toggleStarAPI(id: number) {
try {
await api(`/entries/${id}/bookmark`, { method: 'PUT' });
} catch { /* silent */ }
}
async function markAllReadAPI() {
const ids = articles.filter(a => !a.read).map(a => a.id);
if (!ids.length) return;
try {
await api('/entries', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ entry_ids: ids, status: 'read' })
});
} catch { /* silent */ }
}
// ── Karakeep ──
let karakeepIds = $state<Record<number, string>>({});
let savingToKarakeep = $state<Set<number>>(new Set());
async function toggleKarakeep(article: Article, e?: Event) {
e?.stopPropagation();
e?.preventDefault();
if (savingToKarakeep.has(article.id)) return;
const articleUrl = article.url || '';
console.log('Karakeep: saving', article.id, articleUrl);
if (!articleUrl && !karakeepIds[article.id]) {
console.log('Karakeep: no URL, skipping');
return;
}
savingToKarakeep = new Set([...savingToKarakeep, article.id]);
try {
if (karakeepIds[article.id]) {
const res = await fetch('/api/karakeep/delete', {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: karakeepIds[article.id] })
});
console.log('Karakeep delete:', res.status);
const next = { ...karakeepIds };
delete next[article.id];
karakeepIds = next;
} else {
const res = await fetch('/api/karakeep/save', {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: articleUrl })
});
const text = await res.text();
console.log('Karakeep save:', res.status, text);
try {
const data = JSON.parse(text);
if (res.ok && data.ok) {
karakeepIds = { ...karakeepIds, [article.id]: data.id };
console.log('Karakeep: saved as', data.id);
}
} catch { console.error('Karakeep: bad JSON response'); }
}
} catch (err) {
console.error('Karakeep error:', err);
} finally {
const next = new Set(savingToKarakeep);
next.delete(article.id);
savingToKarakeep = next;
}
}
// ── Touch to stop auto-scroll ──
function handleScrollInterrupt() {
if (autoScrollActive) stopAutoScroll();
}
// ── Event handlers ──
function decrementUnread(count = 1) {
totalUnread = Math.max(0, totalUnread - count);
navItems[0].count = totalUnread;
navItems = [...navItems];
}
const filteredArticles = $derived(articles);
const currentIndex = $derived(
selectedArticle ? filteredArticles.findIndex(a => a.id === selectedArticle!.id) : -1
);
function selectArticle(article: Article) {
selectedArticle = article;
if (!article.read) {
article.read = true;
articles = [...articles];
markEntryRead(article.id);
decrementUnread();
}
}
function closeArticle() { selectedArticle = null; }
function toggleStar(article: Article, e?: Event) {
e?.stopPropagation();
article.starred = !article.starred;
articles = [...articles];
toggleStarAPI(article.id);
}
function toggleRead(article: Article) {
const wasRead = article.read;
article.read = !article.read;
articles = [...articles];
if (article.read) { markEntryRead(article.id); decrementUnread(); }
else { markEntryUnread(article.id); totalUnread++; navItems[0].count = totalUnread; navItems = [...navItems]; }
}
function markAllRead() {
const unreadCount = articles.filter(a => !a.read).length;
markAllReadAPI();
articles = articles.map(a => ({ ...a, read: true }));
decrementUnread(unreadCount);
}
function goNext() {
if (currentIndex < filteredArticles.length - 1) selectArticle(filteredArticles[currentIndex + 1]);
}
function goPrev() {
if (currentIndex > 0) selectArticle(filteredArticles[currentIndex - 1]);
}
function toggleCategory(index: number) {
feedCategories[index].expanded = !feedCategories[index].expanded;
}
function selectFeed(feedId: number) {
activeFeedId = feedId;
activeNav = '';
sidebarOpen = false;
loadEntries();
}
function selectNav(label: string) {
activeNav = label;
activeFeedId = null;
sidebarOpen = false;
loadEntries();
}
// ── Auto-scroll (requestAnimationFrame for smoothness) ──
function startAutoScroll() {
if (scrollRAF) cancelAnimationFrame(scrollRAF);
if (!articleListEl) return;
autoScrollActive = true;
function step() {
if (!autoScrollActive || !articleListEl) return;
articleListEl.scrollTop += autoScrollSpeed;
scrollRAF = requestAnimationFrame(step);
}
scrollRAF = requestAnimationFrame(step);
}
function stopAutoScroll() {
autoScrollActive = false;
if (scrollRAF) { cancelAnimationFrame(scrollRAF); scrollRAF = null; }
}
function toggleAutoScroll() {
if (autoScrollActive) stopAutoScroll();
else startAutoScroll();
}
function adjustSpeed(delta: number) {
autoScrollSpeed = Math.max(0.5, Math.min(5, +(autoScrollSpeed + delta).toFixed(1)));
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'j' || e.key === 'ArrowDown') { e.preventDefault(); goNext(); }
if (e.key === 'k' || e.key === 'ArrowUp') { e.preventDefault(); goPrev(); }
if (e.key === 'Escape' && selectedArticle) { closeArticle(); }
if (e.key === 's' && selectedArticle) { toggleStar(selectedArticle); }
if (e.key === 'm' && selectedArticle) { toggleRead(selectedArticle); }
}
// ── Mark-as-read on scroll (throttled to avoid jitter) ──
let pendingReadIds: number[] = [];
let flushTimer: ReturnType<typeof setTimeout> | null = null;
let scrollCheckTimer: ReturnType<typeof setTimeout> | null = null;
function handleListScroll() {
// Throttle: only check every 400ms
if (scrollCheckTimer) return;
scrollCheckTimer = setTimeout(() => {
scrollCheckTimer = null;
checkScrolledCards();
}, 400);
}
function checkScrolledCards() {
if (!articleListEl) return;
// Infinite scroll — load more when near bottom
const { scrollTop, scrollHeight, clientHeight } = articleListEl;
if (hasMore && !loadingMore && scrollHeight - scrollTop - clientHeight < 300) {
loadEntries(true);
}
const listTop = articleListEl.getBoundingClientRect().top;
const cards = articleListEl.querySelectorAll('[data-entry-id]');
let newlyRead = 0;
cards.forEach(card => {
if (card.getBoundingClientRect().bottom < listTop + 20) {
const id = Number(card.getAttribute('data-entry-id'));
if (!id) return;
const article = articles.find(a => a.id === id);
if (article && !article.read) {
article.read = true;
pendingReadIds.push(id);
newlyRead++;
}
}
});
if (newlyRead > 0) {
articles = [...articles];
decrementUnread(newlyRead);
if (flushTimer) clearTimeout(flushTimer);
flushTimer = setTimeout(flushPendingReads, 1000);
}
}
async function flushPendingReads() {
if (!pendingReadIds.length) return;
const ids = [...pendingReadIds];
pendingReadIds = [];
try {
await api('/entries', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ entry_ids: ids, status: 'read' })
});
} catch { /* silent */ }
}
// ── Init ──
onMount(() => {
loadSidebar();
loadEntries();
return () => {
if (flushTimer) clearTimeout(flushTimer);
};
});
let { data } = $props();
</script>
<svelte:window onkeydown={handleKeydown} />
<div class="reader-layout">
<!-- Left Sidebar -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<aside class="reader-sidebar" class:open={sidebarOpen}>
<div class="sidebar-header">
<svg class="rss-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 11a9 9 0 0 1 9 9"/><path d="M4 4a16 16 0 0 1 16 16"/><circle cx="5" cy="19" r="1"/></svg>
<span class="sidebar-title">Reader</span>
</div>
<nav class="sidebar-nav">
{#each navItems as item}
<button
class="nav-item"
class:active={activeNav === item.label}
onclick={() => selectNav(item.label)}
>
<div class="nav-icon">
{#if item.icon === 'inbox'}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22 12 16 12 14 15 10 15 8 12 2 12"/><path d="M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/></svg>
{:else if item.icon === 'calendar'}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
{:else if item.icon === 'star'}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg>
{:else}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
{/if}
</div>
<span class="nav-label">{item.label}</span>
{#if item.count > 0}
<span class="nav-count">{item.count}</span>
{/if}
</button>
{/each}
</nav>
<div class="sidebar-separator"></div>
<div class="feeds-section">
<div class="feeds-header">Feeds</div>
{#each feedCategories as cat, i}
<div class="feed-category">
<button class="category-toggle" onclick={() => toggleCategory(i)}>
<svg class="expand-icon" class:expanded={cat.expanded} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 18l6-6-6-6"/></svg>
<span class="category-name">{cat.name}</span>
<span class="category-count">{cat.feeds.reduce((s, f) => s + f.count, 0)}</span>
</button>
{#if cat.expanded}
<div class="feed-list">
{#each cat.feeds as feed}
<button class="feed-item" onclick={() => selectFeed(feed.id || 0)}>
<span class="feed-name">{feed.name}</span>
{#if feed.count > 0}
<span class="feed-count">{feed.count}</span>
{/if}
</button>
{/each}
</div>
{/if}
</div>
{/each}
</div>
</aside>
<!-- Sidebar overlay for mobile -->
{#if sidebarOpen}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="sidebar-overlay" onclick={() => sidebarOpen = false}></div>
{/if}
<!-- Middle Panel: Article List -->
<div class="reader-list">
<div class="list-header">
<div class="list-header-top">
<button class="mobile-menu" onclick={() => sidebarOpen = !sidebarOpen} aria-label="Toggle sidebar">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
</button>
<div class="list-view-name">{activeFeedId ? feedCategories.flatMap(c => c.feeds).find(f => f.id === activeFeedId)?.name || 'Feed' : activeNav} <span class="list-count">{activeNav === 'Today' && !activeFeedId ? totalUnread : filteredArticles.length}</span></div>
<div class="list-actions">
<button class="btn-icon" title="Refresh">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
</button>
<button class="btn-icon" title="Mark all read" onclick={markAllRead}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20 6 9 17 4 12"/></svg>
</button>
<button class="btn-icon" class:active-icon={autoScrollActive} onclick={toggleAutoScroll} title="Auto-scroll feed">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 5v14"/><path d="M19 12l-7 7-7-7"/></svg>
</button>
{#if autoScrollActive}
<div class="speed-control">
<button class="speed-btn" onclick={() => adjustSpeed(-0.5)}>-</button>
<span class="speed-value">{autoScrollSpeed}x</span>
<button class="speed-btn" onclick={() => adjustSpeed(0.5)}>+</button>
</div>
{/if}
</div>
</div>
</div>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="article-list" bind:this={articleListEl} ontouchstart={handleScrollInterrupt} onwheel={handleScrollInterrupt} onscroll={handleListScroll}>
{#each filteredArticles as article (article.id)}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="article-card"
class:selected={selectedArticle?.id === article.id}
class:is-read={article.read}
data-entry-id={article.id}
onclick={() => selectArticle(article)}
>
<!-- Card header: source + time + actions -->
<div class="card-header">
<div class="card-source">
<svg class="card-rss" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 11a9 9 0 0 1 9 9"/><path d="M4 4a16 16 0 0 1 16 16"/><circle cx="5" cy="19" r="1"/></svg>
<span class="card-feed-name">{article.feed}</span>
{#if article.author}
<span class="card-author">· {article.author}</span>
{/if}
</div>
<div class="card-header-right">
<button class="bookmark-btn" class:saved={!!karakeepIds[article.id]} onclick={(e) => toggleKarakeep(article, e)} title={karakeepIds[article.id] ? 'Saved to Karakeep' : 'Save to Karakeep'}>
{#if savingToKarakeep.has(article.id)}
<svg class="spinning" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg>
{:else}
<svg viewBox="0 0 24 24" fill={karakeepIds[article.id] ? 'currentColor' : 'none'} stroke="currentColor" stroke-width="2"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>
{/if}
</button>
<span class="card-time">{article.timeAgo}</span>
</div>
</div>
<!-- Title -->
<div class="card-title">{article.title}</div>
<!-- Banner image -->
{#if article.thumbnail}
<div class="card-banner" style="background-image:url('{article.thumbnail}')"></div>
{/if}
<!-- Preview text -->
<div class="card-preview">{stripHtml(article.content).slice(0, 200)}</div>
<!-- Footer -->
<div class="card-footer">
<span class="card-reading-time">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
{article.readingTime}
</span>
</div>
</div>
{/each}
{#if filteredArticles.length === 0}
<div class="list-empty">No articles to show</div>
{/if}
</div>
</div>
</div>
<!-- Reading pane — slide-in overlay -->
{#if selectedArticle}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="pane-overlay" onclick={closeArticle}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="pane-sheet" onclick={(e) => e.stopPropagation()}>
<div class="pane-toolbar">
<div class="pane-nav">
<button class="pane-nav-btn" onclick={closeArticle} title="Close (Esc)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
<button class="pane-nav-btn" onclick={goPrev} disabled={currentIndex <= 0} title="Previous (k)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M15 18l-6-6 6-6"/></svg>
</button>
<span class="pane-nav-pos">{currentIndex + 1} / {filteredArticles.length}</span>
<button class="pane-nav-btn" onclick={goNext} disabled={currentIndex >= filteredArticles.length - 1} title="Next (j)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 18l6-6-6-6"/></svg>
</button>
</div>
<div class="pane-actions">
<button class="pane-action-btn" class:saved-karakeep={!!karakeepIds[selectedArticle.id]} onclick={() => toggleKarakeep(selectedArticle!)} title={karakeepIds[selectedArticle.id] ? 'Saved to Karakeep' : 'Save to Karakeep'}>
{#if savingToKarakeep.has(selectedArticle.id)}
<svg class="spinning" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg>
{:else}
<svg viewBox="0 0 24 24" fill={karakeepIds[selectedArticle.id] ? 'currentColor' : 'none'} stroke="currentColor" stroke-width="2"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>
{/if}
</button>
<button class="pane-action-btn" onclick={() => toggleRead(selectedArticle!)} title="Toggle read (m)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
</button>
{#if selectedArticle.url}
<a href={selectedArticle.url} target="_blank" rel="noopener" class="pane-action-btn" title="Open original">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
</a>
{/if}
</div>
</div>
<div class="pane-scroll">
<div class="pane-content">
<h1 class="pane-title">{selectedArticle.title}</h1>
<div class="pane-meta">
<span class="pane-source">{selectedArticle.feed}</span>
<span class="pane-dot"></span>
<span>{selectedArticle.timeAgo}</span>
<span class="pane-dot"></span>
<span>{selectedArticle.readingTime} read</span>
{#if selectedArticle.author}
<span class="pane-dot"></span>
<span class="pane-author">by {selectedArticle.author}</span>
{/if}
</div>
<div class="pane-body">
{@html selectedArticle.content}
</div>
</div>
</div>
</div>
</div>
{#if data?.useAtelierShell}
<AtelierReaderPage />
{:else}
<LegacyReaderPage />
{/if}
<style>
.reader-layout {
display: flex; height: calc(100vh - 56px);
overflow: hidden; position: relative;
}
/* ── Mobile menu ── */
.mobile-menu {
display: none; width: 30px; height: 30px; border-radius: var(--radius-sm);
background: none; border: 1px solid var(--border);
align-items: center; justify-content: center; cursor: pointer;
flex-shrink: 0; transition: all var(--transition);
}
.mobile-menu:hover { background: var(--card-hover); }
.mobile-menu svg { width: 16px; height: 16px; color: var(--text-3); }
/* ── Sidebar ── */
.reader-sidebar {
width: 220px; flex-shrink: 0; background: var(--surface);
border-right: 1px solid var(--border); display: flex;
flex-direction: column; overflow-y: auto; padding: var(--sp-4) 0 var(--sp-3);
}
.sidebar-header { display: flex; align-items: center; gap: 7px; padding: 0 16px 12px; }
.rss-icon { width: 16px; height: 16px; color: var(--accent); }
.sidebar-title { font-size: var(--text-base); font-weight: 600; color: var(--text-1); }
.sidebar-nav { display: flex; flex-direction: column; gap: var(--sp-0.5); padding: 0 10px; }
.nav-item {
display: flex; align-items: center; gap: var(--sp-2);
padding: var(--sp-2) 10px; border-radius: var(--radius-sm); background: none; border: none;
font-size: var(--text-sm); color: var(--text-3); cursor: pointer;
transition: all var(--transition); text-align: left; width: 100%; font-family: var(--font);
}
.nav-item:hover { background: var(--card-hover); color: var(--text-1); }
.nav-item.active { background: none; color: var(--accent); font-weight: 600; border-left: 2px solid var(--accent); border-radius: 0 var(--radius-sm) var(--radius-sm) 0; padding-left: 8px; }
.nav-icon { width: 15px; height: 15px; flex-shrink: 0; }
.nav-icon svg { width: 15px; height: 15px; }
.nav-label { flex: 1; }
.nav-count { font-size: var(--text-xs); color: var(--text-4); font-family: var(--mono); }
.nav-item.active .nav-count { color: var(--accent); opacity: 0.7; }
.sidebar-separator { height: 1px; background: var(--border); margin: 10px 16px; }
.feeds-section { padding: 0 10px; flex: 1; }
.feeds-header { font-size: var(--text-xs); font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-4); padding: 4px 10px 6px; }
.feed-category { margin-bottom: 1px; }
.category-toggle {
display: flex; align-items: center; gap: 5px; width: 100%;
padding: 5px 10px; background: none; border: none; font-size: var(--text-sm);
font-weight: 500; color: var(--text-2); cursor: pointer; border-radius: var(--radius-sm);
transition: all var(--transition); font-family: var(--font);
}
.category-toggle:hover { background: var(--card-hover); }
.category-name { flex: 1; text-align: left; }
.category-count { font-size: var(--text-xs); color: var(--text-4); font-family: var(--mono); }
.expand-icon { width: 11px; height: 11px; transition: transform var(--transition); flex-shrink: 0; color: var(--text-4); }
.expand-icon.expanded { transform: rotate(90deg); }
.feed-list { padding-left: var(--sp-4); }
.feed-item {
display: flex; align-items: center; justify-content: space-between; width: 100%;
padding: 4px 10px; background: none; border: none; font-size: var(--text-sm);
color: var(--text-3); cursor: pointer; border-radius: var(--radius-sm);
transition: all var(--transition); text-align: left; font-family: var(--font);
}
.feed-item:hover { background: var(--card-hover); color: var(--text-1); }
.feed-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.feed-count { font-size: var(--text-xs); font-family: var(--mono); color: var(--text-4); flex-shrink: 0; margin-left: var(--sp-1.5); }
.sidebar-overlay { position: fixed; inset: 0; background: var(--overlay); z-index: 39; }
/* ── Feed list panel ── */
.reader-list {
flex: 1; min-width: 0;
display: flex; flex-direction: column; background: var(--canvas);
}
.list-header { padding: 12px 20px 10px; background: var(--surface); border-bottom: 1px solid var(--border); flex-shrink: 0; }
.list-header-top { display: flex; align-items: center; justify-content: space-between; margin-bottom: var(--sp-2); }
.list-view-name { font-size: var(--text-base); font-weight: 600; color: var(--text-1); display: flex; align-items: center; gap: var(--sp-1.5); }
.list-count { font-size: var(--text-xs); font-weight: 400; color: var(--text-4); font-family: var(--mono); }
.list-actions { display: flex; gap: var(--sp-1); }
.btn-icon { width: 30px; height: 30px; border-radius: var(--radius-sm); background: none; border: 1px solid var(--border); display: flex; align-items: center; justify-content: center; cursor: pointer; color: var(--text-3); transition: all var(--transition); }
.btn-icon:hover { color: var(--text-1); background: var(--card-hover); }
.btn-icon.active-icon { color: var(--accent); border-color: var(--accent); background: var(--accent-dim); }
.btn-icon svg { width: 14px; height: 14px; }
.list-filters { display: flex; gap: var(--sp-1); }
.filter-btn {
padding: 4px 10px 6px; border-radius: 0; background: none;
border: none; border-bottom: 2px solid transparent; font-size: var(--text-sm); font-weight: 500;
color: var(--text-3); cursor: pointer; transition: all var(--transition); font-family: var(--font);
position: relative;
}
.filter-btn:hover { color: var(--text-2); }
.filter-btn.active { color: var(--text-1); font-weight: 600; border-bottom-color: var(--accent); }
.speed-control { display: flex; align-items: center; gap: var(--sp-0.5); margin-left: var(--sp-1); }
.speed-btn {
width: 22px; height: 22px; border-radius: var(--radius-xs); background: var(--card);
border: 1px solid var(--border); font-size: var(--text-sm); font-weight: 600;
color: var(--text-3); cursor: pointer; display: flex; align-items: center; justify-content: center;
}
.speed-btn:hover { color: var(--text-1); }
.speed-value { font-size: var(--text-xs); font-family: var(--mono); color: var(--text-2); min-width: 24px; text-align: center; }
/* ── Article cards (feed style) ── */
.article-list { flex: 1; overflow-y: auto; padding: var(--sp-2) var(--sp-5) var(--sp-20); display: flex; flex-direction: column; gap: var(--sp-2); }
.article-card {
padding: 14px 16px; border-radius: var(--radius);
background: var(--card); border: none;
box-shadow: var(--card-shadow-sm);
cursor: pointer; transition: all var(--transition);
}
.article-card:hover { box-shadow: var(--card-shadow); transform: translateY(-1px); }
.article-card.selected { box-shadow: 0 0 0 1.5px var(--accent); }
.article-card.is-read { opacity: 0.6; }
.article-card.is-read .card-title { color: var(--text-3); }
.article-card.is-read .card-preview { color: var(--text-4); }
.article-card.is-read:hover { opacity: 0.8; }
.card-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 7px; }
.card-source { display: flex; align-items: center; gap: var(--sp-1); font-size: var(--text-xs); color: var(--text-3); font-weight: 500; min-width: 0; }
.card-rss { width: 12px; height: 12px; color: var(--text-4); flex-shrink: 0; }
.card-feed-name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.card-author { color: var(--text-4); font-weight: 400; white-space: nowrap; }
.card-header-right { display: flex; align-items: center; gap: var(--sp-1); flex-shrink: 0; }
.card-time { font-size: var(--text-xs); color: var(--text-4); }
.star-btn {
width: 26px; height: 26px; display: flex; align-items: center; justify-content: center;
background: none; border: none; cursor: pointer; color: var(--text-4); border-radius: var(--radius-sm);
transition: all var(--transition); opacity: 0;
}
.article-card:hover .star-btn { opacity: 1; }
.star-btn.starred { opacity: 1; color: #F59E0B; }
.star-btn:hover { color: #F59E0B; }
.star-btn svg { width: 13px; height: 13px; }
.bookmark-btn {
width: 26px; height: 26px; display: flex; align-items: center; justify-content: center;
background: none; border: none; cursor: pointer; color: var(--text-4); border-radius: var(--radius-sm);
transition: all var(--transition); opacity: 0.5;
}
.article-card:hover .bookmark-btn { opacity: 0.8; }
.bookmark-btn.saved { opacity: 1; color: #F59E0B; }
.bookmark-btn:hover { opacity: 1; color: #F59E0B; }
.bookmark-btn svg { width: 13px; height: 13px; }
.spinning { animation: spin 0.8s linear infinite; }
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
.card-title { font-size: var(--text-md); font-weight: 600; color: var(--text-1); line-height: 1.35; margin-bottom: var(--sp-1.5); }
.card-banner { width: 100%; height: 160px; border-radius: var(--radius-md); background: var(--card-hover) center/cover no-repeat; margin-bottom: var(--sp-2); }
.card-preview { font-size: var(--text-sm); color: var(--text-3); line-height: 1.55; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; margin-bottom: var(--sp-1); }
.card-footer { display: flex; align-items: center; gap: var(--sp-2); }
.card-reading-time { display: flex; align-items: center; gap: 3px; font-size: var(--text-xs); color: var(--text-4); }
.card-reading-time svg { width: 11px; height: 11px; }
.list-empty { padding: var(--sp-10); text-align: center; color: var(--text-3); font-size: var(--text-sm); }
/* ── Reading pane (slide-in sheet) ── */
.pane-overlay {
position: fixed; inset: 0; background: var(--overlay); z-index: 60;
display: flex; justify-content: flex-end; animation: paneFadeIn 150ms ease;
}
@keyframes paneFadeIn { from { opacity: 0; } to { opacity: 1; } }
.pane-sheet {
width: 620px; max-width: 100%; height: 100%; background: var(--card);
display: flex; flex-direction: column;
border-left: 1px solid var(--border);
box-shadow: -6px 0 28px rgba(0,0,0,0.08);
animation: paneSlideIn 200ms ease;
}
@keyframes paneSlideIn { from { transform: translateX(100%); } to { transform: translateX(0); } }
.pane-scroll { flex: 1; overflow-y: auto; }
.pane-toolbar {
display: flex; align-items: center; justify-content: space-between;
padding: 12px 20px; border-bottom: 1px solid color-mix(in srgb, var(--border) 70%, transparent); flex-shrink: 0;
}
.pane-nav { display: flex; align-items: center; gap: var(--sp-1); }
.pane-nav-btn {
width: 32px; height: 32px; border-radius: var(--radius-sm); background: none;
border: 1px solid var(--border); display: flex; align-items: center; justify-content: center;
cursor: pointer; color: var(--text-3); transition: all var(--transition);
}
.pane-nav-btn:hover { color: var(--text-1); background: var(--card-hover); }
.pane-nav-btn:disabled { opacity: 0.25; cursor: default; }
.pane-nav-btn svg { width: 14px; height: 14px; }
.pane-nav-pos { font-size: var(--text-xs); color: var(--text-4); font-family: var(--mono); min-width: 44px; text-align: center; }
.pane-actions { display: flex; align-items: center; gap: var(--sp-0.5); }
.pane-action-btn {
width: 32px; height: 32px; border-radius: var(--radius-sm); background: none;
border: none; display: flex; align-items: center; justify-content: center;
cursor: pointer; color: var(--text-3); transition: all var(--transition); text-decoration: none;
}
.pane-action-btn:hover { color: var(--text-1); background: var(--card-hover); }
.pane-action-btn.active { color: var(--accent); }
.pane-action-btn.saved-karakeep { color: #F59E0B; }
.pane-action-btn svg { width: 15px; height: 15px; }
.pane-content { max-width: 640px; margin: 0 auto; padding: var(--sp-8) 36px var(--sp-20); }
.pane-title { font-size: var(--text-xl); font-weight: 600; line-height: 1.3; color: var(--text-1); margin: 0 0 var(--sp-3); letter-spacing: -0.01em; }
.pane-meta {
display: flex; align-items: center; gap: var(--sp-1.5); flex-wrap: wrap;
font-size: var(--text-sm); color: var(--text-3); margin-bottom: var(--sp-6);
padding-bottom: var(--sp-4); border-bottom: 1px solid var(--border);
}
.pane-source { font-weight: 500; color: var(--text-2); }
.pane-author { font-style: italic; }
.pane-dot { width: 3px; height: 3px; border-radius: 50%; background: var(--text-4); }
.pane-body { font-size: var(--text-md); line-height: 1.85; color: var(--text-3); }
.pane-body :global(p) { margin-bottom: 18px; }
.pane-body :global(img) { max-width: 100%; height: auto; border-radius: var(--radius-md); margin: var(--sp-3) 0; }
.pane-body :global(a) { color: var(--accent); text-decoration: underline; }
.pane-body :global(a:hover) { opacity: 0.8; }
.pane-body :global(blockquote) { border-left: 3px solid var(--border); padding-left: var(--sp-4); margin: var(--sp-4) 0; color: var(--text-3); font-style: italic; }
.pane-body :global(pre) { background: var(--surface-secondary); padding: 14px; border-radius: var(--radius-md); overflow-x: auto; font-size: var(--text-sm); font-family: var(--mono); margin: var(--sp-4) 0; }
.pane-body :global(code) { font-family: var(--mono); font-size: 0.9em; }
.pane-body :global(h1), .pane-body :global(h2), .pane-body :global(h3) { color: var(--text-1); margin: 20px 0 10px; }
.pane-body :global(ul), .pane-body :global(ol) { padding-left: var(--sp-5); margin-bottom: var(--sp-4); }
.pane-body :global(li) { margin-bottom: var(--sp-1.5); }
/* ── Responsive ── */
@media (max-width: 1024px) {
.reader-sidebar { display: none; }
.reader-sidebar.open { display: flex; position: fixed; left: 0; top: 56px; bottom: 0; z-index: 40; box-shadow: 8px 0 24px rgba(0,0,0,0.08); }
.mobile-menu { display: flex; }
}
@media (max-width: 768px) {
.reader-layout { height: calc(100vh - 56px); }
.reader-sidebar { display: none; }
.reader-sidebar.open { display: flex; position: fixed; left: 0; top: 56px; bottom: 0; z-index: 40; box-shadow: 8px 0 24px rgba(0,0,0,0.08); width: 260px; }
.mobile-menu { display: flex; }
.article-list { padding: var(--sp-1.5) var(--sp-3) var(--sp-20); gap: var(--sp-1.5); }
.article-card { padding: 12px 14px; }
.card-title { font-size: var(--text-base); }
.card-banner { height: 130px; border-radius: var(--radius-sm); }
.card-preview { -webkit-line-clamp: 2; }
.pane-sheet { width: 100%; }
.pane-content { padding: 20px 18px 80px; }
.pane-title { font-size: var(--text-lg); }
.pane-body { font-size: var(--text-md); line-height: 1.75; }
.pane-toolbar { padding: 8px 14px; }
.list-header { padding: 10px 14px 8px; }
}
</style>

View File

@@ -1,368 +1,12 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import CreateTripModal from '$lib/components/trips/CreateTripModal.svelte';
let createOpen = $state(false);
interface Trip {
id: string;
name: string;
dates: string;
daysAway: string;
status: 'active' | 'upcoming' | 'completed';
cover: string;
duration: string;
cities: string[];
points?: number;
cash?: number;
shareToken?: string;
sortDate: string;
}
let searchQuery = $state('');
let upcoming = $state<Trip[]>([]);
let past = $state<Trip[]>([]);
let stats = $state({ trips: 0, cities: 0, countries: 0, points: 0 });
let loading = $state(true);
const allTrips = $derived([...upcoming, ...past]);
const filteredTrips = $derived(
searchQuery.trim()
? allTrips.filter(t =>
t.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
t.cities.some(c => c.toLowerCase().includes(searchQuery.toLowerCase()))
)
: null
);
// ── Helpers ──
function formatPoints(n: number): string {
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
if (n >= 1000) return Math.round(n / 1000) + 'K';
return n.toString();
}
function formatDateRange(start: string, end: string): string {
if (!start) return '';
const s = new Date(start + 'T00:00:00');
const e = new Date(end + 'T00:00:00');
const sMonth = s.toLocaleDateString('en-US', { month: 'short' });
const eMonth = e.toLocaleDateString('en-US', { month: 'short' });
const sYear = s.getFullYear();
const eYear = e.getFullYear();
if (sMonth === eMonth && sYear === eYear) {
return `${sMonth} ${s.getDate()} ${e.getDate()}, ${sYear}`;
}
if (sYear === eYear) {
return `${sMonth} ${s.getDate()} ${eMonth} ${e.getDate()}, ${sYear}`;
}
return `${sMonth} ${s.getDate()}, ${sYear} ${eMonth} ${e.getDate()}, ${eYear}`;
}
function daysBetween(dateStr: string): string {
const d = new Date(dateStr + 'T00:00:00');
const now = new Date();
now.setHours(0, 0, 0, 0);
const diff = Math.ceil((d.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
if (diff <= 0) return '';
if (diff === 1) return '1 day';
return `${diff} days`;
}
function durationDays(start: string, end: string): string {
if (!start || !end) return '';
const s = new Date(start + 'T00:00:00');
const e = new Date(end + 'T00:00:00');
const days = Math.ceil((e.getTime() - s.getTime()) / (1000 * 60 * 60 * 24)) + 1;
return `${days} days`;
}
function coverUrl(trip: any): string {
if (trip.cover_image) return trip.cover_image;
return '';
}
function mapTrip(raw: any): Trip {
const now = new Date().toISOString().slice(0, 10);
const isActive = raw.start_date <= now && raw.end_date >= now;
const isUpcoming = raw.start_date > now;
return {
id: raw.id,
name: raw.name,
dates: formatDateRange(raw.start_date, raw.end_date),
daysAway: isUpcoming ? daysBetween(raw.start_date) : '',
status: isActive ? 'active' : isUpcoming ? 'upcoming' : 'completed',
cover: coverUrl(raw),
duration: durationDays(raw.start_date, raw.end_date),
cities: [],
points: 0,
cash: 0,
shareToken: raw.share_token || '',
sortDate: raw.start_date || ''
};
}
// ── Load ──
onMount(async () => {
try {
const [tripsRes, statsRes] = await Promise.all([
fetch('/api/trips/trips', { credentials: 'include' }),
fetch('/api/trips/stats', { credentials: 'include' })
]);
if (tripsRes.ok) {
const data = await tripsRes.json();
const now = new Date().toISOString().slice(0, 10);
const all = (data.trips || []).map(mapTrip);
upcoming = all.filter(t => t.status === 'active' || t.status === 'upcoming')
.sort((a, b) => a.sortDate.localeCompare(b.sortDate));
past = all.filter(t => t.status === 'completed')
.sort((a, b) => b.sortDate.localeCompare(a.sortDate));
}
if (statsRes.ok) {
const data = await statsRes.json();
stats = {
trips: data.total_trips || 0,
cities: data.cities_visited || 0,
countries: data.countries_visited || 0,
points: data.total_points_redeemed || 0
};
// Enrich trips with cities from stats
const citiesByCountry = data.cities_by_country || {};
// Can't easily map cities to individual trips from stats alone
// Cities will come from trip detail when opened
}
} catch { /* silent */ }
finally { loading = false; }
});
import { page } from '$app/state';
import AtelierTripsPage from '$lib/pages/trips/AtelierTripsPage.svelte';
import LegacyTripsPage from '$lib/pages/trips/LegacyTripsPage.svelte';
const useAtelierShell = $derived((page as any).data?.useAtelierShell || false);
</script>
<div class="page">
<div class="app-surface">
<!-- Header -->
<div class="header-row">
<div>
<div class="page-title">TRIPS</div>
<div class="page-subtitle">Your Adventures</div>
</div>
<button class="btn-primary" onclick={() => createOpen = true}>+ Plan Trip</button>
</div>
<!-- Stats Bar -->
<div class="stats-bar">
<div class="stat-box">
<div class="stat-value">{stats.trips}</div>
<div class="stat-label">Trips</div>
</div>
<div class="stat-box">
<div class="stat-value">{stats.cities}</div>
<div class="stat-label">Cities</div>
</div>
<div class="stat-box">
<div class="stat-value">{stats.countries}</div>
<div class="stat-label">Countries</div>
</div>
<div class="stat-box">
<div class="stat-value">{formatPoints(stats.points)}</div>
<div class="stat-label">Points Used</div>
</div>
</div>
<!-- Search -->
<div class="search-wrap">
<svg class="search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>
<input class="input search-input" type="text" placeholder="Search trips, cities, hotels, flights..." bind:value={searchQuery} />
{#if searchQuery}
<button class="search-clear" onclick={() => searchQuery = ''}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
{/if}
</div>
{#if filteredTrips}
<!-- Search results -->
<div class="search-results-label">{filteredTrips.length} result{filteredTrips.length !== 1 ? 's' : ''}</div>
<div class="trip-grid">
{#each filteredTrips as trip (trip.id)}
<a href="/trips/trip?id={trip.id}" class="trip-card" class:muted={trip.status === 'completed'}>
<div class="trip-photo" style={trip.cover ? `background-image:url('${trip.cover}')` : ''}>
{#if !trip.cover}
<div class="photo-empty">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/></svg>
</div>
{/if}
</div>
<div class="trip-body">
<div class="trip-name">{trip.name}</div>
{#if trip.dates}<div class="trip-dates">{trip.dates}</div>{/if}
{#if trip.cities.length > 0}<div class="trip-cities">{trip.cities.join(' · ')}</div>{/if}
<div class="trip-footer">
{#if trip.status === 'active'}<span class="badge active">Active</span>{/if}
{#if trip.status === 'completed'}<span class="badge completed">Completed</span>{/if}
{#if trip.daysAway}<span class="trip-countdown">{trip.daysAway}</span>{/if}
{#if trip.points}
<span class="trip-points">{formatPoints(trip.points)} pts</span>
{/if}
</div>
</div>
</a>
{/each}
</div>
{:else}
<!-- Upcoming -->
<section class="section">
<div class="section-title">UPCOMING</div>
<div class="trip-grid">
{#each upcoming as trip (trip.id)}
<a href="/trips/trip?id={trip.id}" class="trip-card">
<div class="trip-photo" style="background-image:url('{trip.cover}')">
{#if trip.shareToken}
<button class="share-btn" title="Share trip">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8"/><polyline points="16 6 12 2 8 6"/><line x1="12" y1="2" x2="12" y2="15"/></svg>
</button>
{/if}
</div>
<div class="trip-body">
<div class="trip-name">{trip.name}</div>
<div class="trip-dates">{trip.dates} · {trip.duration}</div>
<div class="trip-cities">{trip.cities.join(' · ')}</div>
<div class="trip-footer">
{#if trip.status === 'active'}<span class="badge active">Active</span>{/if}
<span class="trip-countdown">{trip.daysAway}</span>
{#if trip.points}
<span class="trip-points">{formatPoints(trip.points)} pts</span>
{/if}
</div>
</div>
</a>
{/each}
</div>
</section>
<!-- Past -->
<section class="section">
<div class="section-title">PAST ADVENTURES</div>
<div class="trip-grid">
{#each past as trip (trip.id)}
<a href="/trips/trip?id={trip.id}" class="trip-card muted">
<div class="trip-photo" style={trip.cover ? `background-image:url('${trip.cover}')` : ''}>
{#if !trip.cover}
<div class="photo-empty">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/></svg>
</div>
{/if}
{#if trip.shareToken}
<button class="share-btn" title="Share trip">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8"/><polyline points="16 6 12 2 8 6"/><line x1="12" y1="2" x2="12" y2="15"/></svg>
</button>
{/if}
</div>
<div class="trip-body">
<div class="trip-name">{trip.name}</div>
{#if trip.dates}<div class="trip-dates">{trip.dates} · {trip.duration}</div>{/if}
{#if trip.cities.length > 0}<div class="trip-cities">{trip.cities.join(' · ')}</div>{/if}
<div class="trip-footer">
<span class="badge completed">Completed</span>
{#if trip.points}
<span class="trip-points">{formatPoints(trip.points)} pts</span>
{/if}
{#if trip.cash}
<span class="trip-cash">${trip.cash.toLocaleString()}</span>
{/if}
</div>
</div>
</a>
{/each}
</div>
</section>
{/if}
</div>
</div>
<CreateTripModal bind:open={createOpen} onCreated={(id) => goto(`/trips/trip?id=${id}`)} />
<style>
/* ── Stats Bar ── */
.stats-bar { display: grid; grid-template-columns: repeat(4, 1fr); gap: var(--sp-2); margin-bottom: 14px; }
.stat-box { background: var(--card); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: var(--sp-2) 6px; text-align: center; }
.stat-value { font-size: var(--text-md); font-weight: 700; font-family: var(--mono); color: var(--text-1); line-height: 1.1; }
.stat-label { font-size: var(--text-xs); color: var(--text-3); margin-top: var(--sp-0.5); text-transform: uppercase; letter-spacing: 0.05em; }
/* ── Header ── */
.header-row { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--sp-4); margin-bottom: var(--sp-3); }
.page-subtitle { font-size: var(--text-2xl); font-weight: 300; color: var(--text-1); line-height: 1.15; margin-top: -1px; }
.btn-primary { padding: var(--sp-2) var(--sp-4); border-radius: var(--radius-md); background: var(--accent); color: white; border: none; font-size: var(--text-sm); font-weight: 600; cursor: pointer; font-family: var(--font); transition: opacity var(--transition); }
.btn-primary:hover { opacity: 0.9; }
/* ── Search ── */
.search-wrap { position: relative; margin-bottom: var(--sp-4); }
.search-icon { position: absolute; left: 14px; top: 50%; transform: translateY(-50%); width: 16px; height: 16px; color: var(--text-4); pointer-events: none; }
.search-input { padding-left: 40px; font-size: var(--text-md); }
.search-clear { position: absolute; right: 12px; top: 50%; transform: translateY(-50%); background: none; border: none; cursor: pointer; color: var(--text-4); padding: var(--sp-1); }
.search-clear svg { width: 16px; height: 16px; }
.search-results-label { font-size: var(--text-sm); color: var(--text-3); margin-bottom: var(--sp-3); }
/* ── Sections ── */
.section { margin-bottom: var(--section-gap); }
.section-title { font-size: var(--text-xs); font-weight: 600; color: var(--text-3); text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: var(--sp-3); }
/* ── Trip Grid ── */
.trip-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--sp-5); }
/* ── Trip Card ── */
.trip-card {
background: var(--card); border-radius: var(--radius); border: 1px solid var(--border);
box-shadow: var(--card-shadow-sm); overflow: hidden; transition: all var(--transition);
text-decoration: none; color: inherit; display: block;
}
.trip-card:hover { transform: translateY(-2px); box-shadow: var(--card-shadow); }
.trip-card.muted { opacity: 0.6; }
.trip-card.muted:hover { opacity: 0.85; }
.trip-photo {
width: 100%; height: 180px; background: var(--card-hover) center/cover no-repeat;
position: relative;
}
.photo-empty {
width: 100%; height: 100%; display: flex; align-items: center; justify-content: center;
color: var(--text-4);
}
.photo-empty svg { width: 28px; height: 28px; opacity: 0.3; }
.share-btn {
position: absolute; top: var(--sp-2); right: var(--sp-2);
width: 28px; height: 28px; border-radius: 50%;
background: rgba(0,0,0,0.35); backdrop-filter: blur(6px);
border: none; color: white; display: flex; align-items: center; justify-content: center;
cursor: pointer; transition: all var(--transition); opacity: 0;
}
.trip-card:hover .share-btn { opacity: 1; }
.share-btn:hover { background: rgba(0,0,0,0.55); }
.share-btn svg { width: 13px; height: 13px; }
.trip-body { padding: 8px 14px 10px; }
.trip-name { font-size: var(--text-base); font-weight: 600; color: var(--text-1); line-height: 1.3; }
.trip-dates { font-size: var(--text-xs); color: var(--text-3); margin-top: 3px; }
.trip-cities { font-size: var(--text-xs); color: var(--text-3); margin-top: 1px; opacity: 0.7; }
.trip-footer { display: flex; align-items: center; gap: var(--sp-1.5); margin-top: var(--sp-1.5); flex-wrap: wrap; border-top: 1px solid var(--border); padding-top: var(--sp-1.5); }
.trip-countdown { font-size: var(--text-xs); color: var(--text-3); font-family: var(--mono); }
.trip-points { font-size: var(--text-xs); font-family: var(--mono); color: var(--accent); font-weight: 500; }
.trip-cash { font-size: var(--text-xs); font-family: var(--mono); color: var(--text-3); }
/* ── Badges ── */
.badge { font-size: var(--text-xs); font-weight: 600; padding: 2px var(--sp-2); border-radius: var(--radius-sm); line-height: 1.5; }
.badge.active { background: var(--accent-bg); color: var(--accent); }
.badge.completed { background: var(--surface-secondary); color: var(--text-3); }
@media (max-width: 768px) {
.stats-bar { grid-template-columns: repeat(2, 1fr); }
.trip-grid { grid-template-columns: 1fr; }
.page-subtitle { font-size: var(--text-xl); }
.trip-photo { height: 160px; }
.trip-body { padding: 10px 16px 12px; }
.trip-name { font-size: var(--text-md); }
}
</style>
{#if useAtelierShell}
<AtelierTripsPage />
{:else}
<LegacyTripsPage />
{/if}

View File

@@ -1,10 +1,13 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/state';
import AtelierTripDetailPage from '$lib/pages/trips/AtelierTripDetailPage.svelte';
import ItemModal from '$lib/components/trips/ItemModal.svelte';
import TripEditModal from '$lib/components/trips/TripEditModal.svelte';
import ImageUpload from '$lib/components/trips/ImageUpload.svelte';
let { data } = $props();
// ── Types ──
interface ItineraryEvent {
time: string; name: string; description: string;
@@ -387,6 +390,9 @@
});
</script>
{#if data?.useAtelierShell}
<AtelierTripDetailPage />
{:else}
<div class="page trip-detail-page">
<div class="app-surface">
<!-- Back (hidden in share mode) -->
@@ -1039,3 +1045,4 @@
.day-story { margin: 6px 0 4px; padding: 10px 14px; }
}
</style>
{/if}

View File

@@ -0,0 +1,885 @@
import { env } from '$env/dynamic/private';
import { json } from '@sveltejs/kit';
import { createHash } from 'node:crypto';
import type { RequestHandler } from './$types';
type ChatRole = 'user' | 'assistant';
type MealType = 'breakfast' | 'lunch' | 'dinner' | 'snack';
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;
default_serving_label?: string;
};
type DraftBundle = Draft[];
type ChatMessage = {
role?: ChatRole;
content?: string;
};
type ResolvedFood = {
id: string;
name: string;
status?: string;
base_unit?: string;
calories_per_base?: number;
protein_per_base?: number;
carbs_per_base?: number;
fat_per_base?: number;
sugar_per_base?: number;
fiber_per_base?: number;
servings?: Array<{ id?: string; name?: string; amount_in_base?: number; is_default?: boolean }>;
};
function todayIso(): string {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
function toNumber(value: unknown, fallback = 0): number {
if (typeof value === 'number' && Number.isFinite(value)) return value;
if (typeof value === 'string') {
const parsed = Number(value);
if (Number.isFinite(parsed)) return parsed;
}
return fallback;
}
function clampDraft(input: Record<string, unknown> | null | undefined): Draft {
const meal = input?.meal_type;
const normalizedMeal: MealType =
meal === 'breakfast' || meal === 'lunch' || meal === 'dinner' || meal === 'snack'
? meal
: 'snack';
return {
food_name: typeof input?.food_name === 'string' ? input.food_name.trim() : '',
meal_type: normalizedMeal,
entry_date: typeof input?.entry_date === 'string' && input.entry_date ? input.entry_date : todayIso(),
quantity: Math.max(toNumber(input?.quantity, 1), 0),
unit: typeof input?.unit === 'string' && input.unit ? input.unit.trim() : 'serving',
calories: Math.max(toNumber(input?.calories), 0),
protein: Math.max(toNumber(input?.protein), 0),
carbs: Math.max(toNumber(input?.carbs), 0),
fat: Math.max(toNumber(input?.fat), 0),
sugar: Math.max(toNumber(input?.sugar), 0),
fiber: Math.max(toNumber(input?.fiber), 0),
note: typeof input?.note === 'string' ? input.note.trim() : '',
default_serving_label:
typeof input?.default_serving_label === 'string' ? input.default_serving_label.trim() : ''
};
}
function parseLeadingQuantity(name: string): { quantity: number | null; cleanedName: string } {
const trimmed = name.trim();
const match = trimmed.match(/^(\d+(?:\.\d+)?)\s+(.+)$/);
if (!match) {
return { quantity: null, cleanedName: trimmed };
}
return {
quantity: Number(match[1]),
cleanedName: match[2].trim()
};
}
function canonicalFoodName(name: string): string {
const cleaned = name
.trim()
.replace(/^(?:a|an|the)\s+/i, '')
.replace(/\s+/g, ' ');
return cleaned
.split(' ')
.filter(Boolean)
.map((part) => {
if (/[A-Z]{2,}/.test(part)) return part;
return part.charAt(0).toUpperCase() + part.slice(1).toLowerCase();
})
.join(' ');
}
function normalizedFoodKey(name: string): string {
return canonicalFoodName(name)
.toLowerCase()
.replace(/[^a-z0-9\s]/g, ' ')
.replace(/\b(\d+(?:\.\d+)?)\b/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
function shouldReuseResolvedFood(canonicalName: string, resolved: ResolvedFood | null, confidence: number): boolean {
if (!resolved?.name) return false;
if (confidence < 0.9) return false;
const draftKey = normalizedFoodKey(canonicalName);
const resolvedKey = normalizedFoodKey(resolved.name);
if (!draftKey || !resolvedKey) return false;
return draftKey === resolvedKey;
}
function foodBaseUnit(draft: Draft): string {
const unit = (draft.unit || 'serving').trim().toLowerCase();
if (unit === 'piece' || unit === 'slice' || unit === 'cup' || unit === 'scoop' || unit === 'serving') {
return unit;
}
return 'serving';
}
function defaultServingName(baseUnit: string): string {
return `1 ${baseUnit}`;
}
function hasMaterialNutritionMismatch(draft: Draft, matchedFood: ResolvedFood, entryQuantity: number): boolean {
const draftPerBase = {
calories: Math.max((draft.calories || 0) / entryQuantity, 0),
protein: Math.max((draft.protein || 0) / entryQuantity, 0),
carbs: Math.max((draft.carbs || 0) / entryQuantity, 0),
fat: Math.max((draft.fat || 0) / entryQuantity, 0),
sugar: Math.max((draft.sugar || 0) / entryQuantity, 0),
fiber: Math.max((draft.fiber || 0) / entryQuantity, 0)
};
const currentPerBase = {
calories: matchedFood.calories_per_base || 0,
protein: matchedFood.protein_per_base || 0,
carbs: matchedFood.carbs_per_base || 0,
fat: matchedFood.fat_per_base || 0,
sugar: matchedFood.sugar_per_base || 0,
fiber: matchedFood.fiber_per_base || 0
};
return (Object.keys(draftPerBase) as Array<keyof typeof draftPerBase>).some((key) => {
const next = draftPerBase[key];
const current = currentPerBase[key];
if (Math.abs(next - current) >= 5) return true;
if (Math.max(next, current) <= 0) return false;
return Math.abs(next - current) / Math.max(next, current) >= 0.12;
});
}
function entryIdempotencyKey(draft: Draft, index = 0): string {
return createHash('sha256')
.update(
JSON.stringify({
index,
food_name: draft.food_name || '',
meal_type: draft.meal_type || 'snack',
entry_date: draft.entry_date || todayIso(),
quantity: draft.quantity || 1,
unit: draft.unit || 'serving',
calories: draft.calories || 0,
protein: draft.protein || 0,
carbs: draft.carbs || 0,
fat: draft.fat || 0,
sugar: draft.sugar || 0,
fiber: draft.fiber || 0,
note: draft.note || ''
})
)
.digest('hex');
}
function hasCompleteDraft(draft: Draft): boolean {
return !!draft.food_name && !!draft.meal_type && Number.isFinite(draft.calories ?? NaN);
}
function hasCompleteDrafts(drafts: DraftBundle | null | undefined): drafts is DraftBundle {
return Array.isArray(drafts) && drafts.length > 0 && drafts.every((draft) => hasCompleteDraft(draft));
}
function isExplicitConfirmation(message: string): boolean {
const text = message.trim().toLowerCase();
if (!text) return false;
return [
/^add it[.!]?$/,
/^log it[.!]?$/,
/^save it[.!]?$/,
/^looks good[.!]?$/,
/^looks good add it[.!]?$/,
/^that looks good[.!]?$/,
/^that looks good add it[.!]?$/,
/^confirm[.!]?$/,
/^go ahead[.!]?$/,
/^yes add it[.!]?$/,
/^yes log it[.!]?$/,
/^yes save it[.!]?$/
].some((pattern) => pattern.test(text));
}
function isRetryRequest(message: string): boolean {
const text = message.trim().toLowerCase();
if (!text) return false;
return [
"that's not right",
"that is not right",
'wrong item',
'wrong food',
'wrong meal',
'try again',
'search again',
'guess again',
'redo that',
'start over',
'that is wrong',
"that's wrong"
].some((phrase) => text.includes(phrase));
}
function draftForRetry(draft: Draft): Draft {
return {
meal_type: draft.meal_type,
entry_date: draft.entry_date || todayIso(),
quantity: draft.quantity || 1,
unit: draft.unit || 'serving',
food_name: '',
calories: 0,
protein: 0,
carbs: 0,
fat: 0,
sugar: 0,
fiber: 0,
note: draft.note || ''
};
}
async function applyDraft(fetchFn: typeof fetch, draft: Draft, index = 0) {
const parsedName = parseLeadingQuantity(draft.food_name || '');
const entryQuantity = Math.max(
draft.quantity && draft.quantity > 0 ? draft.quantity : parsedName.quantity || 1,
0.1
);
const canonicalName = canonicalFoodName(parsedName.cleanedName || draft.food_name || 'Quick add');
const baseUnit = foodBaseUnit(draft);
const resolveResponse = await fetchFn('/api/fitness/foods/resolve', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
raw_phrase: canonicalName,
meal_type: draft.meal_type || 'snack',
entry_date: draft.entry_date || todayIso(),
source: 'assistant'
})
});
const resolveBody = await resolveResponse.json().catch(() => ({}));
let matchedFood: ResolvedFood | null =
resolveResponse.ok && shouldReuseResolvedFood(canonicalName, resolveBody?.matched_food ?? null, Number(resolveBody?.confidence || 0))
? (resolveBody?.matched_food ?? null)
: null;
if (!matchedFood) {
const perBaseCalories = Math.max((draft.calories || 0) / entryQuantity, 0);
const perBaseProtein = Math.max((draft.protein || 0) / entryQuantity, 0);
const perBaseCarbs = Math.max((draft.carbs || 0) / entryQuantity, 0);
const perBaseFat = Math.max((draft.fat || 0) / entryQuantity, 0);
const perBaseSugar = Math.max((draft.sugar || 0) / entryQuantity, 0);
const perBaseFiber = Math.max((draft.fiber || 0) / entryQuantity, 0);
const createFoodResponse = await fetchFn('/api/fitness/foods', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
name: canonicalName,
calories_per_base: perBaseCalories,
protein_per_base: perBaseProtein,
carbs_per_base: perBaseCarbs,
fat_per_base: perBaseFat,
sugar_per_base: perBaseSugar,
fiber_per_base: perBaseFiber,
base_unit: baseUnit,
status: 'assistant_created',
notes: `Assistant created from chat draft: ${draft.food_name || canonicalName}`,
servings: [
{
name: draft.default_serving_label?.trim() || defaultServingName(baseUnit),
amount_in_base: 1.0,
is_default: true
}
]
})
});
const createdFoodBody = await createFoodResponse.json().catch(() => ({}));
if (!createFoodResponse.ok) {
return { ok: false, status: createFoodResponse.status, body: createdFoodBody };
}
matchedFood = createdFoodBody as ResolvedFood;
} else if (
(matchedFood.status === 'assistant_created' || matchedFood.status === 'ai_created') &&
hasMaterialNutritionMismatch(draft, matchedFood, entryQuantity)
) {
const updateFoodResponse = await fetchFn(`/api/fitness/foods/${matchedFood.id}`, {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
calories_per_base: Math.max((draft.calories || 0) / entryQuantity, 0),
protein_per_base: Math.max((draft.protein || 0) / entryQuantity, 0),
carbs_per_base: Math.max((draft.carbs || 0) / entryQuantity, 0),
fat_per_base: Math.max((draft.fat || 0) / entryQuantity, 0),
sugar_per_base: Math.max((draft.sugar || 0) / entryQuantity, 0),
fiber_per_base: Math.max((draft.fiber || 0) / entryQuantity, 0)
})
});
const updatedFoodBody = await updateFoodResponse.json().catch(() => ({}));
if (updateFoodResponse.ok) {
matchedFood = updatedFoodBody as ResolvedFood;
}
}
const entryPayload = {
food_id: matchedFood.id,
quantity: entryQuantity,
unit: baseUnit,
serving_id: matchedFood.servings?.find((serving) => serving.is_default)?.id,
meal_type: draft.meal_type || 'snack',
entry_date: draft.entry_date || todayIso(),
entry_method: 'assistant',
source: 'assistant',
note: draft.note || undefined,
idempotency_key: entryIdempotencyKey(draft, index),
snapshot_food_name_override:
draft.food_name && draft.food_name.trim() !== canonicalName ? draft.food_name.trim() : undefined
};
const response = await fetchFn('/api/fitness/entries', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(entryPayload)
});
const body = await response.json().catch(() => ({}));
return { ok: response.ok, status: response.status, body };
}
async function splitInputItems(fetchFn: typeof fetch, phrase: string): Promise<string[]> {
try {
const response = await fetchFn('/api/fitness/foods/split', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ phrase })
});
const body = await response.json().catch(() => ({}));
if (response.ok && Array.isArray(body?.items) && body.items.length > 0) {
return body.items.map((item: unknown) => String(item).trim()).filter(Boolean);
}
} catch {
// fall back below
}
return phrase
.split(/,/)
.map((item) => item.trim())
.filter(Boolean);
}
function draftFromResolvedItem(resolved: Record<string, unknown>, entryDate: string): Draft {
const parsed = (resolved?.parsed as Record<string, unknown> | undefined) || {};
const matchedFood = (resolved?.matched_food as Record<string, unknown> | undefined) || null;
const aiEstimate = (resolved?.ai_estimate as Record<string, unknown> | undefined) || null;
const quantity = Math.max(toNumber(parsed.quantity, 1), 0.1);
const unit = typeof parsed.unit === 'string' && parsed.unit ? parsed.unit : 'serving';
const meal = parsed.meal_type;
const mealType: MealType =
meal === 'breakfast' || meal === 'lunch' || meal === 'dinner' || meal === 'snack'
? meal
: 'snack';
let calories = 0;
let protein = 0;
let carbs = 0;
let fat = 0;
let sugar = 0;
let fiber = 0;
if (matchedFood) {
calories = toNumber(matchedFood.calories_per_base) * quantity;
protein = toNumber(matchedFood.protein_per_base) * quantity;
carbs = toNumber(matchedFood.carbs_per_base) * quantity;
fat = toNumber(matchedFood.fat_per_base) * quantity;
sugar = toNumber(matchedFood.sugar_per_base) * quantity;
fiber = toNumber(matchedFood.fiber_per_base) * quantity;
} else if (aiEstimate) {
calories = toNumber(aiEstimate.calories_per_base) * quantity;
protein = toNumber(aiEstimate.protein_per_base) * quantity;
carbs = toNumber(aiEstimate.carbs_per_base) * quantity;
fat = toNumber(aiEstimate.fat_per_base) * quantity;
sugar = toNumber(aiEstimate.sugar_per_base) * quantity;
fiber = toNumber(aiEstimate.fiber_per_base) * quantity;
} else if (resolved?.resolution_type === 'quick_add') {
calories = toNumber(parsed.quantity);
}
const foodName =
(typeof resolved?.snapshot_name_override === 'string' && resolved.snapshot_name_override) ||
(typeof matchedFood?.name === 'string' && matchedFood.name) ||
(typeof aiEstimate?.food_name === 'string' && aiEstimate.food_name) ||
(typeof resolved?.raw_text === 'string' && resolved.raw_text) ||
'Quick add';
return clampDraft({
food_name: foodName,
meal_type: mealType,
entry_date: entryDate,
quantity,
unit,
calories: Math.round(calories),
protein: Math.round(protein),
carbs: Math.round(carbs),
fat: Math.round(fat),
sugar: Math.round(sugar),
fiber: Math.round(fiber),
note: typeof resolved?.note === 'string' ? resolved.note : '',
default_serving_label:
(typeof aiEstimate?.serving_description === 'string' && aiEstimate.serving_description) ||
(quantity > 0 && unit ? `${quantity} ${unit}` : '')
});
}
async function reviseDraftBundle(
fetchFn: typeof fetch,
messages: Array<{ role: 'user' | 'assistant'; content: string }>,
drafts: DraftBundle,
imageDataUrl: string | null
): Promise<{ reply: string; drafts: DraftBundle } | null> {
if (!env.OPENAI_API_KEY || drafts.length === 0) return null;
const systemPrompt = `You are revising a bundled food draft inside a fitness app.
Return ONLY JSON like:
{
"reply": "short assistant reply",
"drafts": [
{
"food_name": "string",
"meal_type": "breakfast|lunch|dinner|snack",
"entry_date": "YYYY-MM-DD",
"quantity": 1,
"unit": "serving",
"calories": 0,
"protein": 0,
"carbs": 0,
"fat": 0,
"sugar": 0,
"fiber": 0,
"note": "",
"default_serving_label": ""
}
]
}
Rules:
- Update only the item or items the user is correcting.
- Keep untouched items unchanged.
- If the user says one item is wrong, replace that item without collapsing the bundle into one merged food.
- Preserve meal and entry date unless the user changes them.
- Keep replies brief and natural.
- If a photo is attached, you may use it again for corrections.`;
const userMessages = messages.map((message) => ({
role: message.role,
content: message.content
}));
if (imageDataUrl) {
const latestUserText = [...messages].reverse().find((message) => message.role === 'user')?.content || 'Revise these items.';
userMessages.push({
role: 'user',
content: [
{ type: 'text', text: latestUserText },
{ type: 'image_url', image_url: { url: imageDataUrl } }
]
} as unknown as { role: ChatRole; content: string });
}
const response = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'content-type': 'application/json',
authorization: `Bearer ${env.OPENAI_API_KEY}`
},
body: JSON.stringify({
model: env.OPENAI_MODEL || 'gpt-5.2',
response_format: { type: 'json_object' },
temperature: 0.2,
max_completion_tokens: 1000,
messages: [
{
role: 'system',
content: `${systemPrompt}\n\nCurrent bundle:\n${JSON.stringify(drafts, null, 2)}`
},
...userMessages
]
})
});
if (!response.ok) return null;
const raw = await response.json().catch(() => ({}));
const content = raw?.choices?.[0]?.message?.content;
if (typeof content !== 'string') return null;
try {
const parsed = JSON.parse(content);
const nextDrafts = Array.isArray(parsed?.drafts)
? parsed.drafts.map((draft: Record<string, unknown>) => clampDraft(draft))
: [];
if (!hasCompleteDrafts(nextDrafts)) return null;
return {
reply: typeof parsed?.reply === 'string' ? parsed.reply : 'I updated those items.',
drafts: nextDrafts
};
} catch {
return null;
}
}
async function buildDraftBundle(fetchFn: typeof fetch, phrase: string, entryDate: string): Promise<DraftBundle | null> {
const parts = await splitInputItems(fetchFn, phrase);
if (parts.length < 2) return null;
const results = await Promise.all(
parts.map(async (part) => {
const response = await fetchFn('/api/fitness/foods/resolve', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
raw_phrase: part,
entry_date: entryDate,
source: 'assistant'
})
});
if (!response.ok) {
throw new Error(`Failed to resolve "${part}"`);
}
const body = await response.json().catch(() => ({}));
return draftFromResolvedItem(body, entryDate);
})
);
return results.filter((draft) => hasCompleteDraft(draft));
}
export const POST: RequestHandler = async ({ request, fetch, cookies }) => {
if (!cookies.get('platform_session')) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
const { messages = [], draft = null, drafts = null, action = 'chat', imageDataUrl = null, entryDate = null } = await request
.json()
.catch(() => ({}));
const requestedDate =
typeof entryDate === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(entryDate) ? entryDate : todayIso();
const currentDraft = clampDraft({
entry_date: requestedDate,
...(draft && typeof draft === 'object' ? draft : {})
});
const currentDrafts = Array.isArray(drafts)
? drafts
.filter((item) => !!item && typeof item === 'object')
.map((item) =>
clampDraft({
entry_date: requestedDate,
...(item as Record<string, unknown>)
})
)
: [];
if (action === 'apply') {
if (hasCompleteDrafts(currentDrafts)) {
const results = await Promise.all(currentDrafts.map((item, index) => applyDraft(fetch, item, index)));
const failed = results.find((result) => !result.ok);
if (failed) {
return json(
{
reply: 'I couldnt add all of those entries yet. Try again in a moment.',
drafts: currentDrafts,
applied: false,
error: failed.body?.error || `Fitness API returned ${failed.status}`
},
{ status: 500 }
);
}
return json({
reply: `Added ${currentDrafts.length} items to ${currentDrafts[0]?.meal_type || 'your log'}.`,
drafts: currentDrafts,
applied: true,
entries: results.map((result) => result.body)
});
}
if (!hasCompleteDraft(currentDraft)) {
return json({
reply: 'I still need a food and calories before I can add it.',
draft: currentDraft,
drafts: currentDrafts,
applied: false
});
}
const result = await applyDraft(fetch, currentDraft);
if (!result.ok) {
return json(
{
reply: 'I couldnt add that entry yet. Try again in a moment.',
draft: currentDraft,
applied: false,
error: result.body?.error || `Fitness API returned ${result.status}`
},
{ status: 500 }
);
}
return json({
reply: `Added ${currentDraft.food_name} to ${currentDraft.meal_type}.`,
draft: currentDraft,
drafts: [],
applied: true,
entry: result.body
});
}
const recentMessages = (Array.isArray(messages)
? messages
.filter((m: unknown) => !!m && typeof m === 'object')
.map((m: ChatMessage) => ({
role: m.role === 'assistant' ? 'assistant' : 'user',
content: typeof m.content === 'string' ? m.content.slice(0, 2000) : ''
}))
.filter((m) => m.content.trim())
.slice(-10)
: []) as Array<{ role: 'user' | 'assistant'; content: string }>;
const lastUserMessage =
[...recentMessages].reverse().find((message) => message.role === 'user')?.content || '';
const allowApply = isExplicitConfirmation(lastUserMessage);
const retryRequested = isRetryRequest(lastUserMessage);
const hasPhoto =
typeof imageDataUrl === 'string' &&
imageDataUrl.startsWith('data:image/') &&
imageDataUrl.length < 8_000_000;
if (!allowApply && currentDrafts.length > 1 && lastUserMessage.trim()) {
const revisedBundle = await reviseDraftBundle(
fetch,
recentMessages,
currentDrafts,
hasPhoto && typeof imageDataUrl === 'string' ? imageDataUrl : null
);
if (revisedBundle) {
return json({
reply: revisedBundle.reply,
drafts: revisedBundle.drafts,
draft: null,
applied: false
});
}
}
if (!hasPhoto && !retryRequested && !allowApply && lastUserMessage.trim()) {
const bundle = await buildDraftBundle(fetch, lastUserMessage, requestedDate);
if (bundle && bundle.length > 1) {
const meal = bundle[0]?.meal_type || 'snack';
return json({
reply: `I split that into ${bundle.length} items for ${meal}: ${bundle.map((item) => item.food_name).join(', ')}. Add them when this looks right.`,
drafts: bundle,
draft: null,
applied: false
});
}
}
const systemPrompt = `You are a conversational fitness logging assistant inside a personal app.
Your job:
- read the chat plus the current draft food entry
- update the draft naturally
- keep the reply short, plain, and useful
- do not add an entry on the first food message
- only set apply_now=true if the latest user message is a pure confirmation like "add it", "log it", "save it", or "looks good add it"
- never say an item was added, logged, or saved unless apply_now=true for that response
- if a food photo is attached, identify the likely food, portion, and meal context before drafting
- if the user says the current guess is wrong, treat that as authoritative and replace the draft instead of defending the previous guess
Return ONLY JSON with this shape:
{
"reply": "short assistant reply",
"draft": {
"food_name": "string",
"meal_type": "breakfast|lunch|dinner|snack",
"entry_date": "YYYY-MM-DD",
"quantity": 1,
"unit": "serving",
"calories": 0,
"protein": 0,
"carbs": 0,
"fat": 0,
"sugar": 0,
"fiber": 0,
"note": "",
"default_serving_label": ""
},
"apply_now": false
}
Rules:
- Preserve the current draft unless the user changes something.
- If the latest user message says the guess is wrong, try again, or search again, do not cling to the old food guess. Replace the draft with a new best guess.
- If the user says "make it 150 calories", update calories and keep the rest unless another field should obviously move with it.
- If the user says a meal, move it to that meal.
- Default meal_type to snack if not specified.
- Default entry_date to today unless the user specifies another date.
- Estimate realistic nutrition when needed.
- Always include sugar and fiber estimates, even if rough.
- Keep food_name human and concise, for example "2 boiled eggs".
- If the photo is a nutrition label or package nutrition panel, extract the serving size from the label and put it in default_serving_label.
- If the user gives a product name plus a nutrition label photo, use the label values instead of guessing from memory.
- If the photo is ambiguous, briefly mention up to 2 likely alternatives instead of sounding overconfident.
- After drafting or revising, summarize the draft with calories and key macros, then ask for confirmation.
- If a photo is unclear, say what you think it is and mention the uncertainty briefly.
- If retrying from a photo, use the image again and produce a different best guess or ask one short clarifying question.
- If details are missing, ask one short follow-up instead of overexplaining.
- When the user confirms, keep the reply brief because the app will add the entry next.
Today is ${todayIso()}.
Current draft:
${JSON.stringify(retryRequested ? draftForRetry(currentDraft) : currentDraft, null, 2)}`;
if (!env.OPENAI_API_KEY) {
return json(
{
reply: 'Assistant is not configured yet.',
draft: currentDraft,
drafts: currentDrafts,
applied: false
},
{ status: 500 }
);
}
const userMessages = recentMessages.map((message) => ({
role: message.role,
content: message.content
}));
if (hasPhoto) {
const latestUserText = lastUserMessage || 'Analyze this food photo and draft a fitness entry.';
userMessages.push({
role: 'user',
content: [
{ type: 'text', text: latestUserText },
{
type: 'image_url',
image_url: {
url: imageDataUrl
}
}
]
} as unknown as { role: ChatRole; content: string });
}
const openAiResponse = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'content-type': 'application/json',
authorization: `Bearer ${env.OPENAI_API_KEY}`
},
body: JSON.stringify({
model: env.OPENAI_MODEL || 'gpt-5.2',
response_format: { type: 'json_object' },
temperature: 0.2,
max_completion_tokens: 900,
messages: [
{ role: 'system', content: systemPrompt },
...userMessages
]
})
});
if (!openAiResponse.ok) {
const errorText = await openAiResponse.text();
return json(
{
reply: 'The assistant did not respond cleanly.',
draft: currentDraft,
drafts: currentDrafts,
applied: false,
error: errorText
},
{ status: 500 }
);
}
const raw = await openAiResponse.json();
const content = raw?.choices?.[0]?.message?.content;
if (typeof content !== 'string') {
return json(
{
reply: 'The assistant response was empty.',
draft: currentDraft,
drafts: currentDrafts,
applied: false
},
{ status: 500 }
);
}
let parsed: { reply?: string; draft?: Draft; apply_now?: boolean };
try {
parsed = JSON.parse(content);
} catch {
return json(
{
reply: 'The assistant response could not be parsed.',
draft: currentDraft,
drafts: currentDrafts,
applied: false
},
{ status: 500 }
);
}
const nextDraft = clampDraft(parsed.draft || currentDraft);
if (parsed.apply_now && allowApply && hasCompleteDraft(nextDraft)) {
const result = await applyDraft(fetch, nextDraft);
if (result.ok) {
return json({
reply: `Added ${nextDraft.food_name} to ${nextDraft.meal_type}.`,
draft: nextDraft,
drafts: [],
applied: true,
entry: result.body
});
}
}
return json({
reply:
parsed.reply ||
(hasCompleteDraft(nextDraft)
? `${nextDraft.food_name} is staged at ${Math.round(nextDraft.calories || 0)} calories.`
: 'I updated the draft.'),
draft: nextDraft,
drafts: [],
applied: false
});
};

View File

@@ -0,0 +1,37 @@
import { redirect } from '@sveltejs/kit';
import type { LayoutServerLoad } from './$types';
import { env } from '$env/dynamic/private';
const gatewayUrl = env.GATEWAY_URL || 'http://localhost:8100';
const devAutoLogin = ['1', 'true', 'yes', 'on'].includes((env.DEV_AUTO_LOGIN || '').toLowerCase());
export const load: LayoutServerLoad = async ({ cookies, url }) => {
const session = cookies.get('platform_session');
const onTestHost = url.host.toLowerCase().includes(':4174') || url.host.toLowerCase().startsWith('test.');
if (!session && !(devAutoLogin && onTestHost)) {
throw redirect(302, `/login?redirect=${encodeURIComponent(url.pathname)}`);
}
try {
const headers: Record<string, string> = {};
if (session) headers.Cookie = `platform_session=${session}`;
if (devAutoLogin && onTestHost) headers['X-Dev-Auto-Login'] = '1';
const res = await fetch(`${gatewayUrl}/api/auth/me`, {
headers
});
if (res.ok) {
const data = await res.json();
if (data.authenticated) {
const allApps = ['tasks', 'trips', 'fitness', 'inventory', 'budget', 'reader', 'media'];
const hiddenByUser: Record<string, string[]> = {
'madiha': ['inventory', 'reader'],
};
const hidden = hiddenByUser[data.user.username] || [];
const visibleApps = allApps.filter(a => !hidden.includes(a));
return { user: data.user, visibleApps };
}
}
} catch { /* gateway down */ }
throw redirect(302, `/login?redirect=${encodeURIComponent(url.pathname)}`);
};

View File

@@ -0,0 +1,32 @@
<script lang="ts">
import AppShell from '$lib/components/layout/AppShell.svelte';
import FitnessAssistantDrawer from '$lib/components/assistant/FitnessAssistantDrawer.svelte';
let { children, data } = $props();
let commandOpen = $state(false);
const visibleApps = data?.visibleApps || ['trips', 'fitness', 'inventory', 'budget', 'reader', 'media'];
const userName = data?.user?.display_name || '';
function openCommand() {
commandOpen = true;
}
function closeCommand() {
commandOpen = false;
}
function handleKeydown(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault();
commandOpen = !commandOpen;
}
}
</script>
<svelte:window onkeydown={handleKeydown} />
<AppShell onOpenCommand={openCommand} {visibleApps} {userName}>
{@render children()}
</AppShell>
<FitnessAssistantDrawer bind:open={commandOpen} onclose={closeCommand} />

View File

@@ -0,0 +1,34 @@
<script lang="ts">
import PageIntro from '$lib/components/shared/PageIntro.svelte';
</script>
<div class="page">
<div class="app-surface">
<PageIntro
eyebrow="Atelier"
title="A parallel frontend for the next Platform"
description="This route tree is the new interface track. It can evolve against the same backend APIs while the current app remains live and unchanged."
meta="Safe parallel build"
/>
<div class="links">
<a href="/atelier/inventory">Open inventory</a>
<a href="/atelier/fitness">Open fitness</a>
<a href="/atelier/reader">Open reader</a>
</div>
</div>
</div>
<style>
.links {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.links a {
padding: 12px 16px;
border-radius: 999px;
background: rgba(255,255,255,0.68);
border: 1px solid rgba(35,26,17,0.08);
}
</style>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,327 @@
<script lang="ts">
import { page } from '$app/state';
import { BookOpen, CalendarDays, CircleDot, Compass, Dumbbell, Landmark, LibraryBig, Package2, Settings2, SquareCheckBig } from '@lucide/svelte';
import { mockupNav, routeMeta } from '$lib/mockup/data';
const icons = {
'/mockup': CircleDot,
'/mockup/tasks': SquareCheckBig,
'/mockup/trips': Compass,
'/mockup/fitness': Dumbbell,
'/mockup/budget': Landmark,
'/mockup/inventory': Package2,
'/mockup/reader': BookOpen,
'/mockup/media': LibraryBig,
'/mockup/settings': Settings2
};
const navItems = mockupNav.map((item) => ({
...item,
icon: icons[item.href as keyof typeof icons]
}));
let { children } = $props();
const meta = $derived(routeMeta[page.url.pathname] || routeMeta['/mockup']);
const today = $derived(
new Intl.DateTimeFormat('en-US', {
weekday: 'short',
month: 'short',
day: 'numeric'
}).format(new Date())
);
</script>
<svelte:head>
<title>Platform Mockup</title>
</svelte:head>
<div class="mockup-shell">
<div class="mockup-glow mockup-glow-a"></div>
<div class="mockup-glow mockup-glow-b"></div>
<aside class="mockup-rail reveal">
<div class="rail-brand">
<div class="brand-mark">P</div>
<div>
<div class="brand-name">Platform Atelier</div>
<div class="brand-sub">concept workspace</div>
</div>
</div>
<nav class="rail-nav">
{#each navItems as item}
<a
href={item.href}
class:selected={page.url.pathname === item.href}
>
<item.icon size={16} strokeWidth={1.8} />
<span>{item.label}</span>
</a>
{/each}
</nav>
<div class="rail-note">
<div class="note-label">Intent</div>
<p>Separate concept route for browsing a rebuilt visual direction without changing the live app.</p>
</div>
</aside>
<div class="mockup-main">
<header class="mockup-header reveal">
<div>
<div class="eyebrow">{meta.eyebrow}</div>
<h1>{meta.title}</h1>
<p>{meta.description}</p>
</div>
<div class="header-side">
<div class="header-chip">
<CalendarDays size={15} strokeWidth={1.8} />
<span>{today}</span>
</div>
<div class="header-meta">Prototype routes only</div>
</div>
</header>
<section class="mockup-content">
{@render children()}
</section>
</div>
</div>
<style>
:global(body) {
background:
radial-gradient(circle at top left, rgba(214, 120, 58, 0.14), transparent 26%),
radial-gradient(circle at 85% 12%, rgba(65, 91, 82, 0.18), transparent 24%),
linear-gradient(180deg, #f5efe6 0%, #efe6da 48%, #ede7de 100%);
color: #1a1612;
}
.mockup-shell {
--sand: #f7f1e8;
--sand-deep: rgba(247, 241, 232, 0.72);
--ink: #1e1812;
--muted: #6b6256;
--line: rgba(35, 26, 17, 0.12);
--accent: #b35c32;
--accent-soft: rgba(179, 92, 50, 0.12);
--forest: #445f56;
--forest-soft: rgba(68, 95, 86, 0.12);
min-height: 100vh;
display: grid;
grid-template-columns: 280px minmax(0, 1fr);
position: relative;
overflow: clip;
}
.mockup-glow {
position: fixed;
border-radius: 999px;
filter: blur(80px);
pointer-events: none;
opacity: 0.45;
}
.mockup-glow-a {
width: 360px;
height: 360px;
top: -120px;
right: 12%;
background: rgba(179, 92, 50, 0.2);
}
.mockup-glow-b {
width: 280px;
height: 280px;
bottom: 10%;
left: 6%;
background: rgba(68, 95, 86, 0.18);
}
.mockup-rail {
position: sticky;
top: 0;
height: 100vh;
padding: 28px 22px 24px;
display: flex;
flex-direction: column;
justify-content: space-between;
background: linear-gradient(180deg, rgba(250, 246, 239, 0.84), rgba(244, 237, 228, 0.68));
border-right: 1px solid var(--line);
backdrop-filter: blur(22px);
}
.rail-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, var(--ink), #5d4c3f);
color: white;
font-size: 18px;
font-weight: 700;
letter-spacing: 0.06em;
}
.brand-name {
font-size: 14px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.brand-sub,
.note-label,
.eyebrow,
.header-meta {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--muted);
}
.rail-nav {
display: grid;
gap: 8px;
margin: 30px 0 auto;
}
.rail-nav a {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 14px;
border-radius: 999px;
color: var(--muted);
transition: background 160ms ease, color 160ms ease, transform 160ms ease;
}
.rail-nav a:hover,
.rail-nav a.selected {
background: rgba(255, 255, 255, 0.62);
color: var(--ink);
transform: translateX(3px);
}
.rail-note {
padding: 18px 0 0;
border-top: 1px solid var(--line);
}
.rail-note p {
margin: 8px 0 0;
font-size: 14px;
line-height: 1.6;
color: var(--muted);
}
.mockup-main {
padding: 28px 28px 40px;
}
.mockup-header {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 20px;
padding: 12px 0 28px;
}
.mockup-header h1 {
max-width: 10ch;
margin: 8px 0 10px;
font-size: clamp(2.6rem, 5vw, 4.7rem);
line-height: 0.95;
letter-spacing: -0.06em;
font-weight: 700;
}
.mockup-header p {
max-width: 48rem;
margin: 0;
font-size: 16px;
line-height: 1.65;
color: var(--muted);
}
.header-side {
display: grid;
gap: 12px;
justify-items: end;
}
.header-chip {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.66);
border: 1px solid rgba(35, 26, 17, 0.08);
color: var(--ink);
}
.mockup-content {
display: grid;
}
@media (max-width: 980px) {
.mockup-shell {
grid-template-columns: 1fr;
}
.mockup-rail {
position: relative;
height: auto;
gap: 24px;
padding-bottom: 18px;
}
.rail-nav {
grid-template-columns: repeat(9, minmax(0, 1fr));
overflow-x: auto;
margin: 0;
}
.rail-nav a {
justify-content: center;
min-width: 118px;
}
.rail-note {
display: none;
}
.mockup-main {
padding: 18px 16px 28px;
}
.mockup-header {
flex-direction: column;
align-items: flex-start;
}
.header-side {
justify-items: start;
}
}
@media (max-width: 640px) {
.mockup-header h1 {
max-width: 12ch;
font-size: 2.8rem;
}
.rail-nav {
grid-template-columns: repeat(9, 118px);
}
}
</style>

View File

@@ -0,0 +1,334 @@
<script lang="ts">
import { ArrowUpRight, Dot, MoveRight } from '@lucide/svelte';
import { overview, trips, fitness, budget, inventory } from '$lib/mockup/data';
</script>
<div class="overview-grid">
<section class="poster stagger">
<div class="poster-copy">
<div class="kicker">Today at a glance</div>
<h2>One desk for movement, intake, stock, and spend.</h2>
<p>This concept treats the platform like a quiet studio wall: fewer boxes, stronger hierarchy, and clearer next actions.</p>
</div>
<div class="poster-metrics">
{#each overview.status as item}
<div class="metric-line">
<div>
<div class="metric-label">{item.label}</div>
<div class="metric-note">{item.note}</div>
</div>
<div class="metric-value">{item.value}</div>
</div>
{/each}
</div>
</section>
<section class="agenda reveal">
<div class="section-head">
<div>
<div class="section-label">Daily sequence</div>
<h3>Agenda</h3>
</div>
<a href="/mockup/trips">Open routes <ArrowUpRight size={15} /></a>
</div>
<div class="agenda-list">
{#each overview.agenda as item}
<div class="agenda-row">
<div class="agenda-time">{item.time}</div>
<div class="agenda-main">
<div class="agenda-title">{item.title}</div>
<div class="agenda-tag">{item.tag}</div>
</div>
</div>
{/each}
</div>
</section>
<section class="strip stagger">
<a href="/mockup/trips" class="strip-block">
<div class="strip-label">Trips</div>
<div class="strip-title">{trips.itineraries[0].name}</div>
<div class="strip-detail">{trips.itineraries[0].status}</div>
</a>
<a href="/mockup/fitness" class="strip-block">
<div class="strip-label">Fitness</div>
<div class="strip-title">{fitness.macros[0].value} protein</div>
<div class="strip-detail">Recovery: {fitness.recovery[0]}</div>
</a>
<a href="/mockup/budget" class="strip-block">
<div class="strip-label">Budget</div>
<div class="strip-title">{budget.streams[1].amount}</div>
<div class="strip-detail">{budget.streams[1].name}</div>
</a>
<a href="/mockup/inventory" class="strip-block">
<div class="strip-label">Inventory</div>
<div class="strip-title">{inventory.rooms[1].coverage}</div>
<div class="strip-detail">{inventory.rooms[1].name}</div>
</a>
</section>
<section class="signals reveal">
<div class="section-head">
<div>
<div class="section-label">Signals</div>
<h3>Operating context</h3>
</div>
<div class="mini-link">Scan everything <MoveRight size={15} /></div>
</div>
<div class="signal-list">
{#each overview.signals as item}
<div class="signal-row">
<div>
<div class="signal-value">{item.value}</div>
<div class="signal-label">{item.label}</div>
</div>
<div class="signal-detail">{item.detail}</div>
</div>
{/each}
</div>
</section>
<section class="notes reveal">
<div class="section-head">
<div>
<div class="section-label">Cross-app notes</div>
<h3>What needs attention next</h3>
</div>
</div>
<div class="notes-list">
<div class="note-item">
<Dot size={22} />
<span>{trips.notes[0]}</span>
</div>
<div class="note-item">
<Dot size={22} />
<span>{budget.watchlist[0]}</span>
</div>
<div class="note-item">
<Dot size={22} />
<span>{inventory.rooms[2].note}</span>
</div>
</div>
</section>
</div>
<style>
.overview-grid {
display: grid;
grid-template-columns: minmax(0, 1.45fr) minmax(280px, 0.9fr);
gap: 18px;
align-items: start;
}
.poster,
.agenda,
.signals,
.notes {
background: rgba(255, 252, 248, 0.66);
border: 1px solid rgba(35, 26, 17, 0.1);
backdrop-filter: blur(14px);
}
.poster {
grid-column: 1 / 2;
padding: 28px;
min-height: 420px;
display: grid;
align-content: space-between;
background:
linear-gradient(145deg, rgba(255, 250, 244, 0.88), rgba(246, 233, 220, 0.55)),
radial-gradient(circle at 85% 15%, rgba(179, 92, 50, 0.18), transparent 28%);
border-radius: 30px;
}
.poster-copy h2 {
max-width: 10ch;
margin: 8px 0 12px;
font-size: clamp(2rem, 4vw, 3.4rem);
line-height: 0.95;
letter-spacing: -0.05em;
}
.poster-copy p,
.metric-note,
.signal-detail,
.note-item span {
color: #6b6256;
line-height: 1.6;
}
.kicker,
.section-label,
.strip-label,
.metric-label,
.agenda-tag {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.12em;
color: #8b7b6a;
}
.poster-metrics {
display: grid;
gap: 14px;
}
.metric-line {
display: flex;
align-items: end;
justify-content: space-between;
padding-top: 14px;
border-top: 1px solid rgba(35, 26, 17, 0.1);
}
.metric-value {
font-size: clamp(1.6rem, 3vw, 2.4rem);
letter-spacing: -0.05em;
}
.agenda,
.signals,
.notes {
border-radius: 24px;
padding: 22px;
}
.agenda {
grid-column: 2 / 3;
}
.section-head {
display: flex;
align-items: end;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
.section-head h3 {
margin: 4px 0 0;
font-size: 1.5rem;
letter-spacing: -0.04em;
}
.section-head a,
.mini-link {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: #6b6256;
}
.agenda-list,
.signal-list,
.notes-list {
display: grid;
gap: 14px;
}
.agenda-row,
.signal-row {
display: grid;
grid-template-columns: 72px minmax(0, 1fr);
gap: 12px;
padding-top: 14px;
border-top: 1px solid rgba(35, 26, 17, 0.09);
}
.agenda-time {
font-size: 1.1rem;
letter-spacing: -0.03em;
}
.agenda-title,
.strip-title,
.signal-value {
font-size: 1.35rem;
letter-spacing: -0.04em;
}
.strip {
grid-column: 1 / -1;
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.strip-block {
padding: 18px 18px 20px;
border-radius: 24px;
background: rgba(255, 255, 255, 0.64);
border: 1px solid rgba(35, 26, 17, 0.08);
transition: transform 160ms ease, background 160ms ease;
}
.strip-block:hover {
transform: translateY(-3px);
background: rgba(255, 255, 255, 0.8);
}
.strip-detail,
.signal-label {
margin-top: 4px;
color: #6b6256;
}
.signals {
grid-column: 1 / 2;
}
.signal-row {
grid-template-columns: minmax(0, 0.75fr) minmax(0, 1fr);
}
.notes {
grid-column: 2 / 3;
}
.note-item {
display: flex;
align-items: start;
gap: 8px;
padding-top: 14px;
border-top: 1px solid rgba(35, 26, 17, 0.09);
}
@media (max-width: 980px) {
.overview-grid {
grid-template-columns: 1fr;
}
.poster,
.agenda,
.strip,
.signals,
.notes {
grid-column: 1 / -1;
}
.strip {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 640px) {
.poster,
.agenda,
.signals,
.notes {
padding: 18px;
border-radius: 22px;
}
.agenda-row,
.signal-row {
grid-template-columns: 1fr;
}
.strip {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,96 @@
<script lang="ts">
import { budget } from '$lib/mockup/data';
</script>
<div class="budget-layout">
<section class="flow-band stagger">
{#each budget.streams as item}
<article class="flow-item">
<div class="flow-label">{item.name}</div>
<h2>{item.amount}</h2>
<p>{item.note}</p>
</article>
{/each}
</section>
<section class="watch reveal">
<div class="section-label">Watchlist</div>
<h3>Category drift and decisions</h3>
<div class="watch-list">
{#each budget.watchlist as item}
<div class="watch-row">{item}</div>
{/each}
</div>
</section>
</div>
<style>
.budget-layout {
display: grid;
gap: 18px;
}
.flow-band,
.watch {
border-radius: 28px;
border: 1px solid rgba(35, 26, 17, 0.1);
background: rgba(255, 252, 248, 0.68);
backdrop-filter: blur(14px);
}
.flow-band {
padding: 18px;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.flow-item {
padding: 22px;
border-radius: 22px;
background: linear-gradient(140deg, rgba(255, 248, 242, 0.92), rgba(239, 232, 224, 0.66));
}
.flow-label,
.section-label {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.12em;
color: #8b7b6a;
}
.flow-item h2,
.watch h3 {
margin: 8px 0 10px;
font-size: clamp(2rem, 4vw, 3rem);
line-height: 0.95;
letter-spacing: -0.05em;
}
.flow-item p,
.watch-row {
color: #6b6256;
line-height: 1.6;
}
.watch {
padding: 22px;
}
.watch-list {
display: grid;
gap: 12px;
margin-top: 18px;
}
.watch-row {
padding-top: 12px;
border-top: 1px solid rgba(35, 26, 17, 0.09);
}
@media (max-width: 920px) {
.flow-band {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,149 @@
<script lang="ts">
import { fitness } from '$lib/mockup/data';
</script>
<div class="fitness-layout">
<section class="macro-board reveal">
<div class="section-label">Live intake</div>
<h2>Today</h2>
<div class="macro-list">
{#each fitness.macros as item}
<div class="macro-row">
<div>
<div class="macro-label">{item.label}</div>
<div class="macro-value">{item.value}</div>
</div>
<div class="macro-target">{item.target}</div>
</div>
{/each}
</div>
<div class="recovery-band">
{#each fitness.recovery as item}
<span>{item}</span>
{/each}
</div>
</section>
<section class="meal-log stagger">
{#each fitness.today as item}
<article class="meal-row">
<div class="meal-meta">{item.meal}</div>
<div class="meal-main">
<h3>{item.detail}</h3>
<p>{item.value}</p>
</div>
</article>
{/each}
</section>
</div>
<style>
.fitness-layout {
display: grid;
grid-template-columns: 380px minmax(0, 1fr);
gap: 18px;
}
.macro-board,
.meal-log {
border-radius: 28px;
border: 1px solid rgba(35, 26, 17, 0.1);
background: rgba(255, 252, 248, 0.68);
backdrop-filter: blur(14px);
}
.macro-board {
padding: 24px;
position: sticky;
top: 28px;
height: fit-content;
}
.section-label,
.meal-meta,
.macro-label {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.12em;
color: #8b7b6a;
}
.macro-board h2 {
margin: 8px 0 20px;
font-size: 3rem;
line-height: 0.95;
letter-spacing: -0.05em;
}
.macro-list {
display: grid;
gap: 14px;
}
.macro-row {
display: flex;
align-items: end;
justify-content: space-between;
padding-top: 14px;
border-top: 1px solid rgba(35, 26, 17, 0.09);
}
.macro-value,
.meal-main h3 {
margin: 4px 0 0;
font-size: 1.6rem;
line-height: 1.05;
letter-spacing: -0.04em;
}
.macro-target,
.meal-main p {
color: #6b6256;
}
.recovery-band {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 20px;
}
.recovery-band span {
padding: 10px 14px;
border-radius: 999px;
background: rgba(68, 95, 86, 0.1);
color: #34443d;
font-size: 13px;
}
.meal-log {
padding: 18px;
display: grid;
gap: 12px;
}
.meal-row {
display: grid;
grid-template-columns: 120px minmax(0, 1fr);
gap: 14px;
padding: 20px;
border-radius: 22px;
background: linear-gradient(135deg, rgba(255, 251, 246, 0.92), rgba(236, 244, 238, 0.55));
}
@media (max-width: 920px) {
.fitness-layout {
grid-template-columns: 1fr;
}
.macro-board {
position: static;
}
}
@media (max-width: 640px) {
.meal-row {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,112 @@
<script lang="ts">
import { inventory } from '$lib/mockup/data';
</script>
<div class="inventory-layout">
<section class="rooms stagger">
{#each inventory.rooms as item}
<article class="room-row">
<div>
<div class="room-label">{item.name}</div>
<h2>{item.coverage}</h2>
</div>
<p>{item.note}</p>
</article>
{/each}
</section>
<section class="restock reveal">
<div class="section-label">Restock run</div>
<h3>Next pickup list</h3>
<div class="restock-list">
{#each inventory.restock as item}
<div class="restock-pill">{item}</div>
{/each}
</div>
</section>
</div>
<style>
.inventory-layout {
display: grid;
grid-template-columns: minmax(0, 1.2fr) 320px;
gap: 18px;
}
.rooms,
.restock {
border-radius: 28px;
border: 1px solid rgba(35, 26, 17, 0.1);
background: rgba(255, 252, 248, 0.68);
backdrop-filter: blur(14px);
}
.rooms {
padding: 18px;
display: grid;
gap: 12px;
}
.room-row {
display: grid;
grid-template-columns: 240px minmax(0, 1fr);
gap: 16px;
padding: 22px;
border-radius: 22px;
background: linear-gradient(140deg, rgba(255, 251, 246, 0.92), rgba(233, 238, 233, 0.56));
}
.room-label,
.section-label {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.12em;
color: #8b7b6a;
}
.room-row h2,
.restock h3 {
margin: 8px 0 0;
font-size: clamp(1.8rem, 4vw, 2.7rem);
line-height: 0.95;
letter-spacing: -0.05em;
}
.room-row p {
margin: 0;
color: #6b6256;
line-height: 1.6;
align-self: center;
}
.restock {
padding: 22px;
}
.restock-list {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 18px;
}
.restock-pill {
padding: 11px 14px;
border-radius: 999px;
background: rgba(179, 92, 50, 0.1);
color: #7b472c;
font-size: 13px;
}
@media (max-width: 920px) {
.inventory-layout {
grid-template-columns: 1fr;
}
}
@media (max-width: 640px) {
.room-row {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,117 @@
<script lang="ts">
import { media } from '$lib/mockup/data';
</script>
<div class="media-layout">
<section class="tab-strip reveal">
{#each media.tabs as item, index}
<div class:active={index === 0} class="tab-pill">{item}</div>
{/each}
</section>
<section class="media-grid">
<div class="media-panel stagger">
<div class="panel-head">
<div class="section-label">Books</div>
<h2>Downloads</h2>
</div>
{#each media.books as item}
<article class="media-card">
<h3>{item.title}</h3>
<p>{item.detail}</p>
</article>
{/each}
</div>
<div class="media-panel stagger">
<div class="panel-head">
<div class="section-label">Music</div>
<h2>Saved listening</h2>
</div>
{#each media.music as item}
<article class="media-card">
<h3>{item.title}</h3>
<p>{item.detail}</p>
</article>
{/each}
</div>
<div class="media-panel stagger">
<div class="panel-head">
<div class="section-label">Library</div>
<h2>Curated shelves</h2>
</div>
{#each media.library as item}
<article class="media-card">
<h3>{item.title}</h3>
<p>{item.detail}</p>
</article>
{/each}
</div>
</section>
</div>
<style>
.media-layout { display: grid; gap: 18px; }
.tab-strip, .media-panel {
border-radius: 28px;
border: 1px solid rgba(35,26,17,0.1);
background: rgba(255,252,248,0.68);
backdrop-filter: blur(14px);
}
.tab-strip {
padding: 14px;
display: flex;
gap: 10px;
}
.tab-pill {
padding: 10px 14px;
border-radius: 999px;
color: #6b6256;
background: rgba(255,255,255,0.5);
}
.tab-pill.active {
color: #1e1812;
background: rgba(179,92,50,0.12);
}
.media-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 18px;
}
.media-panel {
padding: 18px;
display: grid;
gap: 12px;
}
.section-label {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.12em;
color: #8b7b6a;
}
.panel-head h2 {
margin: 8px 0 0;
font-size: 1.8rem;
line-height: 1.02;
letter-spacing: -0.04em;
}
.media-card {
padding: 18px;
border-radius: 22px;
background: linear-gradient(145deg, rgba(255,251,246,0.92), rgba(236,244,238,0.56));
}
.media-card h3 {
margin: 0 0 8px;
font-size: 1.1rem;
letter-spacing: -0.03em;
}
.media-card p {
margin: 0;
color: #6b6256;
line-height: 1.6;
}
@media (max-width: 980px) {
.media-grid { grid-template-columns: 1fr; }
}
</style>

View File

@@ -0,0 +1,109 @@
<script lang="ts">
import { reader } from '$lib/mockup/data';
</script>
<div class="reader-layout">
<aside class="reader-sidebar reveal">
<div class="section-label">Reading queues</div>
<div class="nav-list">
{#each reader.nav as item}
<div class="nav-row">
<span>{item.label}</span>
<span>{item.count}</span>
</div>
{/each}
</div>
<div class="section-label second">Feeds</div>
<div class="feed-list">
{#each reader.feeds as item}
<div class="feed-row">
<span>{item.name}</span>
<span>{item.count}</span>
</div>
{/each}
</div>
</aside>
<section class="reader-list stagger">
{#each reader.articles as item, index}
<article class:selected={index === 0} class="article-card">
<div class="article-meta">{item.source} · {item.time}</div>
<h2>{item.title}</h2>
<p>{item.excerpt}</p>
</article>
{/each}
</section>
<section class="reader-detail reveal">
<div class="detail-kicker">Focused article</div>
<h3>{reader.articles[0].title}</h3>
<p>{reader.articles[0].excerpt}</p>
<div class="detail-body">
The mock direction here is a three-part reading surface: narrow source navigation, a dense but calm article list, and a wide reading panel with room for starring, archiving, and sending to your knowledge system.
</div>
</section>
</div>
<style>
.reader-layout {
display: grid;
grid-template-columns: 260px minmax(0, 0.8fr) minmax(0, 1.1fr);
gap: 18px;
}
.reader-sidebar, .reader-list, .reader-detail {
border-radius: 28px;
border: 1px solid rgba(35,26,17,0.1);
background: rgba(255,252,248,0.68);
backdrop-filter: blur(14px);
}
.reader-sidebar, .reader-detail { padding: 22px; }
.reader-list {
padding: 18px;
display: grid;
gap: 12px;
}
.section-label, .detail-kicker, .article-meta {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.12em;
color: #8b7b6a;
}
.second { margin-top: 22px; }
.nav-list, .feed-list { display: grid; gap: 8px; margin-top: 12px; }
.nav-row, .feed-row {
display: flex;
justify-content: space-between;
padding: 10px 12px;
border-radius: 14px;
background: rgba(255,255,255,0.6);
color: #6b6256;
}
.article-card {
padding: 18px;
border-radius: 22px;
background: rgba(255,255,255,0.62);
border: 1px solid rgba(35,26,17,0.08);
}
.article-card.selected {
background: linear-gradient(145deg, rgba(255,248,242,0.9), rgba(245,232,221,0.6));
}
.article-card h2, .reader-detail h3 {
margin: 8px 0 10px;
font-size: clamp(1.4rem, 2.5vw, 2.2rem);
line-height: 1.02;
letter-spacing: -0.04em;
}
.article-card p, .reader-detail p, .detail-body {
color: #6b6256;
line-height: 1.7;
}
.detail-body {
margin-top: 18px;
padding-top: 18px;
border-top: 1px solid rgba(35,26,17,0.09);
}
@media (max-width: 1100px) {
.reader-layout { grid-template-columns: 1fr; }
}
</style>

View File

@@ -0,0 +1,95 @@
<script lang="ts">
import { settings } from '$lib/mockup/data';
</script>
<div class="settings-layout">
<section class="settings-panel reveal">
<div class="section-label">Account</div>
<div class="row-list">
{#each settings.account as item}
<div class="setting-row">
<span>{item.label}</span>
<strong>{item.value}</strong>
</div>
{/each}
</div>
</section>
<section class="settings-panel stagger">
<div class="section-label">Connections</div>
<div class="row-list">
{#each settings.connections as item}
<div class="setting-row">
<span>{item.name}</span>
<strong>{item.state}</strong>
</div>
{/each}
</div>
</section>
<section class="settings-panel stagger">
<div class="section-label">Goals</div>
<div class="goal-grid">
{#each settings.goals as item}
<div class="goal-card">
<div class="goal-label">{item.label}</div>
<div class="goal-value">{item.value}</div>
</div>
{/each}
</div>
</section>
</div>
<style>
.settings-layout {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 18px;
}
.settings-panel {
padding: 22px;
border-radius: 28px;
border: 1px solid rgba(35,26,17,0.1);
background: rgba(255,252,248,0.68);
backdrop-filter: blur(14px);
}
.section-label, .goal-label {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.12em;
color: #8b7b6a;
}
.row-list {
display: grid;
gap: 12px;
margin-top: 18px;
}
.setting-row {
display: flex;
justify-content: space-between;
gap: 12px;
padding-top: 12px;
border-top: 1px solid rgba(35,26,17,0.09);
color: #6b6256;
}
.setting-row strong { color: #1e1812; }
.goal-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
margin-top: 18px;
}
.goal-card {
padding: 18px;
border-radius: 22px;
background: linear-gradient(145deg, rgba(255,248,242,0.92), rgba(245,232,221,0.56));
}
.goal-value {
margin-top: 8px;
font-size: 1.8rem;
letter-spacing: -0.04em;
}
@media (max-width: 980px) {
.settings-layout { grid-template-columns: 1fr; }
}
</style>

View File

@@ -0,0 +1,108 @@
<script lang="ts">
import { tasks } from '$lib/mockup/data';
</script>
<div class="tasks-layout">
<section class="project-strip reveal">
<div class="section-label">Projects</div>
<div class="project-list">
{#each tasks.projects as item}
<div class="project-pill">{item}</div>
{/each}
</div>
</section>
<section class="board stagger">
{#each tasks.columns as column}
<div class="column">
<div class="column-head">
<div class="column-label">{column.name}</div>
<div class="column-count">{column.items.length}</div>
</div>
<div class="column-items">
{#each column.items as item}
<article class="task-card">
<h3>{item.title}</h3>
<p>{item.meta}</p>
</article>
{/each}
</div>
</div>
{/each}
</section>
</div>
<style>
.tasks-layout { display: grid; gap: 18px; }
.project-strip, .board {
border-radius: 28px;
border: 1px solid rgba(35,26,17,0.1);
background: rgba(255,252,248,0.68);
backdrop-filter: blur(14px);
}
.project-strip { padding: 22px; }
.section-label, .column-label {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.12em;
color: #8b7b6a;
}
.project-list {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 14px;
}
.project-pill {
padding: 10px 14px;
border-radius: 999px;
background: rgba(68,95,86,0.1);
color: #34443d;
font-size: 13px;
}
.board {
padding: 18px;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
}
.column {
padding: 16px;
border-radius: 22px;
background: linear-gradient(140deg, rgba(255,251,246,0.92), rgba(241,234,227,0.58));
}
.column-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 14px;
}
.column-count {
font-size: 13px;
color: #6b6256;
}
.column-items {
display: grid;
gap: 10px;
}
.task-card {
padding: 16px;
border-radius: 18px;
background: rgba(255,255,255,0.7);
border: 1px solid rgba(35,26,17,0.08);
}
.task-card h3 {
margin: 0 0 8px;
font-size: 1.1rem;
line-height: 1.2;
letter-spacing: -0.03em;
}
.task-card p {
margin: 0;
color: #6b6256;
line-height: 1.5;
}
@media (max-width: 920px) {
.board { grid-template-columns: 1fr; }
}
</style>

View File

@@ -0,0 +1,128 @@
<script lang="ts">
import { MoveRight } from '@lucide/svelte';
import { trips } from '$lib/mockup/data';
</script>
<div class="page-grid">
<section class="route-board stagger">
{#each trips.itineraries as item}
<article class="route-item">
<div class="route-meta">{item.window}</div>
<h2>{item.name}</h2>
<p>{item.status}</p>
<div class="stop-line">
{#each item.stops as stop, index}
<span>{stop}</span>{#if index < item.stops.length - 1}<MoveRight size={14} />{/if}
{/each}
</div>
<div class="weather">{item.weather}</div>
</article>
{/each}
</section>
<section class="detail-pane reveal">
<div class="section-label">Field notes</div>
<h3>Travel logic</h3>
<div class="notes-list">
{#each trips.notes as note}
<div class="note-row">{note}</div>
{/each}
</div>
</section>
</div>
<style>
.page-grid {
display: grid;
grid-template-columns: minmax(0, 1.35fr) 320px;
gap: 18px;
}
.route-board,
.detail-pane {
background: rgba(255, 252, 248, 0.68);
border: 1px solid rgba(35, 26, 17, 0.1);
border-radius: 28px;
backdrop-filter: blur(14px);
}
.route-board {
padding: 18px;
display: grid;
gap: 14px;
}
.route-item {
padding: 22px;
border-radius: 22px;
background: linear-gradient(135deg, rgba(255, 248, 242, 0.9), rgba(245, 232, 221, 0.58));
min-height: 230px;
display: grid;
align-content: start;
gap: 14px;
transition: transform 160ms ease;
}
.route-item:hover {
transform: translateY(-4px);
}
.route-meta,
.section-label {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.12em;
color: #8b7b6a;
}
.route-item h2,
.detail-pane h3 {
margin: 0;
font-size: clamp(1.8rem, 3vw, 2.5rem);
line-height: 0.98;
letter-spacing: -0.05em;
}
.route-item p,
.weather,
.note-row {
color: #6b6256;
line-height: 1.6;
}
.stop-line {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
font-size: 14px;
}
.detail-pane {
padding: 22px;
position: sticky;
top: 28px;
height: fit-content;
}
.notes-list {
display: grid;
gap: 12px;
margin-top: 18px;
}
.note-row {
padding-top: 12px;
border-top: 1px solid rgba(35, 26, 17, 0.09);
}
@media (max-width: 920px) {
.page-grid {
grid-template-columns: 1fr;
}
.detail-pane {
position: static;
}
}
</style>