trips) adding initial commit

This commit is contained in:
2026-02-18 20:23:34 -05:00
commit 1068145261
67 changed files with 3602 additions and 0 deletions

1
src/app.css Normal file
View File

@@ -0,0 +1 @@
@import "tailwindcss";

17
src/app.d.ts vendored Normal file
View File

@@ -0,0 +1,17 @@
import type { DefaultSession } from '@auth/sveltekit';
declare global {
namespace App {
// interface Error {}
interface Locals {
auth(): Promise<DefaultSession | null>;
}
interface PageData {
session: DefaultSession | null;
}
// interface PageState {}
// interface Platform {}
}
}
export {};

12
src/app.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en" style="background-color: #f9fafb">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/trips/favicon.svg" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

44
src/auth.ts Normal file
View File

@@ -0,0 +1,44 @@
import { SvelteKitAuth } from '@auth/sveltekit';
import { env } from '$env/dynamic/private';
export const { handle, signIn, signOut } = SvelteKitAuth({
providers: [
{
id: 'synology',
name: 'Synology',
type: 'oidc',
issuer: env.SYNOLOGY_ISSUER,
clientId: env.SYNOLOGY_CLIENT_ID,
clientSecret: env.SYNOLOGY_CLIENT_SECRET,
redirectProxyUrl: env.AUTH_URL,
profile(profile) {
return {
id: profile.sub as string,
name: (profile.username ?? profile.name ?? profile.preferred_username) as string,
email: profile.email as string | undefined
};
}
}
],
trustHost: true,
callbacks: {
jwt({ token, profile }) {
if (profile?.sub) token.sub = profile.sub as string;
return token;
},
session({ session, token }) {
if (token.sub) session.user.id = token.sub;
return session;
},
redirect({ url, baseUrl }) {
const appRoot = env.AUTH_URL?.replace(/\/auth$/, '') ?? baseUrl;
if (url.startsWith(appRoot)) return url;
return appRoot;
}
},
cookies: {
sessionToken: { name: 'authjs.session-token' },
callbackUrl: { name: 'authjs.callback-url' },
csrfToken: { name: 'authjs.csrf-token' }
}
});

15
src/hooks.server.ts Normal file
View File

@@ -0,0 +1,15 @@
import { handle as authHandle } from './auth.js';
import { sequence } from '@sveltejs/kit/hooks';
import type { Handle } from '@sveltejs/kit';
// When behind a reverse proxy that terminates SSL, rewrite the request URL
// to use https so that SvelteKit generates correct absolute URLs and form actions.
const httpsProxy: Handle = async ({ event, resolve }) => {
const proto = event.request.headers.get('x-forwarded-proto');
if (proto === 'https' && event.url.protocol === 'http:') {
event.url.protocol = 'https:';
}
return resolve(event);
};
export const handle = sequence(httpsProxy, authHandle);

