feat: brain service — self-contained second brain knowledge manager
Full backend service with: - FastAPI REST API with CRUD, search, reprocess endpoints - PostgreSQL + pgvector for items and semantic search - Redis + RQ for background job processing - Meilisearch for fast keyword/filter search - Browserless/Chrome for JS rendering and screenshots - OpenAI structured output for AI classification - Local file storage with S3-ready abstraction - Gateway auth via X-Gateway-User-Id header - Own docker-compose stack (6 containers) Classification: fixed folders (Home/Family/Work/Travel/Knowledge/Faith/Projects) and fixed tags (28 predefined). AI assigns exactly 1 folder, 2-3 tags, title, summary, and confidence score per item. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,982 @@
|
||||
<script lang="ts">
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import { tick } from 'svelte';
|
||||
import { Bot, Dumbbell, ImagePlus, SendHorizonal, Sparkles, X } from '@lucide/svelte';
|
||||
|
||||
type ChatRole = 'user' | 'assistant';
|
||||
type MealType = 'breakfast' | 'lunch' | 'dinner' | 'snack';
|
||||
|
||||
type Message = {
|
||||
role: ChatRole;
|
||||
content: string;
|
||||
image?: string;
|
||||
imageName?: string;
|
||||
};
|
||||
|
||||
type Draft = {
|
||||
food_name?: string;
|
||||
meal_type?: MealType;
|
||||
entry_date?: string;
|
||||
quantity?: number;
|
||||
unit?: string;
|
||||
calories?: number;
|
||||
protein?: number;
|
||||
carbs?: number;
|
||||
fat?: number;
|
||||
sugar?: number;
|
||||
fiber?: number;
|
||||
note?: string;
|
||||
};
|
||||
|
||||
type DraftBundle = Draft[];
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onclose: () => void;
|
||||
entryDate?: string | null;
|
||||
}
|
||||
|
||||
let { open = $bindable(), onclose, entryDate = null }: Props = $props();
|
||||
|
||||
const intro = `Tell me what you ate, including multiple items or a nutrition label photo, then correct me naturally until it looks right.`;
|
||||
|
||||
let messages = $state<Message[]>([
|
||||
{ role: 'assistant', content: intro }
|
||||
]);
|
||||
let draft = $state<Draft | null>(null);
|
||||
let drafts = $state<DraftBundle>([]);
|
||||
let input = $state('');
|
||||
let sending = $state(false);
|
||||
let error = $state('');
|
||||
let composerEl: HTMLInputElement | null = $state(null);
|
||||
let fileInputEl: HTMLInputElement | null = $state(null);
|
||||
let threadEl: HTMLElement | null = $state(null);
|
||||
let threadEndEl: HTMLDivElement | null = $state(null);
|
||||
let photoPreview = $state<string | null>(null);
|
||||
let photoName = $state('');
|
||||
let attachedPhoto = $state<string | null>(null);
|
||||
let attachedPhotoName = $state('');
|
||||
|
||||
async function scrollThreadToBottom(behavior: ScrollBehavior = 'smooth') {
|
||||
await tick();
|
||||
requestAnimationFrame(() => {
|
||||
threadEndEl?.scrollIntoView({ block: 'end', behavior });
|
||||
});
|
||||
}
|
||||
|
||||
function resetThread() {
|
||||
messages = [{ role: 'assistant', content: intro }];
|
||||
draft = null;
|
||||
drafts = [];
|
||||
input = '';
|
||||
error = '';
|
||||
photoPreview = null;
|
||||
photoName = '';
|
||||
attachedPhoto = null;
|
||||
attachedPhotoName = '';
|
||||
if (fileInputEl) fileInputEl.value = '';
|
||||
}
|
||||
|
||||
function beginRevision() {
|
||||
if (!input.trim()) {
|
||||
input = 'Actually ';
|
||||
}
|
||||
requestAnimationFrame(() => composerEl?.focus());
|
||||
}
|
||||
|
||||
function retryCurrentGuess() {
|
||||
void sendMessage("That's not right. Try again.", 'chat');
|
||||
}
|
||||
|
||||
async function sendMessage(content: string, action: 'chat' | 'apply' = 'chat') {
|
||||
const clean = content.trim();
|
||||
if (!clean && !photoPreview && action === 'chat') return;
|
||||
|
||||
error = '';
|
||||
sending = true;
|
||||
const outgoingPhoto = action === 'chat' ? photoPreview : null;
|
||||
const outgoingPhotoName = action === 'chat' ? photoName : '';
|
||||
|
||||
const nextMessages =
|
||||
action === 'chat'
|
||||
? [
|
||||
...messages,
|
||||
{
|
||||
role: 'user' as const,
|
||||
content: clean,
|
||||
image: outgoingPhoto || undefined,
|
||||
imageName: outgoingPhotoName || undefined
|
||||
}
|
||||
]
|
||||
: messages;
|
||||
|
||||
if (action === 'chat') {
|
||||
messages = nextMessages;
|
||||
input = '';
|
||||
if (outgoingPhoto) {
|
||||
attachedPhoto = outgoingPhoto;
|
||||
attachedPhotoName = outgoingPhotoName;
|
||||
photoPreview = null;
|
||||
photoName = '';
|
||||
if (fileInputEl) fileInputEl.value = '';
|
||||
}
|
||||
void scrollThreadToBottom('auto');
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/assistant/fitness', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
action,
|
||||
messages: nextMessages,
|
||||
draft,
|
||||
drafts,
|
||||
entryDate,
|
||||
imageDataUrl: action === 'chat' ? outgoingPhoto || attachedPhoto : null
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(data?.error || 'Assistant request failed');
|
||||
}
|
||||
|
||||
if (data.draft) {
|
||||
draft = data.draft;
|
||||
}
|
||||
|
||||
if (Array.isArray(data.drafts)) {
|
||||
drafts = data.drafts;
|
||||
if (data.drafts.length > 0) {
|
||||
draft = null;
|
||||
}
|
||||
} else if (data.draft) {
|
||||
drafts = [];
|
||||
}
|
||||
|
||||
if (data.reply) {
|
||||
messages = [...nextMessages, { role: 'assistant', content: data.reply }];
|
||||
}
|
||||
|
||||
if (data.applied) {
|
||||
photoPreview = null;
|
||||
photoName = '';
|
||||
attachedPhoto = null;
|
||||
attachedPhotoName = '';
|
||||
if (fileInputEl) fileInputEl.value = '';
|
||||
draft = null;
|
||||
drafts = [];
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('fitnessassistantapplied', {
|
||||
detail: { entryDate: data.draft?.entry_date || data.drafts?.[0]?.entry_date || entryDate || null }
|
||||
})
|
||||
);
|
||||
await invalidateAll();
|
||||
}
|
||||
|
||||
await scrollThreadToBottom();
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'Assistant request failed';
|
||||
} finally {
|
||||
sending = false;
|
||||
requestAnimationFrame(() => composerEl?.focus());
|
||||
}
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
void sendMessage(input, 'chat');
|
||||
}
|
||||
|
||||
function openPhotoPicker() {
|
||||
fileInputEl?.click();
|
||||
}
|
||||
|
||||
function loadPhoto(file: File | null) {
|
||||
if (!file) return;
|
||||
photoName = file.name;
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
if (typeof reader.result === 'string') {
|
||||
photoPreview = reader.result;
|
||||
}
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
function clearPhoto() {
|
||||
photoPreview = null;
|
||||
photoName = '';
|
||||
if (fileInputEl) fileInputEl.value = '';
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') {
|
||||
onclose();
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
requestAnimationFrame(() => composerEl?.focus());
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!open) return;
|
||||
|
||||
document.body.classList.add('assistant-open');
|
||||
|
||||
return () => {
|
||||
document.body.classList.remove('assistant-open');
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if open}
|
||||
<button class="assistant-backdrop" aria-label="Close assistant" onclick={onclose}></button>
|
||||
|
||||
<div class="assistant-drawer" role="dialog" aria-label="Fitness assistant">
|
||||
<header class="assistant-head">
|
||||
<div class="assistant-title-wrap">
|
||||
<div class="assistant-mark">
|
||||
<Dumbbell size={16} strokeWidth={1.9} />
|
||||
</div>
|
||||
<div>
|
||||
<div class="assistant-kicker">Assistant</div>
|
||||
<div class="assistant-title">Fitness chat</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="assistant-head-actions">
|
||||
<button class="assistant-ghost" onclick={resetThread}>Reset</button>
|
||||
<button class="assistant-close" onclick={onclose} aria-label="Close assistant">
|
||||
<X size={17} strokeWidth={1.9} />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="assistant-body">
|
||||
<section class="thread" bind:this={threadEl}>
|
||||
<div class="thread-spacer" aria-hidden="true"></div>
|
||||
{#each messages as message}
|
||||
<div class="bubble-row" class:user={message.role === 'user'}>
|
||||
{#if message.role === 'assistant'}
|
||||
<div class="bubble-icon">
|
||||
<Bot size={14} strokeWidth={1.8} />
|
||||
</div>
|
||||
{/if}
|
||||
<div class="bubble-stack" class:user={message.role === 'user'}>
|
||||
{#if message.image}
|
||||
<div class="image-bubble" class:user={message.role === 'user'}>
|
||||
<img src={message.image} alt={message.imageName || 'Sent food photo'} />
|
||||
{#if message.imageName}
|
||||
<div class="image-bubble-name">{message.imageName}</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if message.content}
|
||||
<div class="bubble" class:user={message.role === 'user'}>
|
||||
{message.content}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if drafts.length > 1}
|
||||
<div class="bubble-row draft-row">
|
||||
<div class="bubble-icon">
|
||||
<Sparkles size={14} strokeWidth={1.8} />
|
||||
</div>
|
||||
<div class="draft-card bundle-card">
|
||||
<div class="draft-card-top">
|
||||
<div>
|
||||
<div class="draft-card-kicker">Ready to add</div>
|
||||
<div class="draft-card-title">{drafts.length} items</div>
|
||||
</div>
|
||||
<div class="draft-meal">{drafts[0]?.meal_type || 'meal'}</div>
|
||||
</div>
|
||||
<div class="bundle-list">
|
||||
{#each drafts as item}
|
||||
<div class="bundle-row">
|
||||
<div class="bundle-name">{item.food_name}</div>
|
||||
<div class="bundle-calories">{Math.round(item.calories || 0)} cal</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="draft-inline-metrics">
|
||||
<span>{drafts.reduce((sum, item) => sum + Math.round(item.calories || 0), 0)} cal total</span>
|
||||
</div>
|
||||
<div class="draft-card-actions">
|
||||
<button class="draft-apply subtle" onclick={beginRevision} disabled={sending}>
|
||||
Edit
|
||||
</button>
|
||||
<button class="draft-apply" onclick={() => void sendMessage('', 'apply')} disabled={sending}>
|
||||
{sending ? 'Adding...' : 'Add all'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else if draft?.food_name}
|
||||
<div class="bubble-row draft-row">
|
||||
<div class="bubble-icon">
|
||||
<Sparkles size={14} strokeWidth={1.8} />
|
||||
</div>
|
||||
<div class="draft-card">
|
||||
<div class="draft-card-top">
|
||||
<div>
|
||||
<div class="draft-card-kicker">Ready to add</div>
|
||||
<div class="draft-card-title">{draft.food_name}</div>
|
||||
</div>
|
||||
{#if draft.meal_type}
|
||||
<div class="draft-meal">{draft.meal_type}</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="draft-inline-metrics">
|
||||
<span>{Math.round(draft.calories || 0)} cal</span>
|
||||
<span>{Math.round(draft.protein || 0)}p</span>
|
||||
<span>{Math.round(draft.carbs || 0)}c</span>
|
||||
<span>{Math.round(draft.fat || 0)}f</span>
|
||||
<span>{Math.round(draft.sugar || 0)} sugar</span>
|
||||
<span>{Math.round(draft.fiber || 0)} fiber</span>
|
||||
</div>
|
||||
<div class="draft-card-actions">
|
||||
<button class="draft-apply subtle" onclick={beginRevision} disabled={sending}>
|
||||
Edit
|
||||
</button>
|
||||
<button class="draft-apply subtle" onclick={retryCurrentGuess} disabled={sending}>
|
||||
Try again
|
||||
</button>
|
||||
<button class="draft-apply" onclick={() => void sendMessage('', 'apply')} disabled={sending}>
|
||||
{sending ? 'Adding...' : 'Add it'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="thread-end" bind:this={threadEndEl} aria-hidden="true"></div>
|
||||
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="assistant-error">{error}</div>
|
||||
{/if}
|
||||
|
||||
<footer class="assistant-compose">
|
||||
<div class="compose-shell">
|
||||
<input
|
||||
class="sr-only-file"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
bind:this={fileInputEl}
|
||||
onchange={(event) => loadPhoto((event.currentTarget as HTMLInputElement).files?.[0] || null)}
|
||||
/>
|
||||
<div class="compose-tools">
|
||||
<button class="compose-tool" type="button" onclick={openPhotoPicker}>
|
||||
<ImagePlus size={14} strokeWidth={1.9} />
|
||||
<span>Photo</span>
|
||||
</button>
|
||||
</div>
|
||||
{#if photoPreview}
|
||||
<div class="photo-staged">
|
||||
<img src={photoPreview} alt="Selected food" />
|
||||
<div class="photo-staged-copy">
|
||||
<div class="photo-staged-title">{photoName || 'Food photo'}</div>
|
||||
<div class="photo-staged-note">I’ll draft the entry from this photo.</div>
|
||||
</div>
|
||||
<button class="photo-staged-clear" type="button" onclick={clearPhoto}>Remove</button>
|
||||
</div>
|
||||
{:else if attachedPhoto}
|
||||
<div class="photo-staged attached">
|
||||
<img src={attachedPhoto} alt="Attached food context" />
|
||||
<div class="photo-staged-copy">
|
||||
<div class="photo-staged-title">{attachedPhotoName || 'Attached photo'}</div>
|
||||
<div class="photo-staged-note">Still attached for follow-up corrections.</div>
|
||||
</div>
|
||||
<button class="photo-staged-clear" type="button" onclick={() => { attachedPhoto = null; attachedPhotoName = ''; }}>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
<input
|
||||
class="compose-input"
|
||||
bind:value={input}
|
||||
bind:this={composerEl}
|
||||
placeholder="Add 2 boiled eggs for breakfast..."
|
||||
onkeydown={handleKeydown}
|
||||
type="text"
|
||||
/>
|
||||
<button
|
||||
class="compose-send"
|
||||
onclick={handleSubmit}
|
||||
disabled={sending || (!input.trim() && !photoPreview)}
|
||||
aria-label="Send message"
|
||||
>
|
||||
<SendHorizonal size={16} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.assistant-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
border: none;
|
||||
background: linear-gradient(180deg, rgba(239, 231, 220, 0.52), rgba(225, 215, 202, 0.34));
|
||||
backdrop-filter: blur(6px) saturate(1.02);
|
||||
z-index: 88;
|
||||
overscroll-behavior: none;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
:global(body.assistant-open) {
|
||||
overflow: hidden;
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
.assistant-drawer {
|
||||
position: fixed;
|
||||
top: 18px;
|
||||
right: 18px;
|
||||
bottom: 18px;
|
||||
width: min(430px, calc(100vw - 24px));
|
||||
border-radius: 30px;
|
||||
border: 1px solid rgba(36, 26, 18, 0.1);
|
||||
background:
|
||||
radial-gradient(circle at top right, rgba(54, 108, 86, 0.08), transparent 24%),
|
||||
linear-gradient(180deg, rgba(252, 248, 242, 0.96), rgba(244, 236, 227, 0.94));
|
||||
box-shadow: 0 28px 80px rgba(24, 16, 11, 0.2);
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr) auto auto;
|
||||
overflow: hidden;
|
||||
z-index: 89;
|
||||
}
|
||||
|
||||
.assistant-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 18px 18px 14px;
|
||||
border-bottom: 1px solid rgba(36, 26, 18, 0.08);
|
||||
}
|
||||
|
||||
.assistant-title-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.assistant-mark {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 14px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: linear-gradient(135deg, #215043, #3f7862);
|
||||
color: #fff9f2;
|
||||
}
|
||||
|
||||
.assistant-kicker {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: #857362;
|
||||
}
|
||||
|
||||
.assistant-title {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.03em;
|
||||
color: #1f1710;
|
||||
}
|
||||
|
||||
.assistant-head-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.assistant-ghost,
|
||||
.assistant-close {
|
||||
border: 1px solid rgba(36, 26, 18, 0.08);
|
||||
background: rgba(255, 255, 255, 0.62);
|
||||
color: #65584c;
|
||||
}
|
||||
|
||||
.assistant-ghost {
|
||||
height: 34px;
|
||||
padding: 0 12px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.84rem;
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.assistant-close {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 999px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.assistant-body {
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.thread {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
touch-action: pan-y;
|
||||
padding: 14px 18px 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.bundle-card {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.bundle-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.bundle-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 255, 255, 0.56);
|
||||
border: 1px solid rgba(36, 26, 18, 0.08);
|
||||
}
|
||||
|
||||
.bundle-name {
|
||||
font-size: 0.92rem;
|
||||
font-weight: 600;
|
||||
color: #201811;
|
||||
}
|
||||
|
||||
.bundle-calories {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
color: #6d5b4f;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.thread-spacer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.thread-end {
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
.bubble-row {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.bubble-row.user {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.bubble-stack {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
max-width: 84%;
|
||||
}
|
||||
|
||||
.bubble-stack.user {
|
||||
justify-items: end;
|
||||
}
|
||||
|
||||
.bubble-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 999px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: rgba(47, 106, 82, 0.08);
|
||||
color: #2f6a52;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bubble {
|
||||
padding: 11px 13px;
|
||||
border-radius: 18px 18px 18px 8px;
|
||||
background: rgba(255, 255, 255, 0.78);
|
||||
border: 1px solid rgba(36, 26, 18, 0.08);
|
||||
color: #241c14;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.bubble.user {
|
||||
border-radius: 18px 18px 8px 18px;
|
||||
background: linear-gradient(180deg, #2d6450, #245242);
|
||||
border-color: transparent;
|
||||
color: #f8f4ee;
|
||||
}
|
||||
|
||||
.image-bubble {
|
||||
width: min(240px, 100%);
|
||||
padding: 8px;
|
||||
border-radius: 20px 20px 20px 10px;
|
||||
background: rgba(255, 255, 255, 0.82);
|
||||
border: 1px solid rgba(36, 26, 18, 0.08);
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.image-bubble.user {
|
||||
border-radius: 20px 20px 10px 20px;
|
||||
background: linear-gradient(180deg, rgba(53, 102, 83, 0.16), rgba(39, 83, 66, 0.12)), rgba(255, 251, 246, 0.96);
|
||||
}
|
||||
|
||||
.image-bubble img {
|
||||
width: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
object-fit: cover;
|
||||
border-radius: 14px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.image-bubble-name {
|
||||
padding: 0 4px 2px;
|
||||
font-size: 0.74rem;
|
||||
font-weight: 700;
|
||||
color: #6b5c4f;
|
||||
line-height: 1.25;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.draft-meal {
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(47, 106, 82, 0.1);
|
||||
color: #2f6a52;
|
||||
font-size: 0.76rem;
|
||||
font-weight: 700;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.draft-apply {
|
||||
height: 40px;
|
||||
padding: 0 14px;
|
||||
border: none;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(180deg, #366d57, #245141);
|
||||
color: #f8f4ee;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 700;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.draft-apply.subtle {
|
||||
background: rgba(255, 255, 255, 0.82);
|
||||
color: #2c2016;
|
||||
border: 1px solid rgba(36, 26, 18, 0.08);
|
||||
}
|
||||
|
||||
.draft-apply:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.draft-card {
|
||||
max-width: 100%;
|
||||
padding: 14px;
|
||||
border-radius: 20px 20px 20px 10px;
|
||||
border: 1px solid rgba(36, 26, 18, 0.08);
|
||||
background:
|
||||
radial-gradient(circle at top right, rgba(199, 137, 50, 0.08), transparent 30%),
|
||||
rgba(255, 251, 246, 0.88);
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.draft-card-top {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.draft-card-kicker {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: #8c7a68;
|
||||
}
|
||||
|
||||
.draft-card-title {
|
||||
margin-top: 4px;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.03em;
|
||||
color: #201811;
|
||||
}
|
||||
|
||||
.draft-inline-metrics {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.draft-inline-metrics span {
|
||||
padding: 6px 9px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
border: 1px solid rgba(36, 26, 18, 0.06);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: #5e5246;
|
||||
}
|
||||
|
||||
.draft-card-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.draft-row {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.assistant-error {
|
||||
padding: 0 18px 10px;
|
||||
color: #9b4337;
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
.assistant-compose {
|
||||
padding: 14px 18px 18px;
|
||||
border-top: 1px solid rgba(36, 26, 18, 0.08);
|
||||
}
|
||||
|
||||
.compose-shell {
|
||||
padding: 10px;
|
||||
border-radius: 24px;
|
||||
border: 1px solid rgba(36, 26, 18, 0.08);
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.sr-only-file {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.compose-tools {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.compose-tool {
|
||||
height: 30px;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(36, 26, 18, 0.08);
|
||||
background: rgba(249, 245, 239, 0.78);
|
||||
color: #6f6254;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 0.76rem;
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.compose-input {
|
||||
width: 100%;
|
||||
border: none;
|
||||
background: transparent;
|
||||
outline: none;
|
||||
color: #201810;
|
||||
font-size: 1rem;
|
||||
line-height: 1.5;
|
||||
font-family: inherit;
|
||||
height: 42px;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.compose-input::placeholder {
|
||||
color: #8f8071;
|
||||
}
|
||||
|
||||
.compose-send {
|
||||
justify-self: end;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 14px;
|
||||
border: none;
|
||||
background: #2e6851;
|
||||
color: #fff7f0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.compose-send:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.photo-staged {
|
||||
display: grid;
|
||||
grid-template-columns: 54px minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
border-radius: 18px;
|
||||
background: rgba(255, 251, 246, 0.86);
|
||||
border: 1px solid rgba(36, 26, 18, 0.08);
|
||||
}
|
||||
|
||||
.photo-staged.attached {
|
||||
background: rgba(246, 240, 231, 0.8);
|
||||
}
|
||||
|
||||
.photo-staged img {
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
object-fit: cover;
|
||||
border-radius: 12px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.photo-staged-title {
|
||||
font-size: 0.84rem;
|
||||
font-weight: 700;
|
||||
color: #221912;
|
||||
}
|
||||
|
||||
.photo-staged-note {
|
||||
font-size: 0.74rem;
|
||||
color: #7a6858;
|
||||
}
|
||||
|
||||
.photo-staged-clear {
|
||||
border: none;
|
||||
background: none;
|
||||
color: #7d4d3e;
|
||||
font-size: 0.76rem;
|
||||
font-weight: 700;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.assistant-drawer {
|
||||
top: auto;
|
||||
right: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: auto;
|
||||
height: min(86dvh, 760px);
|
||||
max-height: min(86dvh, 760px);
|
||||
border-radius: 28px 28px 0 0;
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
border-bottom: none;
|
||||
box-shadow: 0 -18px 42px rgba(24, 16, 11, 0.18);
|
||||
}
|
||||
|
||||
.assistant-backdrop {
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
|
||||
.assistant-head {
|
||||
padding-top: 14px;
|
||||
}
|
||||
|
||||
.assistant-head::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 42px;
|
||||
height: 4px;
|
||||
border-radius: 999px;
|
||||
background: rgba(44, 31, 19, 0.16);
|
||||
}
|
||||
|
||||
.bubble {
|
||||
max-width: 90%;
|
||||
}
|
||||
|
||||
.bubble-stack {
|
||||
max-width: 90%;
|
||||
}
|
||||
|
||||
.image-bubble {
|
||||
width: min(220px, 100%);
|
||||
}
|
||||
|
||||
.thread {
|
||||
padding-top: 8px;
|
||||
padding-bottom: 16px;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.thread-spacer {
|
||||
display: block;
|
||||
height: clamp(120px, 22vh, 220px);
|
||||
}
|
||||
|
||||
.assistant-compose {
|
||||
padding-bottom: calc(14px + env(safe-area-inset-bottom));
|
||||
background: linear-gradient(180deg, rgba(247, 240, 231, 0.88), rgba(243, 235, 225, 0.96));
|
||||
}
|
||||
|
||||
.compose-shell {
|
||||
border-radius: 22px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.compose-input {
|
||||
font-size: 16px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.draft-card-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.draft-apply,
|
||||
.draft-apply.subtle {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
406
frontend-v2/src/lib/components/layout/AppShell.svelte
Normal file
406
frontend-v2/src/lib/components/layout/AppShell.svelte
Normal file
@@ -0,0 +1,406 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import {
|
||||
BookOpen,
|
||||
CalendarDays,
|
||||
CircleDot,
|
||||
Compass,
|
||||
Dumbbell,
|
||||
Landmark,
|
||||
LibraryBig,
|
||||
Menu,
|
||||
Package2,
|
||||
Search,
|
||||
Settings2,
|
||||
SquareCheckBig,
|
||||
X
|
||||
} from '@lucide/svelte';
|
||||
|
||||
interface Props {
|
||||
children: any;
|
||||
userName?: string;
|
||||
visibleApps?: string[];
|
||||
onOpenCommand?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
children,
|
||||
userName = '',
|
||||
visibleApps = ['tasks', 'trips', 'fitness', 'inventory', 'budget', 'reader', 'media'],
|
||||
onOpenCommand
|
||||
}: Props = $props();
|
||||
|
||||
const baseItems = [
|
||||
{ id: 'dashboard', href: '/', label: 'Overview', icon: CircleDot },
|
||||
{ id: 'tasks', href: '/tasks', label: 'Tasks', icon: SquareCheckBig },
|
||||
{ id: 'trips', href: '/trips', label: 'Trips', icon: Compass },
|
||||
{ id: 'fitness', href: '/fitness', label: 'Fitness', icon: Dumbbell },
|
||||
{ id: 'budget', href: '/budget', label: 'Budget', icon: Landmark },
|
||||
{ id: 'inventory', href: '/inventory', label: 'Inventory', icon: Package2 },
|
||||
{ id: 'reader', href: '/reader', label: 'Reader', icon: BookOpen },
|
||||
{ id: 'media', href: '/media', label: 'Media', icon: LibraryBig },
|
||||
{ id: 'settings', href: '/settings', label: 'Settings', icon: Settings2 }
|
||||
];
|
||||
|
||||
const navItems = $derived(
|
||||
baseItems.filter((item) => item.id === 'dashboard' || item.id === 'settings' || visibleApps.includes(item.id))
|
||||
);
|
||||
|
||||
let mobileNavOpen = $state(false);
|
||||
|
||||
function isActive(href: string): boolean {
|
||||
if (href === '/') return page.url.pathname === '/';
|
||||
return page.url.pathname === href || page.url.pathname.startsWith(href + '/');
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="shell">
|
||||
<div class="shell-glow shell-glow-a"></div>
|
||||
<div class="shell-glow shell-glow-b"></div>
|
||||
|
||||
<aside class="shell-rail reveal">
|
||||
<div class="rail-top">
|
||||
<a class="brand" href="/">
|
||||
<div class="brand-mark">P</div>
|
||||
<div>
|
||||
<div class="brand-name">Platform</div>
|
||||
<div class="brand-sub">ops workspace</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<button class="command-trigger" onclick={onOpenCommand}>
|
||||
<Search size={15} strokeWidth={1.8} />
|
||||
<span>Assistant</span>
|
||||
<kbd>Ctrl K</kbd>
|
||||
</button>
|
||||
|
||||
<nav class="rail-nav">
|
||||
{#each navItems as item}
|
||||
<a href={item.href} class:active={isActive(item.href)}>
|
||||
<item.icon size={16} strokeWidth={1.8} />
|
||||
<span>{item.label}</span>
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div class="rail-bottom">
|
||||
<div class="rail-date">
|
||||
<CalendarDays size={14} strokeWidth={1.8} />
|
||||
<span>{new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric' }).format(new Date())}</span>
|
||||
</div>
|
||||
<div class="rail-user">{userName || 'Platform user'}</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="shell-main">
|
||||
<header class="mobile-bar">
|
||||
<button class="mobile-menu-btn" onclick={() => (mobileNavOpen = true)} aria-label="Open navigation">
|
||||
<Menu size={18} strokeWidth={1.9} />
|
||||
</button>
|
||||
|
||||
<a class="mobile-brand" href="/">
|
||||
<div class="brand-mark">P</div>
|
||||
<div>
|
||||
<div class="brand-name">Platform</div>
|
||||
<div class="brand-sub">ops workspace</div>
|
||||
</div>
|
||||
</a>
|
||||
<button class="command-trigger mobile" onclick={onOpenCommand}>
|
||||
<Search size={15} strokeWidth={1.8} />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{#if mobileNavOpen}
|
||||
<button class="mobile-nav-overlay" aria-label="Close navigation" onclick={() => (mobileNavOpen = false)}></button>
|
||||
<aside class="mobile-nav-sheet">
|
||||
<div class="mobile-nav-head">
|
||||
<div>
|
||||
<div class="brand-name">Platform</div>
|
||||
<div class="brand-sub">ops workspace</div>
|
||||
</div>
|
||||
<button class="mobile-menu-btn" onclick={() => (mobileNavOpen = false)} aria-label="Close navigation">
|
||||
<X size={18} strokeWidth={1.9} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav class="mobile-nav-list">
|
||||
{#each navItems as item}
|
||||
<a href={item.href} class:active={isActive(item.href)} onclick={() => (mobileNavOpen = false)}>
|
||||
<item.icon size={16} strokeWidth={1.8} />
|
||||
<span>{item.label}</span>
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
</aside>
|
||||
{/if}
|
||||
|
||||
<main class="shell-content">
|
||||
{@render children()}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
:global(body) {
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(214, 120, 58, 0.08), transparent 22%),
|
||||
radial-gradient(circle at 85% 10%, rgba(65, 91, 82, 0.12), transparent 20%),
|
||||
linear-gradient(180deg, #f5efe6 0%, #efe6da 48%, #ede7de 100%);
|
||||
}
|
||||
|
||||
.shell {
|
||||
--shell-ink: #1e1812;
|
||||
--shell-muted: #6b6256;
|
||||
--shell-line: rgba(35, 26, 17, 0.11);
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
grid-template-columns: 270px minmax(0, 1fr);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.shell-glow {
|
||||
position: fixed;
|
||||
border-radius: 999px;
|
||||
filter: blur(80px);
|
||||
pointer-events: none;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.shell-glow-a {
|
||||
width: 340px;
|
||||
height: 340px;
|
||||
top: -120px;
|
||||
right: 12%;
|
||||
background: rgba(179, 92, 50, 0.16);
|
||||
}
|
||||
|
||||
.shell-glow-b {
|
||||
width: 260px;
|
||||
height: 260px;
|
||||
bottom: 10%;
|
||||
left: 4%;
|
||||
background: rgba(68, 95, 86, 0.14);
|
||||
}
|
||||
|
||||
.shell-rail {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
padding: 28px 20px 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
background: linear-gradient(180deg, rgba(250, 246, 239, 0.82), rgba(244, 237, 228, 0.66));
|
||||
border-right: 1px solid var(--shell-line);
|
||||
backdrop-filter: blur(20px);
|
||||
}
|
||||
|
||||
.rail-top {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.brand,
|
||||
.mobile-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(135deg, #211912, #5d4c3f);
|
||||
color: white;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.brand-name {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--shell-ink);
|
||||
}
|
||||
|
||||
.brand-sub,
|
||||
.rail-user {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--shell-muted);
|
||||
}
|
||||
|
||||
.command-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(35, 26, 17, 0.09);
|
||||
background: rgba(255, 255, 255, 0.56);
|
||||
color: var(--shell-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.command-trigger kbd {
|
||||
margin-left: auto;
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
color: var(--shell-muted);
|
||||
}
|
||||
|
||||
.rail-nav {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.rail-nav a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 999px;
|
||||
color: var(--shell-muted);
|
||||
transition: background 160ms ease, color 160ms ease, transform 160ms ease;
|
||||
}
|
||||
|
||||
.rail-nav a:hover,
|
||||
.rail-nav a.active {
|
||||
background: rgba(255, 255, 255, 0.62);
|
||||
color: var(--shell-ink);
|
||||
transform: translateX(3px);
|
||||
}
|
||||
|
||||
.rail-bottom {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding-top: 18px;
|
||||
border-top: 1px solid var(--shell-line);
|
||||
}
|
||||
|
||||
.rail-date {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--shell-muted);
|
||||
}
|
||||
|
||||
.shell-main {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mobile-bar {
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid var(--shell-line);
|
||||
background: rgba(250, 246, 239, 0.78);
|
||||
backdrop-filter: blur(20px);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.command-trigger.mobile {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.mobile-menu-btn {
|
||||
display: none;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 12px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid rgba(35, 26, 17, 0.09);
|
||||
background: rgba(255, 255, 255, 0.56);
|
||||
color: var(--shell-muted);
|
||||
}
|
||||
|
||||
.mobile-nav-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
border: 0;
|
||||
background: rgba(17, 13, 10, 0.28);
|
||||
backdrop-filter: blur(4px);
|
||||
z-index: 39;
|
||||
}
|
||||
|
||||
.mobile-nav-sheet {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: min(320px, 86vw);
|
||||
padding: 18px 16px;
|
||||
background: linear-gradient(180deg, rgba(250, 246, 239, 0.96), rgba(244, 237, 228, 0.94));
|
||||
border-right: 1px solid var(--shell-line);
|
||||
backdrop-filter: blur(20px);
|
||||
z-index: 40;
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.mobile-nav-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.mobile-nav-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mobile-nav-list a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 16px;
|
||||
color: var(--shell-muted);
|
||||
background: transparent;
|
||||
transition: background 160ms ease, color 160ms ease;
|
||||
}
|
||||
|
||||
.mobile-nav-list a.active {
|
||||
background: rgba(255, 255, 255, 0.62);
|
||||
color: var(--shell-ink);
|
||||
}
|
||||
|
||||
.shell-content {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.shell {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.shell-rail {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobile-bar {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.mobile-menu-btn {
|
||||
display: inline-flex;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
87
frontend-v2/src/lib/components/shared/PageIntro.svelte
Normal file
87
frontend-v2/src/lib/components/shared/PageIntro.svelte
Normal file
@@ -0,0 +1,87 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
meta?: string;
|
||||
actions?: any;
|
||||
}
|
||||
|
||||
let { eyebrow, title, description = '', meta = '', actions }: Props = $props();
|
||||
</script>
|
||||
|
||||
<section class="page-intro reveal">
|
||||
<div class="copy">
|
||||
<div class="eyebrow">{eyebrow}</div>
|
||||
<h1>{title}</h1>
|
||||
{#if description}
|
||||
<p>{description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="side">
|
||||
{#if meta}
|
||||
<div class="meta">{meta}</div>
|
||||
{/if}
|
||||
{#if actions}
|
||||
<div class="actions">{@render actions()}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.page-intro {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.eyebrow,
|
||||
.meta {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
color: #8b7b6a;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 8px 0 10px;
|
||||
max-width: 10ch;
|
||||
font-size: clamp(2.2rem, 4vw, 3.8rem);
|
||||
line-height: 0.95;
|
||||
letter-spacing: -0.05em;
|
||||
color: #1e1812;
|
||||
}
|
||||
|
||||
p {
|
||||
max-width: 46rem;
|
||||
margin: 0;
|
||||
color: #6b6256;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.side {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
justify-items: end;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.page-intro {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.side {
|
||||
justify-items: start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
243
frontend-v2/src/lib/mockup/data.ts
Normal file
243
frontend-v2/src/lib/mockup/data.ts
Normal 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' }
|
||||
]
|
||||
};
|
||||
798
frontend-v2/src/lib/pages/dashboard/AtelierDashboardPage.svelte
Normal file
798
frontend-v2/src/lib/pages/dashboard/AtelierDashboardPage.svelte
Normal 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>
|
||||
444
frontend-v2/src/lib/pages/dashboard/LegacyDashboardPage.svelte
Normal file
444
frontend-v2/src/lib/pages/dashboard/LegacyDashboardPage.svelte
Normal 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]} · 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> · {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> · {fitnessProtein}g protein · {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 · {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>
|
||||
3340
frontend-v2/src/lib/pages/fitness/AtelierFitnessPage.svelte
Normal file
3340
frontend-v2/src/lib/pages/fitness/AtelierFitnessPage.svelte
Normal file
File diff suppressed because it is too large
Load Diff
1605
frontend-v2/src/lib/pages/fitness/LegacyFitnessPage.svelte
Normal file
1605
frontend-v2/src/lib/pages/fitness/LegacyFitnessPage.svelte
Normal file
File diff suppressed because it is too large
Load Diff
1054
frontend-v2/src/lib/pages/inventory/AtelierInventoryPage.svelte
Normal file
1054
frontend-v2/src/lib/pages/inventory/AtelierInventoryPage.svelte
Normal file
File diff suppressed because it is too large
Load Diff
902
frontend-v2/src/lib/pages/inventory/LegacyInventoryPage.svelte
Normal file
902
frontend-v2/src/lib/pages/inventory/LegacyInventoryPage.svelte
Normal 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>
|
||||
1183
frontend-v2/src/lib/pages/reader/AtelierReaderPage.svelte
Normal file
1183
frontend-v2/src/lib/pages/reader/AtelierReaderPage.svelte
Normal file
File diff suppressed because it is too large
Load Diff
880
frontend-v2/src/lib/pages/reader/LegacyReaderPage.svelte
Normal file
880
frontend-v2/src/lib/pages/reader/LegacyReaderPage.svelte
Normal 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(/&/g, '&');
|
||||
}
|
||||
|
||||
function stripHtml(html: string): string {
|
||||
if (!html) return '';
|
||||
return html.replace(/<[^>]*>/g, '').replace(/ /g, ' ').replace(/&/g, '&').replace(/</g, '<').replace(/>/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>
|
||||
1721
frontend-v2/src/lib/pages/trips/AtelierTripDetailPage.svelte
Normal file
1721
frontend-v2/src/lib/pages/trips/AtelierTripDetailPage.svelte
Normal file
File diff suppressed because it is too large
Load Diff
849
frontend-v2/src/lib/pages/trips/AtelierTripsPage.svelte
Normal file
849
frontend-v2/src/lib/pages/trips/AtelierTripsPage.svelte
Normal 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>
|
||||
345
frontend-v2/src/lib/pages/trips/LegacyTripsPage.svelte
Normal file
345
frontend-v2/src/lib/pages/trips/LegacyTripsPage.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user