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

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

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

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,243 @@
export const mockupNav = [
{ href: '/mockup', label: 'Overview' },
{ href: '/mockup/tasks', label: 'Tasks' },
{ href: '/mockup/trips', label: 'Trips' },
{ href: '/mockup/fitness', label: 'Fitness' },
{ href: '/mockup/budget', label: 'Budget' },
{ href: '/mockup/inventory', label: 'Inventory' },
{ href: '/mockup/reader', label: 'Reader' },
{ href: '/mockup/media', label: 'Media' },
{ href: '/mockup/settings', label: 'Settings' }
];
export const routeMeta: Record<string, { eyebrow: string; title: string; description: string }> = {
'/mockup': {
eyebrow: 'Platform mockup',
title: 'A calmer command center for everyday planning',
description: 'A single workspace for routes, meals, household stock, and spending without the current app chrome.'
},
'/mockup/tasks': {
eyebrow: 'Tasks',
title: 'Structure work by urgency, not by clutter',
description: 'A focused task board with today, backlog, and project lanes that stay readable under load.'
},
'/mockup/trips': {
eyebrow: 'Trips',
title: 'Plan each move against time, place, and weather',
description: 'Routes, stays, notes, and movement live in one continuous travel surface.'
},
'/mockup/fitness': {
eyebrow: 'Fitness',
title: 'Treat nutrition like a live operating log',
description: 'Meals, macros, hydration, and recovery stay readable at a glance.'
},
'/mockup/budget': {
eyebrow: 'Budget',
title: 'Track spend in the context of real life',
description: 'Cash flow, upcoming obligations, and category drift are grouped into one working view.'
},
'/mockup/inventory': {
eyebrow: 'Inventory',
title: 'Run the house like a stocked studio',
description: 'Expiry, restock, condition, and room-level coverage are laid out as an operational board.'
},
'/mockup/reader': {
eyebrow: 'Reader',
title: 'Read feeds like an editor, not a queue manager',
description: 'A split reading surface that keeps source, story, and save actions in one quiet frame.'
},
'/mockup/media': {
eyebrow: 'Media',
title: 'Collect books and music in one browsing room',
description: 'Discovery, downloads, and library curation share one flexible media workspace.'
},
'/mockup/settings': {
eyebrow: 'Settings',
title: 'Make connections and preferences feel deliberate',
description: 'Account state, themes, service links, and goals are grouped into one clear system page.'
}
};
export const overview = {
status: [
{ label: 'Open tasks', value: '14', note: '3 due before noon' },
{ label: 'Trip horizon', value: '2 routes', note: 'Austin and Santa Fe' },
{ label: 'Daily nutrition', value: '1,640 kcal', note: '82% of target logged' }
],
agenda: [
{ time: '08:30', title: 'Finalize Austin lodging shortlist', tag: 'Trips' },
{ time: '12:15', title: 'Log lunch and sync protein target', tag: 'Fitness' },
{ time: '15:00', title: 'Review uncategorized hardware spend', tag: 'Budget' },
{ time: '18:45', title: 'Restock pantry oils and rice', tag: 'Inventory' }
],
signals: [
{ label: 'Runway', value: '$4,280', detail: 'free cash after fixed obligations' },
{ label: 'Pantry coverage', value: '12 days', detail: 'grains, oils, frozen basics' },
{ label: 'Miles mapped', value: '1,148', detail: 'current spring route cluster' }
]
};
export const tasks = {
columns: [
{
name: 'Today',
items: [
{ title: 'Lock Austin hotel before price jump', meta: 'Trips · 31h left' },
{ title: 'Log lunch and hydration block', meta: 'Fitness · after noon' },
{ title: 'Tag camera battery expense', meta: 'Budget · quick admin' }
]
},
{
name: 'Next',
items: [
{ title: 'Pack spring road kit', meta: 'Inventory · checklist' },
{ title: 'Clear starred longreads', meta: 'Reader · evening' },
{ title: 'Pull jazz vinyl shortlist', meta: 'Media · weekend' }
]
},
{
name: 'Later',
items: [
{ title: 'Review pantry reorder levels', meta: 'Inventory · monthly' },
{ title: 'Tune May travel reserve', meta: 'Budget · before May 1' }
]
}
],
projects: ['Platform', 'Road Trips', 'Household', 'Reading', 'Studio']
};
export const trips = {
itineraries: [
{
name: 'Austin sprint',
window: 'Apr 11 to Apr 14',
status: 'Lodging hold expires in 31h',
stops: ['Home', 'Austin', 'Hill Country'],
weather: 'Warm evenings, light rain on arrival'
},
{
name: 'Santa Fe reset',
window: 'May 02 to May 07',
status: 'Drive blocks drafted',
stops: ['Home', 'Amarillo', 'Santa Fe'],
weather: 'Dry air, cold nights'
}
],
notes: [
'Keep one gas stop buffer before Amarillo.',
'Bookmark ceramic studios near Canyon Road.',
'Shift hotel check-in later if rain slows departure.'
]
};
export const fitness = {
today: [
{ meal: 'Breakfast', detail: 'Greek yogurt, berries, oats', value: '420 kcal' },
{ meal: 'Lunch', detail: 'Chicken wrap, citrus greens', value: '560 kcal' },
{ meal: 'Snack', detail: 'Protein shake, banana', value: '280 kcal' }
],
macros: [
{ label: 'Protein', value: '132g', target: '160g' },
{ label: 'Carbs', value: '148g', target: '220g' },
{ label: 'Fat', value: '46g', target: '70g' }
],
recovery: ['6.8h sleep', '3.1L water', 'Rest day with mobility block']
};
export const budget = {
streams: [
{ name: 'Home and studio', amount: '$1,240', note: 'rent, tools, utilities' },
{ name: 'Travel reserve', amount: '$680', note: 'fuel, stay holds, food buffer' },
{ name: 'Household flow', amount: '$420', note: 'groceries and restock cycle' }
],
watchlist: [
'Camera battery order still uncategorized',
'Flights remain off because both active trips are drive-first',
'April software spend drops after annual renewals clear'
]
};
export const inventory = {
rooms: [
{ name: 'Kitchen core', coverage: 'Stable', note: 'Grains and oils are healthy; spices need refill' },
{ name: 'Travel kit', coverage: 'Light', note: 'Restock charger pouch and toiletry minis' },
{ name: 'Studio shelf', coverage: 'Watch', note: 'Paper stock and tape are below preferred floor' }
],
restock: [
'Jasmine rice',
'Olive oil',
'AA batteries',
'Packing cubes',
'Gaffer tape'
]
};
export const reader = {
nav: [
{ label: 'Today', count: 38 },
{ label: 'Starred', count: 12 },
{ label: 'History', count: 164 }
],
feeds: [
{ name: 'Design Systems', count: 9 },
{ name: 'Travel Notes', count: 7 },
{ name: 'Personal Knowledge', count: 6 },
{ name: 'Tech Briefing', count: 16 }
],
articles: [
{
title: 'Why small software feels more trustworthy',
source: 'Dense Discovery',
time: '18 min',
excerpt: 'A better reading surface should help you keep context, make a decision, and move on without fighting the interface.'
},
{
title: 'The slow pleasures of regional train stations',
source: 'Field Notes',
time: '9 min',
excerpt: 'Travel planning improves when movement details sit beside atmosphere, weather, and timing rather than in separate tools.'
},
{
title: 'What to keep in a personal media archive',
source: 'Studio Ledger',
time: '11 min',
excerpt: 'Collections feel alive when acquisition, annotation, and retrieval share the same visual language.'
}
]
};
export const media = {
tabs: ['Books', 'Music', 'Library'],
books: [
{ title: 'The Rings of Saturn', detail: 'Downloaded · EPUB · 294 pages' },
{ title: 'A Swim in a Pond in the Rain', detail: 'Queued · EPUB · craft reading' }
],
music: [
{ title: 'Bill Evans Trio', detail: 'Jazz piano · 7 saved recordings' },
{ title: 'Khruangbin radio', detail: 'Travel mix · offline ready' }
],
library: [
{ title: 'Architecture shelf', detail: '42 items · strong notes density' },
{ title: 'Road reading', detail: '18 items · light, portable, re-readable' }
]
};
export const settings = {
account: [
{ label: 'Display name', value: 'Yusi' },
{ label: 'Theme', value: 'Sand / slate concept' },
{ label: 'Session mode', value: 'Connected to platform shell' }
],
connections: [
{ name: 'Trips', state: 'Connected' },
{ name: 'Fitness', state: 'Connected' },
{ name: 'Reader', state: 'Connected' },
{ name: 'Media', state: 'Needs review' }
],
goals: [
{ label: 'Calories', value: '2,000' },
{ label: 'Protein', value: '160g' },
{ label: 'Carbs', value: '220g' },
{ label: 'Fat', value: '70g' }
]
};

