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:
17
services/trips/frontend-legacy/src/routes/+layout.svelte
Normal file
17
services/trips/frontend-legacy/src/routes/+layout.svelte
Normal file
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import '../app.css';
|
||||
import Navbar from '$lib/components/Navbar.svelte';
|
||||
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Trips</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="min-h-screen bg-base-100 flex flex-col">
|
||||
<Navbar />
|
||||
<main class="flex-1">
|
||||
{@render children()}
|
||||
</main>
|
||||
</div>
|
||||
627
services/trips/frontend-legacy/src/routes/+page.svelte
Normal file
627
services/trips/frontend-legacy/src/routes/+page.svelte
Normal file
@@ -0,0 +1,627 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { get, post } from '$lib/api/client';
|
||||
import type { Trip } from '$lib/api/types';
|
||||
import TripCard from '$lib/components/TripCard.svelte';
|
||||
import StatsBar from '$lib/components/StatsBar.svelte';
|
||||
|
||||
interface TripsResponse {
|
||||
trips: Trip[];
|
||||
}
|
||||
|
||||
interface SearchResult {
|
||||
type: string;
|
||||
id: string;
|
||||
trip_id: string;
|
||||
name: string;
|
||||
detail: string;
|
||||
trip_name: string;
|
||||
}
|
||||
|
||||
let trips = $state<Trip[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state('');
|
||||
let searchQuery = $state('');
|
||||
let searchResults = $state<SearchResult[]>([]);
|
||||
let showSearchResults = $state(false);
|
||||
let searchDebounce: ReturnType<typeof setTimeout>;
|
||||
|
||||
// New trip modal
|
||||
let newTripModal = $state<HTMLDialogElement | null>(null);
|
||||
let newTrip = $state({ name: '', description: '', start_date: '', end_date: '' });
|
||||
let creating = $state(false);
|
||||
|
||||
function typeIcon(type: string): string {
|
||||
switch (type) {
|
||||
case 'trip': return 'M3 7l6-3 6 3 6-3v13l-6 3-6-3-6 3V7z';
|
||||
case 'location': return 'M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z';
|
||||
case 'lodging': return 'M3 14h18v7H3zM3 7v7M21 7v7M3 7l9-4 9 4';
|
||||
case 'transportation': return 'M22 16.21v-1.895L14 8.42V4a2 2 0 1 0-4 0v4.42L2 14.315v1.895l8-2.526V18l-2 1.5V21l4-1 4 1v-1.5L16 18v-4.316z';
|
||||
case 'note': return 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z';
|
||||
default: return 'M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z';
|
||||
}
|
||||
}
|
||||
|
||||
function typeLabel(type: string): string {
|
||||
switch (type) {
|
||||
case 'trip': return 'Trip';
|
||||
case 'location': return 'Activity';
|
||||
case 'lodging': return 'Hotel';
|
||||
case 'transportation': return 'Flight';
|
||||
case 'note': return 'Note';
|
||||
default: return type;
|
||||
}
|
||||
}
|
||||
|
||||
function typeBadgeClass(type: string): string {
|
||||
switch (type) {
|
||||
case 'trip': return 'badge-primary';
|
||||
case 'location': return 'badge-accent';
|
||||
case 'lodging': return 'badge-secondary';
|
||||
case 'transportation': return 'badge-info';
|
||||
case 'note': return 'badge-warning';
|
||||
default: return 'badge-ghost';
|
||||
}
|
||||
}
|
||||
|
||||
async function doSearch(query: string) {
|
||||
if (query.length < 2) {
|
||||
searchResults = [];
|
||||
showSearchResults = false;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const data = await get<{ results: SearchResult[] }>(`/api/search?q=${encodeURIComponent(query)}`);
|
||||
searchResults = data.results;
|
||||
showSearchResults = true;
|
||||
} catch {
|
||||
searchResults = [];
|
||||
}
|
||||
}
|
||||
|
||||
function onSearchInput() {
|
||||
clearTimeout(searchDebounce);
|
||||
searchDebounce = setTimeout(() => doSearch(searchQuery), 250);
|
||||
}
|
||||
|
||||
function selectResult(result: SearchResult) {
|
||||
showSearchResults = false;
|
||||
searchQuery = '';
|
||||
goto(`/trip/${result.trip_id}`);
|
||||
}
|
||||
|
||||
// Separate trips into categories
|
||||
let activeTrips = $derived(trips.filter(t => {
|
||||
const now = new Date(); now.setHours(0,0,0,0);
|
||||
const start = t.start_date ? new Date(t.start_date + 'T00:00:00') : null;
|
||||
const end = t.end_date ? new Date(t.end_date + 'T00:00:00') : null;
|
||||
return start && end && now >= start && now <= end;
|
||||
}));
|
||||
|
||||
let upcomingTrips = $derived(trips.filter(t => {
|
||||
const now = new Date(); now.setHours(0,0,0,0);
|
||||
const start = t.start_date ? new Date(t.start_date + 'T00:00:00') : null;
|
||||
return start && now < start;
|
||||
}).sort((a, b) => a.start_date.localeCompare(b.start_date)));
|
||||
|
||||
let pastTrips = $derived(trips.filter(t => {
|
||||
const now = new Date(); now.setHours(0,0,0,0);
|
||||
const end = t.end_date ? new Date(t.end_date + 'T00:00:00') : null;
|
||||
return end && now > end;
|
||||
}).sort((a, b) => b.start_date.localeCompare(a.start_date)));
|
||||
|
||||
// Next upcoming trip countdown
|
||||
let nextTrip = $derived(upcomingTrips.length > 0 ? upcomingTrips[0] : null);
|
||||
let daysUntilNext = $derived(() => {
|
||||
if (!nextTrip?.start_date) return 0;
|
||||
const now = new Date(); now.setHours(0,0,0,0);
|
||||
const start = new Date(nextTrip.start_date + 'T00:00:00');
|
||||
return Math.ceil((start.getTime() - now.getTime()) / 86400000);
|
||||
});
|
||||
|
||||
function formatDateShort(d: string): string {
|
||||
if (!d) return '';
|
||||
return new Date(d + 'T00:00:00').toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
loadTrips();
|
||||
});
|
||||
|
||||
async function loadTrips() {
|
||||
try {
|
||||
const data = await get<TripsResponse>('/api/trips');
|
||||
trips = data.trips;
|
||||
} catch (e) {
|
||||
error = 'Failed to load trips';
|
||||
console.error(e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
let quickInput = $state('');
|
||||
let quickParsed = $state(false);
|
||||
let descLookupTimer: ReturnType<typeof setTimeout>;
|
||||
let lastLookedUpName = '';
|
||||
|
||||
async function lookupDescription(name: string) {
|
||||
if (!name || name.length < 2 || name === lastLookedUpName) return;
|
||||
lastLookedUpName = name;
|
||||
try {
|
||||
const data = await post<{ predictions: Array<{ name: string; address: string }> }>(
|
||||
'/api/places/autocomplete', { query: name }
|
||||
);
|
||||
if (data.predictions?.length > 0) {
|
||||
const place = data.predictions[0];
|
||||
const addr = place.address || '';
|
||||
if (addr && !newTrip.description) {
|
||||
newTrip.description = `Trip to ${place.name}${addr ? ', ' + addr : ''}`;
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
function parseQuickInput(input: string) {
|
||||
if (!input.trim()) {
|
||||
quickParsed = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const text = input.trim();
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
// Month name mapping
|
||||
const months: Record<string, string> = {
|
||||
jan: '01', january: '01', feb: '02', february: '02', mar: '03', march: '03',
|
||||
apr: '04', april: '04', may: '05', jun: '06', june: '06',
|
||||
jul: '07', july: '07', aug: '08', august: '08', sep: '09', september: '09',
|
||||
oct: '10', october: '10', nov: '11', november: '11', dec: '12', december: '12'
|
||||
};
|
||||
|
||||
function parseDate(str: string, refYear?: number): string | null {
|
||||
str = str.trim().replace(/,/g, '');
|
||||
|
||||
// ISO format: 2026-10-01
|
||||
const isoMatch = str.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/);
|
||||
if (isoMatch) return `${isoMatch[1]}-${isoMatch[2].padStart(2, '0')}-${isoMatch[3].padStart(2, '0')}`;
|
||||
|
||||
// MM/DD/YYYY or MM/DD/YY or MM/DD
|
||||
const slashMatch = str.match(/^(\d{1,2})\/(\d{1,2})(?:\/(\d{2,4}))?$/);
|
||||
if (slashMatch) {
|
||||
const y = slashMatch[3] ? (slashMatch[3].length === 2 ? '20' + slashMatch[3] : slashMatch[3]) : String(refYear || currentYear);
|
||||
return `${y}-${slashMatch[1].padStart(2, '0')}-${slashMatch[2].padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// "Oct 1" or "October 1" or "Oct 1 2026"
|
||||
const monthNameMatch = str.match(/^([a-z]+)\s+(\d{1,2})(?:\s+(\d{4}))?$/i);
|
||||
if (monthNameMatch) {
|
||||
const m = months[monthNameMatch[1].toLowerCase()];
|
||||
if (m) {
|
||||
const y = monthNameMatch[3] || String(refYear || currentYear);
|
||||
return `${y}-${m}-${monthNameMatch[2].padStart(2, '0')}`;
|
||||
}
|
||||
}
|
||||
|
||||
// "1 Oct" or "1 October 2026"
|
||||
const dayFirstMatch = str.match(/^(\d{1,2})\s+([a-z]+)(?:\s+(\d{4}))?$/i);
|
||||
if (dayFirstMatch) {
|
||||
const m = months[dayFirstMatch[2].toLowerCase()];
|
||||
if (m) {
|
||||
const y = dayFirstMatch[3] || String(refYear || currentYear);
|
||||
return `${y}-${m}-${dayFirstMatch[1].padStart(2, '0')}`;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Try splitting by common separators: "to", "-", "–", "through"
|
||||
const separators = [/\s+to\s+/i, /\s*[-–—]\s*(?=\d|[a-z])/i, /\s+through\s+/i];
|
||||
let name = '';
|
||||
let startStr = '';
|
||||
let endStr = '';
|
||||
let description = '';
|
||||
let matched = false;
|
||||
|
||||
// Date patterns to look for
|
||||
const datePatterns = [
|
||||
/(\d{1,2}\/\d{1,2}(?:\/\d{2,4})?)/,
|
||||
/(\d{4}-\d{1,2}-\d{1,2})/,
|
||||
/([A-Za-z]+\s+\d{1,2}(?:\s+\d{4})?)/,
|
||||
/(\d{1,2}\s+[A-Za-z]+(?:\s+\d{4})?)/,
|
||||
];
|
||||
|
||||
for (const sep of separators) {
|
||||
const parts = text.split(sep);
|
||||
if (parts.length >= 2) {
|
||||
const beforeSep = parts.slice(0, -1).join(' ').trim();
|
||||
const afterSep = parts[parts.length - 1].trim();
|
||||
|
||||
// Find start date in beforeSep (from right)
|
||||
for (const dp of datePatterns) {
|
||||
// Anchor to end for start date search
|
||||
const anchored = new RegExp(dp.source + '\\s*$');
|
||||
const dm = beforeSep.match(anchored);
|
||||
if (dm && dm.index !== undefined) {
|
||||
name = beforeSep.slice(0, dm.index).trim();
|
||||
startStr = dm[1];
|
||||
|
||||
// afterSep may be "1/10/27 Umrah and Maldives trip" or just "1/10/27"
|
||||
// Try to extract end date from the beginning of afterSep
|
||||
for (const edp of datePatterns) {
|
||||
const anchStart = new RegExp('^' + edp.source);
|
||||
const em = afterSep.match(anchStart);
|
||||
if (em) {
|
||||
endStr = em[1];
|
||||
// Everything after the end date is the description
|
||||
const remainder = afterSep.slice(em[0].length).trim();
|
||||
if (remainder) {
|
||||
description = remainder;
|
||||
}
|
||||
matched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!matched) {
|
||||
// No date pattern found — treat entire afterSep as end date
|
||||
endStr = afterSep;
|
||||
matched = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (matched) break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!matched) {
|
||||
// No date range found — just use as name
|
||||
newTrip.name = text;
|
||||
quickParsed = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse the dates
|
||||
const startDate = parseDate(startStr);
|
||||
const startYear = startDate ? parseInt(startDate.split('-')[0]) : currentYear;
|
||||
const endDate = parseDate(endStr, startYear);
|
||||
|
||||
if (name) newTrip.name = name;
|
||||
if (description) newTrip.description = description;
|
||||
if (startDate) newTrip.start_date = startDate;
|
||||
if (endDate) newTrip.end_date = endDate;
|
||||
|
||||
// If end date is before start date, assume next year
|
||||
if (startDate && endDate && endDate < startDate) {
|
||||
const y = parseInt(endDate.split('-')[0]) + 1;
|
||||
newTrip.end_date = `${y}${endDate.slice(4)}`;
|
||||
}
|
||||
|
||||
quickParsed = !!(startDate || endDate);
|
||||
}
|
||||
|
||||
function openNewTrip() {
|
||||
newTrip = { name: '', description: '', start_date: '', end_date: '' };
|
||||
quickInput = '';
|
||||
quickParsed = false;
|
||||
newTripModal?.showModal();
|
||||
}
|
||||
|
||||
async function createTrip() {
|
||||
if (!newTrip.name.trim()) return;
|
||||
creating = true;
|
||||
try {
|
||||
const result = await post<{ success: boolean; id: string }>('/api/trip', newTrip);
|
||||
if (result.success) {
|
||||
newTripModal?.close();
|
||||
goto(`/trip/${result.id}`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to create trip:', e);
|
||||
} finally {
|
||||
creating = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="container mx-auto px-4 py-8 max-w-7xl">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-base-content">My Trips</h1>
|
||||
<p class="text-base-content/50 mt-1">Plan and track your adventures</p>
|
||||
</div>
|
||||
<button class="btn btn-primary gap-2" onclick={openNewTrip}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="12" y1="5" x2="12" y2="19"/>
|
||||
<line x1="5" y1="12" x2="19" y2="12"/>
|
||||
</svg>
|
||||
New Trip
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Stats bar -->
|
||||
<!-- Next trip countdown -->
|
||||
{#if nextTrip}
|
||||
<a href="/trip/{nextTrip.id}" class="flex items-center gap-4 p-4 mb-6 rounded-xl bg-gradient-to-r from-info/10 to-primary/10 border border-info/20 hover:border-info/40 transition-colors group">
|
||||
<div class="p-2.5 rounded-xl bg-info/15 shrink-0">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-info" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M22 16.21v-1.895L14 8.42V4a2 2 0 1 0-4 0v4.42L2 14.315v1.895l8-2.526V18l-2 1.5V21l4-1 4 1v-1.5L16 18v-4.316z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-xs text-info/70 uppercase tracking-wide font-medium mb-0.5">Next Adventure</div>
|
||||
<div class="font-semibold text-base-content group-hover:text-info transition-colors truncate">{nextTrip.name}</div>
|
||||
<div class="text-sm text-base-content/50">{formatDateShort(nextTrip.start_date)} — {formatDateShort(nextTrip.end_date)}</div>
|
||||
</div>
|
||||
<div class="text-center shrink-0">
|
||||
<div class="text-2xl font-bold text-info leading-none">{daysUntilNext()}</div>
|
||||
<div class="text-xs text-base-content/40 mt-0.5">days</div>
|
||||
</div>
|
||||
</a>
|
||||
{:else if activeTrips.length > 0}
|
||||
<a href="/trip/{activeTrips[0].id}" class="flex items-center gap-3 p-4 mb-6 rounded-xl bg-gradient-to-r from-success/10 to-success/5 border border-success/20 hover:border-success/40 transition-colors group">
|
||||
<div class="p-2 rounded-xl bg-success/15">
|
||||
<span class="relative flex h-3 w-3">
|
||||
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-success opacity-75"></span>
|
||||
<span class="relative inline-flex rounded-full h-3 w-3 bg-success"></span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<span class="font-semibold text-base-content group-hover:text-success transition-colors">{activeTrips[0].name}</span>
|
||||
<span class="text-base-content/40 mx-2">·</span>
|
||||
<span class="text-sm text-success">Happening now!</span>
|
||||
</div>
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
<StatsBar />
|
||||
|
||||
<!-- Search -->
|
||||
{#if trips.length > 0}
|
||||
<div class="mb-6">
|
||||
<div class="relative max-w-md">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 absolute left-3 top-1/2 -translate-y-1/2 text-base-content/30" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search trips, hotels, flights, places..."
|
||||
class="input input-bordered w-full pl-10"
|
||||
bind:value={searchQuery}
|
||||
oninput={onSearchInput}
|
||||
onfocus={() => { if (searchResults.length > 0) showSearchResults = true; }}
|
||||
onblur={() => setTimeout(() => showSearchResults = false, 200)}
|
||||
/>
|
||||
{#if searchQuery}
|
||||
<button
|
||||
class="btn btn-ghost btn-sm btn-circle absolute right-2 top-1/2 -translate-y-1/2"
|
||||
onclick={() => { searchQuery = ''; searchResults = []; showSearchResults = false; }}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- Search results dropdown -->
|
||||
{#if showSearchResults && searchResults.length > 0}
|
||||
<div class="absolute top-full left-0 right-0 mt-1 bg-base-200 border border-base-300 rounded-box shadow-xl z-50 max-h-80 overflow-y-auto">
|
||||
{#each searchResults as result}
|
||||
<button
|
||||
class="w-full flex items-center gap-3 p-3 hover:bg-base-300 transition-colors text-left"
|
||||
onmousedown={() => selectResult(result)}
|
||||
>
|
||||
<div class="w-8 h-8 rounded-lg bg-base-300 flex items-center justify-center shrink-0">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-base-content/50" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d={typeIcon(result.type)}/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-medium text-sm text-base-content truncate">{result.name}</div>
|
||||
{#if result.detail}
|
||||
<div class="text-xs text-base-content/40 truncate">{result.detail}</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<span class="badge badge-sm {typeBadgeClass(result.type)}">{typeLabel(result.type)}</span>
|
||||
{#if result.type !== 'trip'}
|
||||
<span class="text-xs text-base-content/30 max-w-24 truncate">{result.trip_name}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if showSearchResults && searchQuery.length >= 2 && searchResults.length === 0}
|
||||
<div class="absolute top-full left-0 right-0 mt-1 bg-base-200 border border-base-300 rounded-box shadow-xl z-50 p-4 text-center text-base-content/40 text-sm">
|
||||
No results for "{searchQuery}"
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if loading}
|
||||
<div class="flex justify-center py-20">
|
||||
<span class="loading loading-spinner loading-lg text-primary"></span>
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="alert alert-error">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 shrink-0 stroke-current" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Active trips -->
|
||||
{#if activeTrips.length > 0}
|
||||
<section class="mb-10">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<span class="badge badge-success badge-sm"></span>
|
||||
<h2 class="text-xl font-semibold text-base-content">Active Now</h2>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{#each activeTrips as trip (trip.id)}
|
||||
<TripCard {trip} />
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- Upcoming trips -->
|
||||
{#if upcomingTrips.length > 0}
|
||||
<section class="mb-10">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<span class="badge badge-info badge-sm"></span>
|
||||
<h2 class="text-xl font-semibold text-base-content">Upcoming</h2>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{#each upcomingTrips as trip (trip.id)}
|
||||
<TripCard {trip} />
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- Past trips -->
|
||||
{#if pastTrips.length > 0}
|
||||
<section class="mb-10">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<span class="badge badge-ghost badge-sm"></span>
|
||||
<h2 class="text-xl font-semibold text-base-content">Past Adventures</h2>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{#each pastTrips as trip (trip.id)}
|
||||
<TripCard {trip} />
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- Empty state -->
|
||||
{#if trips.length === 0}
|
||||
<div class="text-center py-20">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-24 w-24 mx-auto text-base-content/20 mb-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" 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>
|
||||
<h3 class="text-xl font-semibold text-base-content/60 mb-2">No trips yet</h3>
|
||||
<p class="text-base-content/40 mb-6">Start planning your first adventure</p>
|
||||
<button class="btn btn-primary" onclick={openNewTrip}>Create Your First Trip</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- New Trip Modal -->
|
||||
<dialog bind:this={newTripModal} class="modal modal-bottom sm:modal-middle">
|
||||
<div class="modal-box max-w-lg">
|
||||
<form method="dialog">
|
||||
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2">✕</button>
|
||||
</form>
|
||||
<h3 class="font-bold text-lg flex items-center gap-2 mb-4">
|
||||
<div class="p-2 rounded-xl bg-primary/15">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-primary" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
|
||||
</svg>
|
||||
</div>
|
||||
New Trip
|
||||
</h3>
|
||||
<form onsubmit={(e) => { e.preventDefault(); createTrip(); }}>
|
||||
<div class="space-y-4">
|
||||
<!-- Quick input -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="quick-input">
|
||||
<span class="label-text">Quick Add</span>
|
||||
<span class="label-text-alt text-base-content/30">auto-fills below</span>
|
||||
</label>
|
||||
<input
|
||||
id="quick-input"
|
||||
type="text"
|
||||
placeholder="e.g. Toronto Oct 1 to Oct 10 Family vacation"
|
||||
class="input input-bordered input-primary w-full"
|
||||
bind:value={quickInput}
|
||||
oninput={() => parseQuickInput(quickInput)}
|
||||
/>
|
||||
{#if quickParsed}
|
||||
<label class="label pb-0">
|
||||
<span class="label-text-alt text-success flex items-center gap-1">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="20 6 9 17 4 12"/>
|
||||
</svg>
|
||||
Parsed — check fields below
|
||||
</span>
|
||||
</label>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="divider text-base-content/20 text-xs my-1">or fill in manually</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label" for="trip-name">
|
||||
<span class="label-text">Trip Name</span>
|
||||
</label>
|
||||
<input
|
||||
id="trip-name"
|
||||
type="text"
|
||||
placeholder="e.g. Japan 2026"
|
||||
class="input input-bordered w-full"
|
||||
bind:value={newTrip.name}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label" for="trip-desc">
|
||||
<span class="label-text">Description</span>
|
||||
<span class="label-text-alt text-base-content/30">Optional</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="trip-desc"
|
||||
placeholder="What's this trip about?"
|
||||
class="textarea textarea-bordered w-full"
|
||||
rows="2"
|
||||
bind:value={newTrip.description}
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="form-control">
|
||||
<label class="label" for="trip-start">
|
||||
<span class="label-text">Start Date</span>
|
||||
</label>
|
||||
<input
|
||||
id="trip-start"
|
||||
type="date"
|
||||
class="input input-bordered w-full"
|
||||
bind:value={newTrip.start_date}
|
||||
/>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label" for="trip-end">
|
||||
<span class="label-text">End Date</span>
|
||||
</label>
|
||||
<input
|
||||
id="trip-end"
|
||||
type="date"
|
||||
class="input input-bordered w-full"
|
||||
bind:value={newTrip.end_date}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn btn-ghost" onclick={() => newTripModal?.close()}>Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" disabled={creating || !newTrip.name.trim()}>
|
||||
{#if creating}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
{/if}
|
||||
Create Trip
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button>close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
82
services/trips/frontend-legacy/src/routes/login/+page.svelte
Normal file
82
services/trips/frontend-legacy/src/routes/login/+page.svelte
Normal file
@@ -0,0 +1,82 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
let token = $state('');
|
||||
let error = $state('');
|
||||
let loading = $state(false);
|
||||
|
||||
async function handleLogin() {
|
||||
if (!token.trim()) {
|
||||
error = 'Please enter your API token';
|
||||
return;
|
||||
}
|
||||
|
||||
loading = true;
|
||||
error = '';
|
||||
|
||||
try {
|
||||
// Test the token
|
||||
const res = await fetch('/api/trips', {
|
||||
headers: { 'Authorization': `Bearer ${token.trim()}` }
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
localStorage.setItem('api_token', token.trim());
|
||||
goto('/');
|
||||
} else {
|
||||
error = 'Invalid API token';
|
||||
}
|
||||
} catch {
|
||||
error = 'Connection failed';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="min-h-[calc(100vh-4rem)] flex items-center justify-center px-4">
|
||||
<div class="card bg-base-200 shadow-xl w-full max-w-md border border-base-300">
|
||||
<div class="card-body">
|
||||
<div class="text-center mb-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-12 w-12 mx-auto text-primary mb-3" 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>
|
||||
<h2 class="text-2xl font-bold text-base-content">Trips</h2>
|
||||
<p class="text-base-content/50 text-sm mt-1">Enter your API token to continue</p>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="alert alert-error text-sm py-2">
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<form onsubmit={(e) => { e.preventDefault(); handleLogin(); }}>
|
||||
<div class="form-control">
|
||||
<label class="label" for="token">
|
||||
<span class="label-text">API Token</span>
|
||||
</label>
|
||||
<input
|
||||
id="token"
|
||||
type="password"
|
||||
placeholder="Bearer token"
|
||||
class="input input-bordered w-full"
|
||||
bind:value={token}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary w-full mt-4"
|
||||
disabled={loading}
|
||||
>
|
||||
{#if loading}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
{/if}
|
||||
Connect
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,24 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
function logout() {
|
||||
localStorage.removeItem('api_token');
|
||||
goto('/login');
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="container mx-auto px-4 py-8 max-w-2xl">
|
||||
<h1 class="text-3xl font-bold text-base-content mb-8">Settings</h1>
|
||||
|
||||
<div class="card bg-base-200 border border-base-300">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-base-content">Session</h2>
|
||||
<p class="text-base-content/50 text-sm">Clear your API token and return to login.</p>
|
||||
<div class="card-actions justify-end mt-4">
|
||||
<button class="btn btn-error btn-outline" onclick={logout}>
|
||||
Disconnect
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
751
services/trips/frontend-legacy/src/routes/trip/[id]/+page.svelte
Normal file
751
services/trips/frontend-legacy/src/routes/trip/[id]/+page.svelte
Normal file
@@ -0,0 +1,751 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { get } from '$lib/api/client';
|
||||
import type { TripDetail, Location, Lodging, Transportation, Note } from '$lib/api/types';
|
||||
import LocationModal from '$lib/components/LocationModal.svelte';
|
||||
import LodgingModal from '$lib/components/LodgingModal.svelte';
|
||||
import TransportModal from '$lib/components/TransportModal.svelte';
|
||||
import NoteModal from '$lib/components/NoteModal.svelte';
|
||||
import TripEditModal from '$lib/components/TripEditModal.svelte';
|
||||
import ParseModal from '$lib/components/ParseModal.svelte';
|
||||
import TripMap from '$lib/components/TripMap.svelte';
|
||||
import AIGuideModal from '$lib/components/AIGuideModal.svelte';
|
||||
import MapsButton from '$lib/components/MapsButton.svelte';
|
||||
|
||||
interface DayGroup {
|
||||
date: string;
|
||||
dayLabel: string;
|
||||
dayNumber: number;
|
||||
items: DayItem[];
|
||||
}
|
||||
|
||||
interface DayItem {
|
||||
type: 'transportation' | 'lodging' | 'location' | 'note';
|
||||
sortTime: string;
|
||||
data: any;
|
||||
}
|
||||
|
||||
let trip = $state<TripDetail | null>(null);
|
||||
let loading = $state(true);
|
||||
let error = $state('');
|
||||
let currentSlide = $state(0);
|
||||
let fabOpen = $state(false);
|
||||
let autoplayTimer: ReturnType<typeof setInterval>;
|
||||
let expandedDays = $state<Set<string>>(new Set());
|
||||
|
||||
// Modal state
|
||||
let showLocationModal = $state(false);
|
||||
let showLodgingModal = $state(false);
|
||||
let showTransportModal = $state(false);
|
||||
let showNoteModal = $state(false);
|
||||
let showTripEditModal = $state(false);
|
||||
let showParseModal = $state(false);
|
||||
let showMapModal = $state(false);
|
||||
let showAIGuide = $state(false);
|
||||
let editLocation = $state<Location | null>(null);
|
||||
let editLodging = $state<Lodging | null>(null);
|
||||
let editTransport = $state<Transportation | null>(null);
|
||||
let editNote = $state<Note | null>(null);
|
||||
|
||||
// Weather
|
||||
let weather = $state<Record<string, any>>({});
|
||||
|
||||
async function loadWeather() {
|
||||
if (!trip?.start_date) return;
|
||||
const now = new Date();
|
||||
const start = new Date(trip.start_date + 'T00:00:00');
|
||||
const end = new Date(trip.end_date + 'T00:00:00');
|
||||
|
||||
// Only fetch weather for dates within 16 days from now
|
||||
const maxDate = new Date(now); maxDate.setDate(maxDate.getDate() + 16);
|
||||
const dates: string[] = [];
|
||||
for (let d = new Date(Math.max(start.getTime(), now.getTime())); d <= end && d <= maxDate; d.setDate(d.getDate() + 1)) {
|
||||
dates.push(d.toISOString().split('T')[0]);
|
||||
}
|
||||
if (dates.length === 0) return;
|
||||
|
||||
try {
|
||||
const data = await post<{ forecasts: Record<string, any> }>('/api/weather', {
|
||||
trip_id: trip.id,
|
||||
dates
|
||||
});
|
||||
weather = data.forecasts || {};
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
$effect(() => { if (trip) loadWeather(); });
|
||||
|
||||
function openLocationEdit(loc: Location) { editLocation = loc; showLocationModal = true; }
|
||||
function openLodgingEdit(l: Lodging) { editLodging = l; showLodgingModal = true; }
|
||||
function openTransportEdit(t: Transportation) { editTransport = t; showTransportModal = true; }
|
||||
function openNoteEdit(n: Note) { editNote = n; showNoteModal = true; }
|
||||
|
||||
function openNewLocation() { editLocation = null; showLocationModal = true; fabOpen = false; }
|
||||
function openNewLodging() { editLodging = null; showLodgingModal = true; fabOpen = false; }
|
||||
function openNewTransport() { editTransport = null; showTransportModal = true; fabOpen = false; }
|
||||
function openNewNote() { editNote = null; showNoteModal = true; fabOpen = false; }
|
||||
|
||||
function handleModalSave() {
|
||||
showLocationModal = false; showLodgingModal = false;
|
||||
showTransportModal = false; showNoteModal = false;
|
||||
if (tripId) loadTrip(tripId);
|
||||
}
|
||||
|
||||
function handleModalDelete() {
|
||||
showLocationModal = false; showLodgingModal = false;
|
||||
showTransportModal = false; showNoteModal = false;
|
||||
if (tripId) loadTrip(tripId);
|
||||
}
|
||||
|
||||
function handleModalClose() {
|
||||
showLocationModal = false; showLodgingModal = false;
|
||||
showTransportModal = false; showNoteModal = false;
|
||||
}
|
||||
|
||||
let tripId = $derived(page.params.id);
|
||||
|
||||
$effect(() => {
|
||||
if (tripId) loadTrip(tripId);
|
||||
});
|
||||
|
||||
async function loadTrip(id: string) {
|
||||
loading = true;
|
||||
try {
|
||||
trip = await get<TripDetail>(`/api/trip/${id}`);
|
||||
} catch (e) {
|
||||
error = 'Failed to load trip';
|
||||
console.error(e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Build itinerary grouped by day
|
||||
let days = $derived.by(() => {
|
||||
if (!trip?.start_date || !trip?.end_date) return [];
|
||||
|
||||
const start = new Date(trip.start_date + 'T00:00:00');
|
||||
const end = new Date(trip.end_date + 'T00:00:00');
|
||||
const dayMap = new Map<string, DayGroup>();
|
||||
|
||||
// Create all days
|
||||
let dayNum = 1;
|
||||
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
|
||||
const dateStr = d.toISOString().split('T')[0];
|
||||
dayMap.set(dateStr, {
|
||||
date: dateStr,
|
||||
dayLabel: d.toLocaleDateString('en-US', { weekday: 'long', month: 'short', day: 'numeric' }),
|
||||
dayNumber: dayNum++,
|
||||
items: []
|
||||
});
|
||||
}
|
||||
|
||||
// Place transportations
|
||||
for (const t of trip.transportations || []) {
|
||||
const dateStr = extractDate(t.date);
|
||||
if (dateStr && dayMap.has(dateStr)) {
|
||||
dayMap.get(dateStr)!.items.push({
|
||||
type: 'transportation', sortTime: extractTime(t.date) || '00:00', data: t
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Place locations
|
||||
for (const loc of trip.locations || []) {
|
||||
const dateStr = loc.visit_date || extractDate(loc.start_time);
|
||||
if (dateStr && dayMap.has(dateStr)) {
|
||||
dayMap.get(dateStr)!.items.push({
|
||||
type: 'location', sortTime: extractTime(loc.start_time) || '12:00', data: loc
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Place notes
|
||||
for (const n of trip.notes || []) {
|
||||
if (n.date && dayMap.has(n.date)) {
|
||||
dayMap.get(n.date)!.items.push({
|
||||
type: 'note', sortTime: '23:00', data: n
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sort items within each day by time
|
||||
for (const day of dayMap.values()) {
|
||||
day.items.sort((a, b) => a.sortTime.localeCompare(b.sortTime));
|
||||
}
|
||||
|
||||
return Array.from(dayMap.values());
|
||||
});
|
||||
|
||||
// Map each day to its overnight lodging
|
||||
let overnightByDate = $derived.by(() => {
|
||||
const map = new Map<string, any>();
|
||||
if (!trip) return map;
|
||||
for (const l of trip.lodging || []) {
|
||||
const checkIn = extractDate(l.check_in);
|
||||
const checkOut = extractDate(l.check_out);
|
||||
if (!checkIn) continue;
|
||||
// Hotel covers every night from check-in to day before check-out
|
||||
const start = new Date(checkIn + 'T00:00:00');
|
||||
const end = checkOut ? new Date(checkOut + 'T00:00:00') : start;
|
||||
for (let d = new Date(start); d < end; d.setDate(d.getDate() + 1)) {
|
||||
const dateStr = d.toISOString().split('T')[0];
|
||||
map.set(dateStr, l);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
});
|
||||
|
||||
// Unscheduled items (no date or date outside trip range)
|
||||
let unscheduled = $derived.by(() => {
|
||||
if (!trip) return [];
|
||||
const items: DayItem[] = [];
|
||||
const start = trip.start_date;
|
||||
const end = trip.end_date;
|
||||
|
||||
for (const t of trip.transportations || []) {
|
||||
const d = extractDate(t.date);
|
||||
if (!d || d < start || d > end) items.push({ type: 'transportation', sortTime: '', data: t });
|
||||
}
|
||||
for (const loc of trip.locations || []) {
|
||||
const d = loc.visit_date || extractDate(loc.start_time);
|
||||
if (!d || d < start || d > end) items.push({ type: 'location', sortTime: '', data: loc });
|
||||
}
|
||||
for (const n of trip.notes || []) {
|
||||
if (!n.date || n.date < start || n.date > end) items.push({ type: 'note', sortTime: '', data: n });
|
||||
}
|
||||
return items;
|
||||
});
|
||||
|
||||
// Hero images — shuffled on each page load
|
||||
let heroImages = $derived.by(() => {
|
||||
const imgs = (trip as any)?.hero_images || [];
|
||||
if (imgs.length <= 1) return imgs;
|
||||
// Fisher-Yates shuffle (copy first to avoid mutating)
|
||||
const shuffled = [...imgs];
|
||||
for (let i = shuffled.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
||||
}
|
||||
return shuffled;
|
||||
});
|
||||
|
||||
// Stats
|
||||
let totalCash = $derived(
|
||||
(trip?.transportations?.reduce((s, t) => s + (t.cost_cash || 0), 0) || 0) +
|
||||
(trip?.lodging?.reduce((s, l) => s + (l.cost_cash || 0), 0) || 0) +
|
||||
(trip?.locations?.reduce((s, l) => s + (l.cost_cash || 0), 0) || 0)
|
||||
);
|
||||
let totalPoints = $derived(
|
||||
(trip?.transportations?.reduce((s, t) => s + (t.cost_points || 0), 0) || 0) +
|
||||
(trip?.lodging?.reduce((s, l) => s + (l.cost_points || 0), 0) || 0) +
|
||||
(trip?.locations?.reduce((s, l) => s + (l.cost_points || 0), 0) || 0)
|
||||
);
|
||||
|
||||
function extractDate(dateStr: string | undefined): string {
|
||||
if (!dateStr) return '';
|
||||
return dateStr.split('T')[0];
|
||||
}
|
||||
|
||||
function extractTime(dateStr: string | undefined): string {
|
||||
if (!dateStr || !dateStr.includes('T')) return '';
|
||||
return dateStr.split('T')[1]?.split('|')[0] || '';
|
||||
}
|
||||
|
||||
function formatTime(dateStr: string): string {
|
||||
const time = extractTime(dateStr);
|
||||
if (!time) return '';
|
||||
const [h, m] = time.split(':');
|
||||
const hour = parseInt(h);
|
||||
const ampm = hour >= 12 ? 'PM' : 'AM';
|
||||
const h12 = hour % 12 || 12;
|
||||
return `${h12}:${m} ${ampm}`;
|
||||
}
|
||||
|
||||
function formatDateShort(dateStr: string): string {
|
||||
if (!dateStr) return '';
|
||||
const d = new Date(dateStr.split('T')[0] + 'T00:00:00');
|
||||
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
function categoryBadgeClass(category: string): string {
|
||||
switch (category) {
|
||||
case 'restaurant': case 'cafe': case 'bar': return 'badge-warning';
|
||||
case 'hike': return 'badge-success';
|
||||
case 'attraction': return 'badge-info';
|
||||
case 'shopping': return 'badge-secondary';
|
||||
default: return 'badge-ghost';
|
||||
}
|
||||
}
|
||||
|
||||
function startAutoplay() {
|
||||
clearInterval(autoplayTimer);
|
||||
autoplayTimer = setInterval(() => {
|
||||
if (heroImages.length > 1) currentSlide = (currentSlide + 1) % heroImages.length;
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function nextSlide() {
|
||||
if (heroImages.length > 0) currentSlide = (currentSlide + 1) % heroImages.length;
|
||||
startAutoplay(); // reset timer on manual nav
|
||||
}
|
||||
function prevSlide() {
|
||||
if (heroImages.length > 0) currentSlide = (currentSlide - 1 + heroImages.length) % heroImages.length;
|
||||
startAutoplay();
|
||||
}
|
||||
|
||||
// Start autoplay when hero images are available
|
||||
$effect(() => {
|
||||
if (heroImages.length > 1) {
|
||||
startAutoplay();
|
||||
}
|
||||
return () => clearInterval(autoplayTimer);
|
||||
});
|
||||
|
||||
// Auto-expand today's day or first day with items (only on initial load)
|
||||
let hasAutoExpanded = $state(false);
|
||||
$effect(() => {
|
||||
if (days.length > 0 && !hasAutoExpanded) {
|
||||
hasAutoExpanded = true;
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const todayDay = days.find(d => d.date === today && d.items.length > 0);
|
||||
if (todayDay) {
|
||||
expandedDays = new Set([todayDay.date]);
|
||||
} else {
|
||||
const first = days.find(d => d.items.length > 0);
|
||||
if (first) expandedDays = new Set([first.date]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function toggleDay(date: string) {
|
||||
const next = new Set(expandedDays);
|
||||
if (next.has(date)) {
|
||||
next.delete(date);
|
||||
} else {
|
||||
next.add(date);
|
||||
}
|
||||
expandedDays = next;
|
||||
}
|
||||
|
||||
function expandAll() {
|
||||
expandedDays = new Set(days.filter(d => d.items.length > 0).map(d => d.date));
|
||||
}
|
||||
|
||||
function collapseAll() {
|
||||
expandedDays = new Set();
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if loading}
|
||||
<div class="flex justify-center py-20">
|
||||
<span class="loading loading-spinner loading-lg text-primary"></span>
|
||||
</div>
|
||||
{:else if error || !trip}
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<div class="alert alert-error"><span>{error || 'Trip not found'}</span></div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Hero Image Carousel -->
|
||||
{#if heroImages.length > 0}
|
||||
<div class="relative w-full h-64 md:h-80 overflow-hidden bg-base-300">
|
||||
{#each heroImages as img, i}
|
||||
<div
|
||||
class="absolute inset-0 transition-opacity duration-500"
|
||||
class:opacity-100={i === currentSlide}
|
||||
class:opacity-0={i !== currentSlide}
|
||||
>
|
||||
<img src={img.url} alt="" class="w-full h-full object-cover" />
|
||||
</div>
|
||||
{/each}
|
||||
<!-- Gradient overlay -->
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-base-100 via-base-100/10 to-black/30"></div>
|
||||
<!-- Trip info centered on carousel -->
|
||||
<div class="absolute inset-0 flex flex-col items-center justify-center text-center px-6">
|
||||
<h1 class="text-5xl md:text-6xl font-extrabold text-white drop-shadow-[0_2px_8px_rgba(0,0,0,0.7)]">{trip.name}</h1>
|
||||
<div class="flex flex-wrap items-center justify-center gap-2 mt-4">
|
||||
{#if trip.start_date}
|
||||
<span class="badge badge-lg bg-primary border-0 text-white gap-1.5 px-4 py-3">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/>
|
||||
</svg>
|
||||
{formatDateShort(trip.start_date)} - {formatDateShort(trip.end_date)}
|
||||
</span>
|
||||
{/if}
|
||||
{#if (trip.locations?.length || 0) > 0}
|
||||
<span class="badge badge-lg bg-accent border-0 text-white gap-1.5 px-4 py-3">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/>
|
||||
</svg>
|
||||
{trip.locations.length} Locations
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Top buttons -->
|
||||
<div class="absolute top-4 left-4 right-4 flex justify-between z-10">
|
||||
<a href="/" class="inline-flex items-center gap-1 text-sm text-white/60 hover:text-white transition-colors bg-black/20 rounded-lg px-3 py-1.5">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M19 12H5"/><path d="M12 19l-7-7 7-7"/>
|
||||
</svg>
|
||||
Back
|
||||
</a>
|
||||
<button class="inline-flex items-center gap-1 text-sm text-white/60 hover:text-white transition-colors bg-black/20 rounded-lg px-3 py-1.5" onclick={() => showTripEditModal = true}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
|
||||
</svg>
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
<!-- Navigation arrows -->
|
||||
{#if heroImages.length > 1}
|
||||
<button class="absolute left-3 top-1/2 -translate-y-1/2 btn btn-circle btn-sm bg-black/30 border-0 hover:bg-black/50 text-white" onclick={prevSlide}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M15 18l-6-6 6-6"/></svg>
|
||||
</button>
|
||||
<button class="absolute right-3 top-1/2 -translate-y-1/2 btn btn-circle btn-sm bg-black/30 border-0 hover:bg-black/50 text-white" onclick={nextSlide}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 18l6-6-6-6"/></svg>
|
||||
</button>
|
||||
<!-- Image counter -->
|
||||
<div class="absolute bottom-4 right-4 badge bg-black/40 border-0 text-white text-xs">
|
||||
{currentSlide + 1} / {heroImages.length}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="container mx-auto px-4 py-6 max-w-4xl">
|
||||
<!-- Header (only when no hero images) -->
|
||||
{#if heroImages.length === 0}
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<a href="/" class="btn btn-ghost btn-sm gap-1 mb-2 -ml-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M19 12H5"/><path d="M12 19l-7-7 7-7"/>
|
||||
</svg>
|
||||
Back
|
||||
</a>
|
||||
<h1 class="text-3xl font-bold text-base-content">{trip.name}</h1>
|
||||
{#if trip.start_date}
|
||||
<p class="text-base-content/50 mt-1">{formatDateShort(trip.start_date)} — {formatDateShort(trip.end_date)}</p>
|
||||
{/if}
|
||||
{#if trip.description}
|
||||
<p class="text-sm text-base-content/40 mt-1">{trip.description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="flex flex-wrap justify-center gap-6 mb-8 py-4">
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-base-content">{trip.transportations?.length || 0}</div>
|
||||
<div class="text-xs text-base-content/50 uppercase tracking-wide font-medium">Transport</div>
|
||||
</div>
|
||||
<div class="divider divider-horizontal mx-0"></div>
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-base-content">{trip.lodging?.length || 0}</div>
|
||||
<div class="text-xs text-base-content/50 uppercase tracking-wide font-medium">Lodging</div>
|
||||
</div>
|
||||
<div class="divider divider-horizontal mx-0"></div>
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-base-content">{trip.locations?.length || 0}</div>
|
||||
<div class="text-xs text-base-content/50 uppercase tracking-wide font-medium">Activities</div>
|
||||
</div>
|
||||
{#if totalPoints > 0}
|
||||
<div class="divider divider-horizontal mx-0"></div>
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-base-content">{totalPoints.toLocaleString()}</div>
|
||||
<div class="text-xs text-base-content/50 uppercase tracking-wide font-medium">Points</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if totalCash > 0}
|
||||
<div class="divider divider-horizontal mx-0"></div>
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-base-content">${totalCash.toLocaleString()}</div>
|
||||
<div class="text-xs text-base-content/50 uppercase tracking-wide font-medium">Cost</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Itinerary by Day -->
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<div class="flex gap-1">
|
||||
<button class="btn btn-ghost btn-sm gap-2 text-base-content/50" onclick={() => showMapModal = true}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/>
|
||||
</svg>
|
||||
Map
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-sm gap-2 text-base-content/50" onclick={() => showAIGuide = true}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>
|
||||
</svg>
|
||||
AI Guide
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button class="btn btn-ghost btn-xs text-base-content/40" onclick={expandAll}>Expand all</button>
|
||||
<button class="btn btn-ghost btn-xs text-base-content/40" onclick={collapseAll}>Collapse all</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
{#each days as day}
|
||||
<section>
|
||||
<!-- Day header (clickable if has items) -->
|
||||
<button
|
||||
class="w-full flex items-center gap-3 p-3 rounded-xl hover:bg-base-200 transition-colors {day.items.length > 0 ? 'cursor-pointer' : 'cursor-default opacity-50'}"
|
||||
onclick={() => { if (day.items.length > 0) toggleDay(day.date); }}
|
||||
>
|
||||
<div class="w-10 h-10 rounded-full {day.items.length > 0 ? 'bg-primary/15' : 'bg-base-300'} flex items-center justify-center shrink-0">
|
||||
<span class="text-sm font-bold {day.items.length > 0 ? 'text-primary' : 'text-base-content/30'}">{day.dayNumber}</span>
|
||||
</div>
|
||||
<div class="flex-1 text-left">
|
||||
<div class="font-semibold {day.items.length > 0 ? 'text-base-content' : 'text-base-content/40'}">
|
||||
{day.dayLabel}
|
||||
{#if weather[day.date]}
|
||||
<span class="text-xs font-normal text-base-content/50 ml-2">
|
||||
{weather[day.date].high}°/{weather[day.date].low}°
|
||||
{#if weather[day.date].description} · {weather[day.date].description}{/if}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="text-xs text-base-content/40">Day {day.dayNumber}{#if day.items.length > 0} · {day.items.length} {day.items.length === 1 ? 'item' : 'items'}{:else} · No plans yet{/if}</div>
|
||||
</div>
|
||||
{#if day.items.length > 0}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-base-content/30 transition-transform {expandedDays.has(day.date) ? 'rotate-180' : ''}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="6 9 12 15 18 9"/>
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Day items (collapsible) -->
|
||||
{#if expandedDays.has(day.date)}
|
||||
<div class="ml-5 border-l-2 border-base-300 pl-6 space-y-3 mt-2">
|
||||
{#each day.items as item}
|
||||
{#if item.type === 'transportation'}
|
||||
{@const t = item.data}
|
||||
<div class="card bg-base-200 border border-base-300 shadow-sm hover:border-primary/30 transition-colors cursor-pointer" onclick={() => openTransportEdit(t)}>
|
||||
{#if t.images?.length > 0}
|
||||
<figure class="h-32"><img src={t.images[0].url} alt="" class="w-full h-full object-cover" /></figure>
|
||||
{/if}
|
||||
<div class="card-body p-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-9 h-9 rounded-lg bg-primary/10 flex items-center justify-center shrink-0">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-primary" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
{#if t.type === 'plane'}<path d="M22 16.21v-1.895L14 8.42V4a2 2 0 1 0-4 0v4.42L2 14.315v1.895l8-2.526V18l-2 1.5V21l4-1 4 1v-1.5L16 18v-4.316z"/>
|
||||
{:else if t.type === 'train'}<path d="M4 11V4a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v7m-16 0v6a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-6m-16 0h16"/>
|
||||
{:else}<path d="M5 17h14M5 17a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v6a2 2 0 0 1-2 2M5 17l-1 3h16l-1-3"/>{/if}
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-semibold text-sm">{t.name || t.flight_number || `${t.from_location} → ${t.to_location}`}</div>
|
||||
<div class="text-xs text-base-content/50">{t.from_location} → {t.to_location}{#if t.flight_number} · {t.flight_number}{/if}</div>
|
||||
</div>
|
||||
<div class="text-right shrink-0">
|
||||
{#if t.date}<div class="text-xs font-medium">{formatTime(t.date)}</div>{/if}
|
||||
{#if t.cost_points}<div class="badge badge-sm badge-primary badge-outline mt-1">{t.cost_points.toLocaleString()} pts</div>{/if}
|
||||
{#if t.cost_cash}<div class="badge badge-sm badge-outline mt-1">${t.cost_cash}</div>{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if t.to_lat || t.to_place_id || t.to_location}
|
||||
<div class="flex justify-end mt-1">
|
||||
<MapsButton lat={t.to_lat} lng={t.to_lng} name={t.to_location} placeId={t.to_place_id} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{:else if item.type === 'location'}
|
||||
{@const loc = item.data}
|
||||
<div class="card bg-base-200 border border-base-300 shadow-sm hover:border-accent/30 transition-colors cursor-pointer" onclick={() => openLocationEdit(loc)}>
|
||||
{#if loc.images?.length > 0}
|
||||
<figure class="h-32"><img src={loc.images[0].url} alt="" class="w-full h-full object-cover" /></figure>
|
||||
{/if}
|
||||
<div class="card-body p-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-9 h-9 rounded-lg bg-accent/10 flex items-center justify-center shrink-0">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-accent" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-semibold text-sm">{loc.name}</span>
|
||||
{#if loc.category}<span class="badge badge-xs {categoryBadgeClass(loc.category)}">{loc.category}</span>{/if}
|
||||
</div>
|
||||
{#if loc.address}<div class="text-xs text-base-content/40 truncate">{loc.address}</div>{/if}
|
||||
</div>
|
||||
<div class="text-right shrink-0">
|
||||
{#if loc.start_time}
|
||||
<div class="text-xs font-medium">{formatTime(loc.start_time)}</div>
|
||||
{#if loc.end_time}<div class="text-xs text-base-content/40">— {formatTime(loc.end_time)}</div>{/if}
|
||||
{/if}
|
||||
{#if loc.cost_points}<div class="badge badge-sm badge-primary badge-outline mt-1">{loc.cost_points.toLocaleString()} pts</div>{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if loc.description}
|
||||
<div class="text-xs text-base-content/50 mt-2 line-clamp-2">{@html loc.description}</div>
|
||||
{/if}
|
||||
{#if loc.latitude || loc.place_id || loc.address}
|
||||
<div class="flex justify-end mt-1">
|
||||
<MapsButton lat={loc.latitude} lng={loc.longitude} name={loc.name} address={loc.address} placeId={loc.place_id} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{:else if item.type === 'note'}
|
||||
{@const n = item.data}
|
||||
<div class="card bg-base-200 border border-base-300 shadow-sm border-l-4 border-l-warning cursor-pointer" onclick={() => openNoteEdit(n)}>
|
||||
<div class="card-body p-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-warning shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<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>
|
||||
<span class="font-semibold text-sm">{n.name}</span>
|
||||
</div>
|
||||
{#if n.content}
|
||||
<div class="text-xs text-base-content/60 mt-1 prose prose-sm max-w-none">{@html n.content}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Staying overnight -->
|
||||
{#if overnightByDate.has(day.date)}
|
||||
{@const hotel = overnightByDate.get(day.date)}
|
||||
<div class="mt-3 ml-5 pl-6 border-l-2 border-dashed border-secondary/30">
|
||||
<div class="text-sm text-base-content/60 font-medium mb-1.5 flex items-center gap-1.5">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 14h18v7H3z"/><path d="M3 7v7"/><path d="M21 7v7"/><path d="M3 7l9-4 9 4"/>
|
||||
</svg>
|
||||
Staying overnight
|
||||
</div>
|
||||
<div class="flex items-center gap-3 py-2 px-3 rounded-lg bg-secondary/5 border border-secondary/10 cursor-pointer hover:border-secondary/30 transition-colors" onclick={() => openLodgingEdit(hotel)}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-secondary/60 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 14h18v7H3z"/><path d="M3 7v7"/><path d="M21 7v7"/><path d="M3 7l9-4 9 4"/>
|
||||
</svg>
|
||||
<div class="flex-1 min-w-0">
|
||||
<span class="text-sm text-base-content/70">{hotel.name}</span>
|
||||
</div>
|
||||
<span class="text-xs text-base-content/30 shrink-0">Check out: {formatDateShort(hotel.check_out)}</span>
|
||||
<MapsButton lat={hotel.latitude} lng={hotel.longitude} name={hotel.name} address={hotel.location} placeId={hotel.place_id} />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
{/each}
|
||||
|
||||
<!-- Unscheduled items -->
|
||||
{#if unscheduled.length > 0}
|
||||
<section>
|
||||
<div class="flex items-center gap-3 mb-3">
|
||||
<div class="w-10 h-10 rounded-full bg-base-300 flex items-center justify-center shrink-0">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-base-content/40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-semibold text-base-content">Unscheduled</div>
|
||||
<div class="text-xs text-base-content/40">Not assigned to a day</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-5 border-l-2 border-base-300/50 border-dashed pl-6 space-y-3">
|
||||
{#each unscheduled as item}
|
||||
{@const d = item.data}
|
||||
<div class="card bg-base-200 border border-base-300 shadow-sm">
|
||||
<div class="card-body p-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="badge badge-sm badge-ghost">{item.type}</span>
|
||||
<span class="font-semibold text-sm">{d.name || d.flight_number || 'Untitled'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- Empty state -->
|
||||
{#if days.every(d => d.items.length === 0) && unscheduled.length === 0}
|
||||
<div class="text-center py-16">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-16 w-16 mx-auto text-base-content/15 mb-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>
|
||||
</svg>
|
||||
<h3 class="text-lg font-semibold text-base-content/50 mb-1">No items yet</h3>
|
||||
<p class="text-sm text-base-content/30">Tap + to start planning</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Floating Action Button -->
|
||||
<div class="fixed bottom-6 right-6 z-50">
|
||||
{#if fabOpen}
|
||||
<div class="flex flex-col-reverse gap-2 mb-3">
|
||||
<button class="btn btn-sm btn-primary shadow-lg gap-2" onclick={openNewLocation}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>
|
||||
Activity
|
||||
</button>
|
||||
<button class="btn btn-sm btn-secondary shadow-lg gap-2" onclick={openNewLodging}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 14h18v7H3z"/><path d="M3 7v7"/><path d="M21 7v7"/><path d="M3 7l9-4 9 4"/></svg>
|
||||
Lodging
|
||||
</button>
|
||||
<button class="btn btn-sm btn-info shadow-lg gap-2" onclick={openNewTransport}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 16.21v-1.895L14 8.42V4a2 2 0 1 0-4 0v4.42L2 14.315v1.895l8-2.526V18l-2 1.5V21l4-1 4 1v-1.5L16 18v-4.316z"/></svg>
|
||||
Transport
|
||||
</button>
|
||||
<button class="btn btn-sm btn-warning shadow-lg gap-2" onclick={openNewNote}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" 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>
|
||||
Note
|
||||
</button>
|
||||
<button class="btn btn-sm btn-accent shadow-lg gap-2" onclick={() => { showParseModal = true; fabOpen = false; }}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>
|
||||
AI Parse
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
<button
|
||||
class="btn btn-primary btn-circle w-14 h-14 shadow-2xl hover:shadow-primary/25 transition-all duration-200 {fabOpen ? 'rotate-45' : ''}"
|
||||
onclick={() => fabOpen = !fabOpen}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-7 w-7 transition-transform" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Backdrop when FAB is open -->
|
||||
{#if fabOpen}
|
||||
<div class="fixed inset-0 bg-black/20 z-40" onclick={() => fabOpen = false}></div>
|
||||
{/if}
|
||||
|
||||
<!-- Modals -->
|
||||
{#if showAIGuide}
|
||||
<AIGuideModal {tripId} tripName={trip.name} onClose={() => showAIGuide = false} />
|
||||
{/if}
|
||||
{#if showMapModal}
|
||||
<TripMap {trip} onClose={() => showMapModal = false} />
|
||||
{/if}
|
||||
{#if showParseModal}
|
||||
<ParseModal {tripId} tripStart={trip.start_date} tripEnd={trip.end_date} onParsed={() => { showParseModal = false; loadTrip(tripId); }} onClose={() => showParseModal = false} />
|
||||
{/if}
|
||||
{#if showTripEditModal}
|
||||
<TripEditModal {trip} onSave={() => { showTripEditModal = false; loadTrip(tripId); }} onClose={() => showTripEditModal = false} />
|
||||
{/if}
|
||||
{#if showLocationModal}
|
||||
<LocationModal location={editLocation} tripId={trip.id} tripStart={trip.start_date} tripEnd={trip.end_date} onSave={handleModalSave} onDelete={handleModalDelete} onClose={handleModalClose} />
|
||||
{/if}
|
||||
{#if showLodgingModal}
|
||||
<LodgingModal lodging={editLodging} tripId={trip.id} tripStart={trip.start_date} tripEnd={trip.end_date} onSave={handleModalSave} onDelete={handleModalDelete} onClose={handleModalClose} />
|
||||
{/if}
|
||||
{#if showTransportModal}
|
||||
<TransportModal transport={editTransport} tripId={trip.id} tripStart={trip.start_date} tripEnd={trip.end_date} onSave={handleModalSave} onDelete={handleModalDelete} onClose={handleModalClose} />
|
||||
{/if}
|
||||
{#if showNoteModal}
|
||||
<NoteModal note={editNote} tripId={trip.id} onSave={handleModalSave} onDelete={handleModalDelete} onClose={handleModalClose} />
|
||||
{/if}
|
||||
{/if}
|
||||
@@ -0,0 +1,264 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
|
||||
interface ShareTrip {
|
||||
name: string;
|
||||
description: string;
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
transportations: any[];
|
||||
lodging: any[];
|
||||
locations: any[];
|
||||
notes: any[];
|
||||
hero_images: any[];
|
||||
}
|
||||
|
||||
let trip = $state<ShareTrip | null>(null);
|
||||
let loading = $state(true);
|
||||
let error = $state('');
|
||||
let currentSlide = $state(0);
|
||||
|
||||
let token = $derived(page.params.token);
|
||||
|
||||
$effect(() => {
|
||||
if (token) loadShareTrip(token);
|
||||
});
|
||||
|
||||
async function loadShareTrip(t: string) {
|
||||
loading = true;
|
||||
try {
|
||||
const res = await fetch(`/api/share/trip/${t}`);
|
||||
if (res.ok) {
|
||||
trip = await res.json();
|
||||
} else {
|
||||
error = 'Trip not found or link expired';
|
||||
}
|
||||
} catch {
|
||||
error = 'Failed to load trip';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-rotate carousel
|
||||
let autoplayTimer: ReturnType<typeof setInterval>;
|
||||
$effect(() => {
|
||||
if ((trip?.hero_images?.length || 0) > 1) {
|
||||
autoplayTimer = setInterval(() => {
|
||||
currentSlide = (currentSlide + 1) % (trip?.hero_images?.length || 1);
|
||||
}, 5000);
|
||||
}
|
||||
return () => clearInterval(autoplayTimer);
|
||||
});
|
||||
|
||||
function formatDateShort(d: string): string {
|
||||
if (!d) return '';
|
||||
return new Date(d + 'T00:00:00').toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
function formatTime(dateStr: string): string {
|
||||
if (!dateStr || !dateStr.includes('T')) return '';
|
||||
const timePart = dateStr.split('T')[1]?.split('|')[0];
|
||||
if (!timePart) return '';
|
||||
const [h, m] = timePart.split(':');
|
||||
const hour = parseInt(h);
|
||||
const ampm = hour >= 12 ? 'PM' : 'AM';
|
||||
const h12 = hour % 12 || 12;
|
||||
return `${h12}:${m} ${ampm}`;
|
||||
}
|
||||
|
||||
function extractDate(d: string | undefined): string {
|
||||
if (!d) return '';
|
||||
return d.split('T')[0];
|
||||
}
|
||||
|
||||
function categoryBadgeClass(cat: string): string {
|
||||
switch (cat) {
|
||||
case 'restaurant': case 'cafe': case 'bar': return 'badge-warning';
|
||||
case 'hike': return 'badge-success';
|
||||
case 'attraction': return 'badge-info';
|
||||
default: return 'badge-ghost';
|
||||
}
|
||||
}
|
||||
|
||||
// Build day groups
|
||||
let days = $derived.by(() => {
|
||||
if (!trip?.start_date || !trip?.end_date) return [];
|
||||
const start = new Date(trip.start_date + 'T00:00:00');
|
||||
const end = new Date(trip.end_date + 'T00:00:00');
|
||||
const dayMap = new Map<string, { date: string; label: string; num: number; items: any[] }>();
|
||||
|
||||
let num = 1;
|
||||
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
|
||||
const ds = d.toISOString().split('T')[0];
|
||||
dayMap.set(ds, {
|
||||
date: ds,
|
||||
label: d.toLocaleDateString('en-US', { weekday: 'long', month: 'short', day: 'numeric' }),
|
||||
num: num++,
|
||||
items: []
|
||||
});
|
||||
}
|
||||
|
||||
for (const t of trip.transportations || []) {
|
||||
const d = extractDate(t.date);
|
||||
if (d && dayMap.has(d)) dayMap.get(d)!.items.push({ type: 'transport', data: t });
|
||||
}
|
||||
for (const loc of trip.locations || []) {
|
||||
const d = loc.visit_date || extractDate(loc.start_time);
|
||||
if (d && dayMap.has(d)) dayMap.get(d)!.items.push({ type: 'location', data: loc });
|
||||
}
|
||||
for (const n of trip.notes || []) {
|
||||
if (n.date && dayMap.has(n.date)) dayMap.get(n.date)!.items.push({ type: 'note', data: n });
|
||||
}
|
||||
|
||||
return Array.from(dayMap.values());
|
||||
});
|
||||
|
||||
// Overnight lodging
|
||||
let overnightByDate = $derived.by(() => {
|
||||
const map = new Map<string, any>();
|
||||
if (!trip) return map;
|
||||
for (const l of trip.lodging || []) {
|
||||
const checkIn = extractDate(l.check_in);
|
||||
const checkOut = extractDate(l.check_out);
|
||||
if (!checkIn) continue;
|
||||
const start = new Date(checkIn + 'T00:00:00');
|
||||
const end = checkOut ? new Date(checkOut + 'T00:00:00') : start;
|
||||
for (let d = new Date(start); d < end; d.setDate(d.getDate() + 1)) {
|
||||
map.set(d.toISOString().split('T')[0], l);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{trip?.name || 'Shared Trip'}</title>
|
||||
</svelte:head>
|
||||
|
||||
{#if loading}
|
||||
<div class="flex justify-center py-20">
|
||||
<span class="loading loading-spinner loading-lg text-primary"></span>
|
||||
</div>
|
||||
{:else if error || !trip}
|
||||
<div class="container mx-auto px-4 py-20 text-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-16 w-16 mx-auto text-base-content/20 mb-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>
|
||||
<h2 class="text-xl font-bold text-base-content/60">{error || 'Trip not found'}</h2>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Hero carousel -->
|
||||
{#if trip.hero_images && trip.hero_images.length > 0}
|
||||
<div class="relative w-full h-64 md:h-80 overflow-hidden bg-base-300">
|
||||
{#each trip.hero_images as img, i}
|
||||
<div class="absolute inset-0 transition-opacity duration-500" class:opacity-100={i === currentSlide} class:opacity-0={i !== currentSlide}>
|
||||
<img src={img.url} alt="" class="w-full h-full object-cover" />
|
||||
</div>
|
||||
{/each}
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-base-100 via-base-100/10 to-black/30"></div>
|
||||
<div class="absolute inset-0 flex flex-col items-center justify-center text-center px-6">
|
||||
<h1 class="text-5xl md:text-6xl font-extrabold text-white drop-shadow-[0_2px_8px_rgba(0,0,0,0.7)]">{trip.name}</h1>
|
||||
{#if trip.start_date}
|
||||
<span class="badge badge-lg bg-primary border-0 text-white gap-1.5 px-4 py-3 mt-4">
|
||||
{formatDateShort(trip.start_date)} - {formatDateShort(trip.end_date)}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if trip.hero_images.length > 1}
|
||||
<div class="absolute bottom-4 right-4 badge bg-black/40 border-0 text-white text-xs">
|
||||
{currentSlide + 1} / {trip.hero_images.length}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="container mx-auto px-4 py-6 max-w-4xl">
|
||||
<!-- Shared badge -->
|
||||
<div class="flex justify-center mb-6">
|
||||
<span class="badge badge-outline gap-1 text-base-content/40">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8"/><polyline points="16 6 12 2 8 6"/><line x1="12" y1="2" x2="12" y2="15"/></svg>
|
||||
Shared Trip
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if !trip.start_date}
|
||||
<h1 class="text-3xl font-bold text-base-content text-center mb-6">{trip.name}</h1>
|
||||
{/if}
|
||||
|
||||
<!-- Itinerary -->
|
||||
<div class="space-y-3">
|
||||
{#each days as day}
|
||||
{#if day.items.length > 0}
|
||||
<section>
|
||||
<div class="flex items-center gap-3 p-3">
|
||||
<div class="w-10 h-10 rounded-full bg-primary/15 flex items-center justify-center shrink-0">
|
||||
<span class="text-sm font-bold text-primary">{day.num}</span>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-semibold text-base-content">{day.label}</div>
|
||||
<div class="text-xs text-base-content/40">Day {day.num} · {day.items.length} items</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ml-5 border-l-2 border-base-300 pl-6 space-y-3">
|
||||
{#each day.items as item}
|
||||
{#if item.type === 'transport'}
|
||||
{@const t = item.data}
|
||||
<div class="card bg-base-200 border border-base-300 shadow-sm">
|
||||
<div class="card-body p-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-9 h-9 rounded-lg bg-primary/10 flex items-center justify-center shrink-0">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-primary" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 16.21v-1.895L14 8.42V4a2 2 0 1 0-4 0v4.42L2 14.315v1.895l8-2.526V18l-2 1.5V21l4-1 4 1v-1.5L16 18v-4.316z"/></svg>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-semibold text-sm">{t.name || t.flight_number || `${t.from_location} → ${t.to_location}`}</div>
|
||||
<div class="text-xs text-base-content/50">{t.from_location} → {t.to_location}</div>
|
||||
</div>
|
||||
{#if t.date}<div class="text-xs font-medium shrink-0">{formatTime(t.date)}</div>{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else if item.type === 'location'}
|
||||
{@const loc = item.data}
|
||||
<div class="card bg-base-200 border border-base-300 shadow-sm">
|
||||
{#if loc.images?.length > 0}
|
||||
<figure class="h-32"><img src={loc.images[0].url} alt="" class="w-full h-full object-cover" /></figure>
|
||||
{/if}
|
||||
<div class="card-body p-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-semibold text-sm">{loc.name}</span>
|
||||
{#if loc.category}<span class="badge badge-xs {categoryBadgeClass(loc.category)}">{loc.category}</span>{/if}
|
||||
</div>
|
||||
{#if loc.address}<div class="text-xs text-base-content/40">{loc.address}</div>{/if}
|
||||
{#if loc.description}<div class="text-xs text-base-content/50 mt-1 line-clamp-2">{@html loc.description}</div>{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else if item.type === 'note'}
|
||||
{@const n = item.data}
|
||||
<div class="card bg-base-200 border border-base-300 shadow-sm border-l-4 border-l-warning">
|
||||
<div class="card-body p-3">
|
||||
<div class="font-semibold text-sm">{n.name}</div>
|
||||
{#if n.content}<div class="text-xs text-base-content/60 mt-1">{@html n.content}</div>{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if overnightByDate.has(day.date)}
|
||||
{@const hotel = overnightByDate.get(day.date)}
|
||||
<div class="mt-3 ml-5 pl-6 border-l-2 border-dashed border-secondary/30">
|
||||
<div class="text-sm text-base-content/60 font-medium mb-1.5 flex items-center gap-1.5">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 14h18v7H3z"/><path d="M3 7v7"/><path d="M21 7v7"/><path d="M3 7l9-4 9 4"/></svg>
|
||||
Staying overnight
|
||||
</div>
|
||||
<div class="flex items-center gap-3 py-2 px-3 rounded-lg bg-secondary/5 border border-secondary/10">
|
||||
<span class="text-sm text-base-content/70">{hotel.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
Reference in New Issue
Block a user