View File

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

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,252 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { base } from '$app/paths';
interface City {
id: number;
name: string;
country: string;
country_code: string;
population: number | null;
}
interface Props {
open: boolean;
onclose: () => void;
}
let { open, onclose }: Props = $props();
let query = $state('');
let cities = $state<City[]>([]);
let selected = $state<City | null>(null);
let loading = $state(false);
let startDate = $state('');
let endDate = $state('');
let startUnknown = $state(false);
let endUnknown = $state(false);
let status = $state<'idea' | 'tentative' | 'confirmed'>('idea');
let debounceTimer: ReturnType<typeof setTimeout>;
function flagEmoji(code: string): string {
return code
.toUpperCase()
.split('')
.map((c) => String.fromCodePoint(0x1f1e6 + c.charCodeAt(0) - 65))
.join('');
}
function onQueryInput() {
clearTimeout(debounceTimer);
selected = null;
if (!query.trim()) {
cities = [];
return;
}
debounceTimer = setTimeout(async () => {
loading = true;
try {
const res = await fetch(`${base}/api/cities?q=${encodeURIComponent(query)}`);
cities = await res.json();
} finally {
loading = false;
}
}, 250);
}
function selectCity(city: City) {
selected = city;
query = `${city.name}, ${city.country}`;
cities = [];
}
function reset() {
query = '';
cities = [];
selected = null;
startDate = '';
endDate = '';
startUnknown = false;
endUnknown = false;
status = 'idea';
}
function handleClose() {
reset();
onclose();
}
</script>
{#if open}
<!-- Backdrop -->
<div
class="fixed inset-0 z-40 bg-black/30"
role="button"
tabindex="-1"
onclick={handleClose}
onkeydown={(e) => e.key === 'Escape' && handleClose()}
></div>
<!-- Panel -->
<div
class="fixed top-0 right-0 z-50 flex h-full w-full max-w-md flex-col bg-white shadow-xl"
role="dialog"
aria-modal="true"
aria-label="Add destination"
>
<!-- Header -->
<div class="flex items-center justify-between border-b border-gray-200 px-6 py-4">
<h2 class="text-base font-semibold text-gray-900">Add destination</h2>
<button
onclick={handleClose}
class="rounded-md p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
aria-label="Close"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
<!-- Form -->
<form
method="POST"
action="?/addDestination"
class="flex flex-1 flex-col overflow-y-auto"
use:enhance={() => {
return ({ result, update }) => {
update();
if (result.type === 'success') handleClose();
};
}}
>
<div class="flex flex-1 flex-col gap-6 px-6 py-6">
<!-- City search -->
<div class="flex flex-col gap-1.5">
<label for="city-search" class="text-sm font-medium text-gray-700">
City <span class="text-red-500">*</span>
</label>
<div class="relative">
<input
id="city-search"
type="text"
autocomplete="off"
bind:value={query}
oninput={onQueryInput}
placeholder="Search for a city..."
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
{#if loading}
<div class="absolute top-2.5 right-3 text-gray-400">
<svg class="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z"></path>
</svg>
</div>
{/if}
{#if cities.length > 0}
<ul class="absolute z-10 mt-1 w-full overflow-hidden rounded-md border border-gray-200 bg-white shadow-lg">
{#each cities as city}
<li>
<button
type="button"
onclick={() => selectCity(city)}
class="flex w-full items-center gap-3 px-3 py-2.5 text-left text-sm hover:bg-gray-50"
>
<span class="text-lg leading-none">{flagEmoji(city.country_code)}</span>
<div>
<span class="font-medium text-gray-900">{city.name}</span>
<span class="ml-1.5 text-gray-500">{city.country}</span>
</div>
</button>
</li>
{/each}
</ul>
{/if}
</div>
</div>
<!-- Hidden fields for selected city -->
{#if selected}
<input type="hidden" name="city_id" value={selected.id} />
<input type="hidden" name="city_name" value={selected.name} />
<input type="hidden" name="country" value={selected.country} />
<input type="hidden" name="country_code" value={selected.country_code} />
{/if}
<!-- Dates -->
<div class="grid grid-cols-2 gap-4">
<div class="flex flex-col gap-1.5">
<label for="start_date" class="text-sm font-medium text-gray-700">Arrival</label>
<input
id="start_date"
name="start_date"
type="date"
bind:value={startDate}
disabled={startUnknown}
class="rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none disabled:bg-gray-50 disabled:text-gray-400"
/>
<label class="flex items-center gap-2 text-xs text-gray-500">
<input type="checkbox" bind:checked={startUnknown} onchange={() => startUnknown && (startDate = '')} />
I don't know yet
</label>
</div>
<div class="flex flex-col gap-1.5">
<label for="end_date" class="text-sm font-medium text-gray-700">Departure</label>
<input
id="end_date"
name="end_date"
type="date"
bind:value={endDate}
disabled={endUnknown}
class="rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none disabled:bg-gray-50 disabled:text-gray-400"
/>
<label class="flex items-center gap-2 text-xs text-gray-500">
<input type="checkbox" bind:checked={endUnknown} onchange={() => endUnknown && (endDate = '')} />
I don't know yet
</label>
</div>
</div>
<!-- Status -->
<div class="flex flex-col gap-1.5">
<span class="text-sm font-medium text-gray-700">Status</span>
<div class="flex gap-2">
{#each [
{ value: 'idea', label: 'Idea', color: 'bg-gray-100 text-gray-700 border-gray-300' },
{ value: 'tentative', label: 'Tentative', color: 'bg-yellow-50 text-yellow-800 border-yellow-300' },
{ value: 'confirmed', label: 'Confirmed', color: 'bg-green-50 text-green-800 border-green-300' }
] as opt}
<label
class="flex cursor-pointer items-center gap-2 rounded-md border px-3 py-2 text-sm transition-all {status === opt.value ? opt.color + ' ring-2 ring-offset-1 ' + (opt.value === 'idea' ? 'ring-gray-400' : opt.value === 'tentative' ? 'ring-yellow-400' : 'ring-green-500') : 'border-gray-200 bg-white text-gray-600 hover:bg-gray-50'}"
>
<input type="radio" name="status" value={opt.value} bind:group={status} class="sr-only" />
{opt.label}
</label>
{/each}
</div>
</div>
</div>
<!-- Footer -->
<div class="flex justify-end gap-3 border-t border-gray-200 px-6 py-4">
<button
type="button"
onclick={handleClose}
class="rounded-md border border-gray-300 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
>
Cancel
</button>
<button
type="submit"
disabled={!selected}
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
>
Add to trip
</button>
</div>
</form>
</div>
{/if}

View File

@@ -0,0 +1,283 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { Person } from '$lib/server/travellers.js';
interface Props {
open: boolean;
onclose: () => void;
people: Person[]; // all people the user has created
tripTravellerIds: string[]; // person IDs already on this trip
}
let { open, onclose, people, tripTravellerIds }: Props = $props();
// People not yet on this trip
let available = $derived((people ?? []).filter((p) => !(tripTravellerIds ?? []).includes(p.id)));
let hasExisting = $derived(available.length > 0);
let showNewForm = $state(false);
let firstName = $state('');
let lastName = $state('');
let email = $state('');
// Show the new form immediately if there are no existing people to pick from
$effect(() => {
if (open && !hasExisting) showNewForm = true;
if (!open) showNewForm = false;
});
function resetNew() {
firstName = '';
lastName = '';
email = '';
}
function handleClose() {
resetNew();
onclose();
}
function flagInitials(p: Person) {
return `${p.first_name[0] ?? ''}${p.last_name[0] ?? ''}`.toUpperCase();
}
const colours = [
'bg-blue-100 text-blue-700',
'bg-violet-100 text-violet-700',
'bg-pink-100 text-pink-700',
'bg-amber-100 text-amber-700',
'bg-teal-100 text-teal-700',
'bg-orange-100 text-orange-700',
'bg-indigo-100 text-indigo-700',
'bg-green-100 text-green-700'
];
function colour(p: Person) {
return colours[(p.first_name.charCodeAt(0) + p.last_name.charCodeAt(0)) % colours.length];
}
</script>
{#if open}
<!-- Backdrop -->
<div
class="fixed inset-0 z-40 bg-black/30"
role="button"
tabindex="-1"
onclick={handleClose}
onkeydown={(e) => e.key === 'Escape' && handleClose()}
></div>
<!-- Panel -->
<div
class="fixed top-0 right-0 z-50 flex h-full w-full max-w-md flex-col bg-white shadow-xl"
role="dialog"
aria-modal="true"
aria-label="Add traveller"
>
<!-- Header -->
<div class="flex items-center justify-between border-b border-gray-200 px-6 py-4">
<h2 class="text-base font-semibold text-gray-900">Add traveller</h2>
<button
onclick={handleClose}
class="rounded-md p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
aria-label="Close"
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
<div class="flex flex-1 flex-col overflow-y-auto">
<!-- Existing people picker -->
{#if hasExisting}
<div class="px-6 pt-5">
<p class="mb-3 text-xs font-semibold tracking-wider text-gray-400 uppercase">
Your travellers
</p>
<div class="flex flex-col gap-1">
{#each available as person (person.id)}
<form
method="POST"
action="?/addTraveller"
use:enhance={() => {
return ({ result, update }) => {
update();
if (result.type === 'success') handleClose();
};
}}
>
<input type="hidden" name="person_id" value={person.id} />
<button
type="submit"
class="flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left hover:bg-gray-50"
>
<div
class="flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-xs font-semibold {colour(
person
)}"
>
{flagInitials(person)}
</div>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-gray-900">
{person.first_name}
{person.last_name}
{#if person.is_self}
<span
class="ml-1.5 rounded-full bg-indigo-100 px-2 py-0.5 text-xs font-medium text-indigo-700"
>You</span
>
{/if}
</p>
{#if person.email}
<p class="truncate text-xs text-gray-400">{person.email}</p>
{/if}
</div>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="#9CA3AF"
stroke-width="2"
>
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
</svg>
</button>
</form>
{/each}
</div>
</div>
<!-- Divider + toggle -->
<div class="px-6 pt-4 pb-2">
{#if !showNewForm}
<button
type="button"
onclick={() => (showNewForm = true)}
class="flex items-center gap-2 text-sm font-medium text-blue-600 hover:text-blue-700"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
>
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
</svg>
Add someone new
</button>
{:else}
<p class="text-xs font-semibold tracking-wider text-gray-400 uppercase">New person</p>
{/if}
</div>
{/if}
<!-- New person form -->
{#if showNewForm}
<form
method="POST"
action="?/addTraveller"
class="flex flex-1 flex-col"
use:enhance={() => {
return ({ result, update }) => {
update();
if (result.type === 'success') {
resetNew();
handleClose();
}
};
}}
>
<div class="flex flex-col gap-4 px-6 pb-6 {hasExisting ? 'pt-3' : 'pt-5'}">
<div class="grid grid-cols-2 gap-4">
<div class="flex flex-col gap-1.5">
<label for="first_name" class="text-sm font-medium text-gray-700">
First name <span class="text-red-500">*</span>
</label>
<input
id="first_name"
name="first_name"
type="text"
bind:value={firstName}
required
autocomplete="given-name"
placeholder="Jane"
class="rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 placeholder-gray-400 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div class="flex flex-col gap-1.5">
<label for="last_name" class="text-sm font-medium text-gray-700">
Last name <span class="text-red-500">*</span>
</label>
<input
id="last_name"
name="last_name"
type="text"
bind:value={lastName}
required
autocomplete="family-name"
placeholder="Smith"
class="rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 placeholder-gray-400 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
</div>
<div class="flex flex-col gap-1.5">
<label for="email" class="text-sm font-medium text-gray-700">
Email <span class="text-xs font-normal text-gray-400">(optional)</span>
</label>
<input
id="email"
name="email"
type="email"
bind:value={email}
autocomplete="email"
placeholder="jane@example.com"
class="rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 placeholder-gray-400 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div class="flex justify-end gap-3 pt-1">
{#if hasExisting}
<button
type="button"
onclick={() => (showNewForm = false)}
class="rounded-md border border-gray-300 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
>
Cancel
</button>
{:else}
<button
type="button"
onclick={handleClose}
class="rounded-md border border-gray-300 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
>
Cancel
</button>
{/if}
<button
type="submit"
disabled={!firstName.trim() || !lastName.trim()}
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
>
Add traveller
</button>
</div>
</div>
</form>
{/if}
</div>
</div>
{/if}

View File

@@ -0,0 +1,51 @@
<script lang="ts">
import { base } from '$app/paths';
interface App {
name: string;
url: string;
icon: string;
}
const apps: App[] = [
{
name: 'Contacts',
url: 'https://cloud.campbellwireless.net/contacts',
icon: `${base}/icons/contacts.png`
},
{
name: 'Calendar',
url: 'https://cloud.campbellwireless.net/calendar',
icon: `${base}/icons/calendar.png`
},
{
name: 'Docs',
url: 'https://cloud.campbellwireless.net/docs',
icon: `${base}/icons/docs.png`
},
{
name: 'Mail',
url: 'https://cloud.campbellwireless.net/mail',
icon: `${base}/icons/mail.png`
},
{
name: 'Photos',
url: 'https://cloud.campbellwireless.net/photos',
icon: `${base}/icons/photos.png`
}
];
</script>
<div
style="left:59px; top:0; width:280px;"
class="absolute z-20 rounded-lg border border-gray-200 bg-white p-4 shadow-lg"
>
<div class="grid grid-cols-3 gap-2">
{#each apps as app}
<a href={app.url} class="flex flex-col items-center gap-2 rounded-lg p-3 hover:bg-gray-50">
<img src={app.icon} alt={app.name} class="h-14 w-14 rounded-xl" />
<span class="text-center text-xs text-gray-700">{app.name}</span>
</a>
{/each}
</div>
</div>

View File

@@ -0,0 +1,77 @@
<script lang="ts">
import { base } from '$app/paths';
import { page } from '$app/stores';
function isActive(path: string) {
return $page.url.pathname === `${base}${path}`;
}
</script>
<nav
style="width:220px; background:#FFFFFF; border-right:1px solid #E2E4E6;"
class="flex h-screen flex-shrink-0 flex-col"
>
<!-- Banner -->
<div class="flex items-center gap-3 px-5 py-5">
<img src="{base}/favicon.svg" alt="Trips" class="h-8 w-8" />
<span class="text-2xl font-bold text-gray-900">Trips</span>
</div>
<!-- Nav links -->
<div class="flex flex-1 flex-col gap-0.5 px-3">
<!-- Dashboard -->
<a
href="{base}/dashboard"
style={isActive('/dashboard') ? 'background:#E8EAEC; color:#111827; font-weight:500;' : ''}
class="rounded-md px-3 py-2 text-sm text-gray-600 hover:bg-gray-100 hover:text-gray-900"
>
Dashboard
</a>
<!-- Divider -->
<hr class="my-2 border-gray-200" />
<!-- Trips section label -->
<p class="px-3 pb-1 text-xs font-semibold tracking-wider text-gray-400 uppercase">Trips</p>
<a
href="{base}/trips/upcoming"
style={isActive('/trips/upcoming')
? 'background:#E8EAEC; color:#111827; font-weight:500;'
: ''}
class="rounded-md px-3 py-2 text-sm text-gray-600 hover:bg-gray-100 hover:text-gray-900"
>
Upcoming Trips
</a>
<a
href="{base}/trips/past"
style={isActive('/trips/past') ? 'background:#E8EAEC; color:#111827; font-weight:500;' : ''}
class="rounded-md px-3 py-2 text-sm text-gray-600 hover:bg-gray-100 hover:text-gray-900"
>
Past Trips
</a>
</div>
<!-- Plan New Trip button -->
<div class="px-3 pb-4">
<a
href="{base}/trips/new"
style={isActive('/trips/new') ? 'background:#1d4ed8;' : ''}
class="flex w-full items-center justify-center gap-2 rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
Plan New Trip
</a>
</div>
</nav>

View File

@@ -0,0 +1,82 @@
<script lang="ts">
import type { Plan } from '$lib/server/plans.js';
interface Props {
plan: Plan;
onDelete?: () => void;
}
let { plan, onDelete }: Props = $props();
function flagEmoji(code: string | null): string {
if (!code) return '';
return code
.toUpperCase()
.split('')
.map((c) => String.fromCodePoint(0x1f1e6 + c.charCodeAt(0) - 65))
.join('');
}
function formatDate(d: string | null): string {
if (!d) return '';
return new Date(d + 'T00:00:00').toLocaleDateString(undefined, {
day: 'numeric',
month: 'short',
year: 'numeric'
});
}
const statusConfig = {
idea: { label: 'Idea', class: 'bg-gray-100 text-gray-600' },
tentative: { label: 'Tentative', class: 'bg-yellow-100 text-yellow-800' },
confirmed: { label: 'Confirmed', class: 'bg-green-100 text-green-800' }
};
let dateRange = $derived(() => {
const start = formatDate(plan.start_date);
const end = formatDate(plan.end_date);
if (start && end) return `${start}${end}`;
if (start) return `From ${start}`;
if (end) return `Until ${end}`;
return 'Dates TBD';
});
</script>
<div class="flex items-center gap-4 rounded-xl border border-gray-200 bg-white px-5 py-4 shadow-sm">
<!-- Flag -->
<div class="text-3xl leading-none" aria-hidden="true">
{flagEmoji(plan.country_code)}
</div>
<!-- Info -->
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<p class="truncate font-medium text-gray-900">{plan.city_name ?? plan.title}</p>
{#if plan.country}
<span class="text-sm text-gray-400">{plan.country}</span>
{/if}
</div>
<p class="mt-0.5 text-xs text-gray-500">{dateRange()}</p>
</div>
<!-- Status badge -->
<span
class="shrink-0 rounded-full px-2.5 py-0.5 text-xs font-medium {statusConfig[plan.status].class}"
>
{statusConfig[plan.status].label}
</span>
{#if onDelete}
<button
type="button"
onclick={onDelete}
class="shrink-0 rounded-md p-1.5 text-gray-400 hover:bg-gray-100 hover:text-red-600"
aria-label="Remove destination"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
{/if}
</div>

View File

@@ -0,0 +1,151 @@
<script lang="ts">
import { base } from '$app/paths';
import AppSwitcher from './AppSwitcher.svelte';
interface Props {
userName?: string | null;
}
let { userName }: Props = $props();
const initial = $derived(userName ? userName.charAt(0).toUpperCase() : '?');
let appSwitcherOpen = $state(false);
let userMenuOpen = $state(false);
function closeAll() {
appSwitcherOpen = false;
userMenuOpen = false;
}
</script>
<!-- Click-outside overlay -->
{#if appSwitcherOpen || userMenuOpen}
<div class="fixed inset-0 z-10" onclick={closeAll}></div>
{/if}
<aside
style="width:59px; background:#F6F8FA; border-right:1px solid #E2E4E6;"
class="relative flex h-screen flex-shrink-0 flex-col items-center py-3"
>
<!-- Top: app switcher -->
<div class="relative flex flex-1 flex-col items-center gap-1 pt-1">
<button
title="App switcher"
class={`flex h-10 w-10 items-center justify-center rounded hover:bg-black/5 ${appSwitcherOpen ? 'bg-black/5' : ''}`}
onclick={(e) => {
e.stopPropagation();
appSwitcherOpen = !appSwitcherOpen;
userMenuOpen = false;
}}
>
<svg
width="22"
height="22"
viewBox="0 0 22 22"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect
x="5"
y="1"
width="5.5"
height="5.5"
rx="0.5"
transform="rotate(45 5 1)"
fill="#4A4A4A"
/>
<rect x="12.5" y="1.5" width="7" height="7" rx="1" fill="#4A4A4A" />
<rect x="1.5" y="12.5" width="7" height="7" rx="1" fill="#4A4A4A" />
<rect x="12.5" y="12.5" width="7" height="7" rx="1" fill="#4A4A4A" />
</svg>
</button>
{#if appSwitcherOpen}
<AppSwitcher />
{/if}
</div>
<!-- Bottom: user avatar -->
<div class="relative flex flex-col items-center pb-1">
<button
title={userName ?? ''}
style="background:#499DF2;"
class="flex h-8 w-8 items-center justify-center rounded-full text-sm font-medium text-white select-none hover:opacity-90"
onclick={(e) => {
e.stopPropagation();
userMenuOpen = !userMenuOpen;
appSwitcherOpen = false;
}}
>
{initial}
</button>
{#if userMenuOpen}
<div
style="left:59px; bottom:0;"
class="absolute z-20 w-48 rounded-lg border border-gray-200 bg-white py-1 shadow-lg"
>
<div class="border-b border-gray-100 px-4 py-2">
<p class="truncate text-sm font-medium text-gray-900">{userName}</p>
</div>
<a
href="{base}/profile"
onclick={closeAll}
class="flex w-full items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
My Profile
</a>
<a
href="{base}/settings"
onclick={closeAll}
class="flex w-full items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="12" cy="12" r="3" />
<path
d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"
/>
</svg>
Settings
</a>
<form method="POST" action="{base}/auth/signout">
<button
type="submit"
class="flex w-full items-center gap-2 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<polyline points="16 17 21 12 16 7" />
<line x1="21" y1="12" x2="9" y2="12" />
</svg>
Sign out
</button>
</form>
</div>
{/if}
</div>
</aside>

View File

@@ -0,0 +1,61 @@
<script lang="ts">
import type { Person } from '$lib/server/travellers.js';
interface Props {
traveller: Person;
onDelete?: () => void;
}
let { traveller, onDelete }: Props = $props();
// Generate a consistent colour from the name
const colours = [
'bg-blue-100 text-blue-700',
'bg-violet-100 text-violet-700',
'bg-pink-100 text-pink-700',
'bg-amber-100 text-amber-700',
'bg-teal-100 text-teal-700',
'bg-orange-100 text-orange-700',
'bg-indigo-100 text-indigo-700',
'bg-green-100 text-green-700'
];
let colour = $derived(
colours[
(traveller.first_name.charCodeAt(0) + traveller.last_name.charCodeAt(0)) % colours.length
]
);
let initials = $derived(
`${traveller.first_name[0] ?? ''}${traveller.last_name[0] ?? ''}`.toUpperCase()
);
let fullName = $derived(`${traveller.first_name} ${traveller.last_name}`);
</script>
<div
class="flex items-center gap-2.5 rounded-full border border-gray-200 bg-white py-1.5 pr-3 pl-1.5"
>
<div
class="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-xs font-semibold {colour}"
>
{initials}
</div>
<span class="text-sm font-medium text-gray-800">{fullName}</span>
{#if traveller.email}
<span class="text-xs text-gray-400">{traveller.email}</span>
{/if}
{#if onDelete}
<button
type="button"
onclick={onDelete}
class="ml-1.5 rounded-full p-0.5 text-gray-400 hover:bg-gray-100 hover:text-red-600"
aria-label="Remove traveller"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
{/if}
</div>

View File

@@ -0,0 +1,62 @@
<script lang="ts">
import TripTile from './TripTile.svelte';
import TripTable from './TripTable.svelte';
import type { Trip } from '$lib/server/trips.js';
interface Props {
trips: Trip[];
title: string;
emptyMessage: string;
}
let { trips, title, emptyMessage }: Props = $props();
let view = $state<'tile' | 'table'>('tile');
</script>
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold text-gray-900">{title}</h1>
{#if trips.length > 0}
<div class="flex items-center gap-1 rounded-md border border-gray-200 bg-gray-50 p-1">
<button
onclick={() => view = 'tile'}
title="Tile view"
class="rounded p-1.5 transition-colors"
style={view === 'tile' ? 'background:#fff; box-shadow:0 1px 2px rgba(0,0,0,0.08);' : ''}
>
<!-- Grid icon -->
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke={view === 'tile' ? '#111827' : '#9ca3af'} stroke-width="2">
<rect x="3" y="3" width="7" height="7" rx="1" />
<rect x="14" y="3" width="7" height="7" rx="1" />
<rect x="3" y="14" width="7" height="7" rx="1" />
<rect x="14" y="14" width="7" height="7" rx="1" />
</svg>
</button>
<button
onclick={() => view = 'table'}
title="Table view"
class="rounded p-1.5 transition-colors"
style={view === 'table' ? 'background:#fff; box-shadow:0 1px 2px rgba(0,0,0,0.08);' : ''}
>
<!-- List icon -->
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke={view === 'table' ? '#111827' : '#9ca3af'} stroke-width="2">
<line x1="3" y1="6" x2="21" y2="6" />
<line x1="3" y1="12" x2="21" y2="12" />
<line x1="3" y1="18" x2="21" y2="18" />
</svg>
</button>
</div>
{/if}
</div>
{#if trips.length === 0}
<p class="text-gray-500">{emptyMessage}</p>
{:else if view === 'tile'}
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{#each trips as trip}
<TripTile {trip} />
{/each}
</div>
{:else}
<TripTable {trips} />
{/if}

View File

@@ -0,0 +1,37 @@
<script lang="ts">
import { base } from '$app/paths';
import type { Trip } from '$lib/server/trips.js';
interface Props {
trips: Trip[];
}
let { trips }: Props = $props();
function formatDate(d: string | null) {
if (!d) return 'TBD';
return new Date(d + 'T00:00:00').toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' });
}
</script>
<table class="w-full text-sm">
<thead>
<tr class="border-b border-gray-200 text-left text-xs font-semibold uppercase tracking-wider text-gray-400">
<th class="pb-3 pr-6">Name</th>
<th class="pb-3 pr-6">Start</th>
<th class="pb-3 pr-6">End</th>
<th class="pb-3">Description</th>
</tr>
</thead>
<tbody>
{#each trips as trip}
<tr class="border-b border-gray-100 hover:bg-gray-50">
<td class="py-3 pr-6 font-medium text-gray-900">
<a href="{base}/trips/{trip.id}" class="hover:underline">{trip.name}</a>
</td>
<td class="py-3 pr-6 text-gray-600">{formatDate(trip.start_date)}</td>
<td class="py-3 pr-6 text-gray-600">{formatDate(trip.end_date)}</td>
<td class="py-3 text-gray-500 max-w-xs truncate">{trip.description ?? '—'}</td>
</tr>
{/each}
</tbody>
</table>

View File

@@ -0,0 +1,32 @@
<script lang="ts">
import { base } from '$app/paths';
import type { Trip } from '$lib/server/trips.js';
interface Props {
trip: Trip;
}
let { trip }: Props = $props();
function formatDate(d: string | null) {
if (!d) return 'TBD';
return new Date(d + 'T00:00:00').toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' });
}
</script>
<a
href="{base}/trips/{trip.id}"
class="flex flex-col rounded-xl border border-gray-200 bg-white shadow-sm transition-shadow hover:shadow-md overflow-hidden"
>
<!-- Placeholder artwork area -->
<div class="h-32 w-full bg-gradient-to-br from-blue-100 to-blue-200"></div>
<div class="flex flex-col gap-1 p-4">
<h2 class="font-semibold text-gray-900 truncate">{trip.name}</h2>
<p class="text-xs text-gray-500">
{formatDate(trip.start_date)}{formatDate(trip.end_date)}
</p>
{#if trip.description}
<p class="mt-1 text-sm text-gray-600 line-clamp-2">{trip.description}</p>
{/if}
</div>
</a>

View File

@@ -0,0 +1,160 @@
<script lang="ts">
interface Action {
label: string;
description: string;
icon: string;
color: string;
onclick: () => void;
}
interface Props {
tripName: string;
onAddDestination?: () => void;
onAddTraveller?: () => void;
}
let { tripName, onAddDestination, onAddTraveller }: Props = $props();
const actions: Action[] = [
{
label: 'Destinations',
description: "Add the places you're visiting",
color: '#EFF6FF',
icon: `<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#3B82F6" stroke-width="1.5">
<path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7z"/>
<circle cx="12" cy="9" r="2.5"/>
</svg>`,
onclick: () => onAddDestination?.()
},
{
label: 'Attractions & Activities',
description: 'Things to see and do',
color: '#F0FDF4',
icon: `<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#22C55E" stroke-width="1.5">
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>
</svg>`,
onclick: () => {}
},
{
label: 'Transportation',
description: 'Flights, trains, car hire and more',
color: '#FFF7ED',
icon: `<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#F97316" stroke-width="1.5">
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-3.99-3.99A19.79 19.79 0 0 1 4.1 6.18 2 2 0 0 1 6.08 4h3a2 2 0 0 1 2 1.72c.127.96.361 1.903.7 2.81a2 2 0 0 1-.45 2.11L10.09 11a16 16 0 0 0 5.91 5.91l1.27-1.27a2 2 0 0 1 2.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0 1 24 18z"/>
</svg>`,
onclick: () => {}
},
{
label: 'Lodgings',
description: 'Hotels, rentals and accommodation',
color: '#FDF4FF',
icon: `<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#A855F7" stroke-width="1.5">
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
<polyline points="9 22 9 12 15 12 15 22"/>
</svg>`,
onclick: () => {}
},
{
label: 'Restaurants',
description: 'Places to eat and drink',
color: '#FFF1F2',
icon: `<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#F43F5E" stroke-width="1.5">
<path d="M18 8h1a4 4 0 0 1 0 8h-1"/>
<path d="M2 8h16v9a4 4 0 0 1-4 4H6a4 4 0 0 1-4-4V8z"/>
<line x1="6" y1="1" x2="6" y2="4"/>
<line x1="10" y1="1" x2="10" y2="4"/>
<line x1="14" y1="1" x2="14" y2="4"/>
</svg>`,
onclick: () => {}
},
{
label: 'Package Tours',
description: 'Pre-arranged tours and excursions',
color: '#FFFBEB',
icon: `<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#F59E0B" stroke-width="1.5">
<rect x="2" y="7" width="20" height="14" rx="2"/>
<path d="M16 7V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v2"/>
<line x1="12" y1="12" x2="12" y2="16"/>
<line x1="10" y1="14" x2="14" y2="14"/>
</svg>`,
onclick: () => {}
},
{
label: 'Packing List',
description: 'Everything you need to bring',
color: '#F0FDFA',
icon: `<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#14B8A6" stroke-width="1.5">
<path d="M9 11l3 3L22 4"/>
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/>
</svg>`,
onclick: () => {}
},
{
label: 'To-dos',
description: 'Visas, documents and tasks',
color: '#F8FAFC',
icon: `<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#64748B" stroke-width="1.5">
<line x1="8" y1="6" x2="21" y2="6"/>
<line x1="8" y1="12" x2="21" y2="12"/>
<line x1="8" y1="18" x2="21" y2="18"/>
<line x1="3" y1="6" x2="3.01" y2="6"/>
<line x1="3" y1="12" x2="3.01" y2="12"/>
<line x1="3" y1="18" x2="3.01" y2="18"/>
</svg>`,
onclick: () => {}
}
];
</script>
<div class="mt-10 border-t border-gray-100 pt-10">
<div class="mb-8 text-center">
<h2 class="text-xl font-semibold text-gray-900">Start building your trip</h2>
<p class="mt-1 text-sm text-gray-500">
What would you like to add to <span class="font-medium text-gray-700">{tripName}</span>?
</p>
</div>
<!-- Who's travelling — visually distinct from plan types -->
<button
onclick={() => onAddTraveller?.()}
class="mb-4 flex w-full items-center gap-4 rounded-xl border border-indigo-100 bg-indigo-50 px-5 py-4 text-left transition-all hover:border-indigo-200 hover:shadow-sm"
>
<div class="flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-indigo-100">
<svg
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="#6366F1"
stroke-width="1.5"
>
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
</div>
<div class="flex-1">
<p class="font-medium text-indigo-900">Who's travelling?</p>
<p class="mt-0.5 text-sm text-indigo-500">Add the people joining this trip</p>
</div>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#6366F1" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
</svg>
</button>
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
{#each actions as action}
<button
onclick={action.onclick}
style="background:{action.color};"
class="flex flex-col items-start gap-3 rounded-xl border border-gray-100 p-5 text-left transition-all hover:scale-[1.02] hover:shadow-md"
>
<div>{@html action.icon}</div>
<div>
<p class="font-medium text-gray-900">{action.label}</p>
<p class="mt-0.5 text-xs text-gray-500">{action.description}</p>
</div>
</button>
{/each}
</div>
</div>

1
src/lib/index.ts Normal file
View File

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

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,20 @@
import { env } from '$env/dynamic/private';
import { createSqliteDb } from './sqlite.js';
import { runMigrations } from './migrations.js';
import type { Database } from './types.js';
function createDb(): Database {
const url = env.DATABASE_URL ?? 'file:trips.db';
if (url.startsWith('file:') || url.endsWith('.db')) {
return createSqliteDb(url);
}
// Postgres support: add a postgres.ts adapter and import it here
// e.g. if (url.startsWith('postgres')) return createPostgresDb(url);
throw new Error(`Unsupported DATABASE_URL scheme: ${url}`);
}
export const db: Database = createDb();
runMigrations(db);

View File

@@ -0,0 +1,112 @@
import { readFileSync } from 'fs';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
import type { Database } from './types.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
export function runMigrations(db: Database): void {
db.run(`
CREATE TABLE IF NOT EXISTS trips (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
description TEXT,
start_date TEXT,
end_date TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
db.run(`
CREATE TABLE IF NOT EXISTS cities (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
country TEXT NOT NULL,
country_code TEXT NOT NULL,
population INTEGER
)
`);
db.run(`
CREATE TABLE IF NOT EXISTS plans (
id TEXT PRIMARY KEY,
trip_id TEXT NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
user_id TEXT NOT NULL,
type TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'idea',
parent_id TEXT REFERENCES plans(id) ON DELETE SET NULL,
title TEXT NOT NULL,
notes TEXT,
city_id INTEGER REFERENCES cities(id),
city_name TEXT,
country TEXT,
country_code TEXT,
start_date TEXT,
end_date TEXT,
position INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
db.run(`
CREATE TABLE IF NOT EXISTS travellers (
id TEXT PRIMARY KEY,
trip_id TEXT NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
user_id TEXT NOT NULL,
first_name TEXT NOT NULL,
last_name TEXT NOT NULL,
email TEXT,
position INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
db.run(`
CREATE TABLE IF NOT EXISTS people (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
first_name TEXT NOT NULL,
last_name TEXT NOT NULL,
email TEXT,
is_self INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
db.run(`
CREATE TABLE IF NOT EXISTS trip_travellers (
trip_id TEXT NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
person_id TEXT NOT NULL REFERENCES people(id) ON DELETE CASCADE,
position INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (trip_id, person_id)
)
`);
// Seed cities table on first run
const row = db.get<{ count: number }>('SELECT COUNT(*) as count FROM cities');
if ((row?.count ?? 0) === 0) {
seedCities(db);
}
}
function seedCities(db: Database): void {
try {
const data = readFileSync(join(__dirname, '../data/cities.json'), 'utf-8');
const cities: { name: string; country: string; country_code: string; population: number }[] =
JSON.parse(data);
let id = 1;
for (const city of cities) {
db.run(
'INSERT OR IGNORE INTO cities (id, name, country, country_code, population) VALUES (?, ?, ?, ?, ?)',
[id++, city.name, city.country, city.country_code, city.population]
);
}
console.log(`[db] Seeded ${cities.length} cities`);
} catch (e) {
console.error('[db] Failed to seed cities:', e);
}
}

View File

@@ -0,0 +1,26 @@
import BetterSqlite3 from 'better-sqlite3';
import type { Database } from './types.js';
export function createSqliteDb(url: string): Database {
// Strip the "file:" prefix if present
const path = url.startsWith('file:') ? url.slice(5) : url;
const db = new BetterSqlite3(path);
// Enable WAL mode for better concurrent read performance
db.pragma('journal_mode = WAL');
return {
run(sql, params = []) {
db.prepare(sql).run(params);
},
get<T = Record<string, unknown>>(sql: string, params: unknown[] = []) {
return db.prepare(sql).get(params) as T | undefined;
},
all<T = Record<string, unknown>>(sql: string, params: unknown[] = []) {
return db.prepare(sql).all(params) as T[];
},
close() {
db.close();
}
};
}

View File

@@ -0,0 +1,10 @@
export interface Database {
/** Execute a statement that returns no rows (INSERT, UPDATE, DELETE, CREATE, etc.) */
run(sql: string, params?: unknown[]): void;
/** Execute a SELECT and return the first matching row, or undefined */
get<T = Record<string, unknown>>(sql: string, params?: unknown[]): T | undefined;
/** Execute a SELECT and return all matching rows */
all<T = Record<string, unknown>>(sql: string, params?: unknown[]): T[];
/** Close the database connection */
close(): void;
}

124
src/lib/server/plans.ts Normal file
View File

@@ -0,0 +1,124 @@
import { db } from './db/index.js';
import { randomUUID } from 'crypto';
export type PlanType =
| 'destination'
| 'activity'
| 'transport'
| 'lodging'
| 'restaurant'
| 'tour'
| 'packing'
| 'todo';
export type PlanStatus = 'idea' | 'tentative' | 'confirmed';
export interface Plan {
id: string;
trip_id: string;
user_id: string;
type: PlanType;
status: PlanStatus;
parent_id: string | null;
title: string;
notes: string | null;
city_id: number | null;
city_name: string | null;
country: string | null;
country_code: string | null;
start_date: string | null;
end_date: string | null;
position: number;
created_at: string;
updated_at: string;
}
export interface City {
id: number;
name: string;
country: string;
country_code: string;
population: number | null;
}
export interface CreateDestinationInput {
tripId: string;
userId: string;
cityId?: number;
cityName: string;
country: string;
countryCode: string;
startDate?: string;
endDate?: string;
status?: PlanStatus;
notes?: string;
}
export function createDestination(input: CreateDestinationInput): Plan {
const id = randomUUID();
const maxPos = db.get<{ pos: number }>(
`SELECT COALESCE(MAX(position), -1) + 1 as pos FROM plans WHERE trip_id = ? AND user_id = ?`,
[input.tripId, input.userId]
);
const position = maxPos?.pos ?? 0;
db.run(
`INSERT INTO plans (id, trip_id, user_id, type, status, title, notes, city_id, city_name, country, country_code, start_date, end_date, position)
VALUES (?, ?, ?, 'destination', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
id,
input.tripId,
input.userId,
input.status ?? 'idea',
input.cityName,
input.notes ?? null,
input.cityId ?? null,
input.cityName,
input.country,
input.countryCode,
input.startDate ?? null,
input.endDate ?? null,
position
]
);
return db.get<Plan>('SELECT * FROM plans WHERE id = ?', [id])!;
}
export function getPlansForTrip(tripId: string, userId: string): Plan[] {
return db.all<Plan>(
`SELECT * FROM plans WHERE trip_id = ? AND user_id = ? ORDER BY position ASC, created_at ASC`,
[tripId, userId]
);
}
export function getPlanCountForTrip(tripId: string, userId: string): number {
const row = db.get<{ count: number }>(
`SELECT COUNT(*) as count FROM plans WHERE trip_id = ? AND user_id = ?`,
[tripId, userId]
);
return row?.count ?? 0;
}
export function searchCities(query: string): City[] {
if (!query.trim()) return [];
const pattern = `%${query.trim()}%`;
return db.all<City>(
`SELECT id, name, country, country_code, population
FROM cities
WHERE name LIKE ? COLLATE NOCASE
ORDER BY
CASE WHEN name LIKE ? COLLATE NOCASE THEN 0 ELSE 1 END,
population DESC NULLS LAST
LIMIT 10`,
[pattern, `${query.trim()}%`]
);
}
export function deletePlan(planId: string, userId: string): void {
// Verify the plan belongs to the user
const plan = db.get<Plan>('SELECT * FROM plans WHERE id = ? AND user_id = ?', [planId, userId]);
if (!plan) {
throw new Error('Plan not found or not authorized');
}
db.run('DELETE FROM plans WHERE id = ?', [planId]);
}

View File

@@ -0,0 +1,142 @@
import { db } from './db/index.js';
import { randomUUID } from 'crypto';
export interface Person {
id: string;
user_id: string;
first_name: string;
last_name: string;
email: string | null;
is_self: number; // 1 = owner's own profile
created_at: string;
updated_at: string;
}
// ---------------------------------------------------------------------------
// Global people management
// ---------------------------------------------------------------------------
export function getSelfProfile(userId: string): Person | undefined {
return db.get<Person>(`SELECT * FROM people WHERE user_id = ? AND is_self = 1 LIMIT 1`, [userId]);
}
export function upsertSelfProfile(
userId: string,
firstName: string,
lastName: string,
email?: string
): Person {
const existing = getSelfProfile(userId);
if (existing) {
db.run(
`UPDATE people SET first_name = ?, last_name = ?, email = ?, updated_at = datetime('now')
WHERE id = ?`,
[firstName, lastName, email ?? null, existing.id]
);
return db.get<Person>('SELECT * FROM people WHERE id = ?', [existing.id])!;
}
const id = randomUUID();
db.run(
`INSERT INTO people (id, user_id, first_name, last_name, email, is_self)
VALUES (?, ?, ?, ?, ?, 1)`,
[id, userId, firstName, lastName, email ?? null]
);
return db.get<Person>('SELECT * FROM people WHERE id = ?', [id])!;
}
export function createPerson(
userId: string,
firstName: string,
lastName: string,
email?: string
): Person {
const id = randomUUID();
db.run(
`INSERT INTO people (id, user_id, first_name, last_name, email, is_self)
VALUES (?, ?, ?, ?, ?, 0)`,
[id, userId, firstName, lastName, email ?? null]
);
return db.get<Person>('SELECT * FROM people WHERE id = ?', [id])!;
}
export function getPeopleForUser(userId: string): Person[] {
// Own profile first, then others alphabetically
return db.all<Person>(
`SELECT * FROM people WHERE user_id = ?
ORDER BY is_self DESC, first_name ASC, last_name ASC`,
[userId]
);
}
// ---------------------------------------------------------------------------
// Trip assignment
// ---------------------------------------------------------------------------
export function addPersonToTrip(tripId: string, personId: string): void {
const maxPos = db.get<{ pos: number }>(
`SELECT COALESCE(MAX(position), -1) + 1 as pos FROM trip_travellers WHERE trip_id = ?`,
[tripId]
);
db.run(`INSERT OR IGNORE INTO trip_travellers (trip_id, person_id, position) VALUES (?, ?, ?)`, [
tripId,
personId,
maxPos?.pos ?? 0
]);
}
export function addTraveller(input: {
tripId: string;
userId: string;
personId?: string;
firstName?: string;
lastName?: string;
email?: string;
}): void {
let personId = input.personId;
// If no personId provided, create a new person
if (!personId) {
if (!input.firstName || !input.lastName) {
throw new Error('First name and last name are required when creating a new person');
}
const person = createPerson(input.userId, input.firstName, input.lastName, input.email);
personId = person.id;
}
// Add the person to the trip
addPersonToTrip(input.tripId, personId);
}
export function getTravellersForTrip(tripId: string, userId: string): Person[] {
return db.all<Person>(
`SELECT p.* FROM people p
JOIN trip_travellers tt ON tt.person_id = p.id
WHERE tt.trip_id = ? AND p.user_id = ?
ORDER BY tt.position ASC, p.first_name ASC`,
[tripId, userId]
);
}
export function getTravellerCountForTrip(tripId: string, userId: string): number {
const row = db.get<{ count: number }>(
`SELECT COUNT(*) as count FROM trip_travellers tt
JOIN people p ON p.id = tt.person_id
WHERE tt.trip_id = ? AND p.user_id = ?`,
[tripId, userId]
);
return row?.count ?? 0;
}
export function removeTravellerFromTrip(tripId: string, personId: string, userId: string): void {
// Verify the person belongs to the user and is on the trip
const traveller = db.get<Person>(
`SELECT p.* FROM people p
JOIN trip_travellers tt ON tt.person_id = p.id
WHERE tt.trip_id = ? AND p.id = ? AND p.user_id = ?`,
[tripId, personId, userId]
);
if (!traveller) {
throw new Error('Traveller not found or not authorized');
}
db.run(`DELETE FROM trip_travellers WHERE trip_id = ? AND person_id = ?`, [tripId, personId]);
}

89
src/lib/server/trips.ts Normal file
View File

@@ -0,0 +1,89 @@
import { db } from './db/index.js';
import { randomUUID } from 'crypto';
export interface Trip {
id: string;
user_id: string;
name: string;
description: string | null;
start_date: string | null;
end_date: string | null;
created_at: string;
updated_at: string;
}
export interface CreateTripInput {
userId: string;
name: string;
description?: string;
startDate?: string;
endDate?: string;
}
export interface UpdateTripInput {
name: string;
description?: string;
startDate?: string;
endDate?: string;
}
export function createTrip(input: CreateTripInput): Trip {
const id = randomUUID();
db.run(
`INSERT INTO trips (id, user_id, name, description, start_date, end_date)
VALUES (?, ?, ?, ?, ?, ?)`,
[
id,
input.userId,
input.name,
input.description ?? null,
input.startDate ?? null,
input.endDate ?? null
]
);
return db.get<Trip>('SELECT * FROM trips WHERE id = ?', [id])!;
}
export function updateTrip(id: string, userId: string, input: UpdateTripInput): Trip | undefined {
db.run(
`UPDATE trips
SET name = ?, description = ?, start_date = ?, end_date = ?, updated_at = datetime('now')
WHERE id = ? AND user_id = ?`,
[
input.name,
input.description ?? null,
input.startDate ?? null,
input.endDate ?? null,
id,
userId
]
);
return db.get<Trip>('SELECT * FROM trips WHERE id = ? AND user_id = ?', [id, userId]);
}
export function getTripById(id: string, userId: string): Trip | undefined {
return db.get<Trip>('SELECT * FROM trips WHERE id = ? AND user_id = ?', [id, userId]);
}
export function getUpcomingTrips(userId: string): Trip[] {
const today = new Date().toISOString().slice(0, 10);
return db.all<Trip>(
`SELECT * FROM trips
WHERE user_id = ?
AND (end_date IS NULL OR end_date >= ?)
ORDER BY start_date ASC NULLS FIRST`,
[userId, today]
);
}
export function getPastTrips(userId: string): Trip[] {
const today = new Date().toISOString().slice(0, 10);
return db.all<Trip>(
`SELECT * FROM trips
WHERE user_id = ?
AND end_date IS NOT NULL
AND end_date < ?
ORDER BY end_date DESC`,
[userId, today]
);
}

View File

@@ -0,0 +1,11 @@
import { redirect } from '@sveltejs/kit';
import { base } from '$app/paths';
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async (event) => {
const session = await event.locals.auth();
if (!session?.user) {
redirect(303, `${base}/login`);
}
return { session };
};

View File

@@ -0,0 +1,14 @@
<script lang="ts">
import Sidebar from '$lib/components/Sidebar.svelte';
import NavMenu from '$lib/components/NavMenu.svelte';
let { children, data } = $props();
</script>
<div class="flex h-screen overflow-hidden">
<Sidebar userName={data.session.user?.name ?? data.session.user?.email} />
<NavMenu />
<main class="flex-1 overflow-auto bg-white p-8">
{@render children()}
</main>
</div>

View File

@@ -0,0 +1,9 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { searchCities } from '$lib/server/plans.js';
export const GET: RequestHandler = ({ url }) => {
const q = url.searchParams.get('q') ?? '';
const cities = searchCities(q);
return json(cities);
};

View File

@@ -0,0 +1,10 @@
<script lang="ts">
let { data } = $props();
</script>
<svelte:head>
<title>Dashboard — Trips</title>
</svelte:head>
<h1 class="mb-2 text-2xl font-bold text-gray-900">Dashboard</h1>
<p class="text-gray-600">Welcome, {data.session.user?.name ?? data.session.user?.email}.</p>

View File

@@ -0,0 +1,32 @@
import { error, fail } from '@sveltejs/kit';
import { getSelfProfile, upsertSelfProfile } from '$lib/server/travellers.js';
import type { PageServerLoad, Actions } from './$types';
export const load: PageServerLoad = async (event) => {
const session = await event.locals.auth();
const userId = session?.user?.id;
if (!userId) error(401, 'Not authenticated');
const profile = getSelfProfile(userId);
return { profile };
};
export const actions: Actions = {
save: async (event) => {
const session = await event.locals.auth();
const userId = session?.user?.id;
if (!userId) return fail(401, { error: 'Not authenticated' });
const data = await event.request.formData();
const firstName = (data.get('first_name') as string)?.trim();
const lastName = (data.get('last_name') as string)?.trim();
const email = (data.get('email') as string)?.trim() || undefined;
if (!firstName || !lastName) {
return fail(400, { error: 'First and last name are required' });
}
const profile = upsertSelfProfile(userId, firstName, lastName, email);
return { success: true, profile };
}
};

View File

@@ -0,0 +1,104 @@
<script lang="ts">
import { enhance } from '$app/forms';
let { data, form } = $props();
let profile = $derived(form?.profile ?? data.profile);
let saved = $state(false);
</script>
<svelte:head>
<title>My Profile — Trips</title>
</svelte:head>
<div class="mx-auto max-w-lg">
<h1 class="mb-1 text-2xl font-bold text-gray-900">My Profile</h1>
<p class="mb-8 text-sm text-gray-500">
Your traveller profile is shared across all your trips so you don't need to enter your details
again.
</p>
{#if form?.error}
<div class="mb-4 rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{form.error}
</div>
{/if}
{#if saved}
<div class="mb-4 rounded-md border border-green-200 bg-green-50 px-4 py-3 text-sm text-green-700">
Profile saved.
</div>
{/if}
<form
method="POST"
action="?/save"
use:enhance={() => {
return ({ result, update }) => {
update();
if (result.type === 'success') {
saved = true;
setTimeout(() => (saved = false), 3000);
}
};
}}
class="flex flex-col gap-5"
>
<div class="grid grid-cols-2 gap-4">
<div class="flex flex-col gap-1.5">
<label for="first_name" class="text-sm font-medium text-gray-700">
First name <span class="text-red-500">*</span>
</label>
<input
id="first_name"
name="first_name"
type="text"
value={profile?.first_name ?? ''}
required
autocomplete="given-name"
placeholder="Jane"
class="rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 placeholder-gray-400 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div class="flex flex-col gap-1.5">
<label for="last_name" class="text-sm font-medium text-gray-700">
Last name <span class="text-red-500">*</span>
</label>
<input
id="last_name"
name="last_name"
type="text"
value={profile?.last_name ?? ''}
required
autocomplete="family-name"
placeholder="Smith"
class="rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 placeholder-gray-400 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
</div>
<div class="flex flex-col gap-1.5">
<label for="email" class="text-sm font-medium text-gray-700">
Email <span class="text-xs font-normal text-gray-400">(optional)</span>
</label>
<input
id="email"
name="email"
type="email"
value={profile?.email ?? ''}
autocomplete="email"
placeholder="jane@example.com"
class="rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 placeholder-gray-400 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div class="flex justify-end pt-2">
<button
type="submit"
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
Save profile
</button>
</div>
</form>
</div>

View File

@@ -0,0 +1,157 @@
import { error, fail } from '@sveltejs/kit';
import { getTripById, updateTrip } from '$lib/server/trips.js';
import { createDestination, getPlansForTrip, deletePlan } from '$lib/server/plans.js';
import { addTraveller, getTravellersForTrip, getPeopleForUser, removeTravellerFromTrip } from '$lib/server/travellers.js';
import type { PageServerLoad, Actions } from './$types';
export const load: PageServerLoad = async (event) => {
const session = await event.locals.auth();
const userId = session?.user?.id;
if (!userId) error(401, 'Not authenticated');
const trip = getTripById(event.params.id, userId);
if (!trip) error(404, 'Trip not found');
const plans = getPlansForTrip(trip.id, userId);
const travellers = getTravellersForTrip(trip.id, userId);
const people = getPeopleForUser(userId);
return { trip, plans, planCount: plans.length, travellers, travellerCount: travellers.length, people };
};
export const actions: Actions = {
update: async (event) => {
const session = await event.locals.auth();
const userId = session?.user?.id;
if (!userId) return fail(401, { error: 'Not authenticated' });
const data = await event.request.formData();
const name = (data.get('name') as string)?.trim();
const description = (data.get('description') as string)?.trim();
const startDate = (data.get('start_date') as string)?.trim() || undefined;
const endDate = (data.get('end_date') as string)?.trim() || undefined;
if (!name) return fail(400, { error: 'Trip name is required' });
const trip = updateTrip(event.params.id, userId, {
name,
description: description || undefined,
startDate,
endDate
});
if (!trip) error(404, 'Trip not found');
return { success: true, trip };
},
addDestination: async (event) => {
const session = await event.locals.auth();
const userId = session?.user?.id;
if (!userId) return fail(401, { error: 'Not authenticated' });
const trip = getTripById(event.params.id, userId);
if (!trip) return fail(404, { error: 'Trip not found' });
const data = await event.request.formData();
const cityName = (data.get('city_name') as string)?.trim();
const country = (data.get('country') as string)?.trim();
const countryCode = (data.get('country_code') as string)?.trim();
const cityIdRaw = data.get('city_id') as string;
const startDate = (data.get('start_date') as string)?.trim() || undefined;
const endDate = (data.get('end_date') as string)?.trim() || undefined;
const status = data.get('status') as string as 'idea' | 'tentative' | 'confirmed';
if (!cityName || !country || !countryCode) {
return fail(400, { error: 'Please select a city' });
}
createDestination({
tripId: trip.id,
userId,
cityId: cityIdRaw ? parseInt(cityIdRaw) : undefined,
cityName,
country,
countryCode,
startDate,
endDate,
status: ['idea', 'tentative', 'confirmed'].includes(status) ? status : 'idea'
});
return { success: true };
},
addTraveller: async (event) => {
const session = await event.locals.auth();
const userId = session?.user?.id;
if (!userId) return fail(401, { error: 'Not authenticated' });
const trip = getTripById(event.params.id, userId);
if (!trip) return fail(404, { error: 'Trip not found' });
const data = await event.request.formData();
const personId = (data.get('person_id') as string)?.trim();
const firstName = (data.get('first_name') as string)?.trim();
const lastName = (data.get('last_name') as string)?.trim();
const email = (data.get('email') as string)?.trim() || undefined;
// If person_id is provided, add existing person to trip
if (personId) {
addTraveller({ tripId: trip.id, userId, personId });
} else {
// Otherwise, create new person and add to trip
if (!firstName || !lastName) {
return fail(400, { error: 'First and last name are required' });
}
addTraveller({ tripId: trip.id, userId, firstName, lastName, email });
}
return { success: true };
},
removeTraveller: async (event) => {
const session = await event.locals.auth();
const userId = session?.user?.id;
if (!userId) return fail(401, { error: 'Not authenticated' });
const trip = getTripById(event.params.id, userId);
if (!trip) return fail(404, { error: 'Trip not found' });
const data = await event.request.formData();
const personId = (data.get('person_id') as string)?.trim();
if (!personId) {
return fail(400, { error: 'Person ID is required' });
}
try {
removeTravellerFromTrip(trip.id, personId, userId);
return { success: true };
} catch (err) {
return fail(400, { error: err instanceof Error ? err.message : 'Failed to remove traveller' });
}
},
removePlan: async (event) => {
const session = await event.locals.auth();
const userId = session?.user?.id;
if (!userId) return fail(401, { error: 'Not authenticated' });
const trip = getTripById(event.params.id, userId);
if (!trip) return fail(404, { error: 'Trip not found' });
const data = await event.request.formData();
const planId = (data.get('plan_id') as string)?.trim();
if (!planId) {
return fail(400, { error: 'Plan ID is required' });
}
try {
deletePlan(planId, userId);
return { success: true };
} catch (err) {
return fail(400, { error: err instanceof Error ? err.message : 'Failed to remove plan' });
}
}
};

View File

@@ -0,0 +1,425 @@
<script lang="ts">
import { enhance } from '$app/forms';
import TripWelcome from '$lib/components/TripWelcome.svelte';
import AddDestinationModal from '$lib/components/AddDestinationModal.svelte';
import AddTravellerModal from '$lib/components/AddTravellerModal.svelte';
import PlanCard from '$lib/components/PlanCard.svelte';
import TravellerChip from '$lib/components/TravellerChip.svelte';
let { data, form } = $props();
let trip = $derived(form?.trip ?? data.trip);
let plans = $derived(data.plans ?? []);
let planCount = $derived(data.planCount ?? 0);
let travellers = $derived(data.travellers ?? []);
let travellerCount = $derived(data.travellerCount ?? 0);
let people = $derived(data.people ?? []);
let tripTravellerIds = $derived(travellers.map((t) => t.id));
let editing = $state(false);
let showAddDestination = $state(false);
let showAddTraveller = $state(false);
let showAddMenu = $state(false);
const menuItems = [
{
label: 'Destinations',
icon: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#3B82F6" stroke-width="1.5"><path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7z"/><circle cx="12" cy="9" r="2.5"/></svg>`,
onclick: () => {
showAddDestination = true;
showAddMenu = false;
}
},
{
label: 'Attractions & Activities',
icon: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#22C55E" stroke-width="1.5"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>`,
onclick: () => {
showAddMenu = false;
}
},
{
label: 'Transportation',
icon: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#F97316" stroke-width="1.5"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-3.99-3.99A19.79 19.79 0 0 1 4.1 6.18 2 2 0 0 1 6.08 4h3a2 2 0 0 1 2 1.72c.127.96.361 1.903.7 2.81a2 2 0 0 1-.45 2.11L10.09 11a16 16 0 0 0 5.91 5.91l1.27-1.27a2 2 0 0 1 2.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0 1 24 18z"/></svg>`,
onclick: () => {
showAddMenu = false;
}
},
{
label: 'Lodgings',
icon: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#A855F7" stroke-width="1.5"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>`,
onclick: () => {
showAddMenu = false;
}
},
{
label: 'Restaurants',
icon: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#F43F5E" stroke-width="1.5"><path d="M18 8h1a4 4 0 0 1 0 8h-1"/><path d="M2 8h16v9a4 4 0 0 1-4 4H6a4 4 0 0 1-4-4V8z"/><line x1="6" y1="1" x2="6" y2="4"/><line x1="10" y1="1" x2="10" y2="4"/><line x1="14" y1="1" x2="14" y2="4"/></svg>`,
onclick: () => {
showAddMenu = false;
}
},
{
label: 'Package Tours',
icon: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#F59E0B" stroke-width="1.5"><rect x="2" y="7" width="20" height="14" rx="2"/><path d="M16 7V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v2"/><line x1="12" y1="12" x2="12" y2="16"/><line x1="10" y1="14" x2="14" y2="14"/></svg>`,
onclick: () => {
showAddMenu = false;
}
},
{
label: 'Packing List',
icon: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#14B8A6" stroke-width="1.5"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>`,
onclick: () => {
showAddMenu = false;
}
},
{
label: 'To-dos',
icon: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#64748B" stroke-width="1.5"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>`,
onclick: () => {
showAddMenu = false;
}
}
];
function formatDate(d: string | null) {
if (!d) return 'TBD';
return new Date(d + 'T00:00:00').toLocaleDateString(undefined, {
day: 'numeric',
month: 'long',
year: 'numeric'
});
}
</script>
<svelte:head>
<title>{trip.name} — Trips</title>
</svelte:head>
<AddDestinationModal open={showAddDestination} onclose={() => (showAddDestination = false)} />
<AddTravellerModal
open={showAddTraveller}
onclose={() => (showAddTraveller = false)}
{people}
{tripTravellerIds}
/>
<div class="mx-auto max-w-2xl">
<!-- Header -->
<div class="mb-6 flex items-start justify-between gap-4">
{#if editing}
<h1 class="text-2xl font-bold text-gray-900">Edit Trip</h1>
{:else}
<h1 class="text-2xl font-bold text-gray-900">{trip.name}</h1>
<div class="flex shrink-0 items-center gap-2">
{#if planCount > 0 || travellerCount > 0}
<div class="relative">
<button
onclick={() => (showAddMenu = !showAddMenu)}
class="flex items-center gap-1.5 rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
Add to trip
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
>
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
{#if showAddMenu}
<div
class="fixed inset-0 z-10"
role="button"
tabindex="-1"
onclick={() => (showAddMenu = false)}
onkeydown={(e) => e.key === 'Escape' && (showAddMenu = false)}
></div>
<div
class="absolute right-0 z-20 mt-1 w-56 overflow-hidden rounded-lg border border-gray-200 bg-white py-1 shadow-lg"
>
<!-- Travellers — separate from plan types -->
<button
onclick={() => {
showAddTraveller = true;
showAddMenu = false;
}}
class="flex w-full items-center gap-3 px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-50"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="#6366F1"
stroke-width="1.5"
><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" /><circle
cx="9"
cy="7"
r="4"
/><path d="M23 21v-2a4 4 0 0 0-3-3.87" /><path
d="M16 3.13a4 4 0 0 1 0 7.75"
/></svg
>
Travellers
</button>
<hr class="my-1 border-gray-100" />
{#each menuItems as item}
<button
onclick={item.onclick}
class="flex w-full items-center gap-3 px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-50"
>
{@html item.icon}
{item.label}
</button>
{/each}
</div>
{/if}
</div>
{/if}
<button
onclick={() => (editing = true)}
class="flex items-center gap-1.5 rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-50"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<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>
{/if}
</div>
{#if form?.error}
<div class="mb-4 rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{form.error}
</div>
{/if}
{#if editing}
<!-- Edit form -->
<form
method="POST"
action="?/update"
use:enhance={() => {
return ({ result, update }) => {
update();
if (result.type === 'success') editing = false;
};
}}
class="flex flex-col gap-5"
>
<div class="flex flex-col gap-1.5">
<label for="name" class="text-sm font-medium text-gray-700"
>Trip name <span class="text-red-500">*</span></label
>
<input
id="name"
name="name"
type="text"
value={trip.name}
required
class="rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="flex flex-col gap-1.5">
<label for="start_date" class="text-sm font-medium text-gray-700">Start date</label>
<input
id="start_date"
name="start_date"
type="date"
value={trip.start_date ?? ''}
class="rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
<label class="flex items-center gap-2 text-xs text-gray-500">
<input
type="checkbox"
checked={!trip.start_date}
onchange={(e) => {
const input = document.getElementById('start_date') as HTMLInputElement;
input.disabled = (e.target as HTMLInputElement).checked;
if (input.disabled) input.value = '';
}}
/>
I don't know yet
</label>
</div>
<div class="flex flex-col gap-1.5">
<label for="end_date" class="text-sm font-medium text-gray-700">End date</label>
<input
id="end_date"
name="end_date"
type="date"
value={trip.end_date ?? ''}
class="rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
<label class="flex items-center gap-2 text-xs text-gray-500">
<input
type="checkbox"
checked={!trip.end_date}
onchange={(e) => {
const input = document.getElementById('end_date') as HTMLInputElement;
input.disabled = (e.target as HTMLInputElement).checked;
if (input.disabled) input.value = '';
}}
/>
I don't know yet
</label>
</div>
</div>
<div class="flex flex-col gap-1.5">
<label for="description" class="text-sm font-medium text-gray-700">Description</label>
<textarea
id="description"
name="description"
rows="4"
class="rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
>{trip.description ?? ''}</textarea
>
</div>
<div class="flex justify-end gap-3 pt-2">
<button
type="button"
onclick={() => (editing = false)}
class="rounded-md border border-gray-300 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
>
Cancel
</button>
<button
type="submit"
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
Save
</button>
</div>
</form>
{:else}
<!-- Read view -->
<dl class="flex flex-col gap-4">
<div>
<dt class="text-xs font-semibold tracking-wider text-gray-400 uppercase">Dates</dt>
<dd class="mt-1 text-sm text-gray-700">
{formatDate(trip.start_date)}{formatDate(trip.end_date)}
</dd>
</div>
{#if trip.description}
<div>
<dt class="text-xs font-semibold tracking-wider text-gray-400 uppercase">Description</dt>
<dd class="mt-1 text-sm whitespace-pre-wrap text-gray-700">{trip.description}</dd>
</div>
{/if}
</dl>
<!-- Travellers section (always visible when there are travellers) -->
{#if travellerCount > 0}
<div class="mt-8">
<h2 class="mb-3 text-sm font-semibold tracking-wider text-gray-400 uppercase">
Travelling
</h2>
<div class="flex flex-wrap gap-2">
{#each travellers as traveller (traveller.id)}
{@const formId = `remove-traveller-${traveller.id}`}
{@const submitForm = () => {
const form = document.getElementById(formId) as HTMLFormElement;
form?.requestSubmit();
}}
<form
id={formId}
method="POST"
action="?/removeTraveller"
use:enhance={() => {
return ({ update }) => {
update();
};
}}
class="contents"
>
<input type="hidden" name="person_id" value={traveller.id} />
<TravellerChip {traveller} onDelete={submitForm} />
</form>
{/each}
<button
onclick={() => (showAddTraveller = true)}
class="flex items-center gap-1.5 rounded-full border border-dashed border-gray-300 px-3 py-1.5 text-sm text-gray-400 hover:border-gray-400 hover:text-gray-600"
>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
>
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
</svg>
Add
</button>
</div>
</div>
{/if}
{#if planCount === 0}
<TripWelcome
tripName={trip.name}
onAddDestination={() => (showAddDestination = true)}
onAddTraveller={() => (showAddTraveller = true)}
/>
{:else}
<!-- Plans list -->
<div class="mt-8">
<h2 class="mb-3 text-sm font-semibold tracking-wider text-gray-400 uppercase">
Destinations
</h2>
<div class="flex flex-col gap-3">
{#each plans.filter((p) => p.type === 'destination') as plan (plan.id)}
{@const formId = `remove-plan-${plan.id}`}
{@const submitForm = () => {
const form = document.getElementById(formId) as HTMLFormElement;
form?.requestSubmit();
}}
<form
id={formId}
method="POST"
action="?/removePlan"
use:enhance={() => {
return ({ update }) => {
update();
};
}}
class="contents"
>
<input type="hidden" name="plan_id" value={plan.id} />
<PlanCard {plan} onDelete={submitForm} />
</form>
{/each}
</div>
</div>
{/if}
{/if}
</div>

View File

@@ -0,0 +1,32 @@
import { fail, redirect } from '@sveltejs/kit';
import { base } from '$app/paths';
import { createTrip } from '$lib/server/trips.js';
import type { Actions } from './$types';
export const actions: Actions = {
default: async (event) => {
const session = await event.locals.auth();
const userId = session?.user?.id;
if (!userId) return fail(401, { error: 'Not authenticated' });
const data = await event.request.formData();
const name = (data.get('name') as string)?.trim();
const description = (data.get('description') as string)?.trim();
const startDate = (data.get('start_date') as string)?.trim() || undefined;
const endDate = (data.get('end_date') as string)?.trim() || undefined;
if (!name) {
return fail(400, { error: 'Trip name is required', name, description, startDate, endDate });
}
const trip = createTrip({
userId,
name,
description: description || undefined,
startDate,
endDate
});
redirect(303, `${base}/trips/${trip.id}`);
}
};

View File

@@ -0,0 +1,112 @@
<script lang="ts">
import { enhance } from '$app/forms';
let { form } = $props();
</script>
<svelte:head>
<title>Plan New Trip — Trips</title>
</svelte:head>
<div class="mx-auto max-w-xl">
<h1 class="mb-6 text-2xl font-bold text-gray-900">Plan New Trip</h1>
{#if form?.error}
<div class="mb-4 rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{form.error}
</div>
{/if}
<form method="POST" use:enhance class="flex flex-col gap-5">
<!-- Name -->
<div class="flex flex-col gap-1.5">
<label for="name" class="text-sm font-medium text-gray-700"
>Trip name <span class="text-red-500">*</span></label
>
<input
id="name"
name="name"
type="text"
value={form?.name ?? ''}
placeholder="e.g. Summer in Japan"
required
class="rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 placeholder-gray-400 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
</div>
<!-- Dates -->
<div class="grid grid-cols-2 gap-4">
<div class="flex flex-col gap-1.5">
<label for="start_date" class="text-sm font-medium text-gray-700">Start date</label>
<input
id="start_date"
name="start_date"
type="date"
value={form?.startDate ?? ''}
class="rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
<label class="flex items-center gap-2 text-xs text-gray-500">
<input
type="checkbox"
onchange={(e) => {
const input = document.getElementById('start_date') as HTMLInputElement;
input.disabled = (e.target as HTMLInputElement).checked;
if (input.disabled) input.value = '';
}}
/>
I don't know yet
</label>
</div>
<div class="flex flex-col gap-1.5">
<label for="end_date" class="text-sm font-medium text-gray-700">End date</label>
<input
id="end_date"
name="end_date"
type="date"
value={form?.endDate ?? ''}
class="rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
/>
<label class="flex items-center gap-2 text-xs text-gray-500">
<input
type="checkbox"
onchange={(e) => {
const input = document.getElementById('end_date') as HTMLInputElement;
input.disabled = (e.target as HTMLInputElement).checked;
if (input.disabled) input.value = '';
}}
/>
I don't know yet
</label>
</div>
</div>
<!-- Description -->
<div class="flex flex-col gap-1.5">
<label for="description" class="text-sm font-medium text-gray-700">Description</label>
<textarea
id="description"
name="description"
rows="4"
placeholder="What's this trip about?"
class="rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-900 placeholder-gray-400 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 focus:outline-none"
>{form?.description ?? ''}</textarea
>
</div>
<!-- Actions -->
<div class="flex justify-end gap-3 pt-2">
<a
href="../dashboard"
class="rounded-md border border-gray-300 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
>
Cancel
</a>
<button
type="submit"
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
Save
</button>
</div>
</form>
</div>

View File

@@ -0,0 +1,11 @@
import { error } from '@sveltejs/kit';
import { getPastTrips } from '$lib/server/trips.js';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async (event) => {
const session = await event.locals.auth();
const userId = session?.user?.id;
if (!userId) error(401, 'Not authenticated');
return { trips: getPastTrips(userId) };
};

View File

@@ -0,0 +1,10 @@
<script lang="ts">
import TripList from '$lib/components/TripList.svelte';
let { data } = $props();
</script>
<svelte:head>
<title>Past Trips — Trips</title>
</svelte:head>
<TripList trips={data.trips} title="Past Trips" emptyMessage="No past trips yet." />

View File

@@ -0,0 +1,11 @@
import { error } from '@sveltejs/kit';
import { getUpcomingTrips } from '$lib/server/trips.js';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async (event) => {
const session = await event.locals.auth();
const userId = session?.user?.id;
if (!userId) error(401, 'Not authenticated');
return { trips: getUpcomingTrips(userId) };
};

View File

@@ -0,0 +1,14 @@
<script lang="ts">
import TripList from '$lib/components/TripList.svelte';
let { data } = $props();
</script>
<svelte:head>
<title>Upcoming Trips — Trips</title>
</svelte:head>
<TripList
trips={data.trips}
title="Upcoming Trips"
emptyMessage="No upcoming trips. Click 'Plan New Trip' to get started."
/>

View File

@@ -0,0 +1,7 @@
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async (event) => {
return {
session: await event.locals.auth()
};
};

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import '../app.css';
let { children } = $props();
</script>
{@render children()}

View File

@@ -0,0 +1,12 @@
import { redirect } from '@sveltejs/kit';
import { base } from '$app/paths';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async (event) => {
const session = await event.locals.auth();
if (session?.user) {
redirect(303, `${base}/dashboard`);
} else {
redirect(303, `${base}/login`);
}
};

View File

@@ -0,0 +1,8 @@
import { env } from '$env/dynamic/private';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async () => {
return {
signinUrl: `${env.AUTH_URL}/signin/synology`
};
};

View File

@@ -0,0 +1,22 @@
<script lang="ts">
import { onMount } from 'svelte';
let { data } = $props();
let form: HTMLFormElement;
onMount(() => {
form.submit();
});
</script>
<svelte:head>
<title>Sign in — Trips</title>
</svelte:head>
<form bind:this={form} method="POST" action={data.signinUrl} class="hidden">
<input type="hidden" name="csrfToken" />
</form>
<div class="flex min-h-[60vh] items-center justify-center">
<p class="text-gray-500">Redirecting to sign in…</p>
</div>