View File

@@ -0,0 +1,798 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/state';
import TaskSlideOver from '$lib/components/dashboard/TaskSlideOver.svelte';
interface QuickTask {
id: string;
title: string;
startDate?: string;
dueDate?: string;
isAllDay?: boolean;
projectId: string;
_projectName: string;
_projectId: string;
}
const userName = $derived((page as any).data?.user?.display_name || 'there');
let loading = $state(true);
let taskPanelOpen = $state(false);
let taskToday = $state<QuickTask[]>([]);
let taskOverdue = $state<QuickTask[]>([]);
let inventoryIssueCount = $state(0);
let inventoryReviewCount = $state(0);
let budgetUncatCount = $state(0);
let budgetSpending = $state(0);
let budgetIncome = $state(0);
let readerUnread = $state(0);
let readerStarred = $state(0);
let fitnessCalLogged = $state(0);
let fitnessCalGoal = $state(2000);
let fitnessProtein = $state(0);
let fitnessProteinGoal = $state(150);
let fitnessCarbs = $state(0);
let fitnessCarbsGoal = $state(200);
let fitnessFat = $state(0);
let fitnessFatGoal = $state(65);
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 formatMoney(value: number): string {
return '$' + Math.abs(value || 0).toLocaleString('en-US');
}
function formatSignedMoney(value: number): string {
const abs = '$' + Math.abs(value || 0).toLocaleString('en-US');
return value < 0 ? `-${abs}` : abs;
}
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();
const 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 taskCount = $derived(taskToday.length + taskOverdue.length);
const fitPercent = $derived(fitnessCalGoal > 0 ? Math.min(100, Math.round((fitnessCalLogged / fitnessCalGoal) * 100)) : 0);
const fitnessRemaining = $derived(Math.max(0, fitnessCalGoal - fitnessCalLogged));
const nextTask = $derived(taskOverdue[0] || taskToday[0] || null);
const spendMagnitude = $derived(Math.abs(budgetSpending || 0));
const netCash = $derived(budgetIncome - spendMagnitude);
const spendVsIncomePercent = $derived(
budgetIncome > 0 ? Math.min(100, Math.round((spendMagnitude / budgetIncome) * 100)) : 0
);
const topSignals = $derived([
{
label: 'Open tasks',
value: String(taskCount),
note: taskOverdue.length > 0 ? `${taskOverdue.length} overdue` : taskToday[0] ? `${taskToday.length} scheduled today` : 'Nothing urgent'
},
{
label: 'Inventory pressure',
value: `${inventoryIssueCount + inventoryReviewCount}`,
note: `${inventoryIssueCount} issues · ${inventoryReviewCount} review`
},
{
label: 'Budget open',
value: `${budgetUncatCount}`,
note: `${formatMoney(budgetSpending)} spent this month`
},
{
label: 'Reader queue',
value: `${readerUnread}`,
note: `${readerStarred} starred reads`
}
]);
async function loadDashboard() {
loading = true;
const now = new Date();
const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
try {
const [tasksRes, invRes, budgetRes, uncatRes, fitTotalsRes, fitGoalsRes, readerCountersRes, readerStarredRes] = await Promise.all([
fetch('/api/tasks/today', { credentials: 'include' }),
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' }),
fetch('/api/reader/feeds/counters', { credentials: 'include' }),
fetch('/api/reader/entries?starred=true&limit=1', { credentials: 'include' })
]);
if (tasksRes.ok) {
const t = await tasksRes.json();
taskToday = t.today || [];
taskOverdue = t.overdue || [];
}
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 = data.spendingDollars || 0;
budgetIncome = data.incomeDollars || 0;
}
if (uncatRes.ok) {
const data = await uncatRes.json();
budgetUncatCount = data.count || 0;
}
if (fitTotalsRes.ok) {
const data = await fitTotalsRes.json();
fitnessCalLogged = Math.round(data.total_calories || 0);
fitnessProtein = Math.round(data.total_protein || 0);
fitnessCarbs = Math.round(data.total_carbs || 0);
fitnessFat = Math.round(data.total_fat || 0);
}
if (fitGoalsRes.ok) {
const data = await fitGoalsRes.json();
fitnessCalGoal = data.calories || 2000;
fitnessProteinGoal = data.protein || 150;
fitnessCarbsGoal = data.carbs || 200;
fitnessFatGoal = data.fat || 65;
}
if (readerCountersRes.ok) {
const data = await readerCountersRes.json();
readerUnread = Object.values(data.unreads || {}).reduce((sum: number, value: any) => sum + (value as number), 0);
}
if (readerStarredRes.ok) {
const data = await readerStarredRes.json();
readerStarred = data.total || 0;
}
} catch {
// Keep partial dashboard rendering if one source fails.
}
loading = false;
}
onMount(loadDashboard);
</script>
<div class="dashboard-page">
<TaskSlideOver bind:open={taskPanelOpen} onclose={() => { taskPanelOpen = false; loadDashboard(); }} />
<div class="dashboard-grid">
<section class="hero-panel panel intro-sequence">
<div class="hero-copy">
<div class="panel-kicker">Today at a glance</div>
<h1>{getGreeting()}, {userName}.</h1>
<p>Scan the work that actually needs attention, then drop into the right app without bouncing through repeated modules.</p>
</div>
<div class="hero-signals">
{#each topSignals as item, index}
<div class="signal-line reveal-item" style={`--delay:${index * 80}ms`}>
<div>
<div class="signal-label">{item.label}</div>
<div class="signal-note">{item.note}</div>
</div>
<div class="signal-value">{loading ? '…' : item.value}</div>
</div>
{/each}
</div>
</section>
<section class="agenda-panel panel intro-sequence">
<div class="panel-head">
<div>
<div class="panel-kicker">Daily sequence</div>
<h2>Agenda</h2>
</div>
<button class="inline-link" onclick={() => taskPanelOpen = true}>Open tasks</button>
</div>
<div class="agenda-list">
{#if loading}
{#each [1, 2, 3, 4] as _}
<div class="agenda-row skeleton-row"></div>
{/each}
{:else if taskCount === 0}
<div class="agenda-empty">No tasks are due today.</div>
{:else}
{#each [...taskOverdue.slice(0, 2), ...taskToday.slice(0, 3)].slice(0, 4) as task}
<button class="agenda-row" onclick={() => taskPanelOpen = true}>
<div class="agenda-time">{taskOverdue.find((t) => t.id === task.id) ? 'Overdue' : formatTaskTime(task) || 'Today'}</div>
<div class="agenda-main">
<div class="agenda-title">{task.title}</div>
<div class="agenda-tag">{task._projectName}</div>
</div>
</button>
{/each}
{/if}
</div>
</section>
<section class="main-column intro-sequence">
<div class="panel budget-panel">
<div class="panel-head">
<div>
<div class="panel-kicker">Budget state</div>
<h2>Cash movement</h2>
</div>
<a href="/budget" class="inline-link">Open budget</a>
</div>
<div class="budget-figure">{formatMoney(spendMagnitude)}</div>
<div class="budget-copy">Spent this month with {budgetUncatCount} uncategorized transactions still open for review.</div>
<div class="budget-meter">
<div class="budget-meter-top">
<span>Spend vs income</span>
<strong>{spendVsIncomePercent}%</strong>
</div>
<div class="budget-bar"><span style={`width:${spendVsIncomePercent}%`}></span></div>
</div>
<div class="budget-breakdown">
<div>
<div class="mini-label">Income</div>
<div class="mini-value">{formatMoney(budgetIncome)}</div>
</div>
<div>
<div class="mini-label">Uncategorized</div>
<div class="mini-value">{budgetUncatCount}</div>
</div>
<div>
<div class="mini-label">Net</div>
<div class="mini-value">{formatSignedMoney(netCash)}</div>
</div>
</div>
</div>
</section>
<section class="side-column intro-sequence">
<div class="panel section-panel">
<div class="panel-head">
<div>
<div class="panel-kicker">Calorie target</div>
<h2>Calories</h2>
</div>
<a href="/fitness" class="inline-link">Open fitness</a>
</div>
<div class="nutrition-summary calories-only">
<div class="calorie-block">
<div class="calorie-head">
<strong>{fitnessCalLogged.toLocaleString()} / {fitnessCalGoal.toLocaleString()}</strong>
<span>{fitnessRemaining.toLocaleString()} left</span>
</div>
<div class="macro-bar calories"><span style={`width:${fitPercent}%`}></span></div>
<div class="calorie-note">{fitPercent}% of target logged today</div>
</div>
</div>
</div>
</section>
</div>
</div>
<style>
.dashboard-page {
min-height: calc(100vh - 56px);
}
.dashboard-grid {
display: grid;
grid-template-columns: minmax(0, 1.45fr) minmax(320px, 0.92fr);
gap: 18px;
padding: 24px 24px 120px;
max-width: 1460px;
margin: 0 auto;
}
.panel {
background: rgba(255, 252, 248, 0.82);
border: 1px solid rgba(35, 26, 17, 0.12);
backdrop-filter: blur(14px);
box-shadow: 0 18px 40px rgba(35, 24, 15, 0.05);
}
.hero-panel,
.agenda-panel,
.section-panel,
.budget-panel {
border-radius: 28px;
}
.hero-panel {
padding: 24px;
min-height: 330px;
display: grid;
align-content: space-between;
background:
linear-gradient(145deg, rgba(255, 250, 244, 0.9), rgba(246, 233, 220, 0.58)),
radial-gradient(circle at 86% 14%, rgba(179, 92, 50, 0.18), transparent 28%);
}
.hero-copy h1 {
margin: 8px 0 12px;
max-width: 10ch;
font-size: clamp(2.25rem, 3.9vw, 3.8rem);
line-height: 0.92;
letter-spacing: -0.07em;
color: #1e1812;
}
.hero-copy p,
.signal-note,
.budget-copy,
.focus-meta {
color: #5c5247;
line-height: 1.6;
}
.panel-kicker,
.signal-label,
.strip-label,
.mini-label,
.agenda-tag {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.14em;
color: #7b6d5f;
font-weight: 700;
}
.hero-signals {
display: grid;
gap: 14px;
}
.signal-line {
display: flex;
align-items: end;
justify-content: space-between;
gap: 14px;
padding-top: 16px;
border-top: 1px solid rgba(35, 26, 17, 0.12);
}
.signal-value {
font-size: clamp(1.7rem, 3vw, 2.45rem);
font-weight: 600;
letter-spacing: -0.05em;
color: #1e1812;
}
.agenda-panel,
.section-panel,
.budget-panel {
padding: 20px 22px;
}
.panel-head {
display: flex;
justify-content: space-between;
align-items: end;
gap: 12px;
margin-bottom: 16px;
}
.panel-head h2 {
margin: 4px 0 0;
font-size: 1.45rem;
font-weight: 650;
line-height: 1;
letter-spacing: -0.05em;
color: #1e1812;
}
.inline-link,
.focus-link {
display: inline-flex;
align-items: center;
gap: 8px;
border: none;
background: none;
padding: 0;
font-size: 13px;
font-weight: 600;
color: #2f6a52;
cursor: pointer;
font-family: var(--font);
}
.agenda-list {
display: grid;
gap: 14px;
}
.agenda-row {
display: grid;
grid-template-columns: 82px minmax(0, 1fr);
gap: 12px;
align-items: start;
text-align: left;
padding-top: 12px;
border-top: 1px solid rgba(35, 26, 17, 0.11);
background: none;
border-left: none;
border-right: none;
border-bottom: none;
cursor: pointer;
font-family: var(--font);
transition: color 160ms ease, transform 160ms ease;
}
.agenda-row:hover {
transform: translateX(2px);
}
.agenda-time {
font-size: 1rem;
color: #4e443a;
letter-spacing: -0.03em;
}
.agenda-title {
font-size: 1.1rem;
font-weight: 600;
letter-spacing: -0.04em;
color: #1e1812;
}
.agenda-main {
display: grid;
gap: 4px;
}
.agenda-empty {
padding: 18px 0 8px;
color: #6b6256;
}
.skeleton-row {
min-height: 48px;
opacity: 0.4;
}
.strip-row {
grid-column: 1 / -1;
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.strip-block {
padding: 18px 18px 20px;
border-radius: 22px;
background: rgba(255, 252, 248, 0.66);
border: 1px solid rgba(35, 26, 17, 0.1);
backdrop-filter: blur(14px);
text-decoration: none;
color: inherit;
transition: transform 180ms ease, box-shadow 180ms ease, border-color 180ms ease;
}
.strip-block:hover {
transform: translateY(-2px);
box-shadow: 0 16px 34px rgba(35, 24, 15, 0.08);
}
.strip-top {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
}
.strip-meta,
.strip-detail {
color: #6b6256;
font-size: 0.92rem;
}
.strip-title {
margin-top: 10px;
font-size: 1.55rem;
line-height: 1;
letter-spacing: -0.05em;
color: #1e1812;
}
.strip-detail {
margin-top: 6px;
line-height: 1.5;
}
.strip-bar,
.lane-meter,
.macro-bar {
height: 6px;
background: rgba(35, 26, 17, 0.08);
border-radius: 999px;
overflow: hidden;
}
.strip-bar {
margin-top: 14px;
}
.strip-fill,
.lane-meter span,
.macro-bar span {
display: block;
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, #346c56 0%, #4d8f73 100%);
transition: width 900ms cubic-bezier(0.16, 1, 0.3, 1);
}
.main-column {
display: grid;
gap: 18px;
}
.lane-title,
.focus-title {
font-size: 1.08rem;
font-weight: 600;
letter-spacing: -0.03em;
color: #1e1812;
}
.lane-value,
.mini-value {
font-size: 1rem;
font-family: var(--mono);
color: #1e1812;
}
.budget-panel {
background:
linear-gradient(145deg, rgba(255, 251, 246, 0.96), rgba(241, 247, 243, 0.82)),
radial-gradient(circle at 88% 14%, rgba(47, 106, 82, 0.1), transparent 28%);
}
.budget-figure {
font-size: clamp(2.2rem, 4vw, 3.35rem);
line-height: 0.92;
letter-spacing: -0.07em;
color: #1e1812;
margin-bottom: 8px;
}
.budget-breakdown {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
margin-top: 18px;
padding-top: 14px;
border-top: 1px solid rgba(35, 26, 17, 0.09);
}
.budget-meter {
margin-top: 20px;
display: grid;
gap: 8px;
}
.budget-meter-top {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
color: #6b6256;
font-size: 0.9rem;
}
.budget-meter-top strong {
color: #1e1812;
font-family: var(--mono);
font-weight: 600;
}
.budget-bar {
height: 10px;
border-radius: 999px;
background: rgba(35, 26, 17, 0.08);
overflow: hidden;
}
.budget-bar span {
display: block;
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, #2f6a52 0%, #7db592 100%);
transition: width 900ms cubic-bezier(0.16, 1, 0.3, 1);
}
.side-column {
display: grid;
gap: 18px;
align-content: start;
}
.nutrition-summary {
display: grid;
gap: 14px;
}
.calories-only {
min-height: 140px;
align-content: start;
}
.calorie-block {
display: grid;
gap: 10px;
}
.calorie-head {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
}
.calorie-head strong {
color: #1e1812;
font-size: 1.18rem;
font-family: var(--mono);
font-weight: 600;
}
.calorie-head span,
.calorie-note {
color: #6b6256;
font-size: 0.92rem;
}
.macro-bar.calories span {
background: linear-gradient(90deg, #346c56 0%, #4d8f73 100%);
}
.macro-rows {
display: grid;
gap: 14px;
}
.macro-row {
display: grid;
gap: 6px;
}
.macro-top {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
color: #6b6256;
font-size: 0.92rem;
}
.macro-top strong {
color: #1e1812;
font-family: var(--mono);
font-weight: 600;
}
.macro-bar.carbs span {
background: linear-gradient(90deg, #c88932 0%, #dca95e 100%);
}
.macro-bar.fat span {
background: linear-gradient(90deg, #8f5d42 0%, #b67c5b 100%);
}
.intro-sequence {
animation: riseIn 720ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
.intro-sequence:nth-child(1) { animation-delay: 20ms; }
.intro-sequence:nth-child(2) { animation-delay: 90ms; }
.intro-sequence:nth-child(3) { animation-delay: 140ms; }
.intro-sequence:nth-child(4) { animation-delay: 190ms; }
.intro-sequence:nth-child(5) { animation-delay: 230ms; }
.reveal-item {
opacity: 0;
animation: revealLine 560ms cubic-bezier(0.16, 1, 0.3, 1) forwards;
animation-delay: var(--delay, 0ms);
}
@keyframes riseIn {
from {
opacity: 0;
transform: translateY(18px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes revealLine {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (max-width: 1100px) {
.dashboard-grid {
grid-template-columns: 1fr;
}
.strip-row {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 768px) {
.dashboard-grid {
padding: 20px 16px 120px;
gap: 14px;
}
.hero-panel,
.agenda-panel,
.section-panel,
.budget-panel {
border-radius: 24px;
padding: 18px;
}
.hero-copy h1 {
font-size: 2.6rem;
}
.strip-row,
.budget-breakdown {
grid-template-columns: 1fr;
}
.lane-row {
grid-template-columns: 1fr;
}
.agenda-row {
grid-template-columns: 1fr;
gap: 4px;
}
.panel-head {
align-items: start;
flex-direction: column;
}
}
</style>

View File

@@ -0,0 +1,444 @@
<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();
});
</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>

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,902 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/state';
import ImmichPicker from '$lib/components/shared/ImmichPicker.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));
});
</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}
{#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>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,880 @@
<script lang="ts">
import { onMount } from '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);
};
});
</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}
<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>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,849 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import CreateTripModal from '$lib/components/trips/CreateTripModal.svelte';
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 createOpen = $state(false);
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 nextTrip = $derived(upcoming.find((trip) => trip.status === 'upcoming') || upcoming[0] || null);
const activeTrip = $derived(upcoming.find((trip) => trip.status === 'active') || null);
const leadTrip = $derived(activeTrip || nextTrip);
const searchResults = $derived(
searchQuery.trim()
? allTrips.filter((trip) =>
trip.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
trip.cities.some((city) => city.toLowerCase().includes(searchQuery.toLowerCase()))
)
: null
);
const completedCount = $derived(past.length);
const activeCount = $derived(upcoming.filter((trip) => trip.status === 'active').length);
const openTripCount = $derived(upcoming.length);
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 || ''
};
}
async function loadTrips() {
loading = true;
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 all = (data.trips || []).map(mapTrip);
upcoming = all
.filter((trip) => trip.status === 'active' || trip.status === 'upcoming')
.sort((a, b) => a.sortDate.localeCompare(b.sortDate));
past = all
.filter((trip) => trip.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
};
}
} catch {
// keep route stable
} finally {
loading = false;
}
}
onMount(loadTrips);
</script>
<div class="trips-page">
<section class="journey-hero reveal">
<div class="hero-copy">
<div class="panel-kicker">Travel desk</div>
<h1>Trips</h1>
<p>Keep the active run, the next departure, and the archive in one calmer travel surface.</p>
</div>
<div class="hero-actions">
<button class="hero-button primary" onclick={() => (createOpen = true)}>Plan trip</button>
<a class="hero-button ghost" href={leadTrip ? `/trips/trip?id=${leadTrip.id}` : '/trips'}>
{activeTrip ? 'Open active' : 'Open next'}
</a>
</div>
</section>
<section class="signal-strip reveal">
<div class="signal-line">
<div>
<div class="signal-label">Open itineraries</div>
<div class="signal-note">{activeCount} active · {Math.max(0, upcoming.length - activeCount)} upcoming</div>
</div>
<div class="signal-value">{loading ? '…' : upcoming.length}</div>
</div>
<div class="signal-line">
<div>
<div class="signal-label">Coverage</div>
<div class="signal-note">{stats.cities} cities across {stats.countries} countries</div>
</div>
<div class="signal-value">{loading ? '…' : stats.countries}</div>
</div>
<div class="signal-line">
<div>
<div class="signal-label">Archive</div>
<div class="signal-note">{completedCount} completed journeys on record</div>
</div>
<div class="signal-value">{loading ? '…' : completedCount}</div>
</div>
<div class="signal-line">
<div>
<div class="signal-label">Points redeemed</div>
<div class="signal-note">Long-range travel cost pulled from live stats</div>
</div>
<div class="signal-value">{loading ? '…' : formatPoints(stats.points)}</div>
</div>
</section>
<section class="search-band reveal">
<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="search-input" type="text" placeholder="Search trips, cities, hotels, routes..." 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>
<div class="search-note">
{#if searchResults}
{searchResults.length} result{searchResults.length !== 1 ? 's' : ''}
{:else if activeTrip}
Active now: {activeTrip.name}
{:else if nextTrip}
Next departure: {nextTrip.daysAway || nextTrip.dates}
{:else}
No active itinerary right now.
{/if}
</div>
</section>
<div class="trips-grid">
<section class="journey-column reveal">
{#if searchResults}
<div class="section-head">
<div>
<div class="section-label">Search</div>
<h2>Matching trips</h2>
</div>
</div>
<div class="journey-list">
{#each searchResults as trip (trip.id)}
<a class="journey-row" class:muted={trip.status === 'completed'} href={`/trips/trip?id=${trip.id}`}>
<div class="journey-cover" style={trip.cover ? `background-image:url('${trip.cover}')` : ''}>
{#if !trip.cover}
<div class="journey-cover-empty">{trip.name.charAt(0)}</div>
{/if}
</div>
<div class="journey-main">
<div class="journey-meta">
<span class={`status-pill ${trip.status}`}>{trip.status === 'completed' ? 'Completed' : trip.status === 'active' ? 'Active now' : 'Upcoming'}</span>
<span>{trip.dates}</span>
{#if trip.daysAway}<span>{trip.daysAway}</span>{/if}
</div>
<div class="journey-name">{trip.name}</div>
<div class="journey-notes">{trip.duration}{#if trip.points} · {formatPoints(trip.points)} pts{/if}</div>
</div>
</a>
{/each}
</div>
{:else}
<div class="section-head">
<div>
<div class="section-label">Active board</div>
<h2>Current and upcoming</h2>
</div>
{#if nextTrip}
<a class="inline-link" href={`/trips/trip?id=${nextTrip.id}`}>Open next trip</a>
{/if}
</div>
<div class="journey-list">
{#if upcoming.length === 0}
<div class="journey-empty">No live itinerary. Start a new trip to build the travel board.</div>
{:else}
{#each upcoming as trip (trip.id)}
<a class="journey-row featured" href={`/trips/trip?id=${trip.id}`}>
<div class="journey-cover large" style={trip.cover ? `background-image:url('${trip.cover}')` : ''}>
{#if !trip.cover}
<div class="journey-cover-empty">{trip.name.charAt(0)}</div>
{/if}
</div>
<div class="journey-main">
<div class="journey-meta">
<span class={`status-pill ${trip.status}`}>{trip.status === 'active' ? 'Active now' : 'Upcoming'}</span>
<span>{trip.dates}</span>
{#if trip.daysAway}<span>{trip.daysAway}</span>{/if}
</div>
<div class="journey-name">{trip.name}</div>
<div class="journey-notes">{trip.duration}{#if trip.points} · {formatPoints(trip.points)} pts{/if}</div>
<div class="journey-open">Open itinerary</div>
</div>
</a>
{/each}
{/if}
</div>
<div class="section-head archive">
<div>
<div class="section-label">Archive</div>
<h2>Past routes</h2>
</div>
</div>
<div class="journey-list compact">
{#each past as trip (trip.id)}
<a class="journey-row muted compact-row" href={`/trips/trip?id=${trip.id}`}>
<div class="journey-cover compact" style={trip.cover ? `background-image:url('${trip.cover}')` : ''}>
{#if !trip.cover}
<div class="journey-cover-empty">{trip.name.charAt(0)}</div>
{/if}
</div>
<div class="journey-main">
<div class="journey-meta">
<span class="status-pill completed">Completed</span>
<span>{trip.dates}</span>
</div>
<div class="journey-name">{trip.name}</div>
<div class="journey-notes">{trip.duration}{#if trip.cash} · ${trip.cash.toLocaleString()}{/if}</div>
</div>
</a>
{/each}
</div>
{/if}
</section>
<aside class="travel-rail reveal">
<div class="rail-block standout">
<div class="section-label">Next move</div>
<h3>{leadTrip ? leadTrip.name : 'No trip queued'}</h3>
<p>
{#if leadTrip}
{activeTrip ? 'Currently in motion. Keep the itinerary, bookings, and notes close.' : `${leadTrip.daysAway} until departure.`}
{:else}
Open a new itinerary to track dates, bookings, and notes here.
{/if}
</p>
{#if leadTrip}
<div class="rail-meta">{leadTrip.dates} · {leadTrip.duration}</div>
<a class="rail-link" href={`/trips/trip?id=${leadTrip.id}`}>{activeTrip ? 'Resume trip' : 'Open itinerary'}</a>
{/if}
</div>
</aside>
</div>
</div>
<CreateTripModal bind:open={createOpen} onCreated={(id) => goto(`/trips/trip?id=${id}`)} />
<style>
.trips-page {
display: grid;
gap: 18px;
color: #1f1811;
}
.journey-hero,
.signal-strip,
.search-band,
.journey-column,
.travel-rail {
background: rgba(250, 244, 236, 0.72);
border: 1px solid rgba(35, 26, 17, 0.08);
border-radius: 28px;
backdrop-filter: blur(16px);
}
.journey-hero {
padding: 22px 24px;
display: flex;
align-items: end;
justify-content: space-between;
gap: 18px;
background:
radial-gradient(circle at top left, rgba(214, 126, 59, 0.14), transparent 30%),
linear-gradient(135deg, rgba(252, 246, 239, 0.96), rgba(241, 231, 220, 0.88));
}
.hero-copy {
display: grid;
gap: 8px;
max-width: 36rem;
}
.panel-kicker,
.section-label {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.14em;
color: #8a7868;
}
.journey-hero h1,
.section-head h2,
.travel-rail h3 {
margin: 0;
letter-spacing: -0.055em;
color: #1c140c;
}
.journey-hero h1 {
font-size: clamp(2.6rem, 5vw, 4.2rem);
line-height: 0.92;
}
.journey-hero p,
.rail-copy,
.standout p {
margin: 0;
color: #5f5347;
line-height: 1.55;
}
.hero-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.hero-button,
.inline-link {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 999px;
text-decoration: none;
transition: all 160ms ease;
}
.hero-button {
padding: 11px 16px;
font-size: 0.92rem;
font-weight: 600;
border: 1px solid rgba(35, 26, 17, 0.08);
}
.hero-button.primary {
background: #2f241c;
color: #fff7ee;
}
.hero-button.ghost {
background: rgba(255, 251, 246, 0.82);
color: #4f4338;
}
.signal-strip {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
overflow: hidden;
}
.signal-line {
display: flex;
justify-content: space-between;
gap: 14px;
padding: 18px 20px;
border-right: 1px solid rgba(35, 26, 17, 0.08);
}
.signal-line:last-child {
border-right: none;
}
.signal-label {
font-size: 0.84rem;
font-weight: 600;
color: #30261d;
}
.signal-note {
margin-top: 4px;
font-size: 0.84rem;
color: #6f6255;
line-height: 1.45;
}
.signal-value {
font-size: 1.4rem;
font-weight: 700;
color: #1b140d;
letter-spacing: -0.04em;
}
.search-band {
display: flex;
align-items: center;
gap: 16px;
padding: 14px 18px;
}
.search-wrap {
position: relative;
flex: 1;
}
.search-icon {
position: absolute;
left: 14px;
top: 50%;
width: 16px;
height: 16px;
color: #857464;
transform: translateY(-50%);
pointer-events: none;
}
.search-input {
width: 100%;
padding: 13px 44px 13px 42px;
border-radius: 18px;
border: 1px solid rgba(35, 26, 17, 0.08);
background: rgba(255, 251, 246, 0.88);
color: #241c15;
font-size: 0.98rem;
}
.search-clear {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
color: #857464;
}
.search-clear svg {
width: 16px;
height: 16px;
}
.search-note {
flex-shrink: 0;
font-size: 0.88rem;
color: #65584b;
}
.trips-grid {
display: grid;
grid-template-columns: minmax(0, 1.4fr) 320px;
gap: 18px;
align-items: start;
}
.journey-column {
padding: 18px;
}
.travel-rail {
padding: 18px;
display: grid;
gap: 14px;
position: sticky;
top: 28px;
}
.section-head {
display: flex;
align-items: end;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
}
.section-head h2 {
font-size: 1.7rem;
line-height: 0.98;
}
.section-head.archive {
margin-top: 22px;
}
.inline-link {
padding: 10px 14px;
background: rgba(255, 251, 246, 0.84);
border: 1px solid rgba(35, 26, 17, 0.08);
color: #4f4338;
font-size: 0.88rem;
}
.journey-list {
display: grid;
gap: 12px;
}
.journey-row {
display: grid;
grid-template-columns: 200px minmax(0, 1fr);
gap: 16px;
padding: 14px;
border-radius: 22px;
background: linear-gradient(135deg, rgba(255, 249, 243, 0.94), rgba(241, 230, 219, 0.86));
border: 1px solid rgba(35, 26, 17, 0.07);
color: inherit;
text-decoration: none;
transition: transform 160ms ease, box-shadow 160ms ease;
}
.journey-row:hover {
transform: translateY(-2px);
box-shadow: 0 14px 30px rgba(41, 28, 17, 0.07);
}
.journey-row.muted {
opacity: 0.72;
}
.journey-row.compact-row {
grid-template-columns: 92px minmax(0, 1fr);
padding: 12px;
}
.journey-cover {
border-radius: 18px;
background: rgba(229, 218, 206, 0.84) center/cover no-repeat;
min-height: 128px;
overflow: hidden;
}
.journey-cover.large {
min-height: 152px;
}
.journey-cover.compact {
min-height: 84px;
}
.journey-cover-empty {
width: 100%;
height: 100%;
display: grid;
place-items: center;
font-size: 2rem;
font-weight: 700;
color: #8a7868;
}
.journey-main {
min-width: 0;
display: grid;
align-content: start;
gap: 8px;
}
.journey-meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
font-size: 0.78rem;
color: #7c6b5c;
}
.status-pill {
display: inline-flex;
align-items: center;
padding: 3px 8px;
border-radius: 999px;
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.status-pill.active {
background: rgba(74, 101, 83, 0.14);
color: #465f50;
}
.status-pill.upcoming {
background: rgba(167, 111, 67, 0.14);
color: #8d5b35;
}
.status-pill.completed {
background: rgba(35, 26, 17, 0.08);
color: #685c51;
}
.journey-name {
font-size: 1.46rem;
font-weight: 700;
line-height: 1.02;
letter-spacing: -0.04em;
color: #1c140c;
}
.journey-notes,
.journey-open,
.journey-empty,
.rail-meta,
.rail-stat span,
.coverage-row span {
color: #605447;
}
.journey-open {
font-size: 0.88rem;
font-weight: 600;
}
.journey-empty {
padding: 20px;
border-radius: 20px;
background: rgba(255, 250, 245, 0.7);
border: 1px dashed rgba(35, 26, 17, 0.11);
}
.rail-block {
padding: 16px;
border-radius: 22px;
background: rgba(255, 249, 243, 0.84);
border: 1px solid rgba(35, 26, 17, 0.07);
}
.rail-block.standout {
background: linear-gradient(140deg, rgba(255, 247, 240, 0.96), rgba(244, 231, 217, 0.9));
}
.travel-rail h3 {
font-size: 1.5rem;
line-height: 1.02;
margin-top: 6px;
}
.rail-meta {
margin-top: 10px;
font-size: 0.88rem;
}
.rail-link {
display: inline-flex;
align-items: center;
justify-content: center;
margin-top: 14px;
padding: 10px 14px;
border-radius: 999px;
background: rgba(255, 251, 246, 0.88);
border: 1px solid rgba(35, 26, 17, 0.08);
color: #4f4338;
font-size: 0.88rem;
font-weight: 600;
text-decoration: none;
}
.rail-stat,
.coverage-row {
display: flex;
justify-content: space-between;
gap: 12px;
padding-top: 10px;
margin-top: 10px;
border-top: 1px solid rgba(35, 26, 17, 0.08);
}
.rail-stat strong,
.coverage-row strong {
color: #201810;
font-family: var(--mono);
}
@media (max-width: 1100px) {
.signal-strip {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.signal-line:nth-child(2) {
border-right: none;
}
.trips-grid {
grid-template-columns: 1fr;
}
.travel-rail {
position: static;
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 768px) {
.trips-page {
gap: 16px;
padding-inline: 16px;
}
.journey-hero,
.signal-strip,
.search-band,
.journey-column,
.travel-rail {
border-radius: 24px;
}
.journey-hero {
padding: 18px 16px;
grid-template-columns: 1fr;
align-items: start;
}
.hero-actions,
.search-band,
.signal-strip,
.travel-rail {
display: grid;
}
.hero-actions {
grid-template-columns: 1fr 1fr;
}
.search-band {
padding: 14px 16px;
gap: 10px;
}
.search-note {
font-size: 0.82rem;
}
.signal-strip,
.travel-rail {
grid-template-columns: 1fr;
}
.signal-line {
padding: 16px;
border-right: none;
border-bottom: 1px solid rgba(35, 26, 17, 0.08);
}
.signal-line:last-child {
border-bottom: none;
}
.journey-row,
.journey-row.compact-row {
grid-template-columns: 1fr;
padding: 12px;
}
.journey-cover,
.journey-cover.large,
.journey-cover.compact {
min-height: 160px;
}
.journey-name {
font-size: 1.2rem;
}
.section-head {
align-items: start;
gap: 10px;
}
.journey-column,
.travel-rail {
padding: 16px;
}
}
</style>

View File

@@ -0,0 +1,345 @@
<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
);
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 || ''
};
}
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 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
};
}
} catch {
// keep route stable
} finally {
loading = false;
}
});
</script>
<div class="page">
<div class="app-surface">
<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>
<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>
<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}
<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}
<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>
<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 { 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-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-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); }
.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 { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--sp-5); }
.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); }
.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>