Initial commit: Second Brain Platform

Complete platform with unified design system and real API integration.

Apps: Dashboard, Fitness, Budget, Inventory, Trips, Reader, Media, Settings
Infrastructure: SvelteKit + Python gateway + Docker Compose
This commit is contained in:
Yusuf Suleman
2026-03-28 23:20:40 -05:00
commit d3e250e361
159 changed files with 44797 additions and 0 deletions

View File

@@ -0,0 +1,28 @@
import { redirect } from '@sveltejs/kit';
import type { LayoutServerLoad } from './$types';
import { env } from '$env/dynamic/private';
const gatewayUrl = env.GATEWAY_URL || 'http://localhost:8100';
export const load: LayoutServerLoad = async ({ cookies, url }) => {
const session = cookies.get('platform_session');
if (!session) {
throw redirect(302, `/login?redirect=${encodeURIComponent(url.pathname)}`);
}
// Validate session is still active
try {
const res = await fetch(`${gatewayUrl}/api/auth/me`, {
headers: { Cookie: `platform_session=${session}` }
});
if (res.ok) {
const data = await res.json();
if (data.authenticated) {
return { user: data.user };
}
}
} catch { /* gateway down — let client handle */ }
// Invalid/expired session
throw redirect(302, `/login?redirect=${encodeURIComponent(url.pathname)}`);
};

View File

@@ -0,0 +1,58 @@
<script lang="ts">
import Navbar from '$lib/components/layout/Navbar.svelte';
import MobileTabBar from '$lib/components/layout/MobileTabBar.svelte';
import CommandPalette from '$lib/components/layout/CommandPalette.svelte';
let { children } = $props();
let commandOpen = $state(false);
function openCommand() {
commandOpen = true;
}
function closeCommand() {
commandOpen = false;
}
function handleKeydown(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault();
commandOpen = !commandOpen;
}
}
</script>
<svelte:window onkeydown={handleKeydown} />
<div class="app">
<Navbar onOpenCommand={openCommand} />
<main>
{@render children()}
</main>
<MobileTabBar />
<CommandPalette bind:open={commandOpen} onclose={closeCommand} />
</div>
<style>
.app {
min-height: 100vh;
display: flex;
flex-direction: column;
}
main {
flex: 1;
padding: 16px;
width: 100%;
max-width: 640px;
margin: 0 auto;
}
@media (min-width: 768px) {
main {
padding: 20px;
max-width: 1200px;
}
}
</style>

View File

