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
628 lines
22 KiB
Svelte
628 lines
22 KiB
Svelte
<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>
|