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,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>