@@ -0,0 +1,127 @@
<script lang="ts">
import { onMount } from 'svelte';
import DashboardActionCard from '$lib/components/dashboard/DashboardActionCard.svelte';
import BudgetModule from '$lib/components/dashboard/BudgetModule.svelte';
import FitnessModule from '$lib/components/dashboard/FitnessModule.svelte';
import IssuesModule from '$lib/components/dashboard/IssuesModule.svelte';
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 fitnessProtein = $state(0);
let fitnessCarbs = $state(0);
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);
}
if (fitGoalsRes.ok) {
const g = await fitGoalsRes.json();
fitnessCalRemaining = Math.max(0, (g.calories || 2000) - fitnessCalLogged);
}
} catch { /* silent */ }
});
</script>
<div class="page">
<div class="app-surface">
<div class="page-header">
<div class="page-title">Dashboard</div>
<div class="page-greeting">Good to see you, <strong>Yusuf</strong></div>
</div>
<div class="action-cards">
<DashboardActionCard
title="{budgetUncatCount} uncategorized transactions"
description="{budgetSpending} spent &middot; {budgetIncome} income"
action="Review"
variant="budget"
size="primary"
href="/budget"
/>
<DashboardActionCard
title="{inventoryIssueCount} issue{inventoryIssueCount !== 1 ? 's' : ''} · {inventoryReviewCount} needs review"
description="{inventoryIssueCount + inventoryReviewCount} items need attention"
action="View"
variant="inventory"
href="/inventory"
/>
<DashboardActionCard
title="{fitnessCalRemaining.toLocaleString()} calories remaining today"
description="{fitnessCalLogged.toLocaleString()} cal logged &middot; {fitnessProtein}g protein &middot; {fitnessCarbs}g carbs"
action="Log food"
variant="fitness"
href="/fitness"
/>
</div>
<div class="modules-grid">
<BudgetModule />
<div class="right-stack">
<FitnessModule />
<IssuesModule />
</div>
</div>
</div>
</div>
<style>
.action-cards {
display: flex;
flex-direction: column;
gap: var(--section-gap);
margin-bottom: calc(var(--section-gap) + 8px);
padding-top: 4px;
}
.modules-grid {
display: grid;
grid-template-columns: 1.3fr 0.7fr;
gap: var(--module-gap);
align-items: start;
}
.right-stack {
display: flex;
flex-direction: column;
gap: var(--module-gap);
}
@media (max-width: 768px) {
.modules-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,763 @@
<script lang="ts">
import { onMount } from 'svelte';
// ── State (same names as template binds) ──
let activeView = $state<'transactions' | 'budget'>('transactions');
let activeTab = $state('all');
let accountsOpen = $state(false);
let selected = $state<Set<string>>(new Set());
let lastCategory = $state('');
let bulkCategoryOpen = $state(false);
let focusedRowId = $state('');
let loading = $state(true);
let saving = $state(false);
// ── Data (populated from API) ──
let budgetGroups = $state<{ name: string; categories: { name: string; budgeted: number; spent: number; available: number }[] }[]>([]);
let categories = $state<string[]>([]);
let categoryMap = $state<Record<string, string>>({}); // name → id
let accounts = $state<{ name: string; balance: number; positive: boolean; id?: string }[]>([]);
let offBudgetAccounts = $state<{ name: string; balance: number; positive: boolean; id?: string }[]>([]);
let suggestedTransfers = $state<any[]>([]);
let transactions = $state<{ id: string; date: string; payee: string; note: string; account: string; category: string; categoryType: string; amount: number; categoryId?: string; accountId?: string }[]>([]);
// ── Pagination ──
let hasMore = $state(true);
let loadingMore = $state(false);
let activeAccountId = $state<string | null>(null);
const PAGE_SIZE = 100;
// ── Header stats ──
let headerSpending = $state('...');
let headerIncome = $state('...');
let currentMonthLabel = $state('');
// Sort categories with last-used first
let sortedCategories = $derived(() => {
if (!lastCategory) return categories;
const rest = categories.filter(c => c !== lastCategory);
return [lastCategory, ...rest];
});
let canTransfer = $derived(selected.size === 2);
const filteredTransactions = $derived(() => {
if (activeTab === 'uncategorized') return transactions.filter(t => t.categoryType === 'uncat');
if (activeTab === 'categorized') return transactions.filter(t => t.categoryType !== 'uncat');
return transactions;
});
let totalUncatCount = $state(0);
const uncatCount = $derived(activeAccountId
? transactions.filter(t => t.categoryType === 'uncat').length
: totalUncatCount
);
// ── API helper ──
async function api(path: string, opts: RequestInit = {}) {
const res = await fetch(`/api/budget${path}`, { credentials: 'include', ...opts });
if (!res.ok) throw new Error(`${res.status}`);
return res.json();
}
// ── Load data ──
async function loadAccounts() {
try {
const data = await api('/accounts');
const onBudget: typeof accounts = [];
const offBudget: typeof offBudgetAccounts = [];
for (const a of data) {
if (a.closed) continue;
const entry = {
name: a.name,
balance: Math.round(a.balanceDollars),
positive: a.balanceDollars >= 0,
id: a.id
};
if (a.offbudget) offBudget.push(entry);
else onBudget.push(entry);
}
accounts = onBudget;
offBudgetAccounts = offBudget;
} catch { /* silent */ }
}
async function loadCategories() {
try {
const data = await api('/categories');
const allCats: string[] = [];
const map: Record<string, string> = {};
for (const group of data) {
for (const cat of group.categories) {
if (cat.name !== 'Starting Balances') {
allCats.push(cat.name);
map[cat.name] = cat.id;
}
}
}
categories = allCats;
categoryMap = map;
if (!lastCategory && allCats.length > 0) lastCategory = allCats[0];
} catch { /* silent */ }
}
function mapTransaction(t: any) {
return {
id: t.id,
date: formatDateShort(t.date),
payee: t.payeeName || t.payee || '',
note: t.notes || '',
account: t.accountName || '',
accountId: t.accountId || '',
category: t.categoryName || '',
categoryType: t.transfer_id ? 'transfer' : (t.categoryName ? 'normal' : 'uncat'),
amount: t.amountDollars || 0,
categoryId: t.categoryId || ''
};
}
async function loadTransactions(append = false) {
if (loadingMore) return;
if (!append) { loading = true; transactions = []; hasMore = true; }
else loadingMore = true;
try {
let data: any[];
if (activeAccountId) {
// Per-account: supports offset/limit pagination
const offset = append ? transactions.length : 0;
const resp = await api(`/transactions?accountId=${activeAccountId}&limit=${PAGE_SIZE}&offset=${offset}`);
data = resp.transactions || resp || [];
} else {
// All accounts: /recent doesn't support offset
// Load more when viewing uncategorized to capture all of them
const limit = activeTab === 'uncategorized'
? Math.max(500, (append ? transactions.length + PAGE_SIZE : PAGE_SIZE))
: (append ? transactions.length + PAGE_SIZE : PAGE_SIZE);
data = await api(`/transactions/recent?limit=${limit}`);
if (append) {
const existingIds = new Set(transactions.map(t => t.id));
data = data.filter((t: any) => !existingIds.has(t.id));
}
}
const mapped = (Array.isArray(data) ? data : []).map(mapTransaction);
if (append) {
transactions = [...transactions, ...mapped];
} else {
transactions = mapped;
}
hasMore = mapped.length >= PAGE_SIZE;
} catch { /* silent */ }
finally { loading = false; loadingMore = false; }
}
function loadMore() {
if (hasMore && !loadingMore) loadTransactions(true);
}
function selectAccount(accountId: string | null) {
activeAccountId = accountId;
loadTransactions();
}
async function loadBudget() {
try {
const now = new Date();
const month = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
const data = await api(`/budget/${month}`);
budgetGroups = (data.categoryGroups || [])
.filter((g: any) => g.categories?.length > 0)
.map((g: any) => ({
name: g.name,
categories: g.categories
.filter((c: any) => c.name !== 'Starting Balances')
.map((c: any) => ({
name: c.name,
budgeted: Math.round(c.budgeted / 100),
spent: Math.round(Math.abs(c.spent) / 100),
available: Math.round(c.balance / 100)
}))
}))
.filter((g: any) => g.categories.length > 0);
} catch { /* silent */ }
}
async function loadSuggested() {
try {
const data = await api('/suggested-transfers');
suggestedTransfers = data.map((s: any) => ({
id: s.from.id + '-' + s.to.id,
from: { account: s.from.account, payee: s.from.payee },
to: { account: s.to.account, payee: s.to.payee },
amount: s.amountDollars,
confidence: s.confidence,
fromId: s.from.id,
toId: s.to.id
}));
} catch { /* silent */ }
}
// ── Actions ──
async function categorize(id: string, category: string) {
if (!category) return;
lastCategory = category;
let catId = categoryMap[category];
// If category ID not found, refresh category map and retry
if (!catId) {
await loadCategories();
catId = categoryMap[category];
if (!catId) return; // Still not found, bail
}
// Save previous state for revert
const prev = transactions.find(t => t.id === id);
const prevCat = prev?.category || '';
const prevType = prev?.categoryType || 'uncat';
// Optimistic update
const wasUncat = prevType === 'uncat';
transactions = transactions.map(t =>
t.id === id ? { ...t, category, categoryType: 'normal', categoryId: catId } : t
);
if (wasUncat) totalUncatCount = Math.max(0, totalUncatCount - 1);
// Persist to backend
try {
await api(`/transactions/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ category: catId })
});
} catch {
// Revert on failure
transactions = transactions.map(t =>
t.id === id ? { ...t, category: prevCat, categoryType: prevType, categoryId: '' } : t
);
if (wasUncat) totalUncatCount++;
}
}
async function bulkCategorize(category: string) {
if (!category) return;
lastCategory = category;
const catId = categoryMap[category];
const ids = Array.from(selected).filter(id => {
const t = transactions.find(tx => tx.id === id);
return t && t.categoryType === 'uncat';
});
// Optimistic update
transactions = transactions.map(t =>
ids.includes(t.id) ? { ...t, category, categoryType: 'normal', categoryId: catId } : t
);
totalUncatCount = Math.max(0, totalUncatCount - ids.length);
selected = new Set();
bulkCategoryOpen = false;
// Persist each to backend
for (const id of ids) {
try {
await api(`/transactions/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ category: catId })
});
} catch { /* silent - optimistic already applied */ }
}
}
async function makeTransfer(fromId: string, toId: string) {
saving = true;
try {
await api('/make-transfer', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ transactionId1: fromId, transactionId2: toId })
});
// Reload transactions to reflect transfer state
await loadTransactions();
} catch { /* silent */ }
finally { saving = false; }
}
async function linkSelectedAsTransfer() {
if (selected.size !== 2) return;
const ids = Array.from(selected);
await makeTransfer(ids[0], ids[1]);
selected = new Set();
}
async function linkSuggestedTransfer(suggestion: any) {
await makeTransfer(suggestion.fromId, suggestion.toId);
suggestedTransfers = suggestedTransfers.filter(s => s.id !== suggestion.id);
}
function dismissSuggestion(id: string) {
suggestedTransfers = suggestedTransfers.filter(s => s.id !== id);
}
function toggleSelect(id: string) {
const next = new Set(selected);
if (next.has(id)) next.delete(id); else next.add(id);
selected = next;
}
function handleRowKeydown(e: KeyboardEvent, txnId: string) {
if (e.key === 'c' || e.key === 'C') {
e.preventDefault();
const row = (e.target as HTMLElement).closest('.txn-row');
const select = row?.querySelector('.cat-select') as HTMLSelectElement | null;
if (select) { select.focus(); select.click(); }
}
}
// ── Formatters ──
function formatDateShort(dateStr: string): string {
const d = new Date(dateStr + 'T00:00:00');
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
}
function formatBudgetAmount(amount: number): string {
return '$' + Math.abs(amount).toLocaleString('en-US');
}
function formatBalance(balance: number): string {
const abs = Math.abs(balance);
return (balance < 0 ? '-' : '') + '$' + abs.toLocaleString('en-US');
}
function formatAmount(amount: number): string {
const abs = Math.abs(amount);
const formatted = '$' + abs.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
return amount >= 0 ? '+' + formatted : '-' + formatted;
}
async function loadSummary() {
try {
const [summary, uncat] = await Promise.all([
api('/summary'),
api('/uncategorized-count')
]);
headerSpending = '$' + Math.abs(summary.spendingDollars || 0).toLocaleString('en-US');
headerIncome = '$' + Math.abs(summary.incomeDollars || 0).toLocaleString('en-US');
totalUncatCount = uncat.count || 0;
const m = summary.month || '';
if (m) {
const d = new Date(m + '-01');
currentMonthLabel = d.toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
}
} catch { /* silent */ }
}
// ── Init ──
onMount(async () => {
await Promise.all([loadAccounts(), loadCategories(), loadTransactions(), loadBudget(), loadSuggested(), loadSummary()]);
loading = false;
});
</script>
<div class="budget-page">
<div class="budget-layout">
<!-- Desktop sidebar -->
<aside class="budget-sidebar desktop-only">
<div class="sidebar-header">Budget</div>
<div class="sidebar-nav">
<button class="sidebar-nav-item" class:active={activeView === 'transactions' && !activeAccountId} onclick={() => { activeView = 'transactions'; selectAccount(null); }}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>
All Transactions
{#if uncatCount > 0}<span class="sidebar-badge">{uncatCount}</span>{/if}
</button>
<button class="sidebar-nav-item" class:active={activeView === 'budget'} onclick={() => activeView = 'budget'}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M2 10h20"/></svg>
Budget
</button>
</div>
<div class="sidebar-accounts">
<div class="acct-group-header"><span>Budget</span><span class="acct-group-total positive">{formatBalance(accounts.reduce((s, a) => s + a.balance, 0))}</span></div>
{#each accounts as acct}
<button class="acct-row" class:active={activeAccountId === acct.id} onclick={() => { activeView = 'transactions'; selectAccount(acct.id || null); }}><span class="acct-name">{acct.name}</span><span class="acct-bal" class:positive={acct.positive} class:negative={!acct.positive}>{formatBalance(acct.balance)}</span></button>
{/each}
<div class="acct-group-header" style="margin-top:8px"><span>Off Budget</span><span class="acct-group-total">{formatBalance(offBudgetAccounts.reduce((s, a) => s + a.balance, 0))}</span></div>
{#each offBudgetAccounts as acct}
<button class="acct-row" class:active={activeAccountId === acct.id} onclick={() => { activeView = 'transactions'; selectAccount(acct.id || null); }}><span class="acct-name">{acct.name}</span><span class="acct-bal positive">{formatBalance(acct.balance)}</span></button>
{/each}
</div>
</aside>
<!-- Main workspace -->
<div class="budget-main">
<div class="budget-header">
<div>
{#if activeAccountId}
<button class="back-to-all" onclick={() => selectAccount(null)}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M15 18l-6-6 6-6"/></svg>
All Transactions
</button>
<div class="budget-title">{accounts.find(a => a.id === activeAccountId)?.name || offBudgetAccounts.find(a => a.id === activeAccountId)?.name || 'Account'}</div>
{:else}
<div class="budget-label">Budget</div>
<div class="budget-title">{currentMonthLabel} · <strong>{headerSpending}</strong> spent</div>
<div class="budget-meta">{headerIncome} income · {uncatCount} uncategorized</div>
{/if}
</div>
<button class="accounts-trigger mobile-only" onclick={() => accountsOpen = !accountsOpen}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M2 10h20"/></svg>
Accounts
<svg viewBox="0 0 10 6" fill="none" class="chevron" class:open={accountsOpen}><path d="M1 1l4 4 4-4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
</div>
<!-- Mobile view toggle -->
<div class="view-toggle mobile-only">
<button class="view-btn" class:active={activeView === 'transactions'} onclick={() => activeView = 'transactions'}>Transactions</button>
<button class="view-btn" class:active={activeView === 'budget'} onclick={() => activeView = 'budget'}>Budget</button>
</div>
{#if accountsOpen}
<div class="mobile-accounts">
<div class="acct-group-header"><span>Budget</span><span class="acct-group-total positive">{formatBalance(accounts.reduce((s, a) => s + a.balance, 0))}</span></div>
{#each accounts as acct}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="mobile-acct-row" onclick={() => { selectAccount(acct.id || null); accountsOpen = false; }}><span class="acct-name">{acct.name}</span><span class="acct-bal" class:positive={acct.positive} class:negative={!acct.positive}>{formatBalance(acct.balance)}</span></div>
{/each}
<div class="acct-group-header" style="margin-top:8px"><span>Off Budget</span><span class="acct-group-total">{formatBalance(offBudgetAccounts.reduce((s, a) => s + a.balance, 0))}</span></div>
{#each offBudgetAccounts as acct}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="mobile-acct-row" onclick={() => { selectAccount(acct.id || null); accountsOpen = false; }}><span class="acct-name">{acct.name}</span><span class="acct-bal positive">{formatBalance(acct.balance)}</span></div>
{/each}
</div>
{/if}
{#if activeView === 'transactions'}
<!-- Suggested Transfers -->
{#if suggestedTransfers.length > 0}
<div class="suggestions">
<div class="suggestions-header">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:16px;height:16px;color:var(--accent)"><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></svg>
<span class="suggestions-title">Suggested Transfers</span>
<span class="suggestions-count">{suggestedTransfers.length}</span>
</div>
{#each suggestedTransfers as s}
<div class="suggestion-row">
<div class="suggestion-pair">
<div class="suggestion-acct">{s.from.account}</div>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:14px;height:14px;color:var(--text-4);flex-shrink:0"><path d="M5 12h14"/><polyline points="12 5 19 12 12 19"/></svg>
<div class="suggestion-acct">{s.to.account}</div>
</div>
<div class="suggestion-amount">${s.amount.toLocaleString('en-US', { minimumFractionDigits: 2 })}</div>
<div class="suggestion-actions">
<button class="sug-btn link" onclick={() => linkSuggestedTransfer(s)}>Link</button>
<button class="sug-btn skip" onclick={() => dismissSuggestion(s.id)}>Skip</button>
</div>
</div>
{/each}
</div>
{/if}
<!-- Selection bar -->
{#if selected.size > 0}
<div class="selection-bar">
<span class="selection-count">{selected.size} selected</span>
{#if canTransfer}
<button class="transfer-btn" onclick={linkSelectedAsTransfer}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:14px;height:14px"><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></svg>
Make Transfer
</button>
{/if}
<div class="bulk-cat">
{#if bulkCategoryOpen}
<select class="bulk-cat-select" onchange={(e) => bulkCategorize((e.target as HTMLSelectElement).value)}>
<option value="">Apply category...</option>
{#each sortedCategories() as cat}
<option value={cat}>{cat}</option>
{/each}
</select>
{:else}
<button class="bulk-cat-btn" onclick={() => bulkCategoryOpen = true}>
Set Category
</button>
{/if}
</div>
<button class="clear-btn" onclick={() => { selected = new Set(); bulkCategoryOpen = false; }}>Clear</button>
</div>
{/if}
<!-- Tabs -->
<div class="budget-tabs">
<button class="tab" class:active={activeTab === 'all'} onclick={() => { activeTab = 'all'; loadTransactions(); }}>All</button>
<button class="tab" class:active={activeTab === 'uncategorized'} onclick={() => { activeTab = 'uncategorized'; loadTransactions(); }}>Uncategorized <span class="tab-badge">{uncatCount}</span></button>
<button class="tab" class:active={activeTab === 'categorized'} onclick={() => { activeTab = 'categorized'; loadTransactions(); }}>Categorized</button>
</div>
<!-- Transactions -->
<div class="txn-card">
{#each filteredTransactions() as txn (txn.id)}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="txn-row"
class:txn-uncat={txn.categoryType === 'uncat'}
class:txn-selected={selected.has(txn.id)}
tabindex="0"
onkeydown={(e) => handleRowKeydown(e, txn.id)}
>
<input type="checkbox" class="txn-check" checked={selected.has(txn.id)} onchange={() => toggleSelect(txn.id)} />
<div class="txn-date">{txn.date}</div>
<div class="txn-payee">
{#if txn.categoryType === 'transfer'}
<div class="txn-name transfer-name">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:13px;height:13px;flex-shrink:0"><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></svg>
{txn.payee}
</div>
{:else}
<div class="txn-name">{txn.payee}</div>
{#if txn.note}<div class="txn-note">{txn.note}</div>{/if}
{/if}
</div>
<div class="txn-account">{txn.account}</div>
<div class="txn-category">
{#if txn.categoryType === 'transfer'}
<span class="cat-pill transfer">Transfer</span>
{:else if txn.categoryType === 'uncat'}
<select class="cat-select" onchange={(e) => categorize(txn.id, (e.target as HTMLSelectElement).value)}>
<option value="">Select category</option>
{#each sortedCategories() as cat}
<option value={cat}>{cat}</option>
{/each}
</select>
{:else}
<span class="cat-pill">{txn.category}</span>
{/if}
</div>
<div class="txn-amount" class:pos={txn.amount >= 0} class:neg={txn.amount < 0}>{formatAmount(txn.amount)}</div>
</div>
{/each}
{#if hasMore}
<button class="load-more-btn" onclick={loadMore} disabled={loadingMore}>
{loadingMore ? 'Loading...' : 'Load more transactions'}
</button>
{/if}
</div>
{:else}
<!-- Budget Overview -->
<div class="budget-overview">
{#each budgetGroups as group}
<div class="budget-group">
<div class="budget-group-header">{group.name}</div>
<div class="budget-table">
<div class="budget-table-header">
<span class="bt-name">Category</span>
<span class="bt-val">Budgeted</span>
<span class="bt-val">Spent</span>
<span class="bt-val">Available</span>
</div>
{#each group.categories as cat}
<div class="budget-table-row" class:overspent={cat.available < 0}>
<span class="bt-name">{cat.name}</span>
<span class="bt-val">{formatBudgetAmount(cat.budgeted)}</span>
<span class="bt-val spent">{formatBudgetAmount(cat.spent)}</span>
<span class="bt-val" class:positive={cat.available > 0} class:negative={cat.available < 0}>{cat.available < 0 ? '-' : ''}{formatBudgetAmount(Math.abs(cat.available))}</span>
</div>
{/each}
</div>
</div>
{/each}
</div>
{/if}
</div>
</div>
</div>
<style>
.budget-page { padding: 0; margin: -16px; }
.budget-layout { display: flex; min-height: calc(100vh - 56px); }
.desktop-only { display: none; }
@media (min-width: 768px) { .desktop-only { display: flex; } }
.mobile-only { display: flex; }
@media (min-width: 768px) { .mobile-only { display: none; } }
/* ── Sidebar ── */
.budget-sidebar { width: 250px; flex-shrink: 0; border-right: 1px solid var(--border); background: var(--surface); flex-direction: column; overflow-y: auto; }
.sidebar-header { font-size: var(--text-md); font-weight: 600; padding: var(--sp-5) var(--sp-4) var(--sp-3); color: var(--text-1); }
.sidebar-nav { padding: 0 var(--sp-2) var(--sp-2); }
.sidebar-nav-item { display: flex; align-items: center; gap: var(--sp-2); width: 100%; padding: 9px 12px; background: none; border: none; border-radius: var(--radius-md); font-size: var(--text-sm); font-weight: 500; color: var(--text-3); cursor: pointer; transition: all 150ms; text-align: left; font-family: var(--font); }
.sidebar-nav-item:hover { color: var(--text-1); background: var(--card-hover); }
.sidebar-nav-item.active { color: var(--text-1); background: var(--accent-dim); }
.sidebar-nav-item :global(svg) { width: 16px; height: 16px; flex-shrink: 0; }
.sidebar-badge { margin-left: auto; font-size: var(--text-xs); font-family: var(--mono); background: var(--accent-dim); color: var(--accent); padding: 1px 6px; border-radius: var(--radius-xs); }
.sidebar-accounts { padding: var(--sp-2) 0; border-top: 1px solid var(--border); flex: 1; }
.acct-group-header { display: flex; justify-content: space-between; padding: var(--sp-2) var(--sp-4) var(--sp-1); font-size: var(--text-xs); font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; color: var(--text-4); }
.acct-group-total { font-family: var(--mono); font-size: var(--text-xs); color: var(--text-3); }
.acct-group-total.positive { color: var(--success); }
.acct-row { display: flex; align-items: center; width: 100%; padding: 6px 16px; background: none; border: none; font-size: var(--text-sm); color: var(--text-2); cursor: pointer; transition: background 150ms; text-align: left; font-family: var(--font); }
.acct-row:hover { background: var(--card-hover); }
.acct-row.active { background: var(--accent-dim); color: var(--accent); }
.load-more-btn {
display: block; width: 100%; padding: var(--sp-3); margin-top: var(--sp-1);
background: none; border: 1px dashed var(--border); border-radius: var(--radius-sm);
font-size: var(--text-sm); font-weight: 500; color: var(--text-3); cursor: pointer;
font-family: var(--font); transition: all var(--transition);
}
.load-more-btn:hover { background: var(--card-hover); color: var(--text-1); }
.load-more-btn:disabled { opacity: 0.5; cursor: default; }
.acct-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.acct-bal { margin-left: auto; font-family: var(--mono); font-size: var(--text-sm); flex-shrink: 0; }
.acct-bal.positive { color: var(--success); }
.acct-bal.negative { color: var(--error); }
/* ── Main ── */
.budget-main { flex: 1; min-width: 0; padding: var(--sp-6); overflow-y: auto; }
.budget-header { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: var(--sp-4); }
.budget-label { font-size: var(--text-sm); font-weight: 600; color: var(--text-4); text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: var(--sp-1); }
.back-to-all {
display: inline-flex; align-items: center; gap: var(--sp-1);
font-size: var(--text-sm); color: var(--text-3); background: none; border: none;
cursor: pointer; font-family: var(--font); padding: 0; margin-bottom: var(--sp-1);
transition: color var(--transition);
}
.back-to-all:hover { color: var(--accent); }
.back-to-all svg { width: 14px; height: 14px; }
.budget-title { font-size: var(--text-xl); font-weight: 300; color: var(--text-1); line-height: 1.2; }
.budget-meta { font-size: var(--text-sm); color: var(--text-3); margin-top: var(--sp-1); }
/* Mobile trigger */
.accounts-trigger { align-items: center; gap: var(--sp-1.5); padding: var(--sp-2) var(--sp-3); border-radius: var(--radius-md); background: var(--card); border: 1px solid var(--border); font-size: var(--text-sm); font-weight: 500; color: var(--text-2); cursor: pointer; font-family: var(--font); transition: all var(--transition); flex-shrink: 0; }
.accounts-trigger:hover { background: var(--card-hover); }
.accounts-trigger :global(svg) { width: 16px; height: 16px; }
.chevron { width: 10px; height: 10px; transition: transform 200ms; }
.chevron.open { transform: rotate(180deg); }
.mobile-accounts { background: var(--card); border: 1px solid var(--border); border-radius: var(--radius); padding: var(--sp-3) var(--sp-4); margin-bottom: var(--sp-4); }
.mobile-acct-row { display: flex; justify-content: space-between; padding: var(--sp-2) 0; font-size: var(--text-sm); color: var(--text-2); cursor: pointer; transition: color var(--transition); }
.mobile-acct-row:hover { color: var(--text-1); }
/* ── Suggested Transfers ── */
.suggestions { margin-bottom: var(--sp-4); padding: 14px; border-radius: var(--radius); background: color-mix(in srgb, var(--accent-dim) 60%, transparent); border: 1px solid var(--accent-border); }
.suggestions-header { display: flex; align-items: center; gap: var(--sp-2); margin-bottom: 10px; }
.suggestions-title { font-size: var(--text-sm); font-weight: 600; color: var(--text-2); }
.suggestions-count { font-size: var(--text-xs); font-family: var(--mono); background: var(--accent); color: white; padding: 1px 5px; border-radius: var(--radius-xs); }
.suggestion-row { display: flex; align-items: center; gap: var(--sp-3); padding: 9px 12px; border-radius: var(--radius-md); background: var(--card); margin-bottom: var(--sp-1); transition: background var(--transition); }
.suggestion-row:last-child { margin-bottom: 0; }
.suggestion-row:hover { background: var(--card-hover); }
.suggestion-pair { display: flex; align-items: center; gap: var(--sp-2); flex: 1; min-width: 0; }
.suggestion-acct { font-size: var(--text-sm); font-weight: 500; color: var(--text-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.suggestion-amount { font-family: var(--mono); font-size: var(--text-sm); font-weight: 500; color: var(--text-3); flex-shrink: 0; }
.suggestion-actions { display: flex; gap: var(--sp-1); flex-shrink: 0; }
.sug-btn { padding: 5px 11px; border-radius: var(--radius-sm); font-size: var(--text-sm); font-weight: 600; border: none; cursor: pointer; font-family: var(--font); transition: all var(--transition); }
.sug-btn.link { background: var(--accent); color: white; }
.sug-btn.link:hover { opacity: 0.9; }
.sug-btn.skip { background: none; color: var(--text-4); }
.sug-btn.skip:hover { color: var(--text-2); }
/* ── Selection bar ── */
.selection-bar { display: flex; align-items: center; gap: var(--sp-3); padding: 10px var(--sp-4); margin-bottom: var(--sp-3); border-radius: var(--radius); background: var(--accent-dim); border: 1px solid var(--accent-focus); }
.selection-count { font-size: var(--text-base); font-weight: 600; color: var(--text-1); }
.selection-hint { font-size: var(--text-sm); color: var(--text-3); }
.transfer-btn { display: flex; align-items: center; gap: var(--sp-1.5); padding: 6px 14px; border-radius: var(--radius-sm); background: var(--accent); color: white; border: none; font-size: var(--text-sm); font-weight: 600; cursor: pointer; font-family: var(--font); }
.bulk-cat { display: flex; align-items: center; }
.bulk-cat-btn { padding: 6px 14px; border-radius: var(--radius-sm); background: var(--card); border: 1px solid var(--border); font-size: var(--text-sm); font-weight: 500; color: var(--text-2); cursor: pointer; font-family: var(--font); transition: all var(--transition); }
.bulk-cat-btn:hover { border-color: var(--accent); color: var(--accent); }
.bulk-cat-select { padding: 6px 10px; border-radius: var(--radius-sm); font-size: var(--text-sm); font-weight: 500; border: 1px solid var(--accent); background: var(--card); color: var(--text-1); font-family: var(--font); cursor: pointer; min-width: 140px; }
.bulk-cat-select:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
.clear-btn { margin-left: auto; background: none; border: none; font-size: var(--text-sm); color: var(--text-3); cursor: pointer; font-family: var(--font); }
.clear-btn:hover { color: var(--text-1); }
/* ── Tabs ── */
.budget-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); }
.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-family: var(--mono); background: var(--accent-dim); color: var(--accent); padding: 1px 6px; border-radius: var(--radius-xs); margin-left: var(--sp-1); }
/* ── Transaction card ── */
.txn-card { background: var(--card); border-radius: var(--radius); border: 1px solid var(--border); box-shadow: var(--shadow-md); overflow: hidden; }
.txn-row { display: flex; align-items: center; gap: 14px; padding: 15px 16px; transition: background var(--transition); }
.txn-row:hover { background: var(--card-hover); }
.txn-row + .txn-row { border-top: 1px solid var(--border); }
.txn-row:nth-child(even) { background: color-mix(in srgb, var(--surface) 68%, var(--card)); }
.txn-row:nth-child(even):hover { background: var(--card-hover); }
.txn-row:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; z-index: 1; }
.txn-uncat { border-left: 4px solid var(--warning); }
.txn-selected { background: color-mix(in srgb, var(--accent) 6%, var(--card)) !important; border-left: 4px solid var(--accent); }
.txn-check { width: 16px; height: 16px; accent-color: var(--accent); cursor: pointer; flex-shrink: 0; }
.txn-date { font-size: var(--text-sm); color: var(--text-4); width: 48px; flex-shrink: 0; }
.txn-payee { flex: 1.5; min-width: 0; }
.txn-name { font-size: var(--text-base); font-weight: 600; color: var(--text-1); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.transfer-name { display: flex; align-items: center; gap: var(--sp-1.5); color: var(--accent); font-weight: 500; }
.txn-note { font-size: var(--text-xs); color: var(--text-4); margin-top: var(--sp-0.5); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.txn-account { flex: 0.7; font-size: var(--text-sm); color: var(--text-4); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.txn-category { flex: 0.9; padding-right: var(--sp-2); }
.cat-pill { display: inline-block; padding: var(--sp-1) 10px; border-radius: var(--radius-sm); font-size: var(--text-sm); font-weight: 500; background: var(--accent-dim); color: var(--accent); }
.cat-pill.transfer { background: var(--accent-dim); color: var(--accent); font-weight: 500; }
.cat-select {
padding: 8px 10px;
border-radius: var(--radius-md);
font-size: var(--text-sm);
font-weight: 500;
border: 1px solid color-mix(in srgb, var(--warning) 50%, var(--border));
background: color-mix(in srgb, var(--warning) 2%, var(--card));
color: var(--text-1);
font-family: var(--font);
cursor: pointer;
width: 100%;
max-width: 160px;
min-height: 38px;
transition: all var(--transition);
}
.cat-select:hover { border-color: var(--accent); background: var(--card); }
.cat-select:focus { outline: 2px solid var(--accent); outline-offset: 1px; border-color: var(--accent); background: var(--card); }
.txn-amount { font-family: var(--mono); font-size: var(--text-base); font-weight: 600; text-align: right; min-width: 90px; padding-left: var(--sp-2); }
.txn-amount.pos { color: var(--success); }
.txn-amount.neg { color: var(--error); }
/* ── Sidebar refinement ── */
.acct-row { padding: 5px 16px; font-size: var(--text-xs); }
.acct-bal { font-size: var(--text-xs); }
/* ── View toggle (mobile) ── */
.view-toggle { gap: var(--sp-0.5); padding: 3px; background: var(--surface); border-radius: 10px; border: 1px solid var(--border); margin-bottom: var(--sp-4); }
.view-btn { flex: 1; padding: 7px 0; border-radius: var(--radius-md); font-size: var(--text-sm); font-weight: 500; border: none; background: none; color: var(--text-3); cursor: pointer; font-family: var(--font); transition: all var(--transition); }
.view-btn.active { background: var(--card); color: var(--text-1); box-shadow: var(--shadow-xs); }
/* ── Budget Overview ── */
.budget-overview { display: flex; flex-direction: column; gap: var(--sp-6); }
.budget-group-header { font-size: var(--text-xs); font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-4); margin-bottom: var(--sp-2); }
.budget-table { background: var(--card); border-radius: var(--radius); border: 1px solid var(--border); box-shadow: var(--shadow-sm); overflow: hidden; }
.budget-table-header { display: flex; padding: 10px 16px; font-size: var(--text-xs); font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-4); border-bottom: 1px solid var(--border); }
.budget-table-row { display: flex; padding: 14px 16px; cursor: default; }
.budget-table-row + .budget-table-row { border-top: 1px solid var(--border); }
.budget-table-row:nth-child(even) { background: color-mix(in srgb, var(--surface) 55%, var(--card)); }
.budget-table-row.overspent { border-left: 3px solid var(--error); }
.bt-name { flex: 1.5; font-size: var(--text-base); font-weight: 400; color: var(--text-2); }
.budget-table-header .bt-name { font-size: var(--text-xs); font-weight: 600; color: var(--text-4); }
.bt-val { flex: 1; text-align: right; font-family: var(--mono); font-size: var(--text-base); font-weight: 600; color: var(--text-1); }
.budget-table-header .bt-val { font-family: var(--font); font-size: var(--text-xs); font-weight: 600; color: var(--text-4); }
.bt-val.spent { color: var(--error); }
.bt-val.positive { color: var(--success); font-weight: 600; }
.bt-val.negative { color: var(--error); font-weight: 600; }
/* ── Mobile ── */
@media (max-width: 767px) {
.budget-page { margin: -16px; }
.budget-main { padding: var(--sp-4) var(--sp-4) var(--sp-20); }
.txn-account { display: none; }
.txn-row { gap: 10px; padding: 16px 14px; }
.txn-payee { flex: 1.2; }
.txn-category { flex: 1; }
.txn-date { width: 42px; font-size: var(--text-sm); }
.txn-name { font-size: var(--text-base); }
.txn-amount { min-width: 75px; font-size: var(--text-sm); }
.cat-select { max-width: none; min-height: 40px; font-size: var(--text-base); }
.suggestion-pair { flex-direction: column; align-items: flex-start; gap: var(--sp-0.5); }
.suggestion-row { flex-wrap: wrap; }
.selection-bar { flex-wrap: wrap; gap: var(--sp-2); }
}
@media (min-width: 768px) {
.budget-page { margin: -20px; }
.mobile-accounts { display: none; }
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,149 @@
<script lang="ts">
import { onMount } from 'svelte';
interface FoodItem { id: string; name: string; info: string; calories: number; }
let searchQuery = $state('');
let foods = $state<FoodItem[]>([]);
let loading = $state(true);
function mapFood(f: any): FoodItem {
const id = f.id || f.food_id || '';
const name = f.name || f.snapshot_food_name || 'Unknown';
const brand = f.brand ? `${f.brand} · ` : '';
const unit = f.base_unit === '100g' ? '100g' : (f.servings?.[0]?.label || f.base_unit || 'serving');
return { id, name, info: `${brand}${unit}`, calories: Math.round(f.calories_per_base || 0) };
}
async function loadFoods(query?: string) {
loading = true;
try {
const url = query
? `/api/fitness/foods/search?q=${encodeURIComponent(query)}&limit=50`
: `/api/fitness/foods?limit=50`;
const res = await fetch(url, { credentials: 'include' });
if (res.ok) {
const raw = await res.json();
foods = (Array.isArray(raw) ? raw : []).map(mapFood);
}
} catch { /* silent */ }
loading = false;
}
let debounceTimer: ReturnType<typeof setTimeout>;
$effect(() => {
const q = searchQuery;
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => loadFoods(q || undefined), q ? 300 : 0);
});
const filteredFoods = $derived(foods);
</script>
<div class="page">
<div class="app-surface">
<div class="page-header">
<div class="page-title">FITNESS</div>
<div class="page-subtitle">Food Library</div>
</div>
<div class="search-bar">
<svg class="search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
<input
class="input search-input"
type="text"
placeholder="Search foods..."
bind:value={searchQuery}
/>
</div>
<div class="food-list">
{#each filteredFoods as food (food.name)}
<div class="food-row">
<div class="food-info">
<div class="food-name">{food.name}</div>
<div class="food-meta">{food.info}</div>
</div>
<div class="food-right">
<span class="food-cal">{food.calories}</span>
<button class="btn-icon add-btn">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
</button>
</div>
</div>
{/each}
</div>
</div>
</div>
<style>
.page-subtitle {
font-size: var(--text-2xl);
font-weight: 300;
color: var(--text-1);
line-height: 1.2;
}
.search-bar {
position: relative;
margin-bottom: var(--section-gap);
}
.search-icon {
position: absolute;
left: 14px;
top: 50%;
transform: translateY(-50%);
width: 16px;
height: 16px;
color: var(--text-3);
pointer-events: none;
}
.search-input {
padding-left: 40px;
}
.food-list {
display: flex;
flex-direction: column;
gap: 0;
}
.food-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 0;
border-bottom: 1px solid var(--border);
}
.food-row:last-child { border-bottom: none; }
.food-name {
font-size: var(--text-base);
font-weight: 500;
color: var(--text-1);
}
.food-meta {
font-size: var(--text-sm);
color: var(--text-3);
margin-top: var(--sp-0.5);
}
.food-right {
display: flex;
align-items: center;
gap: var(--sp-3);
flex-shrink: 0;
}
.food-cal {
font-size: var(--text-base);
font-family: var(--mono);
color: var(--text-1);
}
.add-btn {
width: 32px;
height: 32px;
}
@media (max-width: 768px) {
.page-subtitle { font-size: var(--text-xl); }
}
</style>

View File

@@ -0,0 +1,157 @@
<script lang="ts">
import { onMount } from 'svelte';
let goals = $state([
{ label: 'Calories', value: '...', unit: 'kcal/day' },
{ label: 'Protein', value: '...', unit: 'grams/day' },
{ label: 'Carbs', value: '...', unit: 'grams/day' },
{ label: 'Fat', value: '...', unit: 'grams/day' }
]);
let startDate = $state('');
onMount(async () => {
try {
const n = new Date();
const today = `${n.getFullYear()}-${String(n.getMonth() + 1).padStart(2, '0')}-${String(n.getDate()).padStart(2, '0')}`;
const res = await fetch(`/api/fitness/goals/for-date?date=${today}`, { credentials: 'include' });
if (res.ok) {
const g = await res.json();
goals = [
{ label: 'Calories', value: (g.calories || 0).toLocaleString(), unit: 'kcal/day' },
{ label: 'Protein', value: String(g.protein || 0), unit: 'grams/day' },
{ label: 'Carbs', value: String(g.carbs || 0), unit: 'grams/day' },
{ label: 'Fat', value: String(g.fat || 0), unit: 'grams/day' },
];
if (g.start_date) {
const d = new Date(g.start_date + 'T00:00:00');
startDate = d.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' });
}
}
} catch { /* silent */ }
});
</script>
<div class="page">
<div class="app-surface">
<div class="page-header">
<div class="page-title">FITNESS</div>
<div class="page-subtitle">Daily Goals</div>
</div>
<div class="module">
<div class="module-header">
<div class="module-title">CURRENT GOALS</div>
<button class="module-action">Edit Goals</button>
</div>
<div class="goals-grid">
{#each goals as goal}
<div class="goal-card">
<div class="goal-label">{goal.label}</div>
<div class="goal-value">{goal.value}</div>
<div class="goal-unit">{goal.unit}</div>
</div>
{/each}
</div>
<div class="start-date">
<span class="start-date-label">Start date</span>
<span class="start-date-value">{startDate || '—'}</span>
</div>
</div>
</div>
</div>
<style>
.page-subtitle {
font-size: var(--text-2xl);
font-weight: 300;
color: var(--text-1);
line-height: 1.2;
}
.module {
background: var(--card);
border-radius: var(--radius);
padding: var(--card-pad-primary);
box-shadow: var(--card-shadow);
border: 1px solid var(--border);
}
.module-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--sp-5);
}
.module-title {
font-size: var(--text-sm);
font-weight: 600;
color: var(--text-3);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.module-action {
font-size: var(--text-sm);
color: var(--accent);
font-weight: 500;
cursor: pointer;
background: none;
border: none;
padding: 0;
}
.module-action:hover { text-decoration: underline; }
.goals-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--row-gap);
margin-bottom: var(--sp-5);
}
.goal-card {
background: var(--surface-secondary);
border-radius: var(--radius-sm);
padding: var(--sp-4);
border: 1px solid var(--border);
}
.goal-label {
font-size: var(--text-sm);
font-weight: 500;
color: var(--text-3);
text-transform: uppercase;
letter-spacing: 0.04em;
margin-bottom: var(--sp-2);
}
.goal-value {
font-size: var(--text-xl);
font-weight: 500;
font-family: var(--mono);
color: var(--text-1);
line-height: 1;
}
.goal-unit {
font-size: var(--text-sm);
color: var(--text-3);
margin-top: var(--sp-1);
}
.start-date {
display: flex;
align-items: center;
justify-content: space-between;
padding-top: var(--sp-4);
border-top: 1px solid var(--border);
}
.start-date-label {
font-size: var(--text-sm);
color: var(--text-3);
}
.start-date-value {
font-size: var(--text-sm);
color: var(--text-2);
font-weight: 500;
}
@media (max-width: 768px) {
.page-subtitle { font-size: var(--text-xl); }
}
</style>

View File

@@ -0,0 +1,136 @@
<script lang="ts">
import { onMount } from 'svelte';
interface Template { id: string; name: string; calories: number; items: number; meal: string; }
let templates = $state<Template[]>([]);
let loading = $state(true);
function mealIcon(meal: string): string {
if (meal === 'breakfast') return '🥣';
if (meal === 'lunch') return '🍗';
if (meal === 'dinner') return '🍽️';
return '🥤';
}
onMount(async () => {
try {
const res = await fetch('/api/fitness/templates', { credentials: 'include' });
if (res.ok) {
const raw = await res.json();
templates = (Array.isArray(raw) ? raw : []).map((t: any) => {
const items = t.items || [];
const totalCal = items.reduce((s: number, i: any) => s + (i.snapshot_calories || 0) * (i.quantity || 1), 0);
return { id: t.id, name: t.name, calories: Math.round(totalCal), items: items.length, meal: t.meal_type || 'snack' };
});
}
} catch { /* silent */ }
loading = false;
});
</script>
<div class="page">
<div class="app-surface">
<div class="page-header">
<div class="header-row">
<div>
<div class="page-title">FITNESS</div>
<div class="page-subtitle">Meal Templates</div>
</div>
<button class="btn-primary">+ New Template</button>
</div>
</div>
<div class="template-list">
{#if loading}
<div class="template-empty">Loading...</div>
{:else if templates.length === 0}
<div class="template-empty">No meal templates yet</div>
{:else}
{#each templates as tpl}
<div class="template-row">
<div class="template-icon">{mealIcon(tpl.meal)}</div>
<div class="template-info">
<div class="template-name">{tpl.name}</div>
<div class="template-meta">{tpl.calories} cal &middot; {tpl.items} items</div>
</div>
<svg class="chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18l6-6-6-6"/></svg>
</div>
{/each}
{/if}
</div>
</div>
</div>
<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;
}
.template-list {
display: flex;
flex-direction: column;
gap: var(--row-gap);
}
.template-row {
display: flex;
align-items: center;
gap: 14px;
padding: var(--sp-4) var(--sp-5);
background: var(--card);
border-radius: var(--radius);
border: 1px solid var(--border);
box-shadow: var(--card-shadow-sm);
cursor: pointer;
transition: all var(--transition);
}
.template-row:hover {
transform: translateY(-1px);
box-shadow: var(--card-shadow);
}
.template-icon {
width: 40px;
height: 40px;
border-radius: 10px;
background: var(--surface-secondary);
display: flex;
align-items: center;
justify-content: center;
font-size: var(--text-xl);
flex-shrink: 0;
}
.template-info {
flex: 1;
min-width: 0;
}
.template-name {
font-size: var(--text-base);
font-weight: 500;
color: var(--text-1);
}
.template-meta {
font-size: var(--text-sm);
color: var(--text-3);
margin-top: var(--sp-0.5);
}
.template-empty { padding: var(--sp-8); text-align: center; color: var(--text-3); font-size: var(--text-base); }
.chevron {
width: 16px;
height: 16px;
color: var(--text-3);
flex-shrink: 0;
}
@media (max-width: 768px) {
.page-subtitle { font-size: var(--text-xl); }
}
</style>

View File

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

View File

@@ -0,0 +1,170 @@
<script lang="ts">
const details = [
{ label: 'Order #', value: '33A77277RK' },
{ label: 'Serial #', value: '019284756301' },
{ label: 'SKU', value: 'SURF-LT-1TB' },
{ label: 'Purchase Price', value: '$423.00' },
{ label: 'List Price', value: '$899.99' },
{ label: 'Status', value: 'Used - Like New' },
{ label: 'Condition', value: '—' },
{ label: 'Tracking #', value: '—' },
{ label: 'Notes', value: '—' }
];
</script>
<div class="page">
<div class="app-surface">
<a href="/inventory" class="back-link">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 18l-6-6 6-6"/></svg>
Back to Inventory
</a>
<div class="detail-header">
<h1 class="detail-title">Microsoft Surface Laptop Studio - 1TB TBolt</h1>
<span class="badge-error">Needs Attention</span>
</div>
<!-- Photos -->
<div class="photos">
{#each [1, 2, 3] as _}
<div class="photo-placeholder">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
<circle cx="8.5" cy="8.5" r="1.5"/>
<polyline points="21 15 16 10 5 21"/>
</svg>
</div>
{/each}
</div>
<!-- Actions -->
<div class="actions">
<button class="btn-secondary">Upload Photos</button>
<button class="btn-secondary">Pin</button>
<button class="btn-secondary">Send to Phone</button>
<button class="btn-secondary">Duplicate</button>
<button class="btn-secondary">Open in NocoDB</button>
</div>
<!-- Details -->
<div class="module">
<div class="module-header">
<div class="module-title">Details</div>
</div>
{#each details as row}
<div class="detail-row">
<span class="detail-label">{row.label}</span>
<span class="detail-value">{row.value}</span>
</div>
{/each}
</div>
</div>
</div>
<style>
.back-link {
display: inline-flex;
align-items: center;
gap: var(--sp-1);
font-size: var(--text-sm);
color: var(--text-3);
margin-bottom: var(--sp-5);
transition: color var(--transition);
}
.back-link:hover { color: var(--text-1); }
.back-link svg { width: 16px; height: 16px; }
.detail-header {
display: flex;
align-items: center;
gap: var(--sp-3);
flex-wrap: wrap;
margin-bottom: var(--section-gap);
}
.detail-title {
font-size: var(--text-xl);
font-weight: 600;
color: var(--text-1);
margin: 0;
}
.badge-error {
font-size: var(--text-sm);
font-weight: 600;
color: var(--error);
background: var(--error-bg);
padding: 4px 10px;
border-radius: var(--radius-sm);
}
/* Photos */
.photos {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--row-gap);
margin-bottom: var(--section-gap);
}
.photo-placeholder {
aspect-ratio: 4/3;
background: var(--card-secondary);
border: 1px dashed var(--border-strong);
border-radius: var(--radius);
display: flex;
align-items: center;
justify-content: center;
}
.photo-placeholder svg {
width: 32px;
height: 32px;
color: var(--text-4);
}
/* Actions */
.actions {
display: flex;
flex-wrap: wrap;
gap: var(--sp-2);
margin-bottom: var(--section-gap);
}
/* Module */
.module {
background: var(--card);
border-radius: var(--radius);
padding: var(--card-pad-primary);
box-shadow: var(--card-shadow);
border: 1px solid var(--border);
}
.module-header {
margin-bottom: var(--sp-4);
}
.module-title {
font-size: var(--text-sm);
font-weight: 600;
color: var(--text-3);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.detail-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 0;
border-bottom: 1px solid var(--border);
}
.detail-row:last-child { border-bottom: none; }
.detail-label {
font-size: var(--text-base);
color: var(--text-3);
}
.detail-value {
font-size: var(--text-base);
font-family: var(--mono);
color: var(--text-1);
}
@media (max-width: 768px) {
.photos { grid-template-columns: repeat(2, 1fr); }
.detail-title { font-size: var(--text-xl); }
}
</style>

View File

@@ -0,0 +1,75 @@
<script lang="ts">
import { page } from '$app/state';
import BookSearch from '$lib/components/media/BookSearch.svelte';
import MusicSearch from '$lib/components/media/MusicSearch.svelte';
import BookLibrary from '$lib/components/media/BookLibrary.svelte';
type MediaTab = 'books' | 'music' | 'library';
const urlMode = page.url.searchParams.get('mode');
let activeTab = $state<MediaTab>(urlMode === 'music' ? 'music' : urlMode === 'library' ? 'library' : 'books');
</script>
<div class="page">
<div class="app-surface">
<div class="page-header">
<div class="page-title">MEDIA</div>
<div class="page-subtitle">
{#if activeTab === 'books'}Book Downloads
{:else if activeTab === 'music'}Music Downloads
{:else}Book Library{/if}
</div>
</div>
<div class="media-tabs">
<button class="tab" class:active={activeTab === 'books'} onclick={() => activeTab = 'books'}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>
Books
</button>
<button class="tab" class:active={activeTab === 'music'} onclick={() => activeTab = 'music'}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
Music
</button>
<button class="tab" class:active={activeTab === 'library'} onclick={() => activeTab = 'library'}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/></svg>
Library
</button>
</div>
{#if activeTab === 'books'}
<BookSearch />
{:else if activeTab === 'music'}
<MusicSearch />
{:else}
<BookLibrary />
{/if}
</div>
</div>
<style>
.page-subtitle {
font-size: var(--text-2xl);
font-weight: 300;
color: var(--text-1);
line-height: 1.2;
}
.media-tabs {
display: flex; gap: var(--sp-1); margin-bottom: var(--sp-5);
border-bottom: 1px solid var(--border); padding-bottom: 0;
}
.tab {
display: flex; align-items: center; gap: var(--sp-2);
flex: 1; padding: 10px var(--sp-2) 12px; font-size: var(--text-base); font-weight: 500;
color: var(--text-3); background: none; border: none; border-bottom: 2px solid transparent;
cursor: pointer; font-family: var(--font); transition: all var(--transition);
text-align: center; justify-content: center; margin-bottom: -1px;
}
.tab:hover { color: var(--text-2); }
.tab.active { color: var(--text-1); border-bottom-color: var(--accent); font-weight: 600; }
.tab svg { width: 16px; height: 16px; flex-shrink: 0; }
@media (max-width: 768px) {
.page-subtitle { font-size: var(--text-xl); }
}
</style>

View File

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

View File

@@ -0,0 +1,328 @@
<script lang="ts">
import { onMount } from 'svelte';
interface App {
id: string;
name: string;
icon: string;
route_prefix: string;
connected: boolean;
}
let user = $state<{ username: string; display_name: string } | null>(null);
let apps = $state<App[]>([]);
let loading = $state(true);
let darkMode = $state(false);
// Connect form state
let showConnectForm = $state('');
let connectToken = $state('');
let connectUser = $state('');
let connectPass = $state('');
let connectError = $state('');
let connectingService = $state('');
const iconMap: Record<string, string> = {
'map': '🗺️', 'bar-chart': '📊', 'package': '📦', 'book': '📚',
'music': '🎵', 'rss': '📰', 'wallet': '💰',
};
onMount(async () => {
// Check theme
darkMode = document.documentElement.classList.contains('dark');
try {
const [meRes, appsRes] = await Promise.all([
fetch('/api/auth/me', { credentials: 'include' }),
fetch('/api/apps', { credentials: 'include' }),
]);
if (meRes.ok) {
const data = await meRes.json();
if (data.authenticated) user = data.user;
}
if (appsRes.ok) {
const data = await appsRes.json();
apps = data.apps || [];
}
} catch { /* silent */ }
loading = false;
});
async function connectService(serviceId: string) {
connectError = '';
connectingService = serviceId;
let token = '';
if (serviceId === 'fitness' && connectUser && connectPass) {
try {
const res = await fetch(`/api/${serviceId}/auth/login`, {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: connectUser, password: connectPass }),
});
const data = await res.json();
if (data.token) { token = data.token; }
else { connectError = data.error || 'Login failed'; connectingService = ''; return; }
} catch { connectError = 'Connection failed'; connectingService = ''; return; }
} else {
token = connectToken;
}
if (!token) { connectError = 'Token required'; connectingService = ''; return; }
try {
const res = await fetch('/api/me/connections', {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ service: serviceId, token, auth_type: 'bearer' }),
});
const data = await res.json();
if (data.success) {
apps = apps.map(a => a.id === serviceId ? { ...a, connected: true } : a);
showConnectForm = ''; connectToken = ''; connectUser = ''; connectPass = '';
} else { connectError = data.error || 'Failed to connect'; }
} catch { connectError = 'Connection failed'; }
connectingService = '';
}
async function disconnectService(serviceId: string) {
await fetch('/api/me/connections', {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ service: serviceId, action: 'disconnect' }),
});
apps = apps.map(a => a.id === serviceId ? { ...a, connected: false } : a);
}
function toggleTheme() {
darkMode = !darkMode;
document.documentElement.classList.toggle('dark', darkMode);
}
async function logout() {
await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' });
window.location.href = '/login';
}
</script>
<div class="page">
<div class="app-surface">
<div class="page-header">
<div class="page-title">SETTINGS</div>
<div class="page-subtitle">Preferences</div>
</div>
{#if loading}
<div class="loading-state">Loading...</div>
{:else}
<!-- Account -->
<section class="settings-section">
<div class="section-title">Account</div>
<div class="settings-card">
<div class="setting-row">
<div class="setting-label">Username</div>
<div class="setting-value">{user?.username || '—'}</div>
</div>
<div class="setting-row">
<div class="setting-label">Display Name</div>
<div class="setting-value">{user?.display_name || '—'}</div>
</div>
<div class="setting-row">
<div></div>
<button class="btn-danger" onclick={logout}>Sign Out</button>
</div>
</div>
</section>
<!-- Appearance -->
<section class="settings-section">
<div class="section-title">Appearance</div>
<div class="settings-card">
<div class="setting-row">
<div>
<div class="setting-label">Theme</div>
<div class="setting-hint">Switch between light and dark mode</div>
</div>
<button class="theme-toggle" class:dark={darkMode} onclick={toggleTheme} aria-label="Toggle theme">
<div class="toggle-track">
<div class="toggle-thumb">
{#if darkMode}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
{:else}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
{/if}
</div>
</div>
</button>
</div>
</div>
</section>
<!-- Service Connections -->
<section class="settings-section">
<div class="section-title">Service Connections</div>
<div class="settings-card">
{#each apps as app}
<div class="setting-row">
<div class="service-info">
<span class="service-icon">{iconMap[app.icon] || '📱'}</span>
<div>
<div class="setting-label">{app.name}</div>
<div class="setting-hint">{app.route_prefix}</div>
</div>
</div>
{#if app.connected}
<div class="service-actions">
<span class="conn-badge connected">Connected</span>
<button class="btn-text-danger" onclick={() => disconnectService(app.id)}>Disconnect</button>
</div>
{:else}
<button class="btn-connect" onclick={() => { showConnectForm = app.id; connectError = ''; }}>Connect</button>
{/if}
</div>
{#if showConnectForm === app.id && !app.connected}
<div class="connect-form">
{#if connectError}
<div class="connect-error">{connectError}</div>
{/if}
{#if app.id === 'fitness'}
<input type="text" class="connect-input" placeholder="Fitness username" bind:value={connectUser} />
<input type="password" class="connect-input" placeholder="Fitness password" bind:value={connectPass} />
{:else}
<input type="password" class="connect-input" placeholder="API Token" bind:value={connectToken} />
{/if}
<div class="connect-actions">
<button class="btn-connect" onclick={() => connectService(app.id)} disabled={connectingService === app.id}>
{connectingService === app.id ? 'Connecting...' : 'Connect'}
</button>
<button class="btn-cancel" onclick={() => showConnectForm = ''}>Cancel</button>
</div>
</div>
{/if}
{/each}
{#if apps.length === 0}
<div class="empty-state">No services configured</div>
{/if}
</div>
</section>
{/if}
</div>
</div>
<style>
.page-subtitle {
font-size: var(--text-2xl);
font-weight: 300;
color: var(--text-1);
line-height: 1.2;
}
.loading-state { padding: var(--sp-12); text-align: center; color: var(--text-3); }
.settings-section { margin-bottom: var(--section-gap); }
.section-title {
font-size: var(--text-sm); font-weight: 600; color: var(--text-3);
text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: var(--sp-3);
}
.settings-card {
background: var(--card); border-radius: var(--radius);
border: 1px solid var(--border); box-shadow: var(--shadow-sm); overflow: hidden;
}
.setting-row {
display: flex; align-items: center; justify-content: space-between;
padding: var(--sp-4) var(--sp-5); border-bottom: 1px solid var(--border);
}
.setting-row:last-child { border-bottom: none; }
.setting-label { font-size: var(--text-base); font-weight: 500; color: var(--text-1); }
.setting-value { font-size: var(--text-base); color: var(--text-2); }
.setting-hint { font-size: var(--text-sm); color: var(--text-3); margin-top: var(--sp-0.5); }
.service-info { display: flex; align-items: center; gap: var(--sp-3); }
.service-icon { font-size: var(--text-xl); }
.service-actions { display: flex; align-items: center; gap: var(--sp-2); }
.conn-badge {
font-size: var(--text-sm); font-weight: 600; padding: 3px var(--sp-3);
border-radius: 10px; line-height: 1.5;
}
.conn-badge.connected { background: var(--success-bg); color: var(--success); }
.btn-connect {
padding: var(--sp-1.5) 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-connect:hover { opacity: 0.9; }
.btn-connect:disabled { opacity: 0.5; }
.btn-cancel {
padding: var(--sp-1.5) var(--sp-4); border-radius: var(--radius-md);
background: var(--card-secondary); color: var(--text-2); border: 1px solid var(--border);
font-size: var(--text-sm); font-weight: 500; cursor: pointer; font-family: var(--font);
}
.btn-text-danger {
background: none; border: none; color: var(--error); font-size: var(--text-sm);
font-weight: 500; cursor: pointer; font-family: var(--font);
transition: opacity var(--transition);
}
.btn-text-danger:hover { opacity: 0.7; }
.btn-danger {
padding: var(--sp-2) var(--sp-4); border-radius: var(--radius-md);
background: none; border: 1px solid var(--error); color: var(--error);
font-size: var(--text-sm); font-weight: 500; cursor: pointer; font-family: var(--font);
transition: all var(--transition);
}
.btn-danger:hover { background: var(--error-dim); }
.connect-form {
padding: var(--sp-3) var(--sp-5) var(--sp-4);
border-bottom: 1px solid var(--border);
display: flex; flex-direction: column; gap: var(--sp-2);
}
.connect-input {
width: 100%; padding: var(--sp-2) var(--sp-3); border-radius: var(--radius-md);
border: 1px solid var(--border); background: var(--surface-secondary);
color: var(--text-1); font-size: var(--text-sm); font-family: var(--font);
}
.connect-input:focus { outline: none; border-color: var(--accent); }
.connect-input::placeholder { color: var(--text-4); }
.connect-actions { display: flex; gap: var(--sp-2); }
.connect-error {
font-size: var(--text-sm); color: var(--error); background: var(--error-dim);
padding: var(--sp-2) var(--sp-3); border-radius: var(--radius-sm);
}
.empty-state { padding: var(--sp-8); text-align: center; color: var(--text-3); font-size: var(--text-sm); }
/* Theme Toggle */
.theme-toggle { background: none; border: none; padding: 0; cursor: pointer; }
.toggle-track {
width: 48px; height: 28px; border-radius: 14px;
background: var(--border-strong); position: relative; transition: background var(--transition);
}
.theme-toggle.dark .toggle-track { background: var(--accent); }
.toggle-thumb {
width: 22px; height: 22px; border-radius: 50%; background: white;
position: absolute; top: 3px; left: 3px;
transition: transform var(--transition);
display: flex; align-items: center; justify-content: center;
box-shadow: 0 1px 3px rgba(0,0,0,0.15);
}
.theme-toggle.dark .toggle-thumb { transform: translateX(20px); }
.toggle-thumb svg { width: 14px; height: 14px; color: var(--text-3); }
.theme-toggle.dark .toggle-thumb svg { color: var(--accent); }
@media (max-width: 768px) {
.page-subtitle { font-size: var(--text-xl); }
.setting-row { flex-wrap: wrap; gap: var(--sp-2); }
}
</style>

View File

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

File diff suppressed because it is too large Load Diff