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,78 @@
export type ApiClientOptions = {
basePath: string;
loginPath?: string;
};
export function createApiClient({ basePath, loginPath = '/login' }: ApiClientOptions) {
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
const headers: Record<string, string> = {
...((options.headers as Record<string, string>) || {})
};
if (options.body && typeof options.body === 'string') {
headers['Content-Type'] = 'application/json';
}
const response = await fetch(`${basePath}${path}`, {
...options,
headers,
credentials: 'include' // sends platform_session cookie
});
if (response.status === 401) {
if (typeof window !== 'undefined') {
window.location.href = loginPath;
}
throw new Error('Unauthorized');
}
if (!response.ok) {
const err = await response.json().catch(() => ({ error: response.statusText }));
throw new Error(err.error || `HTTP ${response.status}`);
}
return response.json();
}
return {
get: <T>(path: string) => request<T>(path),
post: <T>(path: string, data: unknown) => request<T>(path, { method: 'POST', body: JSON.stringify(data) }),
patch: <T>(path: string, data: unknown) => request<T>(path, { method: 'PATCH', body: JSON.stringify(data) }),
put: <T>(path: string, data: unknown) => request<T>(path, { method: 'PUT', body: JSON.stringify(data) }),
del: <T>(path: string) => request<T>(path, { method: 'DELETE' }),
// For raw fetch (file uploads etc)
fetch: (path: string, options: RequestInit = {}) =>
fetch(`${basePath}${path}`, { ...options, credentials: 'include' })
};
}
// Platform auth helpers
export const platformAuth = {
async login(username: string, password: string) {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
credentials: 'include'
});
return res.json();
},
async logout() {
await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' });
},
async me() {
const res = await fetch('/api/auth/me', { credentials: 'include' });
return res.json();
},
async isAuthenticated(): Promise<boolean> {
try {
const data = await this.me();
return data.authenticated === true;
} catch {
return false;
}
}
};

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,114 @@
<script lang="ts">
import { onMount } from 'svelte';
let spending = $state('...');
let month = $state('');
let topCats = $state<{ name: string; amount: string; isIncome: boolean }[]>([]);
onMount(async () => {
try {
const res = await fetch('/api/budget/summary', { credentials: 'include' });
if (res.ok) {
const data = await res.json();
spending = '$' + Math.abs(data.spendingDollars || 0).toLocaleString('en-US');
const m = data.month || '';
if (m) {
const d = new Date(m + '-01');
month = d.toLocaleDateString('en-US', { month: 'long' });
}
const income = data.incomeDollars || 0;
const cats = (data.topCategories || []).map((c: any) => ({
name: c.name,
amount: '$' + Math.abs(c.amountDollars || 0).toLocaleString('en-US'),
isIncome: false
}));
if (income > 0) {
topCats = [{ name: 'Income', amount: '+$' + Math.abs(income).toLocaleString('en-US'), isIncome: true }, ...cats];
} else {
topCats = cats;
}
}
} catch { /* silent */ }
});
</script>
<div class="module primary">
<div class="module-header">
<div class="module-title">Budget{month ? ' · ' + month : ''}</div>
<a href="/budget" class="module-action">View all &rarr;</a>
</div>
<div class="budget-hero">
<div class="budget-amount">{spending}</div>
<div class="budget-label">spent this month</div>
</div>
<div class="budget-rows">
{#each topCats as cat}
<div class="budget-row">
<span class="budget-row-name">{cat.name}</span>
<span class="budget-row-amount" class:income={cat.isIncome}>{cat.amount}</span>
</div>
{/each}
</div>
</div>
<style>
/* No .module/.module-header/.module-title/.module-action — using globals from app.css */
.budget-hero {
margin-bottom: var(--sp-5);
}
.budget-amount {
font-size: var(--text-3xl);
font-weight: 500;
font-family: var(--mono);
color: var(--text-1);
line-height: 1;
}
.budget-label {
font-size: var(--text-base);
color: var(--text-3);
margin-top: var(--sp-1.5);
}
.budget-rows {
display: flex;
flex-direction: column;
}
.budget-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--sp-3) 0;
border-bottom: 1px solid var(--border);
}
.budget-row:last-child {
border-bottom: none;
}
.budget-row-name {
font-size: var(--text-base);
color: var(--text-2);
}
.budget-row-amount {
font-size: var(--text-base);
font-family: var(--mono);
color: var(--text-1);
}
.budget-row-amount.income {
color: var(--success);
}
@media (max-width: 768px) {
.budget-amount {
font-size: var(--text-2xl);
}
}
</style>

View File

