trips) adding initial commit
This commit is contained in:
1
src/app.css
Normal file
1
src/app.css
Normal file
@@ -0,0 +1 @@
|
||||
@import "tailwindcss";
|
||||
17
src/app.d.ts
vendored
Normal file
17
src/app.d.ts
vendored
Normal 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
12
src/app.html
Normal 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
44
src/auth.ts
Normal 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
15
src/hooks.server.ts
Normal 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);
|
||||
1
src/lib/assets/favicon.svg
Normal file
1
src/lib/assets/favicon.svg
Normal 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 |
252
src/lib/components/AddDestinationModal.svelte
Normal file
252
src/lib/components/AddDestinationModal.svelte
Normal 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}
|
||||
283
src/lib/components/AddTravellerModal.svelte
Normal file
283
src/lib/components/AddTravellerModal.svelte
Normal 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}
|
||||
51
src/lib/components/AppSwitcher.svelte
Normal file
51
src/lib/components/AppSwitcher.svelte
Normal 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>
|
||||
77
src/lib/components/NavMenu.svelte
Normal file
77
src/lib/components/NavMenu.svelte
Normal 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>
|
||||
82
src/lib/components/PlanCard.svelte
Normal file
82
src/lib/components/PlanCard.svelte
Normal 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>
|
||||
151
src/lib/components/Sidebar.svelte
Normal file
151
src/lib/components/Sidebar.svelte
Normal 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>
|
||||
61
src/lib/components/TravellerChip.svelte
Normal file
61
src/lib/components/TravellerChip.svelte
Normal 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>
|
||||
62
src/lib/components/TripList.svelte
Normal file
62
src/lib/components/TripList.svelte
Normal 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}
|
||||
37
src/lib/components/TripTable.svelte
Normal file
37
src/lib/components/TripTable.svelte
Normal 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>
|
||||
32
src/lib/components/TripTile.svelte
Normal file
32
src/lib/components/TripTile.svelte
Normal 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>
|
||||
160
src/lib/components/TripWelcome.svelte
Normal file
160
src/lib/components/TripWelcome.svelte
Normal 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
1
src/lib/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
// place files you want to import through the `$lib` alias in this folder.
|
||||
1
src/lib/server/data/cities.json
Normal file
1
src/lib/server/data/cities.json
Normal file
File diff suppressed because one or more lines are too long
20
src/lib/server/db/index.ts
Normal file
20
src/lib/server/db/index.ts
Normal 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);
|
||||
112
src/lib/server/db/migrations.ts
Normal file
112
src/lib/server/db/migrations.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
26
src/lib/server/db/sqlite.ts
Normal file
26
src/lib/server/db/sqlite.ts
Normal 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();
|
||||
}
|
||||
};
|
||||
}
|
||||
10
src/lib/server/db/types.ts
Normal file
10
src/lib/server/db/types.ts
Normal 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
124
src/lib/server/plans.ts
Normal 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]);
|
||||
}
|
||||
142
src/lib/server/travellers.ts
Normal file
142
src/lib/server/travellers.ts
Normal 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
89
src/lib/server/trips.ts
Normal 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]
|
||||
);
|
||||
}
|
||||
11
src/routes/(protected)/+layout.server.ts
Normal file
11
src/routes/(protected)/+layout.server.ts
Normal 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 };
|
||||
};
|
||||
14
src/routes/(protected)/+layout.svelte
Normal file
14
src/routes/(protected)/+layout.svelte
Normal 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>
|
||||
9
src/routes/(protected)/api/cities/+server.ts
Normal file
9
src/routes/(protected)/api/cities/+server.ts
Normal 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);
|
||||
};
|
||||
10
src/routes/(protected)/dashboard/+page.svelte
Normal file
10
src/routes/(protected)/dashboard/+page.svelte
Normal 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>
|
||||
32
src/routes/(protected)/profile/+page.server.ts
Normal file
32
src/routes/(protected)/profile/+page.server.ts
Normal 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 };
|
||||
}
|
||||
};
|
||||
104
src/routes/(protected)/profile/+page.svelte
Normal file
104
src/routes/(protected)/profile/+page.svelte
Normal 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>
|
||||
157
src/routes/(protected)/trips/[id]/+page.server.ts
Normal file
157
src/routes/(protected)/trips/[id]/+page.server.ts
Normal 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' });
|
||||
}
|
||||
}
|
||||
};
|
||||
425
src/routes/(protected)/trips/[id]/+page.svelte
Normal file
425
src/routes/(protected)/trips/[id]/+page.svelte
Normal 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>
|
||||
32
src/routes/(protected)/trips/new/+page.server.ts
Normal file
32
src/routes/(protected)/trips/new/+page.server.ts
Normal 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}`);
|
||||
}
|
||||
};
|
||||
112
src/routes/(protected)/trips/new/+page.svelte
Normal file
112
src/routes/(protected)/trips/new/+page.svelte
Normal 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>
|
||||
11
src/routes/(protected)/trips/past/+page.server.ts
Normal file
11
src/routes/(protected)/trips/past/+page.server.ts
Normal 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) };
|
||||
};
|
||||
10
src/routes/(protected)/trips/past/+page.svelte
Normal file
10
src/routes/(protected)/trips/past/+page.svelte
Normal 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." />
|
||||
11
src/routes/(protected)/trips/upcoming/+page.server.ts
Normal file
11
src/routes/(protected)/trips/upcoming/+page.server.ts
Normal 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) };
|
||||
};
|
||||
14
src/routes/(protected)/trips/upcoming/+page.svelte
Normal file
14
src/routes/(protected)/trips/upcoming/+page.svelte
Normal 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."
|
||||
/>
|
||||
7
src/routes/+layout.server.ts
Normal file
7
src/routes/+layout.server.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { LayoutServerLoad } from './$types';
|
||||
|
||||
export const load: LayoutServerLoad = async (event) => {
|
||||
return {
|
||||
session: await event.locals.auth()
|
||||
};
|
||||
};
|
||||
7
src/routes/+layout.svelte
Normal file
7
src/routes/+layout.svelte
Normal file
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import '../app.css';
|
||||
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
{@render children()}
|
||||
12
src/routes/+page.server.ts
Normal file
12
src/routes/+page.server.ts
Normal 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`);
|
||||
}
|
||||
};
|
||||
8
src/routes/login/+page.server.ts
Normal file
8
src/routes/login/+page.server.ts
Normal 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`
|
||||
};
|
||||
};
|
||||
22
src/routes/login/+page.svelte
Normal file
22
src/routes/login/+page.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user