trips) adding initial commit
This commit is contained in:
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