@@ -0,0 +1,157 @@
<script lang="ts">
let {
title,
description,
action,
variant,
size = 'secondary',
href
}: {
title: string;
description: string;
action: string;
variant: 'budget' | 'inventory' | 'fitness';
size?: 'primary' | 'secondary';
href: string;
} = $props();
</script>
<a {href} class="action-card {size}">
<div class="action-card-left">
<div class="action-card-icon {variant}">
{#if variant === 'budget'}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg>
{:else if variant === 'inventory'}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16.5 9.4l-9-5.19"/><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg>
{:else if variant === 'fitness'}
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>
{/if}
</div>
<div class="action-card-text">
<div class="action-card-title">{title}</div>
<div class="action-card-desc">{description}</div>
</div>
</div>
<div class="action-card-right">
{action}
<svg 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>
</a>
<style>
.action-card {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--sp-5) var(--sp-5);
border-radius: var(--radius-lg);
background: var(--card);
border: 1px solid var(--border);
box-shadow: var(--shadow-md);
transition: all var(--transition);
cursor: pointer;
text-decoration: none;
}
.action-card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
}
.action-card.primary {
box-shadow: var(--shadow-lg);
transform: translateY(-1px);
}
.action-card.primary:hover {
transform: translateY(-3px);
box-shadow: var(--shadow-lg);
}
.action-card-left {
display: flex;
align-items: center;
gap: var(--inner-gap);
flex: 1;
min-width: 0;
}
.action-card-icon {
width: 36px;
height: 36px;
border-radius: var(--radius-md);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
/* Icon variants — using semantic tokens for both themes */
.action-card-icon.budget { background: var(--accent-bg); color: var(--accent); }
.action-card-icon.inventory { background: var(--error-bg); color: var(--error); }
.action-card-icon.fitness { background: var(--success-bg); color: var(--success); }
.action-card-icon :global(svg) {
width: 18px;
height: 18px;
}
.action-card-text {
flex: 1;
min-width: 0;
}
.action-card-title {
font-size: var(--text-md);
font-weight: 600;
color: var(--text-1);
line-height: var(--leading-snug);
}
.action-card-desc {
font-size: var(--text-sm);
color: var(--text-3);
margin-top: var(--sp-0.5);
line-height: 1.4;
}
.action-card-right {
font-size: var(--text-sm);
font-weight: 500;
color: var(--text-3);
display: flex;
align-items: center;
gap: 1px;
flex-shrink: 0;
padding: var(--sp-1) var(--sp-2);
border-radius: var(--radius-md);
transition: all var(--transition);
white-space: nowrap;
}
.action-card:hover .action-card-right {
color: var(--accent);
background: var(--accent-dim);
}
.action-card-right :global(svg) {
width: var(--sp-4);
height: var(--sp-4);
transition: transform var(--transition);
}
.action-card:hover .action-card-right :global(svg) {
transform: translateX(2px);
}
@media (max-width: 768px) {
.action-card {
padding: var(--sp-4);
gap: var(--sp-2);
}
.action-card-left {
gap: var(--sp-3);
}
}
</style>

View File

@@ -0,0 +1,199 @@
<script lang="ts">
import { onMount } from 'svelte';
let loading = $state(true);
let error = $state(false);
let notConnected = $state(false);
let eaten = $state(0);
let remaining = $state(0);
let percent = $state(0);
let calorieGoal = $state(0);
let proteinCurrent = $state(0);
let proteinGoal = $state(0);
let carbsCurrent = $state(0);
let carbsGoal = $state(0);
let fatCurrent = $state(0);
let fatGoal = $state(0);
function fmt(n: number): string {
return Math.round(n).toLocaleString();
}
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 [totalsRes, goalsRes] = await Promise.all([
fetch(`/api/fitness/entries/totals?date=${today}`),
fetch(`/api/fitness/goals/for-date?date=${today}`)
]);
if (totalsRes.status === 401 || goalsRes.status === 401) {
notConnected = true;
loading = false;
return;
}
if (!totalsRes.ok || (!goalsRes.ok && goalsRes.status !== 404)) {
error = true;
loading = false;
return;
}
const totals = await totalsRes.json();
eaten = totals.total_calories ?? 0;
proteinCurrent = totals.total_protein ?? 0;
carbsCurrent = totals.total_carbs ?? 0;
fatCurrent = totals.total_fat ?? 0;
if (goalsRes.ok) {
const goals = await goalsRes.json();
calorieGoal = goals.calories ?? 0;
proteinGoal = goals.protein ?? 0;
carbsGoal = goals.carbs ?? 0;
fatGoal = goals.fat ?? 0;
}
remaining = Math.max(0, calorieGoal - eaten);
percent = calorieGoal > 0 ? Math.min(100, Math.round((eaten / calorieGoal) * 100)) : 0;
loading = false;
} catch {
error = true;
loading = false;
}
});
</script>
<div class="module">
<div class="module-header">
<div class="module-title">Fitness &middot; Today</div>
<a href="/fitness" class="module-action">Details &rarr;</a>
</div>
{#if notConnected}
<div class="fitness-top">
<div class="fitness-avatar">Y</div>
<div>
<div class="fitness-name">Yusuf</div>
<div class="fitness-sub">Connect fitness to get started</div>
</div>
</div>
{:else}
<div class="fitness-top">
<div class="fitness-avatar">Y</div>
<div>
<div class="fitness-name">Yusuf</div>
<div class="fitness-sub">
{#if loading}...{:else if error}&mdash;{:else}{fmt(eaten)} cal &middot; {fmt(remaining)} remaining{/if}
</div>
</div>
</div>
<div class="fitness-bar">
<div class="fitness-bar-fill" style="width: {loading || error ? 0 : percent}%"></div>
</div>
<div class="fitness-macros">
<div class="fitness-macro">
<div class="fitness-macro-val">
{#if loading}...{:else if error}&mdash;{:else}{Math.round(proteinCurrent)}<span class="fitness-macro-unit">/{Math.round(proteinGoal)}g</span>{/if}
</div>
<div class="fitness-macro-label">protein</div>
</div>
<div class="fitness-macro">
<div class="fitness-macro-val">
{#if loading}...{:else if error}&mdash;{:else}{Math.round(carbsCurrent)}<span class="fitness-macro-unit">/{Math.round(carbsGoal)}g</span>{/if}
</div>
<div class="fitness-macro-label">carbs</div>
</div>
<div class="fitness-macro">
<div class="fitness-macro-val">
{#if loading}...{:else if error}&mdash;{:else}{Math.round(fatCurrent)}<span class="fitness-macro-unit">/{Math.round(fatGoal)}g</span>{/if}
</div>
<div class="fitness-macro-label">fat</div>
</div>
</div>
{/if}
</div>
<style>
/* No .module/.module-header/.module-title/.module-action — using globals from app.css */
.fitness-top {
display: flex;
align-items: center;
gap: var(--sp-3);
margin-bottom: var(--sp-4);
}
.fitness-avatar {
width: var(--sp-10);
height: var(--sp-10);
border-radius: var(--radius-full);
background: var(--accent-dim);
display: flex;
align-items: center;
justify-content: center;
font-size: var(--text-base);
font-weight: 600;
color: var(--accent);
flex-shrink: 0;
}
.fitness-name {
font-size: var(--text-md);
font-weight: 500;
color: var(--text-1);
}
.fitness-sub {
font-size: var(--text-sm);
color: var(--text-3);
margin-top: 1px;
}
.fitness-bar {
height: var(--sp-1.5);
background: var(--border);
border-radius: 3px;
overflow: hidden;
margin-bottom: var(--sp-4);
}
.fitness-bar-fill {
height: 100%;
background: var(--accent);
border-radius: 3px;
transition: width 0.5s ease;
}
.fitness-macros {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: var(--row-gap);
}
.fitness-macro-val {
font-size: var(--text-md);
font-weight: 500;
font-family: var(--mono);
color: var(--text-1);
}
.fitness-macro-unit {
font-size: var(--text-sm);
font-weight: 400;
color: var(--text-3);
}
.fitness-macro-label {
font-size: var(--text-sm);
color: var(--text-3);
margin-top: var(--sp-0.5);
}
</style>

View File

@@ -0,0 +1,113 @@
<script lang="ts">
import { onMount } from 'svelte';
interface Issue {
id: number;
item: string;
orderNumber: string;
}
let issues = $state<Issue[]>([]);
let loading = $state(true);
onMount(async () => {
try {
const res = await fetch('/api/inventory/summary', { credentials: 'include' });
if (res.ok) {
const data = await res.json();
issues = (data.issues || []).slice(0, 5);
}
} catch { /* silent */ }
loading = false;
});
</script>
<div class="module">
<div class="module-header">
<div class="module-title">Issues</div>
<a href="/inventory" class="module-action">View all &rarr;</a>
</div>
<div class="issue-rows">
{#if loading}
{#each [1, 2, 3] as _}
<div class="issue-row">
<div class="issue-info">
<div class="skeleton" style="width:160px;height:14px"></div>
<div class="skeleton" style="width:120px;height:12px;margin-top:var(--sp-1)"></div>
</div>
</div>
{/each}
{:else if issues.length === 0}
<div class="issue-empty">No issues found</div>
{:else}
{#each issues as issue}
<a href="/inventory?item={issue.id}" class="issue-row">
<div class="issue-info">
<div class="issue-name">{issue.item}</div>
<div class="issue-meta">Order #{issue.orderNumber || '—'}</div>
</div>
<span class="badge error" style="text-transform:uppercase;letter-spacing:0.02em;font-weight:600">Issue</span>
</a>
{/each}
{/if}
</div>
</div>
<style>
/* No .module/.module-header/.module-title/.module-action — using globals from app.css */
.issue-rows {
display: flex;
flex-direction: column;
}
.issue-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--sp-3) var(--sp-3) var(--sp-3) var(--sp-4);
border-bottom: 1px solid var(--border);
border-left: 3px solid var(--error);
margin-left: calc(-1 * var(--sp-1));
transition: background var(--transition);
border-radius: 0 var(--radius-xs) var(--radius-xs) 0;
text-decoration: none;
color: inherit;
}
.issue-row:last-child {
border-bottom: none;
}
.issue-row:hover {
background: var(--card-hover);
}
.issue-info {
min-width: 0;
flex: 1;
}
.issue-name {
font-size: var(--text-base);
font-weight: 500;
color: var(--text-1);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.issue-meta {
font-size: var(--text-sm);
color: var(--text-3);
margin-top: var(--sp-0.5);
}
.issue-empty {
padding: var(--sp-5);
text-align: center;
font-size: var(--text-sm);
color: var(--text-3);
}
</style>

View File

@@ -0,0 +1,187 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { Search, Plus, Package, Clock } from '@lucide/svelte';
interface Props {
open: boolean;
onclose: () => void;
}
let { open = $bindable(), onclose }: Props = $props();
let query = $state('');
let selectedIndex = $state(0);
let inputEl: HTMLInputElement | undefined = $state();
const quickActions = [
{ icon: Plus, label: 'Log food', desc: 'Quick add calories', href: '/fitness' },
{ icon: Package, label: 'New inventory item', desc: 'Add a new item to track', href: '/inventory' },
{ icon: Clock, label: 'Go to Budget', desc: 'View transactions', href: '/budget' },
];
let filteredActions = $derived(
query.trim()
? quickActions.filter(a =>
a.label.toLowerCase().includes(query.toLowerCase()) ||
a.desc.toLowerCase().includes(query.toLowerCase())
)
: quickActions
);
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
onclose();
return;
}
if (e.key === 'ArrowDown') {
e.preventDefault();
selectedIndex = Math.min(selectedIndex + 1, filteredActions.length - 1);
}
if (e.key === 'ArrowUp') {
e.preventDefault();
selectedIndex = Math.max(selectedIndex - 1, 0);
}
if (e.key === 'Enter' && filteredActions[selectedIndex]) {
goto(filteredActions[selectedIndex].href);
onclose();
}
}
$effect(() => {
if (open) {
query = '';
selectedIndex = 0;
// Focus input after render
requestAnimationFrame(() => inputEl?.focus());
}
});
</script>
{#if open}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="cmd-overlay open" onclick={(e) => { if (e.target === e.currentTarget) onclose(); }} onkeydown={handleKeydown}>
<div class="cmd-box" role="dialog" aria-label="Command palette">
<div class="cmd-input-wrap">
<Search size={18} />
<input
class="cmd-input"
placeholder="Search transactions, items, or type a command..."
bind:value={query}
bind:this={inputEl}
onkeydown={handleKeydown}
/>
</div>
<div class="cmd-results">
{#each filteredActions as action, i}
<button
class="cmd-item"
class:selected={i === selectedIndex}
onclick={() => { goto(action.href); onclose(); }}
onmouseenter={() => selectedIndex = i}
>
<div class="cmd-item-icon">
<action.icon size={16} />
</div>
<div>
<div class="cmd-item-text">{action.label}</div>
<div class="cmd-item-desc">{action.desc}</div>
</div>
</button>
{/each}
</div>
<div class="cmd-footer">
<div class="cmd-hint"><kbd>&uarr;&darr;</kbd> navigate</div>
<div class="cmd-hint"><kbd>&crarr;</kbd> select</div>
<div class="cmd-hint"><kbd>esc</kbd> close</div>
</div>
</div>
</div>
{/if}
<style>
.cmd-overlay {
position: fixed;
inset: 0;
background: var(--overlay);
z-index: 100;
display: flex;
align-items: flex-start;
justify-content: center;
padding-top: 20vh;
}
.cmd-box {
width: 560px;
max-width: 90vw;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 14px;
box-shadow: 0 24px 48px rgba(0,0,0,0.2);
overflow: hidden;
}
.cmd-input-wrap {
display: flex;
align-items: center;
gap: 10px;
padding: 14px 16px;
border-bottom: 1px solid var(--border);
}
.cmd-input-wrap :global(svg) { color: var(--text-3); flex-shrink: 0; }
.cmd-input {
flex: 1;
background: none;
border: none;
outline: none;
font-size: var(--text-md);
color: var(--text-1);
font-family: var(--font);
}
.cmd-input::placeholder { color: var(--text-4); }
.cmd-results { max-height: 300px; overflow-y: auto; }
.cmd-item {
display: flex;
align-items: center;
gap: var(--sp-3);
padding: 10px var(--sp-4);
cursor: pointer;
transition: background var(--transition);
width: 100%;
background: none;
border: none;
text-align: left;
}
.cmd-item:hover, .cmd-item.selected { background: var(--accent-dim); }
.cmd-item-icon {
width: 32px;
height: 32px;
border-radius: var(--radius-md);
background: var(--card-secondary);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.cmd-item-icon :global(svg) { color: var(--text-3); }
.cmd-item-text { font-size: var(--text-base); color: var(--text-1); }
.cmd-item-desc { font-size: var(--text-sm); color: var(--text-3); }
.cmd-footer {
padding: var(--sp-2) var(--sp-4);
border-top: 1px solid var(--border);
display: flex;
gap: var(--sp-3);
}
.cmd-hint {
font-size: var(--text-xs);
color: var(--text-4);
display: flex;
align-items: center;
gap: var(--sp-1);
}
.cmd-hint kbd {
font-family: var(--mono);
font-size: var(--text-xs);
padding: 1px 4px;
border-radius: 3px;
background: var(--card-secondary);
border: 1px solid var(--border);
}
</style>

View File

@@ -0,0 +1,165 @@
<script lang="ts">
import { page } from '$app/state';
import { LayoutDashboard, DollarSign, Package, Activity, MoreVertical, MapPin, BookOpen, Library, Settings } from '@lucide/svelte';
let moreOpen = $state(false);
function isActive(path: string): boolean {
if (path === '/') return page.url.pathname === '/';
return page.url.pathname.startsWith(path);
}
function closeMore() {
moreOpen = false;
}
</script>
<div class="mobile-tabbar">
<div class="mobile-tabbar-inner">
<a href="/" class="mobile-tab" class:active={isActive('/')}>
<LayoutDashboard size={22} />
Dashboard
</a>
<a href="/budget" class="mobile-tab" class:active={isActive('/budget')}>
<DollarSign size={22} />
Budget
</a>
<a href="/inventory" class="mobile-tab" class:active={isActive('/inventory')}>
<Package size={22} />
Inventory
</a>
<a href="/fitness" class="mobile-tab" class:active={isActive('/fitness')}>
<Activity size={22} />
Fitness
</a>
<button class="mobile-tab" class:active={moreOpen} onclick={() => moreOpen = true}>
<MoreVertical size={22} />
More
</button>
</div>
</div>
<!-- More sheet overlay -->
{#if moreOpen}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="more-sheet-overlay open" onclick={(e) => { if (e.target === e.currentTarget) closeMore(); }} onkeydown={() => {}}>
<div class="more-sheet">
<div class="more-sheet-handle"></div>
<a href="/trips" class="more-sheet-item" onclick={closeMore}>
<MapPin size={20} />
Trips
</a>
<a href="/reader" class="more-sheet-item" onclick={closeMore}>
<BookOpen size={20} />
Reader
</a>
<a href="/media" class="more-sheet-item" onclick={closeMore}>
<Library size={20} />
Media
</a>
<a href="/settings" class="more-sheet-item" onclick={closeMore}>
<Settings size={20} />
Settings
</a>
</div>
</div>
{/if}
<style>
.mobile-tabbar {
display: none;
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 80;
background: var(--nav-bg);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-top: 1px solid var(--border);
padding-bottom: env(safe-area-inset-bottom, 0px);
}
.mobile-tabbar-inner {
display: flex;
align-items: center;
justify-content: space-around;
height: 56px;
max-width: 500px;
margin: 0 auto;
}
.mobile-tab {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--sp-0.5);
background: none;
border: none;
color: var(--text-3);
font-size: var(--text-xs);
font-weight: 500;
padding: var(--sp-1) var(--sp-2);
border-radius: var(--radius-md);
transition: all var(--transition);
min-width: 56px;
text-decoration: none;
}
.mobile-tab.active { color: var(--accent); }
.mobile-tab:hover { color: var(--text-1); }
/* More sheet */
.more-sheet-overlay {
position: fixed;
inset: 0;
background: var(--overlay);
z-index: 90;
}
.more-sheet {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 91;
background: var(--surface);
border-top-left-radius: 16px;
border-top-right-radius: 16px;
padding: var(--sp-3) var(--sp-4) var(--sp-8);
animation: slideUp 0.3s ease;
}
@keyframes slideUp {
from { transform: translateY(100%); }
to { transform: translateY(0); }
}
.more-sheet-handle {
width: 36px;
height: 4px;
border-radius: 2px;
background: var(--text-4);
margin: 0 auto var(--sp-4);
}
.more-sheet-item {
display: flex;
align-items: center;
gap: var(--sp-3);
padding: 14px var(--sp-3);
border-radius: var(--radius-sm);
font-size: var(--text-md);
font-weight: 500;
color: var(--text-1);
background: none;
border: none;
width: 100%;
text-align: left;
transition: background var(--transition);
text-decoration: none;
}
.more-sheet-item:hover { background: var(--card-hover); }
.more-sheet-item :global(svg) { color: var(--text-3); }
@media (max-width: 768px) {
.mobile-tabbar { display: block; }
}
@media (min-width: 769px) {
.mobile-tabbar { display: none !important; }
.more-sheet-overlay { display: none !important; }
}
</style>

View File

@@ -0,0 +1,215 @@
<script lang="ts">
import { page } from '$app/state';
import { toggleTheme, isDark } from '$lib/stores/theme.svelte';
import { Map, Sun, Moon, User, Search } from '@lucide/svelte';
interface Props {
onOpenCommand?: () => void;
}
let { onOpenCommand }: Props = $props();
let tripsOpen = $state(false);
let fitnessOpen = $state(false);
function isActive(path: string): boolean {
return page.url.pathname === path || page.url.pathname.startsWith(path + '/');
}
function closeDropdowns() {
tripsOpen = false;
fitnessOpen = false;
}
</script>
<svelte:window onclick={closeDropdowns} />
<nav class="navbar">
<div class="navbar-inner">
<a href="/" class="navbar-logo">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7l6-3 6 3 6-3v13l-6 3-6-3-6 3V7z"/><path d="M9 4v13"/><path d="M15 7v13"/></svg>
Platform
</a>
<div class="navbar-links">
<a href="/" class="navbar-link" class:active={page.url.pathname === '/'}>Dashboard</a>
<!-- Trips dropdown -->
<a href="/trips" class="navbar-link" class:active={isActive('/trips')}>Trips</a>
<!-- Fitness dropdown -->
<div class="nav-dropdown" role="menu">
<button
class="navbar-link"
class:active={isActive('/fitness')}
onclick={(e) => { e.stopPropagation(); fitnessOpen = !fitnessOpen; tripsOpen = false; }}
>Fitness</button>
{#if fitnessOpen}
<div class="nav-dropdown-menu" onclick={(e) => e.stopPropagation()}>
<a href="/fitness" class="nav-dropdown-item" onclick={closeDropdowns}>Dashboard</a>
<a href="/fitness/foods" class="nav-dropdown-item" onclick={closeDropdowns}>Foods</a>
<a href="/fitness/goals" class="nav-dropdown-item" onclick={closeDropdowns}>Goals</a>
<a href="/fitness/templates" class="nav-dropdown-item" onclick={closeDropdowns}>Templates</a>
</div>
{/if}
</div>
<a href="/inventory" class="navbar-link" class:active={isActive('/inventory')}>Inventory</a>
<a href="/budget" class="navbar-link" class:active={isActive('/budget')}>Budget</a>
<a href="/reader" class="navbar-link" class:active={isActive('/reader')}>Reader</a>
<a href="/media" class="navbar-link" class:active={isActive('/media')}>Media</a>
</div>
<button class="search-trigger" onclick={onOpenCommand}>
<Search size={14} />
Search...
<kbd>&#8984;K</kbd>
</button>
<div class="navbar-right">
<button class="navbar-icon" onclick={toggleTheme} title="Toggle theme">
{#if isDark()}
<Sun size={18} />
{:else}
<Moon size={18} />
{/if}
</button>
<a href="/settings" class="navbar-icon" title="Settings">
<User size={18} />
</a>
</div>
</div>
</nav>
<style>
.navbar {
position: sticky;
top: 0;
z-index: 50;
background: var(--nav-bg);
border-bottom: 1px solid var(--border);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
}
.navbar-inner {
max-width: 1200px;
margin: 0 auto;
display: flex;
align-items: center;
height: 56px;
padding: 0 var(--sp-6);
gap: var(--sp-6);
}
.navbar-logo {
font-weight: 600;
font-size: var(--text-md);
display: flex;
align-items: center;
gap: var(--sp-2);
color: var(--text-1);
flex-shrink: 0;
}
.navbar-logo svg { width: 20px; height: 20px; }
.navbar-links {
display: flex;
align-items: center;
gap: var(--sp-0.5);
flex: 1;
}
.navbar-link {
padding: 6px var(--sp-3);
border-radius: var(--radius-sm);
font-size: var(--text-base);
font-weight: 500;
color: var(--text-3);
transition: all var(--transition);
background: none;
border: none;
position: relative;
text-decoration: none;
}
.navbar-link:hover { color: var(--text-1); background: var(--accent-dim); }
.navbar-link.active { color: var(--text-1); background: var(--accent-dim); }
.nav-dropdown { position: relative; }
.nav-dropdown-menu {
position: absolute;
top: 100%;
left: 0;
min-width: 160px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
box-shadow: var(--card-shadow);
padding: var(--sp-1);
z-index: 60;
margin-top: var(--sp-1);
}
.nav-dropdown-item {
display: block;
width: 100%;
padding: var(--sp-2) var(--sp-3);
border-radius: var(--radius-sm);
font-size: var(--text-sm);
font-weight: 500;
color: var(--text-2);
background: none;
border: none;
text-align: left;
transition: all var(--transition);
cursor: pointer;
text-decoration: none;
}
.nav-dropdown-item:hover { background: var(--accent-dim); color: var(--text-1); }
.navbar-right {
display: flex;
align-items: center;
gap: var(--sp-1);
margin-left: auto;
}
.navbar-icon {
width: 36px;
height: 36px;
border-radius: var(--radius-md);
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
color: var(--text-3);
transition: all var(--transition);
}
.navbar-icon:hover { color: var(--text-1); background: var(--accent-dim); }
.search-trigger {
display: flex;
align-items: center;
gap: var(--sp-2);
padding: 6px var(--sp-3);
border-radius: var(--radius-md);
background: var(--surface-secondary);
border: 1px solid var(--border);
color: var(--text-3);
font-size: var(--text-sm);
cursor: pointer;
transition: all var(--transition);
min-width: 200px;
}
.search-trigger:hover { border-color: var(--accent); color: var(--text-2); }
.search-trigger kbd {
margin-left: auto;
font-size: var(--text-xs);
font-family: var(--mono);
background: var(--canvas);
padding: 2px 6px;
border-radius: var(--radius-xs);
color: var(--text-4);
border: 1px solid var(--border);
}
@media (max-width: 768px) {
.navbar-links, .search-trigger { display: none !important; }
}
</style>

View File

@@ -0,0 +1,362 @@
<script lang="ts">
import { onMount } from 'svelte';
interface BookloreBook {
id: number; title: string; authors: string[]; libraryId: number; libraryName: string;
categories: string[]; pageCount: number | null; publisher: string | null; addedOn: string;
isbn13: string | null; isbn10: string | null; googleId: string | null; format: string | null;
}
interface Library { id: number; name: string; }
interface KindleTarget { id: string; label: string; email: string; }
let books = $state<BookloreBook[]>([]);
let libraries = $state<Library[]>([]);
let loading = $state(true);
let searchQuery = $state('');
let selectedLibrary = $state<number | null>(null);
// Detail / Kindle
let selectedBook = $state<BookloreBook | null>(null);
let kindleTargets = $state<KindleTarget[]>([]);
let kindleConfigured = $state(false);
let sendingKindle = $state(false);
let kindleResult = $state<string | null>(null);
const filtered = $derived(() => {
let list = books;
if (selectedLibrary !== null) list = list.filter(b => b.libraryId === selectedLibrary);
if (searchQuery) {
const q = searchQuery.toLowerCase();
list = list.filter(b => b.title.toLowerCase().includes(q) || b.authors.some(a => a.toLowerCase().includes(q)));
}
return list;
});
onMount(async () => {
try {
const [booksRes, libsRes] = await Promise.all([
fetch('/api/booklore/books', { credentials: 'include' }),
fetch('/api/booklore/libraries', { credentials: 'include' }),
]);
if (booksRes.ok) { const data = await booksRes.json(); books = data.books || []; }
if (libsRes.ok) { const data = await libsRes.json(); libraries = (data.libraries || []).map((l: any) => ({ id: l.id, name: l.name })); }
} catch { /* silent */ }
loading = false;
// Lazy-resolve covers for books without ISBNs
const needsCover = books.filter(b => !b.isbn13 && !b.isbn10 && !b.googleId);
if (needsCover.length > 0) resolveCoversLazy(needsCover);
// Load Kindle targets
try {
const kRes = await fetch('/api/kindle/targets', { credentials: 'include' });
if (kRes.ok) {
const kData = await kRes.json();
kindleTargets = kData.targets || [];
kindleConfigured = kData.configured || false;
}
} catch { /* silent */ }
});
function openBook(book: BookloreBook) {
selectedBook = book;
kindleResult = null;
}
function closeBook() {
selectedBook = null;
kindleResult = null;
}
let lastKindleSendTime = 0;
async function sendToKindle(targetId: string) {
const now = Date.now();
if (!selectedBook || sendingKindle || now - lastKindleSendTime < 5000) return;
lastKindleSendTime = now;
sendingKindle = true;
kindleResult = null;
try {
const res = await fetch(`/api/booklore/books/${selectedBook.id}/send-to-kindle`, {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ target: targetId }),
});
const data = await res.json();
if (res.ok && data.success) {
kindleResult = `Sent "${data.title}" (${data.format}) to ${data.sentTo}`;
} else {
kindleResult = `Error: ${data.error || 'Failed to send'}`;
}
} catch {
kindleResult = 'Network error';
}
sendingKindle = false;
}
// Cover cache: bookId → cover URL, persisted in localStorage
const COVER_CACHE_KEY = 'booklore_covers';
const COVER_CACHE_MISS = 'booklore_covers_miss'; // IDs we already tried and found nothing
function loadCoverCache(): Record<string, string> {
try { return JSON.parse(localStorage.getItem(COVER_CACHE_KEY) || '{}'); } catch { return {}; }
}
function loadMissCache(): Record<string, boolean> {
try { return JSON.parse(localStorage.getItem(COVER_CACHE_MISS) || '{}'); } catch { return {}; }
}
function saveCoverCache(cache: Record<string, string>) {
try { localStorage.setItem(COVER_CACHE_KEY, JSON.stringify(cache)); } catch { /* silent */ }
}
function saveMissCache(cache: Record<string, boolean>) {
try { localStorage.setItem(COVER_CACHE_MISS, JSON.stringify(cache)); } catch { /* silent */ }
}
let resolvedCovers = $state<Record<string, string>>(loadCoverCache());
let missCache = loadMissCache();
function coverUrl(book: BookloreBook): string | null {
const isbn = book.isbn13 || book.isbn10;
if (isbn) return `https://covers.openlibrary.org/b/isbn/${isbn}-M.jpg`;
if (book.googleId) return `https://books.google.com/books/content?id=${book.googleId}&printsec=frontcover&img=1&zoom=1`;
if (resolvedCovers[book.id]) return resolvedCovers[book.id];
return null;
}
async function resolveCoversLazy(booksToResolve: BookloreBook[]) {
// Filter out books we already cached or tried
const todo = booksToResolve.filter(b => !resolvedCovers[b.id] && !missCache[b.id]);
if (todo.length === 0) return;
for (let i = 0; i < todo.length; i += 5) {
const batch = todo.slice(i, i + 5);
await Promise.all(batch.map(async (book) => {
try {
const q = encodeURIComponent(book.title);
const author = book.authors[0] ? `&author=${encodeURIComponent(book.authors[0])}` : '';
const res = await fetch(`https://openlibrary.org/search.json?title=${q}${author}&limit=1&fields=isbn,cover_edition_key`);
if (res.ok) {
const data = await res.json();
const doc = data.docs?.[0];
if (doc) {
const isbn = doc.isbn?.[0];
if (isbn) {
resolvedCovers = { ...resolvedCovers, [book.id]: `https://covers.openlibrary.org/b/isbn/${isbn}-M.jpg` };
saveCoverCache(resolvedCovers);
return;
}
const olid = doc.cover_edition_key;
if (olid) {
resolvedCovers = { ...resolvedCovers, [book.id]: `https://covers.openlibrary.org/b/olid/${olid}-M.jpg` };
saveCoverCache(resolvedCovers);
return;
}
}
}
// No cover found — mark as miss so we don't retry
missCache[book.id] = true;
saveMissCache(missCache);
} catch { /* silent */ }
}));
}
}
</script>
<div class="filter-row">
<div class="search-wrap">
<svg class="s-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="s-input" type="text" placeholder="Search your library..." bind:value={searchQuery} />
</div>
{#if libraries.length > 1}
<div class="lib-pills">
<button class="lib-pill" class:active={selectedLibrary === null} onclick={() => selectedLibrary = null}>All ({books.length})</button>
{#each libraries as lib}
<button class="lib-pill" class:active={selectedLibrary === lib.id} onclick={() => selectedLibrary = selectedLibrary === lib.id ? null : lib.id}>{lib.name}</button>
{/each}
</div>
{/if}
</div>
{#if loading}
<div class="book-grid">
{#each Array(8) as _}
<div class="book-card"><div class="book-cover skeleton"></div><div class="book-info"><div class="skeleton" style="width:80%;height:14px"></div><div class="skeleton" style="width:60%;height:12px;margin-top:6px"></div></div></div>
{/each}
</div>
{:else if filtered().length === 0}
<div class="empty">{searchQuery ? `No books found for "${searchQuery}"` : 'No books in library'}</div>
{:else}
<div class="book-grid">
{#each filtered() as book (book.id)}
{@const cover = coverUrl(book)}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="book-card" onclick={() => openBook(book)}>
<div class="book-cover">
{#if cover}
<img src={cover} alt="" loading="lazy" onerror={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; (e.currentTarget as HTMLImageElement).nextElementSibling?.removeAttribute('style'); }} />
<span class="book-letter" style="display:none">{book.title.charAt(0)}</span>
{:else}
<span class="book-letter">{book.title.charAt(0)}</span>
{/if}
{#if book.format}
<span class="fmt-badge {book.format === 'EPUB' ? 'epub' : book.format === 'PDF' ? 'pdf' : 'other'}">{book.format}</span>
{/if}
</div>
<div class="book-info">
<div class="book-title">{book.title}</div>
<div class="book-author">{book.authors.join(', ') || 'Unknown'}</div>
<div class="book-meta-row">
<span class="book-lib">{book.libraryName}</span>
{#if book.pageCount}<span class="book-pages">{book.pageCount}p</span>{/if}
</div>
</div>
</div>
{/each}
</div>
{/if}
<!-- Book detail modal -->
{#if selectedBook}
{@const cover = coverUrl(selectedBook)}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="modal-overlay" onclick={closeBook}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="modal-sheet" onclick={(e) => e.stopPropagation()}>
<div class="modal-header">
<div class="modal-title">Book Details</div>
<button class="modal-close" onclick={closeBook}>
<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>
<div class="modal-body">
<div class="detail-top">
{#if cover}
<img class="detail-cover" src={cover} alt="" />
{:else}
<div class="detail-cover-placeholder">{selectedBook.title.charAt(0)}</div>
{/if}
<div class="detail-info">
<div class="detail-title">{selectedBook.title}</div>
<div class="detail-author">{selectedBook.authors.join(', ') || 'Unknown'}</div>
<div class="detail-meta">
{#if selectedBook.publisher}<span>{selectedBook.publisher}</span>{/if}
{#if selectedBook.pageCount}<span>{selectedBook.pageCount} pages</span>{/if}
</div>
<div class="detail-badges">
{#if selectedBook.format}
<span class="fmt-badge-inline {selectedBook.format === 'EPUB' ? 'epub' : selectedBook.format === 'PDF' ? 'pdf' : 'other'}">{selectedBook.format}</span>
{/if}
<span class="detail-lib">{selectedBook.libraryName}</span>
{#if selectedBook.categories.length > 0}
{#each selectedBook.categories.slice(0, 2) as cat}
<span class="detail-cat">{cat}</span>
{/each}
{/if}
</div>
</div>
</div>
{#if kindleConfigured && kindleTargets.length > 0}
<div class="kindle-section">
<div class="kindle-label">Send to Kindle</div>
<div class="kindle-actions">
{#each kindleTargets as target}
<button class="kindle-btn" onclick={() => sendToKindle(target.id)} disabled={sendingKindle}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>
{sendingKindle ? 'Sending...' : target.label}
</button>
{/each}
</div>
{#if kindleResult}
<div class="kindle-result" class:error={kindleResult.startsWith('Error')}>{kindleResult}</div>
{/if}
</div>
{/if}
</div>
</div>
</div>
{/if}
<style>
.filter-row { margin-bottom: var(--sp-5); }
.search-wrap { position: relative; margin-bottom: var(--sp-3); }
.s-icon { position: absolute; left: 14px; top: 50%; transform: translateY(-50%); width: 16px; height: 16px; color: var(--text-4); pointer-events: none; }
.s-input { width: 100%; padding: 10px 14px 10px 40px; 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); }
.s-input:focus { outline: none; border-color: var(--accent); }
.s-input::placeholder { color: var(--text-4); }
.lib-pills { display: flex; flex-wrap: wrap; gap: var(--sp-1); }
.lib-pill { 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; font-family: var(--font); transition: all var(--transition); }
.lib-pill:hover { color: var(--text-1); background: var(--card-hover); }
.lib-pill.active { color: var(--text-1); background: var(--card); box-shadow: var(--shadow-xs); }
.book-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: var(--module-gap); }
.book-card { background: var(--card); border-radius: var(--radius); border: 1px solid var(--border); box-shadow: var(--shadow-sm); overflow: hidden; transition: all var(--transition); }
.book-card:hover { transform: translateY(-2px); box-shadow: var(--shadow-md); }
.book-cover { width: 100%; aspect-ratio: 2/3; background: var(--surface-secondary); display: flex; align-items: center; justify-content: center; overflow: hidden; }
.book-cover img { width: 100%; height: 100%; object-fit: cover; }
.book-letter { font-size: var(--text-3xl); font-weight: 300; color: var(--text-4); user-select: none; }
.book-info { padding: var(--sp-3) 14px 14px; }
.book-title { font-size: var(--text-base); font-weight: 600; color: var(--text-1); line-height: 1.3; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.book-author { font-size: var(--text-sm); color: var(--text-3); margin-top: 3px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.book-meta-row { display: flex; align-items: center; gap: var(--sp-2); margin-top: var(--sp-2); }
.book-lib { font-size: var(--text-xs); font-weight: 500; padding: 2px var(--sp-2); border-radius: var(--radius-full); background: var(--accent-dim); color: var(--accent); }
.book-pages { font-size: var(--text-xs); color: var(--text-4); font-family: var(--mono); }
.empty { padding: var(--sp-12); text-align: center; color: var(--text-3); font-size: var(--text-base); }
.skeleton { background: linear-gradient(90deg, var(--card) 25%, var(--card-hover) 50%, var(--card) 75%); background-size: 200% 100%; animation: shimmer 1.5s infinite; border-radius: var(--radius-xs); }
@keyframes shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
.book-card { cursor: pointer; }
.book-cover { position: relative; }
.fmt-badge { position: absolute; top: var(--sp-2); right: var(--sp-2); font-size: var(--text-xs); font-weight: 700; padding: 2px var(--sp-2); border-radius: var(--radius-xs); text-transform: uppercase; letter-spacing: 0.03em; }
.fmt-badge.epub { background: var(--accent-dim); color: var(--accent); }
.fmt-badge.pdf { background: var(--error-dim); color: var(--error); }
.fmt-badge.other { background: var(--card-hover); color: var(--text-3); }
.fmt-badge-inline { font-size: var(--text-xs); font-weight: 700; padding: 2px var(--sp-2); border-radius: var(--radius-xs); text-transform: uppercase; }
.fmt-badge-inline.epub { background: var(--accent-dim); color: var(--accent); }
.fmt-badge-inline.pdf { background: var(--error-dim); color: var(--error); }
.fmt-badge-inline.other { background: var(--card-hover); color: var(--text-3); }
/* Modal */
.modal-overlay { position: fixed; inset: 0; background: var(--overlay); z-index: 70; display: flex; align-items: center; justify-content: center; animation: fadeIn 150ms ease; }
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
.modal-sheet { width: 480px; max-width: 92vw; background: var(--surface); border-radius: var(--radius); box-shadow: var(--shadow-xl); animation: slideUp 200ms ease; }
@keyframes slideUp { from { transform: translateY(12px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
.modal-header { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-4) var(--sp-5); border-bottom: 1px solid var(--border); }
.modal-title { font-size: var(--text-md); font-weight: 600; color: var(--text-1); }
.modal-close { background: none; border: none; cursor: pointer; color: var(--text-3); padding: var(--sp-1); border-radius: var(--radius-sm); }
.modal-close:hover { color: var(--text-1); background: var(--card-hover); }
.modal-close svg { width: 18px; height: 18px; }
.modal-body { padding: var(--sp-5); }
.detail-top { display: flex; gap: var(--sp-4); }
.detail-cover { width: 100px; height: 150px; border-radius: var(--radius-md); object-fit: cover; flex-shrink: 0; }
.detail-cover-placeholder { width: 100px; height: 150px; border-radius: var(--radius-md); background: var(--surface-secondary); display: flex; align-items: center; justify-content: center; font-size: var(--text-3xl); font-weight: 300; color: var(--text-4); flex-shrink: 0; }
.detail-info { flex: 1; min-width: 0; }
.detail-title { font-size: var(--text-lg); font-weight: 600; color: var(--text-1); line-height: 1.3; }
.detail-author { font-size: var(--text-sm); color: var(--text-3); margin-top: var(--sp-1); }
.detail-meta { display: flex; gap: var(--sp-3); margin-top: var(--sp-2); font-size: var(--text-xs); color: var(--text-4); }
.detail-badges { display: flex; flex-wrap: wrap; gap: var(--sp-1); margin-top: var(--sp-3); }
.detail-lib { font-size: var(--text-xs); font-weight: 500; padding: 2px var(--sp-2); border-radius: var(--radius-full); background: var(--accent-dim); color: var(--accent); }
.detail-cat { font-size: var(--text-xs); padding: 2px var(--sp-2); border-radius: var(--radius-full); background: var(--card-hover); color: var(--text-3); }
.kindle-section { margin-top: var(--sp-5); padding-top: var(--sp-4); border-top: 1px solid var(--border); }
.kindle-label { font-size: var(--text-sm); font-weight: 600; color: var(--text-3); margin-bottom: var(--sp-3); text-transform: uppercase; letter-spacing: 0.04em; }
.kindle-actions { display: flex; gap: var(--sp-2); }
.kindle-btn { display: flex; align-items: center; gap: var(--sp-1.5); 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); }
.kindle-btn:disabled { opacity: 0.5; cursor: default; }
.kindle-btn:hover:not(:disabled) { opacity: 0.9; }
.kindle-btn svg { width: 14px; height: 14px; }
.kindle-result { font-size: var(--text-sm); margin-top: var(--sp-3); color: var(--success); font-weight: 500; }
.kindle-result.error { color: var(--error); }
@media (max-width: 1024px) { .book-grid { grid-template-columns: repeat(3, 1fr); } }
@media (max-width: 768px) {
.book-grid { grid-template-columns: repeat(2, 1fr); }
.detail-top { flex-direction: column; align-items: center; text-align: center; }
.detail-info { text-align: center; }
.detail-badges { justify-content: center; }
.kindle-actions { flex-direction: column; }
}
</style>

View File

@@ -0,0 +1,434 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
interface Release {
title: string; content_type: string; format: string; size: string;
source: string; source_id: string; info_url: string; language: string;
extra: { author?: string; preview?: string; publisher?: string; year?: string };
}
interface Download {
id: string; title: string; author: string; format: string; source: string;
status: string; status_message: string; progress: number; download_path: string;
content_type: string; added_time: string;
}
interface Library { id: number; name: string; paths: { id: number; path: string }[]; }
let query = $state('');
let results = $state<Release[]>([]);
let bookMeta = $state<{ title: string; authors: string[]; cover_url: string | null } | null>(null);
let searching = $state(false);
let searched = $state(false);
let downloads = $state<Record<string, Download>>({});
let libraries = $state<Library[]>([]);
let defaultLibraryId = $state<number | null>(null);
let downloadingIds = $state<Set<string>>(new Set());
let importingIds = $state<Set<string>>(new Set());
let importedIds = $state<Set<string>>(new Set());
let perBookLibrary = $state<Record<string, number>>({});
let prevCompleted = $state<Set<string>>(new Set());
let activeView = $state<'search' | 'downloads'>('search');
let poll: ReturnType<typeof setInterval> | null = null;
// Kindle auto-send
interface KindleTarget { id: string; label: string; email: string; }
let kindleTargets = $state<KindleTarget[]>([]);
let kindleConfigured = $state(false);
let autoKindleTarget = $state<string>('none');
let kindleSending = $state<Set<string>>(new Set());
let kindleSent = $state<Set<string>>(new Set());
const downloadCount = $derived(Object.keys(downloads).length);
const activeDownloads = $derived(
Object.values(downloads).filter(d => ['queued', 'downloading', 'locating', 'resolving'].includes(d.status))
);
async function search() {
if (!query.trim()) return;
searching = true; searched = true; results = []; bookMeta = null;
try {
const res = await fetch(`/api/books/releases?source=direct_download&query=${encodeURIComponent(query.trim())}&limit=40`, { credentials: 'include' });
if (res.ok) { const data = await res.json(); results = data.releases || []; bookMeta = data.book || null; }
} catch { /* silent */ }
searching = false;
}
async function download(release: Release) {
downloadingIds = new Set([...downloadingIds, release.source_id]);
try {
const res = await fetch('/api/books/releases/download', {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ source: release.source, source_id: release.source_id, title: release.title, format: release.format, size: release.size, extra: release.extra })
});
if (res.ok) fetchStatus();
} catch { /* silent */ }
}
async function cancelDownload(id: string) {
try { await fetch(`/api/books/download/${id}/cancel`, { method: 'DELETE', credentials: 'include' }); fetchStatus(); } catch { /* silent */ }
}
async function retryDownload(id: string) {
try { await fetch(`/api/books/download/${id}/retry`, { method: 'POST', credentials: 'include' }); fetchStatus(); } catch { /* silent */ }
}
function getLibraryId(key: string): number | null {
return perBookLibrary[key] ?? defaultLibraryId;
}
async function importToBooklore(dl: Download, libraryId?: number | null) {
const libId = libraryId ?? defaultLibraryId;
if (!libId || importingIds.has(dl.id) || importedIds.has(dl.id)) return;
const lib = libraries.find(l => l.id === libId);
if (!lib || lib.paths.length === 0) return;
importingIds = new Set([...importingIds, dl.id]);
const fileName = dl.download_path?.split('/').pop() || dl.title;
try {
const res = await fetch('/api/booklore/import', {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ fileName, libraryId: lib.id, pathId: lib.paths[0].id })
});
if (res.ok) {
importedIds = new Set([...importedIds, dl.id]);
// Auto-send to Kindle if configured
if (autoKindleTarget !== 'none' && fileName && !kindleSent.has(dl.id)) {
sendFileToKindle(dl.id, fileName, dl.title || fileName, autoKindleTarget);
}
}
} catch { /* silent */ }
const next = new Set(importingIds); next.delete(dl.id); importingIds = next;
}
async function sendFileToKindle(dlId: string, filename: string, title: string, target: string) {
if (kindleSending.has(dlId) || kindleSent.has(dlId)) return;
kindleSending = new Set([...kindleSending, dlId]);
try {
const res = await fetch('/api/kindle/send-file', {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename, title, target }),
});
if (res.ok) { kindleSent = new Set([...kindleSent, dlId]); }
} catch { /* silent */ }
const next = new Set(kindleSending); next.delete(dlId); kindleSending = next;
}
async function fetchStatus() {
try {
const res = await fetch('/api/books/status', { credentials: 'include' });
if (res.ok) {
const data = await res.json();
const all: Record<string, Download> = {};
for (const [groupName, group] of Object.entries(data) as [string, Record<string, Download>][]) {
if (group && typeof group === 'object') {
for (const [id, dl] of Object.entries(group)) {
if (['complete', 'error', 'cancelled'].includes(groupName)) dl.status = groupName;
else if (groupName === 'active') dl.status = dl.status_message?.toLowerCase().includes('download') ? 'downloading' : (dl.status || 'downloading');
all[id] = dl;
}
}
}
if (defaultLibraryId) {
for (const [id, dl] of Object.entries(all)) {
if (dl.status === 'complete' && !prevCompleted.has(id) && !importingIds.has(id) && !importedIds.has(id)) {
importToBooklore(dl, getLibraryId(id));
}
}
}
prevCompleted = new Set(Object.entries(all).filter(([_, d]) => d.status === 'complete').map(([id]) => id));
downloads = all;
}
} catch { /* silent */ }
}
async function fetchLibraries() {
try {
const res = await fetch('/api/booklore/libraries', { credentials: 'include' });
if (res.ok) {
const data = await res.json();
libraries = data.libraries || [];
if (libraries.length > 0 && defaultLibraryId === null) defaultLibraryId = libraries[0].id;
}
} catch { /* silent */ }
}
function coverUrl(release: Release): string | null {
const preview = release.extra?.preview;
if (!preview) return null;
if (preview.startsWith('/api/')) {
return `/api/books${preview.slice(4)}`;
}
return preview;
}
function fmtBadge(fmt: string): string {
const f = fmt?.toLowerCase();
if (f === 'epub') return 'accent'; if (f === 'pdf') return 'error'; if (f === 'mobi' || f === 'azw3') return 'warning'; return 'muted';
}
function statusClass(s: string): string {
if (s === 'complete') return 'success'; if (s === 'downloading' || s === 'locating' || s === 'resolving' || s === 'queued') return 'accent'; if (s === 'error') return 'error'; return 'muted';
}
onMount(async () => {
await fetchLibraries();
// Load Kindle targets
try {
const kRes = await fetch('/api/kindle/targets', { credentials: 'include' });
if (kRes.ok) { const kData = await kRes.json(); kindleTargets = kData.targets || []; kindleConfigured = kData.configured || false; }
} catch { /* silent */ }
const res = await fetch('/api/books/status', { credentials: 'include' }).catch(() => null);
if (res?.ok) {
const data = await res.json();
const ids: string[] = [];
for (const group of Object.values(data) as Record<string, Download>[]) {
if (group && typeof group === 'object') { for (const [id, dl] of Object.entries(group)) { if (dl.status === 'complete') ids.push(id); } }
}
prevCompleted = new Set(ids);
}
fetchStatus();
poll = setInterval(fetchStatus, 3000);
});
onDestroy(() => { if (poll) clearInterval(poll); });
</script>
<!-- Sub-tabs -->
<div class="sub-tabs">
<button class="sub-tab" class:active={activeView === 'search'} onclick={() => activeView = 'search'}>Search</button>
<button class="sub-tab" class:active={activeView === 'downloads'} onclick={() => activeView = 'downloads'}>
Downloads
{#if activeDownloads.length > 0}<span class="sub-badge">{activeDownloads.length}</span>{/if}
</button>
</div>
{#if kindleConfigured && kindleTargets.length > 0}
<div class="kindle-auto-row">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>
<span class="kindle-auto-label">After download, also send to</span>
<select class="kindle-auto-select" bind:value={autoKindleTarget}>
<option value="none">No one (just download)</option>
{#each kindleTargets as t}
<option value={t.id}>{t.label}</option>
{/each}
</select>
</div>
{/if}
{#if activeView === 'search'}
<!-- Search -->
<div class="search-bar">
<div class="search-wrap">
<svg class="s-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="s-input" type="text" placeholder="Search books, authors, ISBNs..." bind:value={query} onkeydown={(e) => { if (e.key === 'Enter') search(); }} />
{#if query}
<button class="s-clear" onclick={() => { query = ''; results = []; searched = false; bookMeta = 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>
<button class="s-btn" onclick={search} disabled={searching || !query.trim()}>
{#if searching}<span class="spinner"></span>{:else}Search{/if}
</button>
</div>
{#if searching}
<div class="empty">Searching Anna's Archive, Libgen, Z-Library...</div>
{:else if searched && results.length === 0}
<div class="empty">No results found for "{query}"</div>
{:else if results.length > 0}
{#if bookMeta}<div class="result-count">{results.length} releases found</div>{/if}
<div class="results-grid">
{#each results as release (release.source_id)}
{@const cover = coverUrl(release)}
{@const isDl = downloadingIds.has(release.source_id) || !!downloads[release.source_id]}
<div class="r-card">
<div class="r-cover">
{#if cover}
<img src={cover} alt="" loading="lazy" onerror={(e) => (e.currentTarget as HTMLImageElement).style.display='none'} />
{/if}
<span class="fmt-badge {fmtBadge(release.format)}">{release.format.toUpperCase()}</span>
</div>
<div class="r-info">
<div class="r-title">{release.title}</div>
{#if release.extra?.author}<div class="r-author">{release.extra.author}</div>{/if}
<div class="r-meta">
{#if release.extra?.year}<span>{release.extra.year}</span>{/if}
{#if release.size}<span>{release.size}</span>{/if}
{#if release.language}<span>{release.language.toUpperCase()}</span>{/if}
</div>
</div>
<div class="r-actions">
{#if libraries.length > 0}
<select class="lib-select" value={perBookLibrary[release.source_id] ?? defaultLibraryId} onchange={(e) => { perBookLibrary[release.source_id] = Number((e.currentTarget as HTMLSelectElement).value); perBookLibrary = {...perBookLibrary}; }}>
{#each libraries as lib}<option value={lib.id}>{lib.name}</option>{/each}
</select>
{/if}
{#if isDl}
{@const dl = downloads[release.source_id]}
{#if dl}
<span class="dl-badge {statusClass(dl.status)}">{dl.status}</span>
{#if dl.progress > 0 && dl.progress < 100}
<div class="dl-bar"><div class="dl-fill" style="width:{dl.progress}%"></div></div>
{/if}
{:else}
<span class="dl-badge muted">Queued...</span>
{/if}
{:else}
<button class="dl-btn" onclick={() => download(release)}>
<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="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
Download
</button>
{/if}
</div>
</div>
{/each}
</div>
{:else}
<div class="empty">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" style="width:64px;height:64px;opacity:0.2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>
<div>Search for books</div>
<div class="empty-sub">Anna's Archive, Libgen, Z-Library</div>
</div>
{/if}
{:else}
<!-- Downloads -->
{#if downloadCount === 0}
<div class="empty">No downloads yet</div>
{:else}
<div class="dl-list">
{#each Object.entries(downloads) as [id, dl] (id)}
<div class="dl-row">
<div class="dl-info">
<div class="dl-title">{dl.title || id}</div>
{#if dl.author}<div class="dl-author">{dl.author}</div>{/if}
<div class="dl-meta-row">
{#if dl.format}<span class="fmt-badge sm {fmtBadge(dl.format)}">{dl.format.toUpperCase()}</span>{/if}
<span class="dl-badge {statusClass(dl.status)}">{dl.status}</span>
{#if dl.status_message}<span class="dl-msg">{dl.status_message}</span>{/if}
</div>
{#if dl.status === 'downloading' && dl.progress > 0}
<div class="dl-bar full"><div class="dl-fill" style="width:{dl.progress}%"></div></div>
{/if}
</div>
<div class="dl-actions">
{#if dl.status === 'complete'}
{#if kindleSent.has(id)}
<span class="dl-badge success">Sent to Kindle ✓</span>
{:else if kindleSending.has(id)}
<span class="dl-badge accent">Sending to Kindle...</span>
{/if}
{#if importedIds.has(id)}
<span class="dl-badge success">Imported ✓</span>
{:else if importingIds.has(id)}
<span class="dl-badge accent">Importing...</span>
{:else}
{#if libraries.length > 0}
<select class="lib-select" value={perBookLibrary[id] ?? defaultLibraryId} onchange={(e) => { perBookLibrary[id] = Number((e.currentTarget as HTMLSelectElement).value); perBookLibrary = {...perBookLibrary}; }}>
{#each libraries as lib}<option value={lib.id}>{lib.name}</option>{/each}
</select>
{/if}
<button class="action-btn" onclick={() => importToBooklore(dl, getLibraryId(id))}>Import</button>
{/if}
{:else if dl.status === 'error'}
<button class="action-btn" onclick={() => retryDownload(id)}>Retry</button>
<button class="action-btn danger" onclick={() => cancelDownload(id)}>Remove</button>
{:else if ['queued', 'downloading', 'locating', 'resolving'].includes(dl.status)}
<button class="action-btn danger" onclick={() => cancelDownload(id)}>Cancel</button>
{:else}
<button class="action-btn danger" onclick={() => cancelDownload(id)}>Remove</button>
{/if}
</div>
</div>
{/each}
</div>
{/if}
{/if}
<style>
.sub-tabs { display: flex; gap: var(--sp-1); margin-bottom: var(--sp-4); }
.sub-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; font-family: var(--font); transition: all var(--transition); display: flex; align-items: center; gap: var(--sp-1.5); }
.sub-tab:hover { color: var(--text-1); background: var(--card-hover); }
.sub-tab.active { color: var(--text-1); background: var(--card); box-shadow: var(--shadow-xs); }
.sub-badge { font-size: var(--text-xs); font-family: var(--mono); background: var(--accent); color: white; padding: 1px 6px; border-radius: var(--radius-xs); }
.search-bar { display: flex; gap: var(--sp-2); margin-bottom: var(--sp-5); align-items: stretch; }
.search-wrap { flex: 1; position: relative; }
.s-icon { position: absolute; left: 14px; top: 50%; transform: translateY(-50%); width: 16px; height: 16px; color: var(--text-4); pointer-events: none; }
.s-input { width: 100%; height: 42px; padding: 0 36px 0 40px; border-radius: var(--radius-md); border: 1px solid var(--border); background: var(--card); color: var(--text-1); font-size: var(--text-base); font-family: var(--font); transition: all var(--transition); box-sizing: border-box; }
.s-input:focus { outline: none; border-color: var(--accent); }
.s-input::placeholder { color: var(--text-4); }
.s-clear { position: absolute; right: 10px; top: 50%; transform: translateY(-50%); background: none; border: none; cursor: pointer; color: var(--text-4); padding: var(--sp-1); }
.s-clear svg { width: 14px; height: 14px; }
.s-btn { height: 42px; padding: 0 var(--sp-5); 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); white-space: nowrap; }
.s-btn:disabled { opacity: 0.4; cursor: default; }
.s-btn:hover:not(:disabled) { opacity: 0.9; }
.spinner { width: 16px; height: 16px; border: 2px solid rgba(255,255,255,0.3); border-top-color: white; border-radius: 50%; animation: spin 0.6s linear infinite; display: inline-block; }
@keyframes spin { to { transform: rotate(360deg); } }
.empty { padding: var(--sp-12); text-align: center; color: var(--text-3); font-size: var(--text-base); display: flex; flex-direction: column; align-items: center; gap: var(--sp-2); }
.empty-sub { font-size: var(--text-sm); color: var(--text-4); }
.result-count { font-size: var(--text-sm); color: var(--text-3); margin-bottom: var(--sp-3); }
.results-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: var(--sp-4); }
.r-card { background: var(--card); border-radius: var(--radius); border: 1px solid var(--border); box-shadow: var(--shadow-sm); overflow: hidden; display: flex; flex-direction: column; }
.r-cover { position: relative; width: 100%; aspect-ratio: 3/2; background: var(--surface-secondary); display: flex; align-items: center; justify-content: center; overflow: hidden; }
.r-cover img { width: 100%; height: 100%; object-fit: cover; }
.fmt-badge { position: absolute; top: var(--sp-2); right: var(--sp-2); font-size: var(--text-xs); font-weight: 700; padding: 2px var(--sp-2); border-radius: var(--radius-xs); text-transform: uppercase; letter-spacing: 0.03em; }
.fmt-badge.accent { background: var(--accent-dim); color: var(--accent); }
.fmt-badge.error { background: var(--error-dim); color: var(--error); }
.fmt-badge.warning { background: var(--warning-bg); color: var(--warning); }
.fmt-badge.muted { background: var(--card-hover); color: var(--text-4); }
.fmt-badge.sm { position: static; }
.r-info { padding: var(--sp-3) var(--sp-4); flex: 1; }
.r-title { font-size: var(--text-base); font-weight: 600; color: var(--text-1); line-height: 1.3; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.r-author { font-size: var(--text-sm); color: var(--text-3); margin-top: 2px; }
.r-meta { display: flex; flex-wrap: wrap; gap: var(--sp-2); margin-top: var(--sp-1); font-size: var(--text-xs); color: var(--text-4); }
.r-actions { padding: var(--sp-2) var(--sp-4) var(--sp-3); display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; }
.lib-select { padding: var(--sp-1) var(--sp-2); border-radius: var(--radius-sm); border: 1px solid var(--border); background: var(--card); color: var(--text-2); font-size: var(--text-xs); font-family: var(--font); cursor: pointer; max-width: 120px; }
.dl-btn { display: flex; align-items: center; gap: var(--sp-1); padding: var(--sp-1.5) var(--sp-3); 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); }
.dl-btn:hover { opacity: 0.9; }
.dl-btn svg { width: 14px; height: 14px; }
.dl-badge { font-size: var(--text-xs); font-weight: 600; padding: 2px var(--sp-2); border-radius: var(--radius-xs); text-transform: uppercase; }
.dl-badge.success { background: var(--success-dim); color: var(--success); }
.dl-badge.accent { background: var(--accent-dim); color: var(--accent); }
.dl-badge.error { background: var(--error-dim); color: var(--error); }
.dl-badge.muted { background: var(--card-hover); color: var(--text-4); }
.dl-bar { width: 80px; height: 4px; background: var(--border); border-radius: 2px; overflow: hidden; }
.dl-bar.full { width: 100%; margin-top: var(--sp-2); }
.dl-fill { height: 100%; background: var(--accent); border-radius: 2px; transition: width 0.3s ease; }
.dl-list { background: var(--card); border-radius: var(--radius); border: 1px solid var(--border); box-shadow: var(--shadow-sm); overflow: hidden; }
.dl-row { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--sp-4); padding: var(--sp-4); border-bottom: 1px solid var(--border); }
.dl-row:last-child { border-bottom: none; }
.dl-info { flex: 1; min-width: 0; }
.dl-title { font-size: var(--text-base); font-weight: 500; color: var(--text-1); }
.dl-author { font-size: var(--text-sm); color: var(--text-3); margin-top: 2px; }
.dl-meta-row { display: flex; align-items: center; gap: var(--sp-2); margin-top: var(--sp-2); flex-wrap: wrap; }
.dl-msg { font-size: var(--text-xs); color: var(--text-4); }
.dl-actions { display: flex; align-items: center; gap: var(--sp-2); flex-shrink: 0; flex-wrap: wrap; }
.action-btn { padding: var(--sp-1.5) var(--sp-3); border-radius: var(--radius-md); background: var(--accent); color: white; border: none; font-size: var(--text-sm); font-weight: 500; cursor: pointer; font-family: var(--font); }
.action-btn.danger { background: none; border: 1px solid var(--error); color: var(--error); }
.action-btn.danger:hover { background: var(--error-dim); }
.kindle-auto-row {
display: flex; align-items: center; gap: var(--sp-2); margin-bottom: var(--sp-4);
padding: var(--sp-2) var(--sp-3); background: var(--surface-secondary); border-radius: var(--radius-md); border: 1px solid var(--border);
}
.kindle-auto-row svg { width: 14px; height: 14px; color: var(--text-3); flex-shrink: 0; }
.kindle-auto-label { font-size: var(--text-sm); color: var(--text-2); white-space: nowrap; }
.kindle-auto-select {
height: 32px; padding: 0 var(--sp-3); border-radius: var(--radius-md); border: 1px solid var(--border);
background: var(--card); color: var(--text-1); font-size: var(--text-sm); font-weight: 500; font-family: var(--font);
cursor: pointer; -webkit-appearance: none; appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%236b6b76' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
background-repeat: no-repeat; background-position: right 8px center; padding-right: 24px;
}
@media (max-width: 768px) {
.results-grid { grid-template-columns: 1fr; }
.dl-row { flex-direction: column; }
.dl-actions { width: 100%; }
}
</style>

View File

@@ -0,0 +1,276 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
interface MusicTrack {
id: string; name: string; artists: { name: string }[];
album: { name: string; images: { url: string }[] };
duration_ms: number; type: string;
images?: { url: string }[]; owner?: { display_name: string };
tracks?: { total: number }; total_tracks?: number; release_date?: string; genres?: string[];
}
interface MusicTask {
task_id: string; status: string; download_type: string;
name?: string; artist?: string; progress?: number;
total_items?: number; completed_items?: number; speed?: string; eta?: string; error_message?: string;
}
let query = $state('');
let searchType = $state<'track' | 'album' | 'artist' | 'playlist'>('track');
let results = $state<MusicTrack[]>([]);
let tasks = $state<MusicTask[]>([]);
let searching = $state(false);
let searched = $state(false);
let downloading = $state<Set<string>>(new Set());
let activeView = $state<'search' | 'downloads'>('search');
let playingId = $state<string | null>(null);
let playingEmbed = $state<string | null>(null);
let poll: ReturnType<typeof setInterval> | null = null;
const activeTasks = $derived(tasks.filter(t => ['downloading', 'queued'].includes(t.status)));
function artistNames(t: MusicTrack): string { return t.artists?.map(a => a.name).join(', ') || ''; }
function albumArt(t: MusicTrack): string | null { return t.album?.images?.[0]?.url || t.images?.[0]?.url || null; }
function fmtDuration(ms: number): string { const m = Math.floor(ms / 60000); const s = Math.floor((ms % 60000) / 1000); return `${m}:${s.toString().padStart(2, '0')}`; }
async function search() {
if (!query.trim()) return;
searching = true; searched = true; results = [];
try {
const res = await fetch(`/api/music/api/search?q=${encodeURIComponent(query.trim())}&search_type=${searchType}&limit=30`, { credentials: 'include' });
if (res.ok) { const data = await res.json(); results = data.items || []; }
} catch { /* silent */ }
searching = false;
}
async function downloadItem(id: string, type: string) {
downloading = new Set([...downloading, id]);
try {
await fetch(`/api/music/api/${type}/download/${id}`, { credentials: 'include' });
fetchTasks();
} catch { /* silent */ }
}
async function fetchTasks() {
try {
const res = await fetch('/api/music/api/prgs/list', { credentials: 'include' });
if (res.ok) { const data = await res.json(); tasks = Array.isArray(data) ? data : (data.tasks || []); }
} catch { /* silent */ }
}
async function cancelTask(taskId: string) {
try { await fetch(`/api/music/api/prgs/cancel/${taskId}`, { method: 'POST', credentials: 'include' }); fetchTasks(); } catch { /* silent */ }
}
function togglePlay(id: string) {
if (playingId === id) { playingId = null; playingEmbed = null; }
else { playingId = id; playingEmbed = `https://open.spotify.com/embed/track/${id}?utm_source=generator&theme=0`; }
}
onMount(() => { fetchTasks(); poll = setInterval(fetchTasks, 3000); });
onDestroy(() => { if (poll) clearInterval(poll); });
</script>
<!-- Sub-tabs -->
<div class="sub-tabs">
<button class="sub-tab" class:active={activeView === 'search'} onclick={() => activeView = 'search'}>Search</button>
<button class="sub-tab" class:active={activeView === 'downloads'} onclick={() => activeView = 'downloads'}>
Downloads
{#if activeTasks.length > 0}<span class="sub-badge">{activeTasks.length}</span>{/if}
</button>
</div>
{#if activeView === 'search'}
<div class="search-bar">
<select class="type-select" bind:value={searchType}>
<option value="track">Tracks</option>
<option value="album">Albums</option>
<option value="artist">Artists</option>
<option value="playlist">Playlists</option>
</select>
<div class="search-wrap">
<svg class="s-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="s-input" type="text" placeholder="Search songs, albums, artists..." bind:value={query} onkeydown={(e) => { if (e.key === 'Enter') search(); }} />
</div>
<button class="s-btn" onclick={search} disabled={searching || !query.trim()}>
{#if searching}<span class="spinner"></span>{:else}Search{/if}
</button>
</div>
{#if searching}
<div class="empty">Searching Spotify...</div>
{:else if searched && results.length === 0}
<div class="empty">No results for "{query}"</div>
{:else if results.length > 0}
{#if searchType === 'track'}
{#if playingEmbed}
<div class="player-wrap">
<iframe src={playingEmbed} width="100%" height="80" frameborder="0" allow="autoplay; clipboard-write; encrypted-media" title="Spotify player"></iframe>
</div>
{/if}
<div class="track-list">
{#each results as track (track.id)}
{@const art = albumArt(track)}
<div class="track-row">
<button class="play-btn" onclick={() => togglePlay(track.id)}>
{#if playingId === track.id}
<svg viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="4" width="4" height="16" rx="1"/><rect x="14" y="4" width="4" height="16" rx="1"/></svg>
{:else}
<svg viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg>
{/if}
</button>
{#if art}<img class="track-art" src={art} alt="" loading="lazy" />{/if}
<div class="track-info">
<div class="track-name">{track.name}</div>
<div class="track-artist">{artistNames(track)}{track.album?.name ? ` · ${track.album.name}` : ''}</div>
</div>
<span class="track-dur">{track.duration_ms ? fmtDuration(track.duration_ms) : ''}</span>
{#if downloading.has(track.id)}
<span class="dl-badge accent">Queued</span>
{:else}
<button class="dl-sm-btn" onclick={() => downloadItem(track.id, 'track')}>
<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="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
</button>
{/if}
</div>
{/each}
</div>
{:else}
<div class="card-grid">
{#each results as item (item.id)}
{@const art = albumArt(item)}
<div class="m-card">
<div class="m-card-img" class:round={searchType === 'artist'}>
{#if art}<img src={art} alt="" loading="lazy" />{:else}<div class="m-card-placeholder">♪</div>{/if}
</div>
<div class="m-card-name">{item.name}</div>
<div class="m-card-sub">
{#if searchType === 'playlist'}{item.owner?.display_name || ''}{item.tracks?.total ? ` · ${item.tracks.total} tracks` : ''}
{:else if searchType === 'album'}{artistNames(item)}{item.release_date ? ` · ${item.release_date.slice(0, 4)}` : ''}
{:else}{item.genres?.slice(0, 2).join(', ') || 'Artist'}{/if}
</div>
<button class="dl-card-btn" onclick={() => downloadItem(item.id, searchType)} disabled={downloading.has(item.id)}>
{downloading.has(item.id) ? 'Queued' : 'Download'}
</button>
</div>
{/each}
</div>
{/if}
{:else}
<div class="empty">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" style="width:64px;height:64px;opacity:0.2"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
<div>Search for music</div>
<div class="empty-sub">Spotify tracks, albums, playlists</div>
</div>
{/if}
{:else}
<!-- Download Tasks -->
{#if tasks.length === 0}
<div class="empty">No music downloads</div>
{:else}
<div class="task-list">
{#each tasks as task (task.task_id)}
<div class="task-row">
<div class="task-info">
<div class="task-name">{task.name || task.task_id}</div>
{#if task.artist}<div class="task-artist">{task.artist}</div>{/if}
<div class="task-meta">
<span class="dl-badge {task.status === 'downloading' ? 'accent' : task.status === 'completed' ? 'success' : task.status === 'error' ? 'error' : 'muted'}">{task.status}</span>
{#if task.completed_items != null && task.total_items}<span class="task-progress-text">{task.completed_items}/{task.total_items} tracks</span>{/if}
{#if task.speed}<span class="task-speed">{task.speed}</span>{/if}
{#if task.eta}<span class="task-eta">ETA {task.eta}</span>{/if}
{#if task.error_message}<span class="task-error">{task.error_message}</span>{/if}
</div>
{#if task.progress != null && task.progress > 0}
<div class="dl-bar full"><div class="dl-fill" style="width:{task.progress}%"></div></div>
{/if}
</div>
{#if ['downloading', 'queued'].includes(task.status)}
<button class="action-btn danger" onclick={() => cancelTask(task.task_id)}>Cancel</button>
{/if}
</div>
{/each}
</div>
{/if}
{/if}
<style>
.sub-tabs { display: flex; gap: var(--sp-1); margin-bottom: var(--sp-4); }
.sub-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; font-family: var(--font); transition: all var(--transition); display: flex; align-items: center; gap: var(--sp-1.5); }
.sub-tab:hover { color: var(--text-1); background: var(--card-hover); }
.sub-tab.active { color: var(--text-1); background: var(--card); box-shadow: var(--shadow-xs); }
.sub-badge { font-size: var(--text-xs); font-family: var(--mono); background: var(--accent); color: white; padding: 1px 6px; border-radius: var(--radius-xs); }
.search-bar { display: flex; gap: var(--sp-2); margin-bottom: var(--sp-5); align-items: stretch; }
.type-select { height: 42px; padding: 0 var(--sp-3); border-radius: var(--radius-md); border: 1px solid var(--border); background: var(--card); color: var(--text-1); font-size: var(--text-sm); font-weight: 500; font-family: var(--font); cursor: pointer; -webkit-appearance: none; appearance: none; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%236b6b76' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 10px center; padding-right: 30px; }
.type-select:focus { outline: none; border-color: var(--accent); }
.search-wrap { flex: 1; position: relative; }
.s-icon { position: absolute; left: 14px; top: 50%; transform: translateY(-50%); width: 16px; height: 16px; color: var(--text-4); pointer-events: none; }
.s-input { width: 100%; height: 42px; padding: 0 14px 0 40px; border-radius: var(--radius-md); border: 1px solid var(--border); background: var(--card); color: var(--text-1); font-size: var(--text-base); font-family: var(--font); box-sizing: border-box; }
.s-input:focus { outline: none; border-color: var(--accent); }
.s-input::placeholder { color: var(--text-4); }
.s-btn { height: 42px; padding: 0 var(--sp-5); 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); white-space: nowrap; }
.s-btn:disabled { opacity: 0.4; cursor: default; }
.spinner { width: 16px; height: 16px; border: 2px solid rgba(255,255,255,0.3); border-top-color: white; border-radius: 50%; animation: spin 0.6s linear infinite; display: inline-block; }
@keyframes spin { to { transform: rotate(360deg); } }
.empty { padding: var(--sp-12); text-align: center; color: var(--text-3); font-size: var(--text-base); display: flex; flex-direction: column; align-items: center; gap: var(--sp-2); }
.empty-sub { font-size: var(--text-sm); color: var(--text-4); }
.player-wrap { margin-bottom: var(--sp-3); border-radius: var(--radius); overflow: hidden; }
.track-list { background: var(--card); border-radius: var(--radius); border: 1px solid var(--border); box-shadow: var(--shadow-sm); overflow: hidden; }
.track-row { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-2) var(--sp-3); border-bottom: 1px solid var(--border); transition: background var(--transition); }
.track-row:last-child { border-bottom: none; }
.track-row:hover { background: var(--card-hover); }
.play-btn { width: 32px; height: 32px; border-radius: var(--radius-full); background: none; border: none; cursor: pointer; color: var(--text-3); display: flex; align-items: center; justify-content: center; flex-shrink: 0; transition: color var(--transition); }
.play-btn:hover { color: var(--accent); }
.play-btn svg { width: 16px; height: 16px; }
.track-art { width: 40px; height: 40px; border-radius: var(--radius-xs); object-fit: cover; flex-shrink: 0; }
.track-info { flex: 1; min-width: 0; }
.track-name { font-size: var(--text-base); font-weight: 500; color: var(--text-1); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.track-artist { font-size: var(--text-sm); color: var(--text-3); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.track-dur { font-size: var(--text-sm); font-family: var(--mono); color: var(--text-4); flex-shrink: 0; }
.dl-sm-btn { width: 32px; height: 32px; border-radius: var(--radius-md); background: none; border: 1px solid var(--border); cursor: pointer; color: var(--text-3); display: flex; align-items: center; justify-content: center; flex-shrink: 0; transition: all var(--transition); }
.dl-sm-btn:hover { color: var(--accent); border-color: var(--accent); }
.dl-sm-btn svg { width: 14px; height: 14px; }
.card-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: var(--sp-4); }
.m-card { text-align: center; }
.m-card-img { width: 100%; aspect-ratio: 1; border-radius: var(--radius); overflow: hidden; background: var(--surface-secondary); margin-bottom: var(--sp-2); }
.m-card-img.round { border-radius: var(--radius-full); }
.m-card-img img { width: 100%; height: 100%; object-fit: cover; }
.m-card-placeholder { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; font-size: var(--text-3xl); color: var(--text-4); }
.m-card-name { font-size: var(--text-sm); font-weight: 600; color: var(--text-1); line-height: 1.3; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.m-card-sub { font-size: var(--text-xs); color: var(--text-3); margin-top: 2px; }
.dl-card-btn { margin-top: var(--sp-2); padding: var(--sp-1) var(--sp-3); border-radius: var(--radius-md); background: var(--accent); color: white; border: none; font-size: var(--text-xs); font-weight: 600; cursor: pointer; font-family: var(--font); }
.dl-card-btn:disabled { opacity: 0.4; }
.dl-badge { font-size: var(--text-xs); font-weight: 600; padding: 2px var(--sp-2); border-radius: var(--radius-xs); text-transform: uppercase; }
.dl-badge.success { background: var(--success-dim); color: var(--success); }
.dl-badge.accent { background: var(--accent-dim); color: var(--accent); }
.dl-badge.error { background: var(--error-dim); color: var(--error); }
.dl-badge.muted { background: var(--card-hover); color: var(--text-4); }
.task-list { background: var(--card); border-radius: var(--radius); border: 1px solid var(--border); box-shadow: var(--shadow-sm); overflow: hidden; }
.task-row { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--sp-4); padding: var(--sp-4); border-bottom: 1px solid var(--border); }
.task-row:last-child { border-bottom: none; }
.task-info { flex: 1; min-width: 0; }
.task-name { font-size: var(--text-base); font-weight: 500; color: var(--text-1); }
.task-artist { font-size: var(--text-sm); color: var(--text-3); margin-top: 2px; }
.task-meta { display: flex; align-items: center; gap: var(--sp-2); margin-top: var(--sp-2); flex-wrap: wrap; }
.task-progress-text { font-size: var(--text-xs); font-family: var(--mono); color: var(--text-3); }
.task-speed { font-size: var(--text-xs); color: var(--text-4); }
.task-eta { font-size: var(--text-xs); color: var(--text-4); }
.task-error { font-size: var(--text-xs); color: var(--error); }
.dl-bar { width: 100%; height: 4px; background: var(--border); border-radius: 2px; overflow: hidden; margin-top: var(--sp-2); }
.dl-bar.full { width: 100%; }
.dl-fill { height: 100%; background: var(--accent); border-radius: 2px; transition: width 0.3s ease; }
.action-btn { padding: var(--sp-1.5) var(--sp-3); border-radius: var(--radius-md); font-size: var(--text-sm); font-weight: 500; cursor: pointer; font-family: var(--font); flex-shrink: 0; }
.action-btn.danger { background: none; border: 1px solid var(--error); color: var(--error); }
@media (max-width: 768px) {
.search-bar { flex-wrap: wrap; }
.type-select { width: 100%; }
.card-grid { grid-template-columns: repeat(2, 1fr); }
}
</style>

View File

@@ -0,0 +1,161 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { cn } from '$lib/utils';
type Variant = 'budget' | 'inventory' | 'fitness';
type Size = 'primary' | 'secondary';
let {
title,
description = '',
action,
variant,
size = 'primary' as Size,
icon,
onclick
}: {
title: string;
description?: string;
action: string;
variant: Variant;
size?: Size;
icon?: Snippet;
onclick?: () => void;
} = $props();
const variantClasses: Record<Variant, string> = {
budget: 'action-card-icon--budget',
inventory: 'action-card-icon--inventory',
fitness: 'action-card-icon--fitness'
};
</script>
<button
class={cn('action-card', size === 'secondary' && 'action-card--secondary')}
onclick={onclick}
type="button"
>
<div class="action-card-left">
<div class={cn('action-card-icon', variantClasses[variant])}>
{#if icon}
{@render icon()}
{/if}
</div>
<div>
<div class={cn('action-card-title', size === 'secondary' && 'action-card-title--sm')}>
{title}
</div>
{#if description}
<div class={cn('action-card-desc', size === 'secondary' && 'action-card-desc--sm')}>
{description}
</div>
{/if}
</div>
</div>
<div class="action-card-right">
<span>{action}</span>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m9 18 6-6-6-6"/>
</svg>
</div>
</button>
<style>
.action-card {
display: flex;
align-items: center;
justify-content: space-between;
border-radius: var(--radius, 12px);
background: var(--card);
box-shadow: var(--card-shadow);
transition: all var(--transition, 180ms ease);
cursor: pointer;
border: 1px solid var(--border);
padding: 20px 24px;
width: 100%;
text-align: left;
font-family: var(--font);
}
.action-card:hover {
transform: translateY(-1px);
box-shadow: var(--card-shadow), 0 2px 8px rgba(0, 0, 0, 0.04);
}
.action-card--secondary {
padding: 14px 20px;
background: var(--card-secondary);
box-shadow: var(--card-shadow-sm);
}
.action-card-left {
display: flex;
align-items: center;
gap: 14px;
}
.action-card-icon {
width: 40px;
height: 40px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.action-card-icon :global(svg) {
width: 20px;
height: 20px;
}
.action-card-icon--budget {
background: var(--accent-bg);
color: var(--accent);
}
.action-card-icon--inventory {
background: var(--error-bg);
color: var(--error);
}
.action-card-icon--fitness {
background: var(--success-bg);
color: var(--success);
}
.action-card-title {
font-size: var(--text-md);
font-weight: 600;
color: var(--text-1);
}
.action-card-title--sm {
font-size: var(--text-base);
font-weight: 500;
}
.action-card-desc {
font-size: var(--text-sm);
color: var(--text-3);
margin-top: 2px;
}
.action-card-desc--sm {
font-size: var(--text-sm);
}
.action-card-right {
font-size: var(--text-sm);
color: var(--text-3);
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
.action-card-right svg {
width: 16px;
height: 16px;
}
</style>

View File

@@ -0,0 +1,61 @@
<script lang="ts">
import type { Snippet } from 'svelte';
let {
icon,
title,
description = ''
}: {
icon?: Snippet;
title: string;
description?: string;
} = $props();
</script>
<div class="empty-state">
{#if icon}
<div class="empty-state-icon">
{@render icon()}
</div>
{/if}
<h3 class="empty-state-title">{title}</h3>
{#if description}
<p class="empty-state-desc">{description}</p>
{/if}
</div>
<style>
.empty-state {
text-align: center;
padding: var(--sp-12) var(--sp-6);
}
.empty-state-icon {
margin: 0 auto var(--sp-4);
width: 48px;
height: 48px;
border-radius: var(--radius);
background: var(--card-secondary);
display: flex;
align-items: center;
justify-content: center;
}
.empty-state-icon :global(svg) {
width: 24px;
height: 24px;
color: var(--text-4);
}
.empty-state-title {
font-size: var(--text-md);
font-weight: 500;
color: var(--text-2);
margin-bottom: 4px;
}
.empty-state-desc {
font-size: var(--text-sm);
color: var(--text-3);
}
</style>

View File

@@ -0,0 +1,250 @@
<script lang="ts">
import { onMount } from 'svelte';
interface ImmichAsset {
id: string;
type: string;
fileCreatedAt: string;
}
let {
open = $bindable(false),
multiple = true,
onselect
}: {
open: boolean;
multiple?: boolean;
onselect: (assetIds: string[]) => void;
} = $props();
let assets = $state<ImmichAsset[]>([]);
let loading = $state(true);
let currentPage = $state(1);
let hasMore = $state(true);
let selected = $state<Set<string>>(new Set());
let searchQuery = $state('');
let searchTimer: ReturnType<typeof setTimeout>;
let errorMsg = $state('');
async function loadAssets(reset = false) {
if (reset) { currentPage = 1; hasMore = true; assets = []; }
loading = true;
errorMsg = '';
try {
const body: Record<string, unknown> = {
type: 'IMAGE',
size: 20,
page: reset ? 1 : currentPage,
order: 'desc'
};
if (searchQuery.trim()) {
body.originalFileName = searchQuery.trim();
}
const res = await fetch('/api/immich/search/metadata', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(body)
});
if (res.ok) {
const data = await res.json();
const items = data.assets?.items || [];
assets = reset ? items : [...assets, ...items];
hasMore = items.length === 20;
currentPage = (reset ? 1 : currentPage) + 1;
} else {
errorMsg = 'Failed to load photos (' + res.status + ')';
}
} catch {
errorMsg = 'Could not connect to photo library';
}
finally { loading = false; }
}
function toggleSelect(id: string) {
const next = new Set(selected);
if (next.has(id)) next.delete(id);
else { if (!multiple) next.clear(); next.add(id); }
selected = next;
}
function confirmSelection() {
onselect(Array.from(selected));
selected = new Set();
open = false;
}
function close() {
selected = new Set();
open = false;
}
function onSearch() {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => loadAssets(true), 400);
}
// Load photos as soon as this component mounts
onMount(() => { loadAssets(true); });
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="immich-overlay" onclick={close}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="immich-modal" onclick={(e) => e.stopPropagation()}>
<div class="immich-header">
<div class="immich-title">Choose from Photos</div>
<button class="immich-close" onclick={close} aria-label="Close">
<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>
<div class="immich-search">
<input type="text" class="immich-search-input" placeholder="Search photos..." bind:value={searchQuery} oninput={onSearch} />
</div>
<div class="immich-grid">
{#each assets as asset (asset.id)}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="immich-thumb" class:selected={selected.has(asset.id)} onclick={() => toggleSelect(asset.id)}>
<img src="/api/immich/assets/{asset.id}/thumbnail" alt="" loading="lazy" />
{#if selected.has(asset.id)}
<div class="immich-check">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><polyline points="20 6 9 17 4 12"/></svg>
</div>
{/if}
</div>
{/each}
{#if loading}
{#each Array(8) as _}
<div class="immich-thumb skeleton"></div>
{/each}
{/if}
{#if !loading && assets.length === 0 && !errorMsg}
<div class="immich-empty">No photos found</div>
{/if}
{#if errorMsg}
<div class="immich-empty">{errorMsg}</div>
{/if}
</div>
{#if hasMore && !loading && assets.length > 0}
<button class="immich-load-more" onclick={() => loadAssets()}>Load more</button>
{/if}
<div class="immich-footer">
<span class="immich-count">{selected.size} selected</span>
<div class="immich-actions">
<button class="immich-btn-cancel" onclick={close}>Cancel</button>
<button class="immich-btn-confirm" onclick={confirmSelection} disabled={selected.size === 0}>
Use {selected.size} photo{selected.size !== 1 ? 's' : ''}
</button>
</div>
</div>
</div>
</div>
<style>
.immich-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 70;
display: flex; align-items: center; justify-content: center;
animation: imFadeIn 150ms ease;
}
@keyframes imFadeIn { from { opacity: 0; } to { opacity: 1; } }
.immich-modal {
background: var(--surface); border-radius: 16px;
width: 560px; max-width: 95vw; max-height: 85vh;
display: flex; flex-direction: column;
box-shadow: 0 20px 60px rgba(0,0,0,0.2);
animation: imSlideUp 200ms ease;
}
@keyframes imSlideUp { from { transform: translateY(20px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
.immich-header {
display: flex; align-items: center; justify-content: space-between;
padding: 18px 20px; border-bottom: 1px solid var(--border);
}
.immich-title { font-size: var(--text-md); font-weight: 600; color: var(--text-1); }
.immich-close { background: none; border: none; cursor: pointer; color: var(--text-3); padding: 4px; border-radius: 6px; transition: all 0.15s; }
.immich-close:hover { color: var(--text-1); background: var(--card-hover); }
.immich-close svg { width: 18px; height: 18px; }
.immich-search { padding: 12px 20px 0; }
.immich-search-input {
width: 100%; padding: 10px 14px; border-radius: 8px;
border: 1px solid var(--border); background: var(--surface-secondary);
color: var(--text-1); font-size: var(--text-md); font-family: var(--font);
}
.immich-search-input:focus { outline: none; border-color: var(--accent); }
.immich-search-input::placeholder { color: var(--text-4); }
.immich-grid {
display: grid; grid-template-columns: repeat(4, 1fr); gap: 4px;
padding: 12px 20px; overflow-y: auto; flex: 1; min-height: 200px;
}
.immich-thumb {
aspect-ratio: 1; border-radius: 8px; overflow: hidden;
cursor: pointer; position: relative; background: var(--card-hover);
transition: transform 0.1s;
}
.immich-thumb:hover { transform: scale(0.97); }
.immich-thumb.selected { outline: 3px solid var(--accent); outline-offset: -3px; }
.immich-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
.immich-check {
position: absolute; top: 6px; right: 6px;
width: 24px; height: 24px; border-radius: 50%;
background: var(--accent); color: white;
display: flex; align-items: center; justify-content: center;
}
.immich-check svg { width: 14px; height: 14px; }
.immich-thumb.skeleton {
background: linear-gradient(90deg, var(--card) 25%, var(--card-hover) 50%, var(--card) 75%);
background-size: 200% 100%; animation: shimmer 1.5s infinite;
}
@keyframes shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
.immich-empty {
grid-column: 1 / -1; text-align: center; padding: 40px;
color: var(--text-3); font-size: var(--text-base);
}
.immich-load-more {
display: block; margin: 0 auto 8px; padding: 8px 20px;
border-radius: 8px; background: none; 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 0.15s;
}
.immich-load-more:hover { background: var(--card-hover); }
.immich-footer {
display: flex; align-items: center; justify-content: space-between;
padding: 14px 20px; border-top: 1px solid var(--border);
}
.immich-count { font-size: var(--text-sm); color: var(--text-3); }
.immich-actions { display: flex; gap: 8px; }
.immich-btn-cancel {
padding: 8px 16px; border-radius: 8px; 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); transition: all 0.15s;
}
.immich-btn-cancel:hover { background: var(--card-hover); }
.immich-btn-confirm {
padding: 8px 16px; border-radius: 8px; background: var(--accent);
color: white; border: none; font-size: var(--text-sm); font-weight: 600;
cursor: pointer; font-family: var(--font); transition: opacity 0.15s;
}
.immich-btn-confirm:hover { opacity: 0.9; }
.immich-btn-confirm:disabled { opacity: 0.4; cursor: default; }
@media (max-width: 768px) {
.immich-modal { max-height: 90vh; border-radius: 16px 16px 0 0; align-self: flex-end; width: 100%; max-width: 100%; }
.immich-grid { grid-template-columns: repeat(3, 1fr); }
}
</style>

View File

@@ -0,0 +1,69 @@
<script lang="ts">
import type { Snippet } from 'svelte';
let {
title,
action = '',
onaction,
children
}: {
title: string;
action?: string;
onaction?: () => void;
children: Snippet;
} = $props();
</script>
<div class="module">
<div class="module-header">
<h3 class="module-title">{title}</h3>
{#if action}
<button class="module-action" onclick={onaction} type="button">
{action}
</button>
{/if}
</div>
<div class="module-content">
{@render children()}
</div>
</div>
<style>
.module {
background: var(--card);
border-radius: var(--radius, 12px);
padding: var(--card-pad-primary, 20px);
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;
font-family: var(--font);
padding: 0;
}
.module-action:hover {
text-decoration: underline;
}
</style>

View File

@@ -0,0 +1,47 @@
<script lang="ts">
let {
label,
title,
subtitle = ''
}: {
label: string;
title: string;
subtitle?: string;
} = $props();
</script>
<div class="page-header">
<p class="page-label">{label}</p>
<h1 class="page-title">{title}</h1>
{#if subtitle}
<p class="page-subtitle">{subtitle}</p>
{/if}
</div>
<style>
.page-header {
margin-bottom: var(--section-gap, 24px);
}
.page-label {
font-size: var(--text-sm);
font-weight: 500;
color: var(--text-3);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: var(--sp-1);
}
.page-title {
font-size: var(--text-2xl);
font-weight: 300;
color: var(--text-1);
line-height: 1.2;
}
.page-subtitle {
font-size: var(--text-base);
color: var(--text-3);
margin-top: var(--sp-1);
}
</style>

View File

@@ -0,0 +1,76 @@
<script lang="ts">
import { cn } from '$lib/utils';
type Tab = {
label: string;
badge?: number;
active?: boolean;
};
let {
tabs,
onselect
}: {
tabs: Tab[];
onselect?: (index: number) => void;
} = $props();
</script>
<div class="page-tabs">
{#each tabs as tab, index}
<button
class={cn('page-tab', tab.active && 'page-tab--active')}
onclick={() => onselect?.(index)}
type="button"
>
{tab.label}
{#if tab.badge !== undefined}
<span class="badge">{tab.badge}</span>
{/if}
</button>
{/each}
</div>
<style>
.page-tabs {
display: flex;
gap: var(--sp-1);
margin-bottom: var(--section-gap, 24px);
}
.page-tab {
padding: var(--sp-2) var(--sp-4);
border-radius: var(--radius-sm, 8px);
font-size: var(--text-base);
font-weight: 500;
color: var(--text-3);
background: none;
border: none;
transition: all var(--transition, 180ms ease);
cursor: pointer;
font-family: var(--font);
display: inline-flex;
align-items: center;
}
.page-tab:hover {
color: var(--text-1);
background: var(--card-secondary);
}
.page-tab--active {
color: var(--text-1);
background: var(--card);
box-shadow: var(--card-shadow-sm);
}
.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.5);
}
</style>

View File

@@ -0,0 +1,66 @@
<script lang="ts">
let {
placeholder = 'Search...',
value = $bindable('')
}: {
placeholder?: string;
value?: string;
} = $props();
</script>
<div class="search-bar">
<div class="input-with-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" 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"/>
<path d="m21 21-4.3-4.3"/>
</svg>
<input
class="input"
type="text"
{placeholder}
bind:value
/>
</div>
</div>
<style>
.search-bar {
margin-bottom: var(--section-gap, 24px);
}
.input-with-icon {
position: relative;
}
.input-with-icon svg {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
width: 16px;
height: 16px;
color: var(--text-3);
pointer-events: none;
}
.input {
width: 100%;
padding: 10px 14px 10px 38px;
border-radius: var(--radius-sm, 8px);
background: var(--surface-secondary);
border: 1px solid var(--border);
color: var(--text-1);
font-size: var(--text-base);
font-family: var(--font);
outline: none;
transition: border-color var(--transition, 180ms ease);
}
.input:focus {
border-color: var(--accent);
}
.input::placeholder {
color: var(--text-4);
}
</style>

View File

@@ -0,0 +1,93 @@
<script lang="ts">
let {
rows = 3,
variant = 'default' as 'default' | 'card'
}: {
rows?: number;
variant?: 'default' | 'card';
} = $props();
</script>
<div class="skeleton-rows">
{#each Array(rows) as _, i}
<div class="skeleton-row">
<div class="skeleton skeleton-circle"></div>
<div class="skeleton-text">
<div class="skeleton skeleton-line w-2-3"></div>
<div class="skeleton skeleton-line w-1-3"></div>
</div>
<div class="skeleton skeleton-line skeleton-amount"></div>
</div>
{/each}
</div>
<style>
.skeleton-rows {
display: flex;
flex-direction: column;
}
.skeleton-row {
display: flex;
align-items: center;
gap: 14px;
padding: 14px 16px;
}
.skeleton-row + .skeleton-row {
border-top: 1px solid var(--border);
}
.skeleton {
background: linear-gradient(
90deg,
var(--card, #161619) 25%,
var(--card-hover, #1c1c20) 50%,
var(--card, #161619) 75%
);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
border-radius: var(--radius-sm);
}
@keyframes shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
.skeleton-circle {
width: 40px;
height: 40px;
border-radius: 50%;
flex-shrink: 0;
}
.skeleton-text {
flex: 1;
display: flex;
flex-direction: column;
gap: var(--sp-1.5);
}
.skeleton-line {
height: 14px;
}
.w-2-3 {
width: 66%;
}
.w-1-3 {
width: 33%;
}
.skeleton-amount {
width: 64px;
height: 14px;
flex-shrink: 0;
}
</style>

View File

@@ -0,0 +1,95 @@
<script lang="ts" module>
type ToastItem = {
id: number;
message: string;
variant: 'success' | 'error';
};
let toasts = $state<ToastItem[]>([]);
let nextId = 0;
export function toast(message: string, variant: 'success' | 'error' = 'success') {
const id = nextId++;
toasts.push({ id, message, variant });
setTimeout(() => {
toasts = toasts.filter((t) => t.id !== id);
}, 3000);
}
</script>
<script lang="ts">
import { cn } from '$lib/utils';
</script>
{#if toasts.length > 0}
<div class="toast-container">
{#each toasts as item (item.id)}
<div class={cn('toast', `toast--${item.variant}`)}>
{#if item.variant === 'success'}
<svg class="toast-icon toast-icon--success" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
<polyline points="22 4 12 14.01 9 11.01"/>
</svg>
{:else}
<svg class="toast-icon toast-icon--error" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"/>
<line x1="15" y1="9" x2="9" y2="15"/>
<line x1="9" y1="9" x2="15" y2="15"/>
</svg>
{/if}
<span>{item.message}</span>
</div>
{/each}
</div>
{/if}
<style>
.toast-container {
position: fixed;
bottom: 24px;
right: 24px;
z-index: 90;
display: flex;
flex-direction: column;
gap: var(--sp-2);
}
.toast {
padding: var(--sp-3) var(--sp-4);
border-radius: var(--radius-sm, 8px);
background: var(--card);
border: 1px solid var(--border);
box-shadow: var(--card-shadow);
font-size: var(--text-sm);
color: var(--text-1);
display: flex;
align-items: center;
gap: var(--sp-2);
animation: slideUp 0.3s ease;
}
@keyframes slideUp {
from {
transform: translateY(10px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.toast-icon {
width: 18px;
height: 18px;
flex-shrink: 0;
}
.toast-icon--success {
color: var(--success);
}
.toast-icon--error {
color: var(--error);
}
</style>

View File

@@ -0,0 +1,148 @@
<script lang="ts">
import { cn } from '$lib/utils';
type CategoryVariant = 'default' | 'uncat' | 'transfer';
let {
date,
payeeName,
payeeNote = '',
account,
category,
categoryVariant = 'default' as CategoryVariant,
amount,
isPositive = false
}: {
date: string;
payeeName: string;
payeeNote?: string;
account: string;
category: string;
categoryVariant?: CategoryVariant;
amount: string;
isPositive?: boolean;
} = $props();
const pillVariantClass: Record<CategoryVariant, string> = {
default: 'txn-cat-pill--default',
uncat: 'txn-cat-pill--uncat',
transfer: 'txn-cat-pill--transfer'
};
</script>
<div class="txn-row">
<div class="txn-date">{date}</div>
<div class="txn-payee">
<div class="txn-payee-name">{payeeName}</div>
{#if payeeNote}
<div class="txn-payee-note">{payeeNote}</div>
{/if}
</div>
<div class="txn-account">{account}</div>
<div class="txn-category">
<span class={cn('txn-cat-pill', pillVariantClass[categoryVariant])}>
{category}
</span>
</div>
<div class={cn('txn-amount', isPositive ? 'txn-amount--pos' : 'txn-amount--neg')}>
{amount}
</div>
</div>
<style>
.txn-row {
display: flex;
align-items: center;
gap: 16px;
padding: 14px 16px;
transition: background var(--transition, 180ms ease);
cursor: default;
}
.txn-row:hover {
background: var(--card-hover);
}
.txn-row + :global(.txn-row) {
border-top: 1px solid var(--border);
}
.txn-date {
font-size: var(--text-sm);
color: var(--text-3);
width: 56px;
flex-shrink: 0;
}
.txn-payee {
flex: 1.5;
min-width: 0;
}
.txn-payee-name {
font-size: var(--text-base);
font-weight: 500;
color: var(--text-1);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.txn-payee-note {
font-size: var(--text-sm);
color: var(--text-4);
margin-top: 1px;
}
.txn-account {
flex: 1;
font-size: var(--text-sm);
color: var(--text-3);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.txn-category {
flex: 1;
}
.txn-cat-pill {
display: inline-block;
padding: 4px 10px;
border-radius: 6px;
font-size: var(--text-sm);
font-weight: 500;
}
.txn-cat-pill--default {
background: var(--accent-dim);
color: var(--accent);
}
.txn-cat-pill--uncat {
background: var(--warning-bg);
color: var(--warning);
}
.txn-cat-pill--transfer {
background: rgba(59, 130, 246, 0.1);
color: #3b82f6;
}
.txn-amount {
font-family: var(--mono);
font-size: var(--text-base);
font-weight: 500;
text-align: right;
min-width: 80px;
}
.txn-amount--pos {
color: var(--success);
}
.txn-amount--neg {
color: var(--error);
}
</style>

View File

@@ -0,0 +1,101 @@
<script lang="ts">
let {
open = $bindable(false),
onCreated
}: {
open: boolean;
onCreated: (tripId: string) => void;
} = $props();
let name = $state('');
let description = $state('');
let startDate = $state('');
let endDate = $state('');
let saving = $state(false);
$effect(() => {
if (open) { name = ''; description = ''; startDate = ''; endDate = ''; }
});
function close() { open = false; }
async function create() {
if (!name.trim()) return;
saving = true;
try {
const res = await fetch('/api/trips/trip', {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, description, start_date: startDate, end_date: endDate })
});
if (res.ok) {
const data = await res.json();
close();
onCreated(data.id);
}
} catch (e) { console.error('Create failed:', e); }
finally { saving = false; }
}
</script>
{#if open}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="modal-overlay" onclick={close}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="modal-sheet" onclick={(e) => e.stopPropagation()}>
<div class="modal-header">
<div class="modal-title">Plan a Trip</div>
<button class="modal-close" onclick={close}>
<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>
<div class="modal-body">
<div class="field">
<label class="field-label">Trip Name</label>
<input class="field-input" type="text" bind:value={name} placeholder="e.g. Tokyo 2027" autofocus />
</div>
<div class="field">
<label class="field-label">Description</label>
<textarea class="field-input field-textarea" bind:value={description} rows="2" placeholder="Optional..."></textarea>
</div>
<div class="field-row">
<div class="field"><label class="field-label">Start Date</label><input class="field-input" type="date" bind:value={startDate} /></div>
<div class="field"><label class="field-label">End Date</label><input class="field-input" type="date" bind:value={endDate} /></div>
</div>
</div>
<div class="modal-footer">
<div></div>
<div class="footer-right">
<button class="btn-cancel" onclick={close}>Cancel</button>
<button class="btn-save" onclick={create} disabled={saving || !name.trim()}>
{saving ? 'Creating...' : 'Create Trip'}
</button>
</div>
</div>
</div>
</div>
{/if}
<style>
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.35); z-index: 70; display: flex; align-items: center; justify-content: center; animation: fade 150ms ease; }
@keyframes fade { from { opacity: 0; } to { opacity: 1; } }
.modal-sheet { width: 440px; max-width: 92vw; background: var(--surface); border-radius: var(--radius); box-shadow: 0 20px 60px rgba(0,0,0,0.15); animation: slideUp 200ms ease; }
@keyframes slideUp { from { transform: translateY(20px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
.modal-header { display: flex; align-items: center; justify-content: space-between; padding: 18px 22px; border-bottom: 1px solid var(--border); }
.modal-title { font-size: var(--text-lg); font-weight: 600; color: var(--text-1); }
.modal-close { background: none; border: none; cursor: pointer; color: var(--text-3); padding: var(--sp-1); border-radius: var(--radius-sm); }
.modal-close:hover { color: var(--text-1); background: var(--card-hover); }
.modal-close svg { width: 18px; height: 18px; }
.modal-body { padding: 22px; display: flex; flex-direction: column; gap: 14px; }
.field { display: flex; flex-direction: column; gap: var(--sp-1); }
.field-label { font-size: var(--text-sm); font-weight: 500; color: var(--text-3); text-transform: uppercase; letter-spacing: 0.04em; }
.field-input { padding: 10px 12px; border-radius: var(--radius-md); border: 1px solid var(--border); background: var(--surface-secondary); color: var(--text-1); font-size: var(--text-md); font-family: var(--font); }
.field-input:focus { outline: none; border-color: var(--accent); }
.field-textarea { resize: vertical; min-height: 50px; }
.field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.modal-footer { display: flex; align-items: center; justify-content: space-between; padding: 14px 22px; border-top: 1px solid var(--border); }
.footer-right { display: flex; gap: var(--sp-2); }
.btn-cancel { padding: 8px 14px; 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-save { padding: 8px 18px; 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); }
.btn-save:disabled { opacity: 0.4; }
</style>

View File

@@ -0,0 +1,274 @@
<script lang="ts">
import ImmichPicker from '$lib/components/shared/ImmichPicker.svelte';
let {
entityType,
entityId,
images = [],
documents = [],
onUpload
}: {
entityType: string;
entityId: string;
images: any[];
documents?: any[];
onUpload: () => void;
} = $props();
let uploading = $state(false);
let deletingId = $state('');
let uploadingDoc = $state(false);
let deletingDocId = $state('');
let showImmich = $state(false);
// Image search
let showSearch = $state(false);
let searchQuery = $state('');
let searchResults = $state<{ url: string; thumbnail: string; title: string }[]>([]);
let searching = $state(false);
let savingUrl = $state('');
async function handleFileSelect(e: Event) {
const input = e.target as HTMLInputElement;
if (!input.files?.length || !entityId) return;
uploading = true;
try {
for (const file of input.files) {
const base64 = await new Promise<string>((resolve) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.readAsDataURL(file);
});
await fetch('/api/trips/image/upload', {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ entity_type: entityType, entity_id: entityId, image_data: base64, filename: file.name })
});
}
onUpload();
} catch (e) { console.error('Upload failed:', e); }
finally { uploading = false; input.value = ''; }
}
async function deleteImage(imageId: string) {
deletingId = imageId;
try {
await fetch('/api/trips/image/delete', {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: imageId })
});
onUpload();
} catch { /* silent */ }
finally { deletingId = ''; }
}
async function searchImages() {
if (!searchQuery.trim()) return;
searching = true;
try {
const res = await fetch('/api/trips/image/search', {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: searchQuery })
});
if (res.ok) {
const data = await res.json();
searchResults = data.images || [];
}
} catch { searchResults = []; }
finally { searching = false; }
}
async function saveFromUrl(url: string) {
savingUrl = url;
try {
await fetch('/api/trips/image/upload-from-url', {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ entity_type: entityType, entity_id: entityId, url })
});
onUpload();
showSearch = false; searchResults = [];
} catch { /* silent */ }
finally { savingUrl = ''; }
}
async function handleDocSelect(e: Event) {
const input = e.target as HTMLInputElement;
if (!input.files?.length || !entityId) return;
uploadingDoc = true;
try {
for (const file of input.files) {
const formData = new FormData();
formData.append('entity_type', entityType);
formData.append('entity_id', entityId);
formData.append('file', file);
await fetch('/api/trips/document/upload', {
method: 'POST', credentials: 'include', body: formData
});
}
onUpload();
} catch { /* silent */ }
finally { uploadingDoc = false; input.value = ''; }
}
async function deleteDoc(docId: string) {
deletingDocId = docId;
try {
await fetch('/api/trips/document/delete', {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ document_id: docId })
});
onUpload();
} catch { /* silent */ }
finally { deletingDocId = ''; }
}
async function handleImmichSelect(assetIds: string[]) {
// Download from Immich and upload to trips
for (const assetId of assetIds) {
try {
const imgRes = await fetch(`/api/immich/assets/${assetId}/thumbnail`);
if (!imgRes.ok) continue;
const blob = await imgRes.blob();
const base64 = await new Promise<string>((resolve) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.readAsDataURL(blob);
});
await fetch('/api/trips/image/upload', {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ entity_type: entityType, entity_id: entityId, image_data: base64, filename: `immich-${assetId}.webp` })
});
} catch { /* silent */ }
}
showImmich = false;
onUpload();
}
</script>
<div class="upload-section">
<!-- Existing images -->
{#if images.length > 0}
<div class="image-strip">
{#each images as img}
<div class="image-thumb-wrap">
<img src={img.url || `/images/${img.file_path}`} alt="" class="image-thumb" />
<button class="image-delete" onclick={() => deleteImage(img.id)} disabled={deletingId === img.id}>
{#if deletingId === img.id}...{:else}×{/if}
</button>
</div>
{/each}
</div>
{/if}
<!-- Existing documents -->
{#if documents && documents.length > 0}
<div class="doc-list">
{#each documents as doc}
<div class="doc-row">
<svg class="doc-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/></svg>
<a href={doc.url || `/api/trips/documents/${doc.file_path}`} target="_blank" class="doc-name">{doc.file_name || doc.original_name || 'Document'}</a>
<button class="doc-delete" onclick={() => deleteDoc(doc.id)} disabled={deletingDocId === doc.id}>×</button>
</div>
{/each}
</div>
{/if}
<!-- Action buttons -->
{#if entityId}
<div class="upload-actions">
<label class="upload-btn">
<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>
{uploading ? 'Uploading...' : 'Photo'}
<input type="file" accept="image/*" multiple class="hidden-input" onchange={handleFileSelect} disabled={uploading} />
</label>
<button class="upload-btn" onclick={() => { showSearch = !showSearch; if (showSearch) searchImages(); }}>
<svg 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>
Search
</button>
<button class="upload-btn" onclick={() => showImmich = 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>
Immich
</button>
<label class="upload-btn">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/></svg>
{uploadingDoc ? 'Uploading...' : 'Document'}
<input type="file" accept=".pdf,.doc,.docx,.txt,.jpg,.jpeg,.png" multiple class="hidden-input" onchange={handleDocSelect} disabled={uploadingDoc} />
</label>
</div>
{:else}
<div class="upload-hint">Save first to add photos and documents</div>
{/if}
<!-- Search panel -->
{#if showSearch}
<div class="search-panel">
<div class="search-row">
<input class="search-input" type="text" placeholder="Search images..." bind:value={searchQuery}
onkeydown={(e) => { if (e.key === 'Enter') { e.preventDefault(); searchImages(); } }} />
<button class="search-btn" onclick={searchImages} disabled={searching}>{searching ? '...' : 'Search'}</button>
<button class="search-close" onclick={() => { showSearch = false; searchResults = []; }}>×</button>
</div>
{#if searchResults.length > 0}
<div class="search-grid">
{#each searchResults as result}
<button class="search-thumb" onclick={() => saveFromUrl(result.url)} disabled={savingUrl === result.url}>
<img src={result.thumbnail} alt={result.title} />
{#if savingUrl === result.url}<div class="search-saving">Saving...</div>{/if}
</button>
{/each}
</div>
{/if}
</div>
{/if}
</div>
{#if showImmich}
<ImmichPicker bind:open={showImmich} onselect={handleImmichSelect} />
{/if}
<style>
.upload-section { display: flex; flex-direction: column; gap: 10px; }
.image-strip { display: flex; gap: 6px; overflow-x: auto; padding-bottom: 4px; }
.image-thumb-wrap { position: relative; flex-shrink: 0; }
.image-thumb { width: 96px; height: 72px; object-fit: cover; border-radius: 8px; }
.image-delete {
position: absolute; top: 3px; right: 3px; width: 20px; height: 20px;
border-radius: 50%; background: rgba(0,0,0,0.5); color: white; border: none;
font-size: var(--text-sm); cursor: pointer; display: flex; align-items: center; justify-content: center;
}
.doc-list { display: flex; flex-direction: column; gap: 4px; }
.doc-row { display: flex; align-items: center; gap: 6px; padding: 6px 8px; border-radius: 6px; background: var(--surface-secondary); }
.doc-icon { width: 14px; height: 14px; color: var(--text-4); flex-shrink: 0; }
.doc-name { flex: 1; font-size: var(--text-sm); color: var(--text-2); text-decoration: none; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.doc-name:hover { color: var(--accent); }
.doc-delete { background: none; border: none; color: var(--text-4); cursor: pointer; font-size: var(--text-base); padding: 2px 4px; }
.doc-delete:hover { color: var(--error); }
.upload-actions { display: flex; flex-wrap: wrap; gap: 6px; }
.upload-btn {
display: flex; align-items: center; gap: 4px; padding: 6px 10px;
border-radius: 6px; background: none; 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);
}
.upload-btn:hover { color: var(--text-1); border-color: var(--text-4); }
.upload-btn svg { width: 14px; height: 14px; }
.hidden-input { display: none; }
.upload-hint { font-size: var(--text-sm); color: var(--text-4); text-align: center; padding: 8px; }
.search-panel { border: 1px solid var(--border); border-radius: 8px; padding: 10px; background: var(--surface-secondary); }
.search-row { display: flex; gap: 6px; margin-bottom: 8px; }
.search-input { flex: 1; padding: 6px 10px; border-radius: 6px; border: 1px solid var(--border); background: var(--card); color: var(--text-1); font-size: var(--text-sm); font-family: var(--font); }
.search-input:focus { outline: none; border-color: var(--accent); }
.search-btn { padding: 6px 12px; border-radius: 6px; background: var(--accent); color: white; border: none; font-size: var(--text-sm); font-weight: 600; cursor: pointer; font-family: var(--font); }
.search-close { background: none; border: none; color: var(--text-3); font-size: var(--text-md); cursor: pointer; padding: 4px 8px; }
.search-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 4px; max-height: 160px; overflow-y: auto; }
.search-thumb { position: relative; aspect-ratio: 1; border-radius: 6px; overflow: hidden; border: none; cursor: pointer; padding: 0; background: var(--card-hover); }
.search-thumb img { width: 100%; height: 100%; object-fit: cover; }
.search-saving { position: absolute; inset: 0; background: rgba(0,0,0,0.5); color: white; display: flex; align-items: center; justify-content: center; font-size: var(--text-xs); }
</style>

View File

@@ -0,0 +1,368 @@
<script lang="ts">
import PlacesAutocomplete from './PlacesAutocomplete.svelte';
import ImageUpload from './ImageUpload.svelte';
type ItemType = 'transportation' | 'lodging' | 'location' | 'note';
let {
open = $bindable(false),
tripId,
itemType = 'location',
editItem = null,
onSaved
}: {
open: boolean;
tripId: string;
itemType: ItemType;
editItem?: any;
onSaved: () => void;
} = $props();
let saving = $state(false);
let confirmDelete = $state(false);
// ── Form state ──
let name = $state('');
let description = $state('');
let category = $state('');
let date = $state('');
let endDate = $state('');
let startTime = $state('');
let endTime = $state('');
let flightNumber = $state('');
let fromLocation = $state('');
let toLocation = $state('');
let reservationNumber = $state('');
let link = $state('');
let costPoints = $state(0);
let costCash = $state(0);
let address = $state('');
let placeId = $state('');
let latitude = $state<number | null>(null);
let longitude = $state<number | null>(null);
let hikeDistance = $state('');
let hikeDifficulty = $state('');
let hikeTime = $state('');
let transportType = $state('plane');
let lodgingType = $state('hotel');
let content = $state('');
let isEdit = $derived(!!editItem?.id);
const locationCategories = ['restaurant', 'cafe', 'bar', 'attraction', 'hike', 'shopping', 'beach', 'museum', 'park'];
const transportTypes = ['plane', 'train', 'car', 'bus', 'ferry'];
const lodgingTypes = ['hotel', 'airbnb', 'hostel', 'resort', 'camping'];
$effect(() => {
if (open && editItem) {
name = editItem.name || '';
description = editItem.description || '';
date = editItem.date || editItem.visit_date || editItem.check_in?.slice(0, 10) || '';
endDate = editItem.end_date || editItem.check_out?.slice(0, 10) || '';
startTime = editItem.start_time || editItem.date || '';
endTime = editItem.end_time || editItem.end_date || '';
category = editItem.category || '';
flightNumber = editItem.flight_number || '';
fromLocation = editItem.from_location || '';
toLocation = editItem.to_location || '';
reservationNumber = editItem.reservation_number || '';
link = editItem.link || '';
costPoints = editItem.cost_points || 0;
costCash = editItem.cost_cash || 0;
address = editItem.address || editItem.location || '';
placeId = editItem.place_id || '';
latitude = editItem.latitude || editItem.from_lat || null;
longitude = editItem.longitude || editItem.from_lng || null;
hikeDistance = editItem.hike_distance || '';
hikeDifficulty = editItem.hike_difficulty || '';
hikeTime = editItem.hike_time || '';
transportType = editItem.type || 'plane';
lodgingType = editItem.type || 'hotel';
content = editItem.content || '';
} else if (open) {
resetForm();
}
confirmDelete = false;
});
function resetForm() {
name = ''; description = ''; category = ''; date = ''; endDate = '';
startTime = ''; endTime = ''; flightNumber = ''; fromLocation = '';
toLocation = ''; reservationNumber = ''; link = ''; costPoints = 0;
costCash = 0; address = ''; placeId = ''; latitude = null; longitude = null;
hikeDistance = ''; hikeDifficulty = ''; hikeTime = '';
transportType = 'plane'; lodgingType = 'hotel'; content = '';
}
function close() { open = false; resetForm(); confirmDelete = false; }
function handlePlaceSelect(details: any) {
if (details.name) name = details.name;
address = details.address || '';
placeId = details.place_id || '';
latitude = details.latitude;
longitude = details.longitude;
if (details.category && !category) category = details.category;
if (itemType === 'lodging') address = details.address || details.name || '';
}
async function save() {
saving = true;
try {
let endpoint: string;
let payload: Record<string, any> = { trip_id: tripId };
if (itemType === 'transportation') {
payload = { ...payload, name, description, type: transportType, flight_number: flightNumber, from_location: fromLocation, to_location: toLocation, date: startTime || date, end_date: endTime || endDate, link, cost_points: costPoints, cost_cash: costCash, from_place_id: placeId, from_lat: latitude, from_lng: longitude };
endpoint = isEdit ? '/api/trips/transportation/update' : '/api/trips/transportation';
} else if (itemType === 'lodging') {
payload = { ...payload, name, description, type: lodgingType, location: address, check_in: date, check_out: endDate, reservation_number: reservationNumber, link, cost_points: costPoints, cost_cash: costCash, place_id: placeId, latitude, longitude };
endpoint = isEdit ? '/api/trips/lodging/update' : '/api/trips/lodging';
} else if (itemType === 'note') {
payload = { ...payload, name, content: description || content, date };
endpoint = isEdit ? '/api/trips/note/update' : '/api/trips/note';
} else {
payload = { ...payload, name, description, category, visit_date: date, start_time: startTime, end_time: endTime, link, cost_points: costPoints, cost_cash: costCash, address, place_id: placeId, latitude, longitude, hike_distance: hikeDistance, hike_difficulty: hikeDifficulty, hike_time: hikeTime };
endpoint = isEdit ? '/api/trips/location/update' : '/api/trips/location';
}
if (isEdit) payload.id = editItem.id;
await fetch(endpoint, {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
close();
onSaved();
} catch (e) { console.error('Save failed:', e); }
finally { saving = false; }
}
async function doDelete() {
if (!isEdit) return;
saving = true;
try {
await fetch(`/api/trips/${itemType}/delete`, {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: editItem.id })
});
close();
onSaved();
} catch (e) { console.error('Delete failed:', e); }
finally { saving = false; }
}
const titles: Record<ItemType, string> = {
transportation: 'Transportation',
lodging: 'Accommodation',
location: 'Activity',
note: 'Note'
};
</script>
{#if open}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="modal-overlay" onclick={close}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="modal-sheet" onclick={(e) => e.stopPropagation()}>
<div class="modal-header">
<div class="modal-title">{isEdit ? 'Edit' : 'Add'} {titles[itemType]}</div>
<button class="modal-close" onclick={close}>
<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>
<div class="modal-body">
<!-- Name (with Places search for location & lodging) -->
{#if itemType === 'location' || itemType === 'lodging'}
<div class="field">
<label class="field-label">Name</label>
<PlacesAutocomplete bind:value={name} placeholder={itemType === 'lodging' ? 'Search hotel or address...' : 'Search place or type name...'} onSelect={handlePlaceSelect} />
</div>
{:else}
<div class="field">
<label class="field-label">Name</label>
<input class="field-input" type="text" bind:value={name} placeholder={itemType === 'note' ? 'Note title' : 'Name'} />
</div>
{/if}
<!-- Type selectors -->
{#if itemType === 'transportation'}
<div class="field">
<label class="field-label">Type</label>
<select class="field-input" bind:value={transportType}>
{#each transportTypes as t}<option value={t}>{t.charAt(0).toUpperCase() + t.slice(1)}</option>{/each}
</select>
</div>
{#if transportType === 'plane'}
<div class="field">
<label class="field-label">Flight Number</label>
<input class="field-input" type="text" bind:value={flightNumber} placeholder="UA 2341" />
</div>
{/if}
<div class="field-row">
<div class="field"><label class="field-label">From</label><input class="field-input" type="text" bind:value={fromLocation} /></div>
<div class="field"><label class="field-label">To</label><input class="field-input" type="text" bind:value={toLocation} /></div>
</div>
<div class="field-row">
<div class="field"><label class="field-label">Departure</label><input class="field-input" type="datetime-local" bind:value={startTime} /></div>
<div class="field"><label class="field-label">Arrival</label><input class="field-input" type="datetime-local" bind:value={endTime} /></div>
</div>
{:else if itemType === 'lodging'}
<div class="field">
<label class="field-label">Type</label>
<select class="field-input" bind:value={lodgingType}>
{#each lodgingTypes as t}<option value={t}>{t.charAt(0).toUpperCase() + t.slice(1)}</option>{/each}
</select>
</div>
<div class="field-row">
<div class="field"><label class="field-label">Check-in</label><input class="field-input" type="date" bind:value={date} /></div>
<div class="field"><label class="field-label">Check-out</label><input class="field-input" type="date" bind:value={endDate} /></div>
</div>
<div class="field">
<label class="field-label">Reservation #</label>
<input class="field-input" type="text" bind:value={reservationNumber} />
</div>
{:else if itemType === 'location'}
<div class="field">
<label class="field-label">Category</label>
<select class="field-input" bind:value={category}>
<option value="">Select...</option>
{#each locationCategories as c}<option value={c}>{c.charAt(0).toUpperCase() + c.slice(1)}</option>{/each}
</select>
</div>
<div class="field">
<label class="field-label">Visit Date</label>
<input class="field-input" type="date" bind:value={date} />
</div>
<div class="field-row">
<div class="field"><label class="field-label">Start Time</label><input class="field-input" type="datetime-local" bind:value={startTime} /></div>
<div class="field"><label class="field-label">End Time</label><input class="field-input" type="datetime-local" bind:value={endTime} /></div>
</div>
{#if category === 'hike'}
<div class="field-row">
<div class="field"><label class="field-label">Distance</label><input class="field-input" type="text" bind:value={hikeDistance} placeholder="5.2 miles" /></div>
<div class="field">
<label class="field-label">Difficulty</label>
<select class="field-input" bind:value={hikeDifficulty}>
<option value="">Select...</option>
<option value="easy">Easy</option>
<option value="moderate">Moderate</option>
<option value="hard">Hard</option>
<option value="strenuous">Strenuous</option>
</select>
</div>
<div class="field"><label class="field-label">Duration</label><input class="field-input" type="text" bind:value={hikeTime} placeholder="3 hours" /></div>
</div>
{/if}
{:else if itemType === 'note'}
<div class="field">
<label class="field-label">Date</label>
<input class="field-input" type="date" bind:value={date} />
</div>
{/if}
<!-- Description / Content -->
{#if itemType === 'note'}
<div class="field">
<label class="field-label">Content</label>
<textarea class="field-input field-textarea" bind:value={content} rows="6" placeholder="Write your note..."></textarea>
</div>
{:else}
<div class="field">
<label class="field-label">Description</label>
<textarea class="field-input field-textarea" bind:value={description} rows="3" placeholder="Details..."></textarea>
</div>
{/if}
<!-- Images & Documents (edit mode only) -->
{#if isEdit && itemType !== 'note'}
<div class="field">
<label class="field-label">Photos & Documents</label>
<ImageUpload
entityType={itemType}
entityId={editItem.id}
images={(editItem.images || []).map((i: any) => ({ ...i, url: `/images/${i.file_path}` }))}
documents={(editItem.documents || []).map((d: any) => ({ ...d, url: `/api/trips/documents/${d.file_path}` }))}
onUpload={onSaved}
/>
</div>
{/if}
<!-- Link -->
{#if itemType !== 'note'}
<div class="field">
<label class="field-label">Link</label>
<input class="field-input" type="url" bind:value={link} placeholder="https://..." />
</div>
<div class="field-row">
<div class="field"><label class="field-label">Points</label><input class="field-input" type="number" bind:value={costPoints} /></div>
<div class="field"><label class="field-label">Cash ($)</label><input class="field-input" type="number" step="0.01" bind:value={costCash} /></div>
</div>
{/if}
</div>
<div class="modal-footer">
{#if isEdit}
{#if confirmDelete}
<div class="delete-confirm">
<span class="delete-msg">Delete this item?</span>
<button class="btn-danger" onclick={doDelete} disabled={saving}>Yes, delete</button>
<button class="btn-cancel" onclick={() => confirmDelete = false}>Cancel</button>
</div>
{:else}
<button class="btn-delete" onclick={() => confirmDelete = true}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
</button>
{/if}
{:else}
<div></div>
{/if}
<div class="footer-right">
<button class="btn-cancel" onclick={close}>Cancel</button>
<button class="btn-save" onclick={save} disabled={saving || !name.trim()}>
{saving ? 'Saving...' : isEdit ? 'Save' : 'Add'}
</button>
</div>
</div>
</div>
</div>
{/if}
<style>
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.35); z-index: 70; display: flex; justify-content: flex-end; animation: modalFade 150ms ease; }
@keyframes modalFade { from { opacity: 0; } to { opacity: 1; } }
.modal-sheet { width: 480px; max-width: 100%; height: 100%; background: var(--surface); display: flex; flex-direction: column; box-shadow: -8px 0 32px rgba(0,0,0,0.1); animation: modalSlide 200ms ease; }
@keyframes modalSlide { from { transform: translateX(100%); } to { transform: translateX(0); } }
.modal-header { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-4) var(--sp-5); border-bottom: 1px solid var(--border); }
.modal-title { font-size: var(--text-md); font-weight: 600; color: var(--text-1); }
.modal-close { background: none; border: none; cursor: pointer; color: var(--text-3); padding: var(--sp-1); border-radius: var(--radius-sm); }
.modal-close:hover { color: var(--text-1); background: var(--card-hover); }
.modal-close svg { width: 18px; height: 18px; }
.modal-body { flex: 1; overflow-y: auto; padding: var(--sp-5); display: flex; flex-direction: column; gap: 14px; }
.field { display: flex; flex-direction: column; gap: var(--sp-1); }
.field-label { font-size: var(--text-sm); font-weight: 500; color: var(--text-3); text-transform: uppercase; letter-spacing: 0.04em; }
.field-input { padding: 10px 12px; border-radius: var(--radius-md); border: 1px solid var(--border); background: var(--surface-secondary); color: var(--text-1); font-size: var(--text-base); font-family: var(--font); }
.field-input:focus { outline: none; border-color: var(--accent); }
.field-textarea { resize: vertical; min-height: 60px; }
.field-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 10px; }
.modal-footer { display: flex; align-items: center; justify-content: space-between; padding: 14px 20px; border-top: 1px solid var(--border); }
.footer-right { display: flex; gap: var(--sp-2); }
.btn-cancel { padding: 8px 14px; 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-save { 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); }
.btn-save:disabled { opacity: 0.4; cursor: default; }
.btn-delete { width: 34px; height: 34px; border-radius: var(--radius-md); background: none; border: 1px solid var(--error); color: var(--error); display: flex; align-items: center; justify-content: center; cursor: pointer; }
.btn-delete:hover { background: var(--error-bg); }
.btn-delete svg { width: 16px; height: 16px; }
.delete-confirm { display: flex; align-items: center; gap: var(--sp-2); }
.delete-msg { font-size: var(--text-sm); color: var(--error); font-weight: 500; }
.btn-danger { padding: 6px var(--sp-3); border-radius: var(--radius-sm); background: var(--error); color: white; border: none; font-size: var(--text-sm); font-weight: 600; cursor: pointer; font-family: var(--font); }
@media (max-width: 768px) { .modal-sheet { width: 100%; } }
</style>

View File

@@ -0,0 +1,95 @@
<script lang="ts">
interface Prediction {
place_id: string;
name: string;
address: string;
types: string[];
}
let {
value = $bindable(''),
placeholder = 'Search for a place...',
onSelect
}: {
value: string;
placeholder?: string;
onSelect: (details: { place_id: string; name: string; address: string; latitude: number | null; longitude: number | null; category: string }) => void;
} = $props();
let predictions = $state<Prediction[]>([]);
let showDropdown = $state(false);
let debounceTimer: ReturnType<typeof setTimeout>;
async function search(query: string) {
if (query.length < 2) { predictions = []; showDropdown = false; return; }
try {
const res = await fetch('/api/trips/places/autocomplete', {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query })
});
if (res.ok) {
const data = await res.json();
predictions = data.predictions || [];
showDropdown = predictions.length > 0;
}
} catch { predictions = []; }
}
function onInput() {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => search(value), 250);
}
async function selectPlace(pred: Prediction) {
showDropdown = false;
value = pred.name;
try {
const res = await fetch(`/api/trips/places/details?place_id=${encodeURIComponent(pred.place_id)}`, { credentials: 'include' });
const details = await res.json();
onSelect({ ...details, place_id: pred.place_id });
} catch {
onSelect({ place_id: pred.place_id, name: pred.name, address: pred.address, latitude: null, longitude: null, category: '' });
}
}
</script>
<div class="places-wrap">
<input type="text" class="places-input" {placeholder} bind:value oninput={onInput}
onfocus={() => { if (predictions.length > 0) showDropdown = true; }}
onblur={() => setTimeout(() => showDropdown = false, 200)} />
{#if showDropdown}
<div class="places-dropdown">
{#each predictions as pred}
<button class="places-option" onmousedown={() => selectPlace(pred)}>
<span class="places-name">{pred.name}</span>
{#if pred.address}<span class="places-addr">{pred.address}</span>{/if}
</button>
{/each}
</div>
{/if}
</div>
<style>
.places-wrap { position: relative; }
.places-input {
width: 100%; padding: 10px 12px; border-radius: var(--radius-md);
border: 1px solid var(--border); background: var(--surface-secondary);
color: var(--text-1); font-size: var(--text-base); font-family: var(--font);
}
.places-input:focus { outline: none; border-color: var(--accent); }
.places-dropdown {
position: absolute; top: 100%; left: 0; right: 0; margin-top: var(--sp-1);
background: var(--card); border: 1px solid var(--border); border-radius: 10px;
box-shadow: var(--card-shadow); z-index: 50; max-height: 200px; overflow-y: auto;
}
.places-option {
display: flex; flex-direction: column; width: 100%; padding: 10px 14px;
background: none; border: none; border-bottom: 1px solid var(--border);
text-align: left; cursor: pointer; transition: background var(--transition); font-family: var(--font);
}
.places-option:last-child { border-bottom: none; }
.places-option:hover { background: var(--card-hover); }
.places-name { font-size: var(--text-sm); font-weight: 500; color: var(--text-1); }
.places-addr { font-size: var(--text-xs); color: var(--text-3); margin-top: 1px; }
</style>

View File

@@ -0,0 +1,193 @@
<script lang="ts">
let {
open = $bindable(false),
tripData,
onSaved
}: {
open: boolean;
tripData: any;
onSaved: () => void;
} = $props();
let saving = $state(false);
let confirmDelete = $state(false);
let sharing = $state(false);
let shareUrl = $state('');
let copied = $state(false);
let name = $state('');
let description = $state('');
let startDate = $state('');
let endDate = $state('');
$effect(() => {
if (open && tripData) {
name = tripData.name || '';
description = tripData.description || '';
startDate = tripData.start_date || '';
endDate = tripData.end_date || '';
shareUrl = tripData.share_token ? `${typeof window !== 'undefined' ? window.location.origin : ''}/trips/view/${tripData.share_token}` : '';
confirmDelete = false;
copied = false;
}
});
function close() { open = false; }
async function save() {
saving = true;
try {
await fetch('/api/trips/trip/update', {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: tripData.id, name, description, start_date: startDate, end_date: endDate })
});
close();
onSaved();
} catch (e) { console.error('Save failed:', e); }
finally { saving = false; }
}
async function doDelete() {
saving = true;
try {
await fetch('/api/trips/trip/delete', {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: tripData.id })
});
window.location.href = '/trips';
} catch (e) { console.error('Delete failed:', e); }
finally { saving = false; }
}
async function toggleShare() {
sharing = true;
try {
if (shareUrl) {
await fetch('/api/trips/share/delete', {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ trip_id: tripData.id })
});
shareUrl = '';
} else {
const res = await fetch('/api/trips/share/create', {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ trip_id: tripData.id })
});
const data = await res.json();
shareUrl = `${window.location.origin}/trips/view/${data.share_token}`;
}
} catch { /* silent */ }
finally { sharing = false; }
}
async function copyUrl() {
await navigator.clipboard.writeText(shareUrl);
copied = true;
setTimeout(() => copied = false, 2000);
}
</script>
{#if open}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="modal-overlay" onclick={close}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="modal-sheet" onclick={(e) => e.stopPropagation()}>
<div class="modal-header">
<div class="modal-title">Edit Trip</div>
<button class="modal-close" onclick={close}>
<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>
<div class="modal-body">
<div class="field">
<label class="field-label">Trip Name</label>
<input class="field-input" type="text" bind:value={name} />
</div>
<div class="field">
<label class="field-label">Description</label>
<textarea class="field-input field-textarea" bind:value={description} rows="3"></textarea>
</div>
<div class="field-row">
<div class="field"><label class="field-label">Start Date</label><input class="field-input" type="date" bind:value={startDate} /></div>
<div class="field"><label class="field-label">End Date</label><input class="field-input" type="date" bind:value={endDate} /></div>
</div>
<!-- Sharing -->
<div class="share-section">
<div class="share-header">
<span class="field-label">Sharing</span>
<button class="share-toggle" onclick={toggleShare} disabled={sharing}>
{shareUrl ? 'Revoke Link' : 'Create Share Link'}
</button>
</div>
{#if shareUrl}
<div class="share-link-row">
<input class="field-input share-url" type="text" readonly value={shareUrl} />
<button class="copy-btn" onclick={copyUrl}>{copied ? 'Copied!' : 'Copy'}</button>
</div>
{/if}
</div>
</div>
<div class="modal-footer">
{#if confirmDelete}
<div class="delete-confirm">
<span class="delete-msg">Delete this trip permanently?</span>
<button class="btn-danger" onclick={doDelete} disabled={saving}>Yes, delete</button>
<button class="btn-cancel" onclick={() => confirmDelete = false}>Cancel</button>
</div>
{:else}
<button class="btn-delete-text" onclick={() => confirmDelete = true}>Delete Trip</button>
{/if}
<div class="footer-right">
<button class="btn-cancel" onclick={close}>Cancel</button>
<button class="btn-save" onclick={save} disabled={saving || !name.trim()}>
{saving ? 'Saving...' : 'Save'}
</button>
</div>
</div>
</div>
</div>
{/if}
<style>
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.35); z-index: 70; display: flex; justify-content: flex-end; animation: modalFade 150ms ease; }
@keyframes modalFade { from { opacity: 0; } to { opacity: 1; } }
.modal-sheet { width: 480px; max-width: 100%; height: 100%; background: var(--surface); display: flex; flex-direction: column; box-shadow: -8px 0 32px rgba(0,0,0,0.1); animation: modalSlide 200ms ease; }
@keyframes modalSlide { from { transform: translateX(100%); } to { transform: translateX(0); } }
.modal-header { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-4) var(--sp-5); border-bottom: 1px solid var(--border); }
.modal-title { font-size: var(--text-md); font-weight: 600; color: var(--text-1); }
.modal-close { background: none; border: none; cursor: pointer; color: var(--text-3); padding: var(--sp-1); border-radius: var(--radius-sm); }
.modal-close:hover { color: var(--text-1); background: var(--card-hover); }
.modal-close svg { width: 18px; height: 18px; }
.modal-body { flex: 1; overflow-y: auto; padding: var(--sp-5); display: flex; flex-direction: column; gap: 14px; }
.field { display: flex; flex-direction: column; gap: var(--sp-1); }
.field-label { font-size: var(--text-sm); font-weight: 500; color: var(--text-3); text-transform: uppercase; letter-spacing: 0.04em; }
.field-input { padding: 10px 12px; border-radius: var(--radius-md); border: 1px solid var(--border); background: var(--surface-secondary); color: var(--text-1); font-size: var(--text-base); font-family: var(--font); }
.field-input:focus { outline: none; border-color: var(--accent); }
.field-textarea { resize: vertical; min-height: 60px; }
.field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.share-section { border-top: 1px solid var(--border); padding-top: 14px; margin-top: var(--sp-1); }
.share-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: var(--sp-2); }
.share-toggle { font-size: var(--text-sm); font-weight: 500; color: var(--accent); background: none; border: none; cursor: pointer; font-family: var(--font); }
.share-toggle:hover { opacity: 0.7; }
.share-link-row { display: flex; gap: var(--sp-2); }
.share-url { flex: 1; font-size: var(--text-sm); font-family: var(--mono); }
.copy-btn { padding: 8px 14px; 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); white-space: nowrap; }
.modal-footer { display: flex; align-items: center; justify-content: space-between; padding: 14px var(--sp-5); border-top: 1px solid var(--border); }
.footer-right { display: flex; gap: var(--sp-2); }
.btn-cancel { padding: 8px 14px; 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-save { 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); }
.btn-save:disabled { opacity: 0.4; }
.btn-delete-text { font-size: var(--text-sm); color: var(--error); background: none; border: none; cursor: pointer; font-weight: 500; font-family: var(--font); }
.btn-delete-text:hover { opacity: 0.7; }
.delete-confirm { display: flex; align-items: center; gap: var(--sp-2); }
.delete-msg { font-size: var(--text-sm); color: var(--error); font-weight: 500; }
.btn-danger { padding: 6px var(--sp-3); border-radius: var(--radius-sm); background: var(--error); color: white; border: none; font-size: var(--text-sm); font-weight: 600; cursor: pointer; font-family: var(--font); }
@media (max-width: 768px) { .modal-sheet { width: 100%; } }
</style>

View File

@@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.

View File

@@ -0,0 +1,26 @@
let theme = $state<'light' | 'dark'>('light');
export function initTheme() {
if (typeof window === 'undefined') return;
const stored = localStorage.getItem('theme') as 'light' | 'dark' | null;
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
theme = stored ?? (prefersDark ? 'dark' : 'light');
applyTheme();
}
function applyTheme() {
if (typeof document === 'undefined') return;
const root = document.documentElement;
root.classList.remove('light', 'dark');
root.classList.add(theme);
}
export function toggleTheme() {
theme = theme === 'light' ? 'dark' : 'light';
localStorage.setItem('theme', theme);
applyTheme();
}
export function isDark(): boolean {
return theme === 'dark';
}

View File

@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}