1
0
Fork 0
mirror of https://github.com/seanmorley15/AdventureLog.git synced 2025-08-04 20:55:19 +02:00

feat: enhance transportation card and modal with image handling

- Added CardCarousel component to TransportationCard for image display.
- Implemented privacy indicator with Eye and EyeOff icons.
- Introduced image upload functionality in TransportationModal, allowing users to upload multiple images.
- Added image management features: remove image and set primary image.
- Updated Transportation and Location types to include images as ContentImage array.
- Enhanced UI for image upload and display in modal, including selected images preview and current images management.
This commit is contained in:
Sean Morley 2025-07-14 18:57:39 -04:00
parent ba162175fe
commit 7a61ba2d22
19 changed files with 3181 additions and 1549 deletions

View file

@ -1,9 +1,9 @@
<script lang="ts">
import type { Location } from '$lib/types';
import type { Location, Lodging, Transportation } from '$lib/types';
import ImageDisplayModal from './ImageDisplayModal.svelte';
import { t } from 'svelte-i18n';
export let adventures: Location[] = [];
export let adventures: Location[] | Transportation[] | Lodging[] = [];
let currentSlide = 0;
let showImageModal = false;

View file

@ -5,6 +5,7 @@
export let categories: Category[] = [];
export let selected_category: Category | null = null;
export let searchTerm: string = '';
let new_category: Category = {
name: '',
display_name: '',
@ -60,62 +61,206 @@
});
</script>
<div class="mt-2 relative" bind:this={dropdownRef}>
<button type="button" class="btn btn-outline w-full text-left" on:click={toggleDropdown}>
{selected_category && selected_category.name
? selected_category.display_name + ' ' + selected_category.icon
: $t('categories.select_category')}
</button>
<div class="dropdown w-full" bind:this={dropdownRef}>
<!-- Main dropdown trigger -->
<div
tabindex="0"
role="button"
class="btn btn-outline w-full justify-between"
on:click={toggleDropdown}
>
<span class="flex items-center gap-2">
{#if selected_category && selected_category.name}
<span class="text-lg">{selected_category.icon}</span>
<span class="truncate">{selected_category.display_name}</span>
{:else}
<span class="text-base-content/70">{$t('categories.select_category')}</span>
{/if}
</span>
<svg
class="w-4 h-4 transition-transform duration-200 {isOpen ? 'rotate-180' : ''}"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</div>
{#if isOpen}
<div class="absolute z-10 w-full mt-1 bg-base-300 rounded shadow-lg p-2">
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div class="flex flex-col gap-2">
<div class="flex items-center gap-2">
<input
type="text"
placeholder={$t('categories.category_name')}
class="input input-bordered w-full max-w-xs"
bind:value={new_category.display_name}
/>
<input
type="text"
placeholder={$t('categories.icon')}
class="input input-bordered w-full max-w-xs"
bind:value={new_category.icon}
/>
<button on:click={toggleEmojiPicker} type="button" class="btn btn-secondary">
{!isEmojiPickerVisible ? $t('adventures.show') : $t('adventures.hide')}
{$t('adventures.emoji_picker')}
</button>
<button on:click={custom_category} type="button" class="btn btn-primary">
{$t('adventures.add')}
</button>
</div>
<!-- Dropdown content -->
<div
class="dropdown-content z-[1] w-full mt-1 bg-base-300 rounded-box shadow-xl border border-base-300 max-h-96 overflow-y-auto"
>
<!-- Category Creator Section -->
<div class="p-4 border-b border-base-300">
<h3 class="font-semibold text-sm text-base-content/80 mb-3 flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 6v6m0 0v6m0-6h6m-6 0H6"
/>
</svg>
{$t('categories.add_new_category')}
</h3>
{#if isEmojiPickerVisible}
<div class="mt-2">
<emoji-picker on:emoji-click={handleEmojiSelect}></emoji-picker>
<div class="space-y-3">
<!-- Input row -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
<div class="form-control">
<input
type="text"
placeholder={$t('categories.category_name')}
class="input input-bordered input-sm w-full"
bind:value={new_category.display_name}
/>
</div>
<div class="form-control">
<div class="input-group">
<input
type="text"
placeholder={$t('categories.icon')}
class="input input-bordered input-sm flex-1"
bind:value={new_category.icon}
/>
<button
on:click={toggleEmojiPicker}
type="button"
class="btn btn-square btn-sm btn-secondary"
class:btn-active={isEmojiPickerVisible}
>
😊
</button>
</div>
</div>
</div>
{/if}
<!-- Action button -->
<div class="flex justify-end">
<button
on:click={custom_category}
type="button"
class="btn btn-primary btn-sm"
disabled={!new_category.display_name.trim()}
>
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 6v6m0 0v6m0-6h6m-6 0H6"
/>
</svg>
{$t('adventures.add')}
</button>
</div>
<!-- Emoji Picker -->
{#if isEmojiPickerVisible}
<div class=" p-3 rounded-lg border border-base-300">
<emoji-picker on:emoji-click={handleEmojiSelect}></emoji-picker>
</div>
{/if}
</div>
</div>
<div class="flex flex-wrap gap-2 mt-2">
<!-- Sort the categories dynamically before rendering -->
{#each categories
.slice()
.sort((a, b) => (b.num_locations || 0) - (a.num_locations || 0)) as category}
<button
type="button"
class="btn btn-neutral flex items-center space-x-2"
on:click={() => selectCategory(category)}
role="option"
aria-selected={selected_category && selected_category.id === category.id}
<!-- Categories List Section -->
<div class="p-4">
<h3 class="font-semibold text-sm text-base-content/80 mb-3 flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
/>
</svg>
{$t('categories.select_category')}
</h3>
{#if categories.length > 0}
<!-- Search/Filter (optional) -->
<div class="form-control mb-3">
<input
type="text"
placeholder={$t('navbar.search')}
class="input input-bordered input-sm w-full"
bind:value={searchTerm}
/>
</div>
<!-- Categories Grid -->
<div
class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2 max-h-60 overflow-y-auto"
>
<span>{category.display_name} {category.icon} ({category.num_locations})</span>
</button>
{/each}
{#each categories
.slice()
.sort((a, b) => (b.num_locations || 0) - (a.num_locations || 0))
.filter((category) => !searchTerm || category.display_name
.toLowerCase()
.includes(searchTerm.toLowerCase())) as category}
<button
type="button"
class="btn btn-ghost btn-sm justify-start h-auto py-2 px-3"
class:btn-active={selected_category && selected_category.id === category.id}
on:click={() => selectCategory(category)}
role="option"
aria-selected={selected_category && selected_category.id === category.id}
>
<div class="flex items-center gap-2 w-full">
<span class="text-lg shrink-0">{category.icon}</span>
<div class="flex-1 text-left">
<div class="font-medium text-sm truncate">{category.display_name}</div>
<div class="text-xs text-base-content/60">
{category.num_locations}
{$t('locations.locations')}
</div>
</div>
</div>
</button>
{/each}
</div>
{#if categories.filter((category) => !searchTerm || category.display_name
.toLowerCase()
.includes(searchTerm.toLowerCase())).length === 0}
<div class="text-center py-8 text-base-content/60">
<svg
class="w-12 h-12 mx-auto mb-2 opacity-50"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9.172 16.172a4 4 0 015.656 0M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
<p class="text-sm">{$t('categories.no_categories_found')}</p>
</div>
{/if}
{:else}
<div class="text-center py-8 text-base-content/60">
<svg
class="w-12 h-12 mx-auto mb-2 opacity-50"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.99 1.99 0 013 12V7a4 4 0 014-4z"
/>
</svg>
<p class="text-sm">{$t('categories.no_categories_yet')}</p>
</div>
{/if}
</div>
</div>
{/if}

View file

@ -172,365 +172,335 @@
}
</script>
<div class="collapse collapse-plus bg-base-200 mb-4 rounded-lg">
<div
class="collapse collapse-plus bg-base-200/50 border border-base-300/50 mb-6 rounded-2xl overflow-hidden"
>
<input type="checkbox" />
<div class="collapse-title text-xl font-semibold">
{$t('adventures.date_information')}
</div>
<div class="collapse-content">
<!-- Timezone Selector Section -->
<div class="rounded-xl border border-base-300 bg-base-100 p-4 space-y-4 shadow-sm mb-4">
<!-- Group Header -->
<h3 class="text-md font-semibold">{$t('navbar.settings')}</h3>
{#if type === 'transportation'}
<!-- Dual timezone selectors for transportation -->
<div class="space-y-4">
<div>
<!-- svelte-ignore a11y-label-has-associated-control -->
<label class="text-sm font-medium block mb-1">
{$t('adventures.departure_timezone')}
</label>
<TimezoneSelector bind:selectedTimezone={selectedStartTimezone} />
</div>
<div>
<!-- svelte-ignore a11y-label-has-associated-control -->
<label class="text-sm font-medium block mb-1">
{$t('adventures.arrival_timezone')}
</label>
<TimezoneSelector bind:selectedTimezone={selectedEndTimezone} />
</div>
</div>
{:else}
<!-- Single timezone selector for other types -->
<TimezoneSelector bind:selectedTimezone={selectedStartTimezone} />
{/if}
<!-- All Day Toggle -->
<div class="flex justify-between items-center">
<span class="text-sm">{$t('adventures.all_day')}</span>
<input
type="checkbox"
class="toggle toggle-primary"
id="all_day"
name="all_day"
bind:checked={allDay}
on:change={() => {
if (allDay) {
localStartDate = localStartDate ? localStartDate.split('T')[0] : '';
localEndDate = localEndDate ? localEndDate.split('T')[0] : '';
} else {
localStartDate = localStartDate + 'T00:00';
localEndDate = localEndDate + 'T23:59';
}
utcStartDate = updateUTCDate({
localDate: localStartDate,
timezone: selectedStartTimezone,
allDay
}).utcDate;
utcEndDate = updateUTCDate({
localDate: localEndDate,
timezone: type === 'transportation' ? selectedEndTimezone : selectedStartTimezone,
allDay
}).utcDate;
localStartDate = updateLocalDate({
utcDate: utcStartDate,
timezone: selectedStartTimezone
}).localDate;
localEndDate = updateLocalDate({
utcDate: utcEndDate,
timezone: type === 'transportation' ? selectedEndTimezone : selectedStartTimezone
}).localDate;
}}
/>
</div>
<!-- Constrain Dates Toggle -->
{#if collection?.start_date && collection?.end_date}
<div class="flex justify-between items-center">
<span class="text-sm">{$t('adventures.date_constrain')}</span>
<input
type="checkbox"
id="constrain_dates"
name="constrain_dates"
class="toggle toggle-primary"
on:change={() => (constrainDates = !constrainDates)}
/>
</div>
{/if}
</div>
<!-- Dates Input Section -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Start Date -->
<div class="space-y-2">
<label for="date" class="text-sm font-medium">
{type === 'transportation'
? $t('adventures.departure_date')
: type === 'lodging'
? $t('adventures.check_in')
: $t('adventures.start_date')}
</label>
{#if allDay}
<input
type="date"
id="date"
name="date"
bind:value={localStartDate}
on:change={handleLocalDateChange}
min={constrainDates ? constraintStartDate : ''}
max={constrainDates ? constraintEndDate : ''}
class="input input-bordered w-full"
/>
{:else}
<input
type="datetime-local"
id="date"
name="date"
bind:value={localStartDate}
on:change={handleLocalDateChange}
min={constrainDates ? constraintStartDate : ''}
max={constrainDates ? constraintEndDate : ''}
class="input input-bordered w-full"
/>
{/if}
</div>
<!-- End Date -->
{#if localStartDate}
<div class="space-y-2">
<label for="end_date" class="text-sm font-medium">
{type === 'transportation'
? $t('adventures.arrival_date')
: type === 'lodging'
? $t('adventures.check_out')
: $t('adventures.end_date')}
</label>
{#if allDay}
<input
type="date"
id="end_date"
name="end_date"
bind:value={localEndDate}
on:change={handleLocalDateChange}
min={constrainDates ? localStartDate : ''}
max={constrainDates ? constraintEndDate : ''}
class="input input-bordered w-full"
/>
{:else}
<input
type="datetime-local"
id="end_date"
name="end_date"
bind:value={localEndDate}
on:change={handleLocalDateChange}
min={constrainDates ? localStartDate : ''}
max={constrainDates ? constraintEndDate : ''}
class="input input-bordered w-full"
/>
{/if}
</div>
{/if}
<!-- Notes (for adventures only) -->
{#if type === 'adventure'}
<div class="md:col-span-2">
<label for="note" class="text-sm font-medium block mb-1">
{$t('adventures.add_notes')}
</label>
<textarea
id="note"
name="note"
class="textarea textarea-bordered w-full"
placeholder={$t('adventures.add_notes')}
bind:value={note}
rows="4"
></textarea>
</div>
{/if}
{#if type === 'adventure'}
<button
class="btn btn-primary mb-2"
type="button"
on:click={() => {
const newVisit = createVisitObject();
// Ensure reactivity by assigning a *new* array
if (visits) {
visits = [...visits, newVisit];
} else {
visits = [newVisit];
}
// Optionally clear the form
note = '';
localStartDate = '';
localEndDate = '';
utcStartDate = null;
utcEndDate = null;
}}
>
{$t('adventures.add')}
</button>
{/if}
</div>
<!-- Validation Message -->
{#if !validateDateRange(utcStartDate ?? '', utcEndDate ?? '').valid}
<div role="alert" class="alert alert-error mt-2">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 shrink-0 stroke-current"
fill="none"
viewBox="0 0 24 24"
>
<div class="collapse-title text-xl font-semibold bg-gradient-to-r from-primary/10 to-primary/5">
<div class="flex items-center gap-3">
<div class="p-2 bg-primary/10 rounded-lg">
<svg class="w-5 h-5 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
<span>{$t('adventures.invalid_date_range')}</span>
</div>
{$t('adventures.date_information')}
</div>
</div>
<div class="collapse-content bg-base-100/50 p-6">
<!-- Settings -->
<div class="card bg-base-100 border border-base-300/50 mb-6">
<div class="card-body p-4">
<h3 class="text-lg font-bold mb-4">Settings</h3>
<div class="space-y-3">
{#if type === 'transportation'}
<div class="grid grid-cols-2 gap-4">
<div>
<label class="label-text text-sm font-medium">Departure Timezone</label>
<div class="mt-1">
<TimezoneSelector bind:selectedTimezone={selectedStartTimezone} />
</div>
</div>
<div>
<label class="label-text text-sm font-medium">Arrival Timezone</label>
<div class="mt-1">
<TimezoneSelector bind:selectedTimezone={selectedEndTimezone} />
</div>
</div>
</div>
{:else}
<div>
<label class="label-text text-sm font-medium">Timezone</label>
<div class="mt-1">
<TimezoneSelector bind:selectedTimezone={selectedStartTimezone} />
</div>
</div>
{/if}
<div class="flex items-center justify-between">
<label class="label-text text-sm font-medium">All Day</label>
<input
type="checkbox"
class="toggle toggle-primary"
bind:checked={allDay}
on:change={() => {
if (allDay) {
localStartDate = localStartDate ? localStartDate.split('T')[0] : '';
localEndDate = localEndDate ? localEndDate.split('T')[0] : '';
} else {
localStartDate = localStartDate + 'T00:00';
localEndDate = localEndDate + 'T23:59';
}
utcStartDate = updateUTCDate({
localDate: localStartDate,
timezone: selectedStartTimezone,
allDay
}).utcDate;
utcEndDate = updateUTCDate({
localDate: localEndDate,
timezone: type === 'transportation' ? selectedEndTimezone : selectedStartTimezone,
allDay
}).utcDate;
localStartDate = updateLocalDate({
utcDate: utcStartDate,
timezone: selectedStartTimezone
}).localDate;
localEndDate = updateLocalDate({
utcDate: utcEndDate,
timezone: type === 'transportation' ? selectedEndTimezone : selectedStartTimezone
}).localDate;
}}
/>
</div>
{#if collection?.start_date && collection?.end_date}
<div class="flex items-center justify-between">
<label class="label-text text-sm font-medium">Constrain to Collection Dates</label>
<input
type="checkbox"
class="toggle toggle-primary"
on:change={() => (constrainDates = !constrainDates)}
/>
</div>
{/if}
</div>
</div>
</div>
<!-- Date Selection -->
<div class="card bg-base-100 border border-base-300/50 mb-6">
<div class="card-body p-4">
<h3 class="text-lg font-bold mb-4">Date Selection</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="label-text text-sm font-medium">
{type === 'transportation'
? 'Departure Date'
: type === 'lodging'
? 'Check In'
: 'Start Date'}
</label>
{#if allDay}
<input
type="date"
class="input input-bordered w-full mt-1"
bind:value={localStartDate}
on:change={handleLocalDateChange}
min={constrainDates ? constraintStartDate : ''}
max={constrainDates ? constraintEndDate : ''}
/>
{:else}
<input
type="datetime-local"
class="input input-bordered w-full mt-1"
bind:value={localStartDate}
on:change={handleLocalDateChange}
min={constrainDates ? constraintStartDate : ''}
max={constrainDates ? constraintEndDate : ''}
/>
{/if}
</div>
{#if localStartDate}
<div>
<label class="label-text text-sm font-medium">
{type === 'transportation'
? 'Arrival Date'
: type === 'lodging'
? 'Check Out'
: 'End Date'}
</label>
{#if allDay}
<input
type="date"
class="input input-bordered w-full mt-1"
bind:value={localEndDate}
on:change={handleLocalDateChange}
min={constrainDates ? localStartDate : ''}
max={constrainDates ? constraintEndDate : ''}
/>
{:else}
<input
type="datetime-local"
class="input input-bordered w-full mt-1"
bind:value={localEndDate}
on:change={handleLocalDateChange}
min={constrainDates ? localStartDate : ''}
max={constrainDates ? constraintEndDate : ''}
/>
{/if}
</div>
{/if}
</div>
{#if type === 'adventure'}
<div class="mt-4">
<label class="label-text text-sm font-medium">Notes</label>
<textarea
class="textarea textarea-bordered w-full mt-1"
rows="3"
placeholder="Add notes..."
bind:value={note}
></textarea>
</div>
<div class="flex justify-end mt-4">
<button
class="btn btn-primary btn-sm"
type="button"
on:click={() => {
const newVisit = createVisitObject();
if (visits) {
visits = [...visits, newVisit];
} else {
visits = [newVisit];
}
note = '';
localStartDate = '';
localEndDate = '';
utcStartDate = null;
utcEndDate = null;
}}
>
Add Visit
</button>
</div>
{/if}
</div>
</div>
<!-- Validation -->
{#if !validateDateRange(utcStartDate ?? '', utcEndDate ?? '').valid}
<div class="alert alert-error mb-6">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>Invalid date range</span>
</div>
{/if}
<!-- Visits List -->
{#if type === 'adventure'}
<div class="border-t border-neutral pt-4 mb-2">
<h3 class="text-xl font-semibold">
{$t('adventures.visits')}
</h3>
<div class="card bg-base-100 border border-base-300/50">
<div class="card-body p-4">
<h3 class="text-lg font-bold mb-4">Visits</h3>
<!-- Visits List -->
{#if visits && visits.length === 0}
<p class="text-sm text-base-content opacity-70">
{$t('adventures.no_visits')}
</p>
{/if}
</div>
{#if visits && visits.length > 0}
<div class="space-y-4">
{#each visits as visit}
<div
class="p-4 border border-neutral rounded-lg bg-base-100 shadow-sm flex flex-col gap-2"
>
<p class="text-sm text-base-content font-medium">
{#if isAllDay(visit.start_date)}
<span class="badge badge-outline mr-2">{$t('adventures.all_day')}</span>
{visit.start_date && typeof visit.start_date === 'string'
? visit.start_date.split('T')[0]
: ''} {visit.end_date && typeof visit.end_date === 'string'
? visit.end_date.split('T')[0]
: ''}
{:else if 'start_timezone' in visit}
{formatDateInTimezone(visit.start_date, visit.start_timezone)} {formatDateInTimezone(
visit.end_date,
visit.end_timezone
)}
{:else if visit.timezone}
{formatDateInTimezone(visit.start_date, visit.timezone)} {formatDateInTimezone(
visit.end_date,
visit.timezone
)}
{:else}
{new Date(visit.start_date).toLocaleString()} {new Date(
visit.end_date
).toLocaleString()}
<!-- showe timezones badge -->
{/if}
{#if 'timezone' in visit && visit.timezone}
<span class="badge badge-outline ml-2">{visit.timezone}</span>
{/if}
</p>
<!-- -->
<!-- Display timezone information for transportation visits -->
{#if 'start_timezone' in visit && 'end_timezone' in visit && visit.start_timezone !== visit.end_timezone}
<p class="text-xs text-base-content">
{visit.start_timezone}{visit.end_timezone}
</p>
{/if}
{#if visit.notes}
<p class="text-sm text-base-content opacity-70 italic">
"{visit.notes}"
</p>
{/if}
<div class="flex gap-2 mt-2">
<button
class="btn btn-primary btn-sm"
type="button"
on:click={() => {
isEditing = true;
const isAllDayEvent = isAllDay(visit.start_date);
allDay = isAllDayEvent;
// Set timezone information if available
if ('start_timezone' in visit) {
// TransportationVisit
selectedStartTimezone = visit.start_timezone;
selectedEndTimezone = visit.end_timezone;
} else if (visit.timezone) {
// Visit
selectedStartTimezone = visit.timezone;
}
if (isAllDayEvent) {
localStartDate = visit.start_date.split('T')[0];
localEndDate = visit.end_date.split('T')[0];
} else {
// Update with timezone awareness
localStartDate = updateLocalDate({
utcDate: visit.start_date,
timezone: selectedStartTimezone
}).localDate;
localEndDate = updateLocalDate({
utcDate: visit.end_date,
timezone:
'end_timezone' in visit ? visit.end_timezone : selectedStartTimezone
}).localDate;
}
// remove it from visits
if (visits) {
visits = visits.filter((v) => v.id !== visit.id);
}
note = visit.notes;
constrainDates = true;
utcStartDate = visit.start_date;
utcEndDate = visit.end_date;
setTimeout(() => {
isEditing = false;
}, 0);
}}
>
{$t('lodging.edit')}
</button>
<button
class="btn btn-error btn-sm"
type="button"
on:click={() => {
if (visits) {
visits = visits.filter((v) => v.id !== visit.id);
}
}}
>
{$t('adventures.remove')}
</button>
</div>
{#if visits && visits.length === 0}
<div class="text-center py-8 text-base-content/60">
<p class="text-sm">No visits added yet</p>
</div>
{/each}
{/if}
{#if visits && visits.length > 0}
<div class="space-y-3">
{#each visits as visit}
<div class="p-3 bg-base-200/50 rounded-lg border border-base-300/30">
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="text-sm font-medium mb-1">
{#if isAllDay(visit.start_date)}
<span class="badge badge-outline badge-sm mr-2">All Day</span>
{visit.start_date && typeof visit.start_date === 'string'
? visit.start_date.split('T')[0]
: ''}
{visit.end_date && typeof visit.end_date === 'string'
? visit.end_date.split('T')[0]
: ''}
{:else if 'start_timezone' in visit}
{formatDateInTimezone(visit.start_date, visit.start_timezone)}
{formatDateInTimezone(visit.end_date, visit.end_timezone)}
{:else if visit.timezone}
{formatDateInTimezone(visit.start_date, visit.timezone)}
{formatDateInTimezone(visit.end_date, visit.timezone)}
{:else}
{new Date(visit.start_date).toLocaleString()}
{new Date(visit.end_date).toLocaleString()}
{/if}
</div>
{#if visit.notes}
<p class="text-xs text-base-content/70 mt-1">"{visit.notes}"</p>
{/if}
</div>
<div class="flex gap-2">
<button
class="btn btn-primary btn-xs"
type="button"
on:click={() => {
isEditing = true;
const isAllDayEvent = isAllDay(visit.start_date);
allDay = isAllDayEvent;
if ('start_timezone' in visit) {
selectedStartTimezone = visit.start_timezone;
selectedEndTimezone = visit.end_timezone;
} else if (visit.timezone) {
selectedStartTimezone = visit.timezone;
}
if (isAllDayEvent) {
localStartDate = visit.start_date.split('T')[0];
localEndDate = visit.end_date.split('T')[0];
} else {
localStartDate = updateLocalDate({
utcDate: visit.start_date,
timezone: selectedStartTimezone
}).localDate;
localEndDate = updateLocalDate({
utcDate: visit.end_date,
timezone:
'end_timezone' in visit ? visit.end_timezone : selectedStartTimezone
}).localDate;
}
if (visits) {
visits = visits.filter((v) => v.id !== visit.id);
}
note = visit.notes;
constrainDates = true;
utcStartDate = visit.start_date;
utcEndDate = visit.end_date;
setTimeout(() => {
isEditing = false;
}, 0);
}}
>
Edit
</button>
<button
class="btn btn-error btn-xs"
type="button"
on:click={() => {
if (visits) {
visits = visits.filter((v) => v.id !== visit.id);
}
}}
>
Remove
</button>
</div>
</div>
</div>
{/each}
</div>
{/if}
</div>
{/if}
</div>
{/if}
</div>
</div>

View file

@ -5,7 +5,7 @@
let modal: HTMLDialogElement;
import type { Location } from '$lib/types';
export let images: { image: string; adventure: Location | null }[] = [];
export let images: { image: string; adventure: any | null }[] = [];
export let initialIndex: number = 0;
import { t } from 'svelte-i18n';

View file

@ -2,6 +2,7 @@
import { createEventDispatcher, onMount } from 'svelte';
import { t } from 'svelte-i18n';
import ImmichLogo from '$lib/assets/immich.svg';
import Upload from '~icons/mdi/upload';
import type { Location, ImmichAlbum } from '$lib/types';
import { debounce } from '$lib';
@ -148,104 +149,145 @@
}
</script>
<div class="mb-4">
<label for="immich" class="block font-medium mb-2">
{$t('immich.immich')}
<img src={ImmichLogo} alt="Immich Logo" class="h-6 w-6 inline-block -mt-1" />
</label>
<div class="mt-4">
<div class="join">
<input
on:click={() => (currentAlbum = '')}
type="radio"
class="join-item btn"
bind:group={searchCategory}
value="search"
aria-label="Search"
/>
<input
type="radio"
class="join-item btn"
bind:group={searchCategory}
value="date"
aria-label="Show by date"
/>
<input
type="radio"
class="join-item btn"
bind:group={searchCategory}
value="album"
aria-label="Select Album"
/>
</div>
<div>
{#if searchCategory === 'search'}
<form on:submit|preventDefault={searchImmich}>
<input
type="text"
placeholder="Type here"
bind:value={immichSearchValue}
class="input input-bordered w-full max-w-xs"
/>
<button type="submit" class="btn btn-neutral mt-2">Search</button>
</form>
{:else if searchCategory === 'date'}
<div class="space-y-4">
<!-- Header -->
<div class="flex items-center gap-2 mb-4">
<h4 class="font-medium text-lg">
{$t('immich.immich')}
</h4>
<img src={ImmichLogo} alt="Immich Logo" class="h-6 w-6" />
</div>
<!-- Search Category Tabs -->
<div class="tabs tabs-boxed w-fit">
<button
class="tab"
class:tab-active={searchCategory === 'search'}
on:click={() => {
searchCategory = 'search';
currentAlbum = '';
}}
>
{$t('immich.search')}
</button>
<button
class="tab"
class:tab-active={searchCategory === 'date'}
on:click={() => (searchCategory = 'date')}
>
{$t('immich.by_date')}
</button>
<button
class="tab"
class:tab-active={searchCategory === 'album'}
on:click={() => (searchCategory = 'album')}
>
{$t('immich.by_album')}
</button>
</div>
<!-- Search Controls -->
<div class="bg-base-100 p-4 rounded-lg border border-base-300">
{#if searchCategory === 'search'}
<form on:submit|preventDefault={searchImmich} class="flex gap-2">
<input
type="date"
bind:value={selectedDate}
class="input input-bordered w-full max-w-xs mt-2"
type="text"
placeholder={$t('immich.search_placeholder')}
bind:value={immichSearchValue}
class="input input-bordered flex-1"
/>
{:else if searchCategory === 'album'}
<select class="select select-bordered w-full max-w-xs mt-2" bind:value={currentAlbum}>
<option value="" disabled selected>Select an Album</option>
<button type="submit" class="btn btn-primary">
{$t('immich.search')}
</button>
</form>
{:else if searchCategory === 'date'}
<div class="flex items-center gap-2">
<label class="label">
<span class="label-text">{$t('immich.select_date')}</span>
</label>
<input type="date" bind:value={selectedDate} class="input input-bordered w-full max-w-xs" />
</div>
{:else if searchCategory === 'album'}
<div class="flex items-center gap-2">
<label class="label">
<span class="label-text">{$t('immich.select_album')}</span>
</label>
<select class="select select-bordered w-full max-w-xs" bind:value={currentAlbum}>
<option value="" disabled selected>{$t('immich.select_album_placeholder')}</option>
{#each albums as album}
<option value={album.id}>{album.albumName}</option>
{/each}
</select>
{/if}
</div>
</div>
{/if}
</div>
<p class="text-red-500">{immichError}</p>
<div class="flex flex-wrap gap-4 mr-4 mt-2">
<!-- Error Message -->
{#if immichError}
<div class="alert alert-error">
<svg
xmlns="http://www.w3.org/2000/svg"
class="stroke-current shrink-0 h-6 w-6"
fill="none"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span class="text-sm">{immichError}</span>
</div>
{/if}
<!-- Images Grid -->
<div class="relative">
<!-- Loading Overlay -->
{#if loading}
<div
class="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 z-[100] w-24 h-24"
class="absolute inset-0 bg-base-200/50 backdrop-blur-sm z-10 flex items-center justify-center rounded-lg"
>
<span class="loading loading-spinner w-24 h-24"></span>
<span class="loading loading-spinner loading-lg"></span>
</div>
{/if}
{#each immichImages as image}
<div class="flex flex-col items-center gap-2" class:blur-sm={loading}>
<!-- svelte-ignore a11y-img-redundant-alt -->
<img
src={`${image.image_url}`}
alt="Image from Immich"
class="h-24 w-24 object-cover rounded-md"
/>
<h4>
{image.fileCreatedAt?.split('T')[0] || 'Unknown'}
</h4>
<button
type="button"
class="btn btn-sm btn-primary"
on:click={() => {
let currentDomain = window.location.origin;
let fullUrl = `${currentDomain}/immich/${image.id}`;
if (copyImmichLocally) {
dispatch('fetchImage', fullUrl);
} else {
saveImmichRemoteUrl(image.id);
}
}}
>
{$t('adventures.upload_image')}
<!-- Images Grid -->
<div class="grid gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4" class:opacity-50={loading}>
{#each immichImages as image}
<div class="card bg-base-100 shadow-sm hover:shadow-md transition-shadow">
<figure class="aspect-square">
<img src={image.image_url} alt="Image from Immich" class="w-full h-full object-cover" />
</figure>
<div class="card-body p-2">
<button
type="button"
class="btn btn-primary btn-sm max-w-full"
on:click={() => {
let currentDomain = window.location.origin;
let fullUrl = `${currentDomain}/immich/${image.id}`;
if (copyImmichLocally) {
dispatch('fetchImage', fullUrl);
} else {
saveImmichRemoteUrl(image.id);
}
}}
>
<Upload class="w-4 h-4" />
</button>
</div>
</div>
{/each}
</div>
<!-- Load More Button -->
{#if immichNextURL}
<div class="flex justify-center mt-6">
<button class="btn btn-outline btn-wide" on:click={loadMoreImmich}>
{$t('immich.load_more')}
</button>
</div>
{/each}
{#if immichNextURL}
<button class="btn btn-neutral" on:click={loadMoreImmich}>{$t('immich.load_more')}</button>
{/if}
</div>
</div>

View file

@ -224,59 +224,152 @@
}
</script>
<div class="collapse collapse-plus bg-base-200 mb-4">
<!-- Location Information Section -->
<div
class="collapse collapse-plus bg-base-200/50 border border-base-300/50 mb-6 rounded-2xl overflow-hidden"
>
<input type="checkbox" />
<div class="collapse-title text-xl font-medium">
{$t('adventures.location_information')}
<div class="collapse-title text-xl font-semibold bg-gradient-to-r from-accent/10 to-accent/5">
<div class="flex items-center gap-3">
<div class="p-2 bg-accent/10 rounded-lg">
<svg class="w-5 h-5 text-accent" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
</div>
{$t('adventures.location_information')}
</div>
</div>
<div class="collapse-content">
<!-- <div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3"> -->
<div>
<label for="latitude">{$t('adventures.location')}</label><br />
<div class="flex items-center">
<div class="collapse-content bg-base-100/50 p-6 space-y-6">
<!-- Location Name Input -->
<div class="form-control">
<label class="label">
<span class="label-text font-medium">{$t('adventures.location')}</span>
</label>
<div class="flex items-center gap-3">
<input
type="text"
id="location"
name="location"
bind:value={item.location}
class="input input-bordered w-full"
class="input input-bordered flex-1 bg-base-100/80 focus:bg-base-100"
placeholder={$t('adventures.enter_location_name')}
/>
{#if is_custom_location}
<button
class="btn btn-primary ml-2"
class="btn btn-primary gap-2"
type="button"
on:click={() => (item.location = reverseGeocodePlace?.display_name)}
>{$t('adventures.set_to_pin')}</button
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
{$t('adventures.set_to_pin')}
</button>
{/if}
</div>
</div>
<div>
<form on:submit={geocode} class="mt-2">
<input
type="text"
placeholder={$t('adventures.search_for_location')}
class="input input-bordered w-full max-w-xs mb-2"
id="search"
name="search"
bind:value={query}
/>
<button class="btn btn-neutral -mt-1" type="submit">{$t('navbar.search')}</button>
<button class="btn btn-neutral -mt-1" type="button" on:click={clearMap}
>{$t('adventures.clear_map')}</button
>
<!-- Location Search -->
<div class="form-control">
<label class="label">
<span class="label-text font-medium">{$t('adventures.search_location')}</span>
</label>
<form on:submit={geocode} class="flex flex-col sm:flex-row gap-3">
<div class="relative flex-1">
<svg
class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-base-content/40"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
<input
type="text"
placeholder={$t('adventures.search_for_location')}
class="input input-bordered w-full pl-10 bg-base-100/80 focus:bg-base-100"
id="search"
name="search"
bind:value={query}
/>
</div>
<div class="flex gap-2">
<button class="btn btn-neutral gap-2" type="submit">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
{$t('navbar.search')}
</button>
<button class="btn btn-neutral btn-outline gap-2" type="button" on:click={clearMap}>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
{$t('adventures.clear_map')}
</button>
</div>
</form>
</div>
{#if places.length > 0}
<div class="mt-4 max-w-full">
<h3 class="font-bold text-lg mb-4">{$t('adventures.search_results')}</h3>
<div class="flex flex-wrap">
<!-- Search Results -->
{#if places.length > 0}
<div class="form-control">
<label class="label">
<span class="label-text font-medium flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
{$t('adventures.search_results')}
</span>
</label>
<div
class="grid grid-cols-1 sm:grid-cols-2 gap-3 p-4 bg-base-100/80 border border-base-300 rounded-xl max-h-60 overflow-y-auto"
>
{#each places as place}
<button
type="button"
class="btn btn-neutral mb-2 mr-2 max-w-full break-words whitespace-normal text-left"
class="btn btn-ghost btn-sm h-auto min-h-0 p-3 justify-start text-left hover:bg-base-200"
on:click={() => {
markers = [
{
@ -292,85 +385,232 @@
}
}}
>
<span>{place.name}</span>
<br />
<small class="text-xs text-neutral-300">{place.display_name}</small>
<div class="flex flex-col items-start w-full">
<span class="font-medium text-sm">{place.name}</span>
<small class="text-xs text-base-content/60 truncate w-full"
>{place.display_name}</small
>
</div>
</button>
{/each}
</div>
</div>
{:else if noPlaces}
<p class="text-error text-lg">{$t('adventures.no_results')}</p>
<div class="alert alert-error">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>{$t('adventures.no_results')}</span>
</div>
{/if}
<!-- </div> -->
<div>
<MapLibre
style={getBasemapUrl()}
class="relative aspect-[9/16] max-h-[70vh] w-full sm:aspect-video sm:max-h-full rounded-lg"
standardControls
zoom={item.latitude && item.longitude ? 12 : 1}
center={{ lng: item.longitude || 0, lat: item.latitude || 0 }}
>
<!-- MapEvents gives you access to map events even from other components inside the map,
where you might not have access to the top-level `MapLibre` component. In this case
it would also work to just use on:click on the MapLibre component itself. -->
<MapEvents on:click={addMarker} />
{#each markers as marker}
<DefaultMarker lngLat={marker.lngLat} />
{/each}
</MapLibre>
{#if reverseGeocodePlace}
<div class="mt-2 p-4 bg-neutral rounded-lg shadow-md">
<h3 class="text-lg font-bold mb-2">{$t('adventures.location_details')}</h3>
<p class="mb-1">
<span class="font-semibold">{$t('adventures.display_name')}:</span>
{reverseGeocodePlace.city
? reverseGeocodePlace.city + ', '
: ''}{reverseGeocodePlace.region}, {reverseGeocodePlace.country}
</p>
<p class="mb-1">
<span class="font-semibold">{$t('adventures.region')}:</span>
{reverseGeocodePlace.region}
{reverseGeocodePlace.region_visited ? '✅' : '❌'}
</p>
{#if reverseGeocodePlace.city}
<p class="mb-1">
<span class="font-semibold">{$t('adventures.city')}:</span>
{reverseGeocodePlace.city}
{reverseGeocodePlace.city_visited ? '✅' : '❌'}
</p>
{/if}
</div>
{#if !reverseGeocodePlace.region_visited || (!reverseGeocodePlace.city_visited && !willBeMarkedVisited)}
<button type="button" class="btn btn-primary mt-2" on:click={markVisited}>
{$t('adventures.mark_visited')}
</button>
{/if}
{#if (willBeMarkedVisited && !reverseGeocodePlace.region_visited && reverseGeocodePlace.region_id) || (!reverseGeocodePlace.city_visited && willBeMarkedVisited && reverseGeocodePlace.city_id)}
<div role="alert" class="alert alert-info mt-2 flex items-center">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
class="h-6 w-6 shrink-0 stroke-current mr-2"
>
<!-- Map Container -->
<div class="form-control">
<label class="label">
<span class="label-text font-medium flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7"
/>
</svg>
{$t('adventures.interactive_map')}
</span>
</label>
<div class="bg-base-100/80 border border-base-300 rounded-xl p-4">
<MapLibre
style={getBasemapUrl()}
class="relative aspect-[9/16] max-h-[70vh] w-full sm:aspect-video sm:max-h-full rounded-lg border border-base-300"
standardControls
zoom={item.latitude && item.longitude ? 12 : 1}
center={{ lng: item.longitude || 0, lat: item.latitude || 0 }}
>
<MapEvents on:click={addMarker} />
{#each markers as marker}
<DefaultMarker lngLat={marker.lngLat} />
{/each}
</MapLibre>
</div>
</div>
<!-- Location Details -->
{#if reverseGeocodePlace}
<div class="bg-gradient-to-r from-info/10 to-info/5 border border-info/20 rounded-xl p-6">
<h3 class="text-lg font-bold flex items-center gap-2 mb-4">
<div class="p-2 bg-info/10 rounded-lg">
<svg class="w-5 h-5 text-info" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
></path>
/>
</svg>
<span>
</div>
{$t('adventures.location_details')}
</h3>
<div class="space-y-3">
<div class="flex items-center gap-3 p-3 bg-base-100/50 rounded-lg">
<svg
class="w-4 h-4 text-base-content/60"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.99 1.99 0 013 12V7a4 4 0 014-4z"
/>
</svg>
<span class="font-medium text-sm">{$t('adventures.display_name')}:</span>
<span class="text-sm">
{reverseGeocodePlace.city
? reverseGeocodePlace.city + ', '
: ''}{reverseGeocodePlace.region}, {reverseGeocodePlace.country}
{$t('adventures.will_be_marked_location')}
</span>
</div>
<div class="flex items-center gap-3 p-3 bg-base-100/50 rounded-lg">
<svg
class="w-4 h-4 text-base-content/60"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
/>
</svg>
<span class="font-medium text-sm">{$t('adventures.region')}:</span>
<span class="text-sm">{reverseGeocodePlace.region}</span>
<div class="ml-auto">
{#if reverseGeocodePlace.region_visited}
<div class="badge badge-success badge-sm gap-1">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
{$t('adventures.visited')}
</div>
{:else}
<div class="badge badge-error badge-sm gap-1">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
{$t('adventures.not_visited')}
</div>
{/if}
</div>
</div>
{#if reverseGeocodePlace.city}
<div class="flex items-center gap-3 p-3 bg-base-100/50 rounded-lg">
<svg
class="w-4 h-4 text-base-content/60"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"
/>
</svg>
<span class="font-medium text-sm">{$t('adventures.city')}:</span>
<span class="text-sm">{reverseGeocodePlace.city}</span>
<div class="ml-auto">
{#if reverseGeocodePlace.city_visited}
<div class="badge badge-success badge-sm gap-1">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
{$t('adventures.visited')}
</div>
{:else}
<div class="badge badge-error badge-sm gap-1">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
{$t('adventures.not_visited')}
</div>
{/if}
</div>
</div>
{/if}
</div>
<!-- Mark Visited Button -->
{#if !reverseGeocodePlace.region_visited || (!reverseGeocodePlace.city_visited && !willBeMarkedVisited)}
<button type="button" class="btn btn-primary gap-2 mt-4" on:click={markVisited}>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
{$t('adventures.mark_visited')}
</button>
{/if}
{/if}
</div>
<!-- Will be marked visited alert -->
{#if (willBeMarkedVisited && !reverseGeocodePlace.region_visited && reverseGeocodePlace.region_id) || (!reverseGeocodePlace.city_visited && willBeMarkedVisited && reverseGeocodePlace.city_id)}
<div class="alert alert-info mt-4">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<div>
<h4 class="font-bold">{$t('adventures.location_will_be_marked')}</h4>
<div class="text-sm">
{reverseGeocodePlace.city
? reverseGeocodePlace.city + ', '
: ''}{reverseGeocodePlace.region}, {reverseGeocodePlace.country}
{$t('adventures.will_be_marked_location')}
</div>
</div>
</div>
{/if}
</div>
{/if}
</div>
</div>

File diff suppressed because it is too large Load diff

View file

@ -10,6 +10,7 @@
import { formatDateInTimezone } from '$lib/dateUtils';
import { formatAllDayDate } from '$lib/dateUtils';
import { isAllDay } from '$lib';
import CardCarousel from './CardCarousel.svelte';
const dispatch = createEventDispatcher();
@ -96,6 +97,20 @@
<div
class="card w-full max-w-md bg-base-300 text-base-content shadow-2xl hover:shadow-3xl transition-all duration-300 border border-base-300 hover:border-primary/20 group"
>
<!-- Image Section with Overlay -->
<div class="relative overflow-hidden rounded-t-2xl">
<CardCarousel adventures={[lodging]} />
<!-- Category Badge -->
{#if lodging.type}
<div class="absolute bottom-4 left-4">
<div class="badge badge-primary shadow-lg font-medium">
{$t(`lodging.${lodging.type}`)}
{getLodgingIcon(lodging.type)}
</div>
</div>
{/if}
</div>
<div class="card-body p-6 space-y-4">
<!-- Header -->
<div class="flex flex-col gap-3">

View file

@ -7,6 +7,7 @@
import LocationDropdown from './LocationDropdown.svelte';
import DateRangeCollapse from './DateRangeCollapse.svelte';
import { isAllDay } from '$lib';
import { deserialize } from '$app/forms';
// @ts-ignore
import { DateTime } from 'luxon';
@ -15,16 +16,14 @@
export let collection: Collection;
export let lodgingToEdit: Lodging | null = null;
let imageInput: HTMLInputElement;
let imageFiles: File[] = [];
let modal: HTMLDialogElement;
let lodging: Lodging = { ...initializeLodging(lodgingToEdit) };
let fullStartDate: string = '';
let fullEndDate: string = '';
type LodgingType = {
value: string;
label: string;
};
let lodgingTimezone: string | undefined = lodging.timezone ?? undefined;
// Initialize hotel with values from lodgingToEdit or default values
@ -48,10 +47,81 @@
collection: lodgingToEdit?.collection || collection.id,
created_at: lodgingToEdit?.created_at || '',
updated_at: lodgingToEdit?.updated_at || '',
timezone: lodgingToEdit?.timezone || ''
timezone: lodgingToEdit?.timezone || '',
images: lodgingToEdit?.images || []
};
}
function handleImageChange(event: Event) {
const target = event.target as HTMLInputElement;
if (target?.files) {
if (!lodging.id) {
imageFiles = Array.from(target.files);
console.log('Images ready for deferred upload:', imageFiles);
} else {
imageFiles = Array.from(target.files);
for (const file of imageFiles) {
uploadImage(file);
}
}
}
}
async function uploadImage(file: File) {
let formData = new FormData();
formData.append('image', file);
formData.append('object_id', lodging.id);
formData.append('content_type', 'lodging');
let res = await fetch(`/locations?/image`, {
method: 'POST',
body: formData
});
if (res.ok) {
let newData = deserialize(await res.text()) as { data: { id: string; image: string } };
let newImage = {
id: newData.data.id,
image: newData.data.image,
is_primary: false,
immich_id: null
};
lodging.images = [...(lodging.images || []), newImage];
addToast('success', $t('adventures.image_upload_success'));
} else {
addToast('error', $t('adventures.image_upload_error'));
}
}
async function removeImage(id: string) {
let res = await fetch(`/api/images/${id}/image_delete`, {
method: 'POST'
});
if (res.status === 204) {
lodging.images = lodging.images.filter((image) => image.id !== id);
addToast('success', $t('adventures.image_removed_success'));
} else {
addToast('error', $t('adventures.image_removed_error'));
}
}
async function makePrimaryImage(image_id: string) {
let res = await fetch(`/api/images/${image_id}/toggle_primary`, {
method: 'POST'
});
if (res.ok) {
lodging.images = lodging.images.map((image) => {
if (image.id === image_id) {
image.is_primary = true;
} else {
image.is_primary = false;
}
return image;
});
} else {
console.error('Error in makePrimaryImage:', res);
}
}
// Set full start and end dates from collection
if (collection.start_date && collection.end_date) {
fullStartDate = `${collection.start_date}T00:00`;
@ -129,187 +199,314 @@
}
</script>
<dialog id="my_modal_1" class="modal">
<dialog id="my_modal_1" class="modal backdrop-blur-sm">
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<div class="modal-box w-11/12 max-w-3xl" role="dialog" on:keydown={handleKeydown} tabindex="0">
<h3 class="font-bold text-2xl">
{lodgingToEdit ? $t('lodging.edit_lodging') : $t('lodging.new_lodging')}
</h3>
<div class="modal-action items-center">
<div
class="modal-box w-11/12 max-w-6xl bg-gradient-to-br from-base-100 via-base-100 to-base-200 border border-base-300 shadow-2xl"
role="dialog"
on:keydown={handleKeydown}
tabindex="0"
>
<!-- Header Section -->
<div
class="top-0 z-10 bg-base-100/90 backdrop-blur-lg border-b border-base-300 -mx-6 -mt-6 px-6 py-4 mb-6"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="p-2 bg-primary/10 rounded-xl">
<svg class="w-8 h-8 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"
/>
</svg>
</div>
<div>
<h1 class="text-3xl font-bold text-primary bg-clip-text">
{lodgingToEdit ? $t('lodging.edit_lodging') : $t('lodging.new_lodging')}
</h1>
<p class="text-sm text-base-content/60">
{lodgingToEdit
? $t('lodging.update_lodging_details')
: $t('lodging.create_new_lodging')}
</p>
</div>
</div>
<!-- Close Button -->
<button class="btn btn-ghost btn-square" on:click={close}>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
</div>
<!-- Main Content -->
<div class="px-2">
<form method="post" style="width: 100%;" on:submit={handleSubmit}>
<!-- Basic Information Section -->
<div class="collapse collapse-plus bg-base-200 mb-4">
<div
class="collapse collapse-plus bg-base-200/50 border border-base-300/50 mb-6 rounded-2xl overflow-hidden"
>
<input type="checkbox" checked />
<div class="collapse-title text-xl font-medium">
{$t('adventures.basic_information')}
</div>
<div class="collapse-content">
<!-- Name -->
<div>
<label for="name">
{$t('adventures.name')}<span class="text-red-500">*</span>
</label>
<input
type="text"
id="name"
name="name"
bind:value={lodging.name}
class="input input-bordered w-full"
required
/>
</div>
<div>
<label for="type">
{$t('transportation.type')}<span class="text-red-500">*</span>
</label>
<div>
<select
class="select select-bordered w-full max-w-xs"
name="type"
id="type"
bind:value={lodging.type}
<div
class="collapse-title text-xl font-semibold bg-gradient-to-r from-primary/10 to-primary/5"
>
<div class="flex items-center gap-3">
<div class="p-2 bg-primary/10 rounded-lg">
<svg
class="w-5 h-5 text-primary"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<option disabled selected>{$t('transportation.type')}</option>
<option value="hotel">{$t('lodging.hotel')}</option>
<option value="hostel">{$t('lodging.hostel')}</option>
<option value="resort">{$t('lodging.resort')}</option>
<option value="bnb">{$t('lodging.bnb')}</option>
<option value="campground">{$t('lodging.campground')}</option>
<option value="cabin">{$t('lodging.cabin')}</option>
<option value="apartment">{$t('lodging.apartment')}</option>
<option value="house">{$t('lodging.house')}</option>
<option value="villa">{$t('lodging.villa')}</option>
<option value="motel">{$t('lodging.motel')}</option>
<option value="other">{$t('lodging.other')}</option>
</select>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
{$t('adventures.basic_information')}
</div>
<!-- Description -->
<div>
<label for="description">{$t('adventures.description')}</label><br />
<MarkdownEditor bind:text={lodging.description} editor_height={'h-32'} />
</div>
<!-- Rating -->
<div>
<label for="rating">{$t('adventures.rating')}</label><br />
<input
type="number"
min="0"
max="5"
hidden
bind:value={lodging.rating}
id="rating"
name="rating"
class="input input-bordered w-full max-w-xs mt-1"
/>
<div class="rating -ml-3 mt-1">
<input
type="radio"
name="rating-2"
class="rating-hidden"
checked={Number.isNaN(lodging.rating)}
/>
<input
type="radio"
name="rating-2"
class="mask mask-star-2 bg-orange-400"
on:click={() => (lodging.rating = 1)}
checked={lodging.rating === 1}
/>
<input
type="radio"
name="rating-2"
class="mask mask-star-2 bg-orange-400"
on:click={() => (lodging.rating = 2)}
checked={lodging.rating === 2}
/>
<input
type="radio"
name="rating-2"
class="mask mask-star-2 bg-orange-400"
on:click={() => (lodging.rating = 3)}
checked={lodging.rating === 3}
/>
<input
type="radio"
name="rating-2"
class="mask mask-star-2 bg-orange-400"
on:click={() => (lodging.rating = 4)}
checked={lodging.rating === 4}
/>
<input
type="radio"
name="rating-2"
class="mask mask-star-2 bg-orange-400"
on:click={() => (lodging.rating = 5)}
checked={lodging.rating === 5}
/>
{#if lodging.rating}
<button
type="button"
class="btn btn-sm btn-error ml-2"
on:click={() => (lodging.rating = NaN)}
</div>
<div class="collapse-content bg-base-100/50 pt-4 p-6 space-y-3">
<!-- Dual Column Layout for Large Screens -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Left Column -->
<div class="space-y-4">
<!-- Name Field -->
<div class="form-control">
<label class="label" for="name">
<span class="label-text font-medium"
>{$t('adventures.name')}<span class="text-error ml-1">*</span></span
>
</label>
<input
type="text"
id="name"
name="name"
bind:value={lodging.name}
class="input input-bordered w-full bg-base-100/80 focus:bg-base-100"
placeholder={$t('lodging.enter_lodging_name')}
required
/>
</div>
<!-- Type Selection -->
<div class="form-control">
<label class="label" for="type">
<span class="label-text font-medium"
>{$t('transportation.type')}<span class="text-error ml-1">*</span></span
>
</label>
<select
class="select select-bordered w-full bg-base-100/80 focus:bg-base-100"
name="type"
id="type"
bind:value={lodging.type}
>
{$t('adventures.remove')}
</button>
{/if}
<option disabled selected>{$t('lodging.select_type')}</option>
<option value="hotel">{$t('lodging.hotel')}</option>
<option value="hostel">{$t('lodging.hostel')}</option>
<option value="resort">{$t('lodging.resort')}</option>
<option value="bnb">{$t('lodging.bnb')}</option>
<option value="campground">{$t('lodging.campground')}</option>
<option value="cabin">{$t('lodging.cabin')}</option>
<option value="apartment">{$t('lodging.apartment')}</option>
<option value="house">{$t('lodging.house')}</option>
<option value="villa">{$t('lodging.villa')}</option>
<option value="motel">{$t('lodging.motel')}</option>
<option value="other">{$t('lodging.other')}</option>
</select>
</div>
<!-- Rating Field -->
<div class="form-control">
<label class="label" for="rating">
<span class="label-text font-medium">{$t('adventures.rating')}</span>
</label>
<input
type="number"
min="0"
max="5"
hidden
bind:value={lodging.rating}
id="rating"
name="rating"
class="input input-bordered w-full max-w-xs"
/>
<div
class="flex items-center gap-4 p-4 bg-base-100/80 border border-base-300 rounded-xl"
>
<div class="rating">
<input
type="radio"
name="rating-2"
class="rating-hidden"
checked={Number.isNaN(lodging.rating)}
/>
<input
type="radio"
name="rating-2"
class="mask mask-star-2 bg-warning"
on:click={() => (lodging.rating = 1)}
checked={lodging.rating === 1}
/>
<input
type="radio"
name="rating-2"
class="mask mask-star-2 bg-warning"
on:click={() => (lodging.rating = 2)}
checked={lodging.rating === 2}
/>
<input
type="radio"
name="rating-2"
class="mask mask-star-2 bg-warning"
on:click={() => (lodging.rating = 3)}
checked={lodging.rating === 3}
/>
<input
type="radio"
name="rating-2"
class="mask mask-star-2 bg-warning"
on:click={() => (lodging.rating = 4)}
checked={lodging.rating === 4}
/>
<input
type="radio"
name="rating-2"
class="mask mask-star-2 bg-warning"
on:click={() => (lodging.rating = 5)}
checked={lodging.rating === 5}
/>
</div>
{#if lodging.rating}
<button
type="button"
class="btn btn-error btn-sm"
on:click={() => (lodging.rating = NaN)}
>
{$t('adventures.remove')}
</button>
{/if}
</div>
</div>
</div>
<!-- Right Column -->
<div class="space-y-4">
<!-- Link Field -->
<div class="form-control">
<label class="label" for="link">
<span class="label-text font-medium">{$t('adventures.link')}</span>
</label>
<input
type="url"
id="link"
name="link"
bind:value={lodging.link}
class="input input-bordered w-full bg-base-100/80 focus:bg-base-100"
placeholder={$t('lodging.enter_link')}
/>
</div>
<!-- Description Field -->
<div class="form-control">
<label class="label" for="description">
<span class="label-text font-medium">{$t('adventures.description')}</span>
</label>
<div class="bg-base-100/80 border border-base-300 rounded-xl p-2">
<MarkdownEditor bind:text={lodging.description} editor_height={'h-32'} />
</div>
</div>
</div>
</div>
<!-- Link -->
<div>
<label for="link">{$t('adventures.link')}</label>
<input
type="url"
id="link"
name="link"
bind:value={lodging.link}
class="input input-bordered w-full"
/>
</div>
</div>
</div>
<div class="collapse collapse-plus bg-base-200 mb-4">
<!-- Lodging Information Section -->
<div
class="collapse collapse-plus bg-base-200/50 border border-base-300/50 mb-6 rounded-2xl overflow-hidden"
>
<input type="checkbox" checked />
<div class="collapse-title text-xl font-medium">
{$t('adventures.lodging_information')}
</div>
<div class="collapse-content">
<!-- Reservation Number -->
<div>
<label for="date">
{$t('lodging.reservation_number')}
</label>
<div>
<input
type="text"
id="reservation_number"
name="reservation_number"
bind:value={lodging.reservation_number}
class="input input-bordered w-full max-w-xs mt-1"
/>
<div
class="collapse-title text-xl font-semibold bg-gradient-to-r from-primary/10 to-primary/5"
>
<div class="flex items-center gap-3">
<div class="p-2 bg-primary/10 rounded-lg">
<svg
class="w-5 h-5 text-primary"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"
/>
</svg>
</div>
{$t('adventures.lodging_information')}
</div>
<!-- Price -->
<div>
<label for="price">
{$t('adventures.price')}
</label>
<div>
<input
type="number"
id="price"
name="price"
bind:value={lodging.price}
step="0.01"
class="input input-bordered w-full max-w-xs mt-1"
/>
</div>
<div class="collapse-content bg-base-100/50 pt-4 p-6 space-y-3">
<!-- Dual Column Layout -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Left Column -->
<div class="space-y-4">
<!-- Reservation Number -->
<div class="form-control">
<label class="label" for="reservation_number">
<span class="label-text font-medium">{$t('lodging.reservation_number')}</span>
</label>
<input
type="text"
id="reservation_number"
name="reservation_number"
bind:value={lodging.reservation_number}
class="input input-bordered w-full bg-base-100/80 focus:bg-base-100"
placeholder={$t('lodging.enter_reservation_number')}
/>
</div>
</div>
<!-- Right Column -->
<div class="space-y-4">
<!-- Price -->
<div class="form-control">
<label class="label" for="price">
<span class="label-text font-medium">{$t('adventures.price')}</span>
</label>
<input
type="number"
id="price"
name="price"
bind:value={lodging.price}
step="0.01"
class="input input-bordered w-full bg-base-100/80 focus:bg-base-100"
placeholder={$t('lodging.enter_price')}
/>
</div>
</div>
</div>
</div>
</div>
<!-- Date Range Section -->
<DateRangeCollapse
type="lodging"
bind:utcStartDate={lodging.check_in}
@ -318,17 +515,175 @@
{collection}
/>
<!-- Location Information -->
<!-- Location Information Section -->
<LocationDropdown bind:item={lodging} />
<!-- Images Section -->
<div
class="collapse collapse-plus bg-base-200/50 border border-base-300/50 mb-6 rounded-2xl overflow-hidden"
>
<input type="checkbox" checked />
<div
class="collapse-title text-xl font-semibold bg-gradient-to-r from-primary/10 to-primary/5"
>
<div class="flex items-center gap-3">
<div class="p-2 bg-primary/10 rounded-lg">
<svg
class="w-5 h-5 text-primary"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
</div>
{$t('adventures.images')}
</div>
</div>
<div class="collapse-content bg-base-100/50 pt-4 p-6">
<div class="form-control">
<label class="label" for="image">
<span class="label-text font-medium">{$t('adventures.upload_image')}</span>
</label>
<input
type="file"
id="image"
name="image"
accept="image/*"
multiple
bind:this={imageInput}
on:change={handleImageChange}
class="file-input file-input-bordered file-input-primary w-full bg-base-100/80 focus:bg-base-100"
/>
</div>
<p class="text-sm text-base-content/60 mt-2">
{$t('adventures.image_upload_desc')}
</p>
{#if imageFiles.length > 0 && !lodging.id}
<div class="mt-4">
<h4 class="font-semibold text-base-content mb-2">
{$t('adventures.selected_images')}
</h4>
<ul class="list-disc pl-5 space-y-1">
{#each imageFiles as file}
<li>{file.name} ({Math.round(file.size / 1024)} KB)</li>
{/each}
</ul>
</div>
{/if}
{#if lodging.id}
<div class="divider my-6"></div>
<!-- Current Images -->
<div class="space-y-4">
<h4 class="font-semibold text-lg">{$t('adventures.my_images')}</h4>
{#if lodging.images && lodging.images.length > 0}
<div class="grid gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
{#each lodging.images as image}
<div class="relative group">
<div class="aspect-square overflow-hidden rounded-lg bg-base-300">
<img
src={image.image}
alt={image.id}
class="w-full h-full object-cover transition-transform group-hover:scale-105"
/>
</div>
<!-- Image Controls -->
<div
class="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity rounded-lg flex items-center justify-center gap-2"
>
{#if !image.is_primary}
<button
type="button"
class="btn btn-success btn-sm"
on:click={() => makePrimaryImage(image.id)}
title="Make Primary"
>
<svg
class="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z"
></path>
</svg>
</button>
{/if}
<button
type="button"
class="btn btn-error btn-sm"
on:click={() => removeImage(image.id)}
title="Remove"
>
</button>
</div>
<!-- Primary Badge -->
{#if image.is_primary}
<div
class="absolute top-2 left-2 bg-warning text-warning-content rounded-full p-1"
>
<svg
class="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 3l14 9-14 9V3z"
></path>
</svg>
</div>
{/if}
</div>
{/each}
</div>
{:else}
<div class="text-center py-8">
<div class="text-base-content/60 text-lg mb-2">
{$t('adventures.no_images')}
</div>
<p class="text-sm text-base-content/40">Upload images to get started</p>
</div>
{/if}
</div>
{/if}
</div>
</div>
<!-- Form Actions -->
<div class="mt-4">
<button type="submit" class="btn btn-primary">
{$t('notes.save')}
</button>
<button type="button" class="btn" on:click={close}>
<div class="flex justify-end gap-3 mt-8 pt-6 border-t border-base-300">
<button type="button" class="btn btn-ghost" on:click={close}>
{$t('about.close')}
</button>
<button type="submit" class="btn btn-primary gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3 3m0 0l-3-3m3 3V4"
/>
</svg>
{$t('notes.save')}
</button>
</div>
</form>
</div>

View file

@ -10,6 +10,10 @@
import { TRANSPORTATION_TYPES_ICONS } from '$lib';
import { formatAllDayDate, formatDateInTimezone } from '$lib/dateUtils';
import { isAllDay } from '$lib';
import CardCarousel from './CardCarousel.svelte';
import Eye from '~icons/mdi/eye';
import EyeOff from '~icons/mdi/eye-off';
function getTransportationIcon(type: string) {
if (type in TRANSPORTATION_TYPES_ICONS) {
@ -112,6 +116,39 @@
<div
class="card w-full max-w-md bg-base-300 text-base-content shadow-2xl hover:shadow-3xl transition-all duration-300 border border-base-300 hover:border-primary/20 group"
>
<!-- Image Section with Overlay -->
<div class="relative overflow-hidden rounded-t-2xl">
<CardCarousel adventures={[transportation]} />
<!-- Privacy Indicator -->
<div class="absolute top-4 right-4">
<div
class="tooltip tooltip-left"
data-tip={transportation.is_public ? $t('adventures.public') : $t('adventures.private')}
>
<div
class="btn btn-circle btn-sm btn-ghost bg-black/20 backdrop-blur-sm border-0 text-white"
>
{#if transportation.is_public}
<Eye class="w-4 h-4" />
{:else}
<EyeOff class="w-4 h-4" />
{/if}
</div>
</div>
</div>
<!-- Category Badge -->
{#if transportation.type}
<div class="absolute bottom-4 left-4">
<div class="badge badge-primary shadow-lg font-medium">
{$t(`transportation.modes.${transportation.type}`)}
{getTransportationIcon(transportation.type)}
</div>
</div>
{/if}
</div>
<div class="card-body p-6 space-y-6">
<!-- Header -->
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">

View file

@ -12,6 +12,11 @@
import { DefaultMarker, MapLibre } from 'svelte-maplibre';
import DateRangeCollapse from './DateRangeCollapse.svelte';
import { getBasemapUrl } from '$lib';
import { deserialize } from '$app/forms';
import FileImage from '~icons/mdi/file-image';
import Star from '~icons/mdi/star';
import Crown from '~icons/mdi/crown';
export let collection: Collection;
export let transportationToEdit: Transportation | null = null;
@ -40,7 +45,8 @@
destination_longitude: transportationToEdit?.destination_longitude || NaN,
start_timezone: transportationToEdit?.start_timezone || '',
end_timezone: transportationToEdit?.end_timezone || '',
distance: null
distance: null,
images: transportationToEdit?.images || []
};
let startTimezone: string | undefined = transportation.start_timezone ?? undefined;
@ -53,12 +59,56 @@
let starting_airport: string = '';
let ending_airport: string = '';
// hold image files so they can be uploaded later
let imageInput: HTMLInputElement;
let imageFiles: File[] = [];
$: {
if (!transportation.rating) {
transportation.rating = NaN;
}
}
function handleImageChange(event: Event) {
const target = event.target as HTMLInputElement;
if (target?.files) {
if (!transportation.id) {
imageFiles = Array.from(target.files);
console.log('Images ready for deferred upload:', imageFiles);
} else {
imageFiles = Array.from(target.files);
for (const file of imageFiles) {
uploadImage(file);
}
}
}
}
async function uploadImage(file: File) {
let formData = new FormData();
formData.append('image', file);
formData.append('object_id', transportation.id);
formData.append('content_type', 'transportation');
let res = await fetch(`/locations?/image`, {
method: 'POST',
body: formData
});
if (res.ok) {
let newData = deserialize(await res.text()) as { data: { id: string; image: string } };
let newImage = {
id: newData.data.id,
image: newData.data.image,
is_primary: false,
immich_id: null
};
transportation.images = [...(transportation.images || []), newImage];
addToast('success', $t('adventures.image_upload_success'));
} else {
addToast('error', $t('adventures.image_upload_error'));
}
}
onMount(async () => {
modal = document.getElementById('my_modal_1') as HTMLDialogElement;
if (modal) {
@ -76,6 +126,36 @@
}
}
async function removeImage(id: string) {
let res = await fetch(`/api/images/${id}/image_delete`, {
method: 'POST'
});
if (res.status === 204) {
transportation.images = transportation.images.filter((image) => image.id !== id);
addToast('success', $t('adventures.image_removed_success'));
} else {
addToast('error', $t('adventures.image_removed_error'));
}
}
async function makePrimaryImage(image_id: string) {
let res = await fetch(`/api/images/${image_id}/toggle_primary`, {
method: 'POST'
});
if (res.ok) {
transportation.images = transportation.images.map((image) => {
if (image.id === image_id) {
image.is_primary = true;
} else {
image.is_primary = false;
}
return image;
});
} else {
console.error('Error in makePrimaryImage:', res);
}
}
async function geocode(e: Event | null) {
// Geocoding logic unchanged
if (e) {
@ -183,6 +263,10 @@
transportation = data as Transportation;
addToast('success', $t('adventures.location_created'));
// Handle image uploads after transportation is created
for (const file of imageFiles) {
await uploadImage(file);
}
dispatch('save', transportation);
} else {
console.error(data);
@ -209,147 +293,244 @@
}
</script>
<dialog id="my_modal_1" class="modal">
<dialog id="my_modal_1" class="modal backdrop-blur-sm">
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<div class="modal-box w-11/12 max-w-3xl" role="dialog" on:keydown={handleKeydown} tabindex="0">
<h3 class="font-bold text-2xl">
{transportationToEdit
? $t('transportation.edit_transportation')
: $t('transportation.new_transportation')}
</h3>
<div class="modal-action items-center">
<div
class="modal-box w-11/12 max-w-6xl bg-gradient-to-br from-base-100 via-base-100 to-base-200 border border-base-300 shadow-2xl"
role="dialog"
on:keydown={handleKeydown}
tabindex="0"
>
<!-- Header Section - Following adventurelog pattern -->
<div
class="top-0 z-10 bg-base-100/90 backdrop-blur-lg border-b border-base-300 -mx-6 -mt-6 px-6 py-4 mb-6"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="p-2 bg-primary/10 rounded-xl">
<svg class="w-8 h-8 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 9l3 3m0 0l-3 3m3-3H8m13 0a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<div>
<h1 class="text-3xl font-bold text-primary bg-clip-text">
{transportationToEdit
? $t('transportation.edit_transportation')
: $t('transportation.new_transportation')}
</h1>
<p class="text-sm text-base-content/60">
{transportationToEdit
? $t('transportation.update_transportation_details')
: $t('transportation.create_new_transportation')}
</p>
</div>
</div>
<!-- Close Button -->
<button class="btn btn-ghost btn-square" on:click={close}>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
</div>
<!-- Main Content -->
<div class="px-2">
<form method="post" style="width: 100%;" on:submit={handleSubmit}>
<!-- Basic Information Section -->
<div class="collapse collapse-plus bg-base-200 mb-4">
<div
class="collapse collapse-plus bg-base-200/50 border border-base-300/50 mb-6 rounded-2xl overflow-hidden"
>
<input type="checkbox" checked />
<div class="collapse-title text-xl font-medium">
{$t('adventures.basic_information')}
</div>
<div class="collapse-content">
<!-- Name -->
<div>
<label for="name">
{$t('adventures.name')}<span class="text-red-500">*</span>
</label>
<input
type="text"
id="name"
name="name"
bind:value={transportation.name}
class="input input-bordered w-full"
required
/>
</div>
<!-- Type selection -->
<div>
<label for="type">
{$t('transportation.type')}<span class="text-red-500">*</span>
</label>
<div>
<select
class="select select-bordered w-full max-w-xs"
name="type"
id="type"
bind:value={transportation.type}
<div
class="collapse-title text-xl font-semibold bg-gradient-to-r from-primary/10 to-primary/5"
>
<div class="flex items-center gap-3">
<div class="p-2 bg-primary/10 rounded-lg">
<svg
class="w-5 h-5 text-primary"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<option disabled selected>{$t('transportation.type')}</option>
<option value="car">{$t('transportation.modes.car')}</option>
<option value="plane">{$t('transportation.modes.plane')}</option>
<option value="train">{$t('transportation.modes.train')}</option>
<option value="bus">{$t('transportation.modes.bus')}</option>
<option value="boat">{$t('transportation.modes.boat')}</option>
<option value="bike">{$t('transportation.modes.bike')}</option>
<option value="walking">{$t('transportation.modes.walking')}</option>
<option value="other">{$t('transportation.modes.other')}</option>
</select>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
{$t('adventures.basic_information')}
</div>
<!-- Description -->
<div>
<label for="description">{$t('adventures.description')}</label><br />
<MarkdownEditor bind:text={transportation.description} editor_height={'h-32'} />
</div>
<!-- Rating -->
<div>
<label for="rating">{$t('adventures.rating')}</label><br />
<input
type="number"
min="0"
max="5"
hidden
bind:value={transportation.rating}
id="rating"
name="rating"
class="input input-bordered w-full max-w-xs mt-1"
/>
<div class="rating -ml-3 mt-1">
<input
type="radio"
name="rating-2"
class="rating-hidden"
checked={Number.isNaN(transportation.rating)}
/>
<input
type="radio"
name="rating-2"
class="mask mask-star-2 bg-orange-400"
on:click={() => (transportation.rating = 1)}
checked={transportation.rating === 1}
/>
<input
type="radio"
name="rating-2"
class="mask mask-star-2 bg-orange-400"
on:click={() => (transportation.rating = 2)}
checked={transportation.rating === 2}
/>
<input
type="radio"
name="rating-2"
class="mask mask-star-2 bg-orange-400"
on:click={() => (transportation.rating = 3)}
checked={transportation.rating === 3}
/>
<input
type="radio"
name="rating-2"
class="mask mask-star-2 bg-orange-400"
on:click={() => (transportation.rating = 4)}
checked={transportation.rating === 4}
/>
<input
type="radio"
name="rating-2"
class="mask mask-star-2 bg-orange-400"
on:click={() => (transportation.rating = 5)}
checked={transportation.rating === 5}
/>
{#if transportation.rating}
<button
type="button"
class="btn btn-sm btn-error ml-2"
on:click={() => (transportation.rating = NaN)}
</div>
<div class="collapse-content bg-base-100/50 pt-4 p-6 space-y-3">
<!-- Dual Column Layout for Large Screens -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Left Column -->
<div class="space-y-4">
<!-- Name Field -->
<div class="form-control">
<label class="label" for="name">
<span class="label-text font-medium"
>{$t('adventures.name')}<span class="text-error ml-1">*</span></span
>
</label>
<input
type="text"
id="name"
name="name"
bind:value={transportation.name}
class="input input-bordered w-full bg-base-100/80 focus:bg-base-100"
placeholder={$t('transportation.enter_transportation_name')}
required
/>
</div>
<!-- Type Selection -->
<div class="form-control">
<label class="label" for="type">
<span class="label-text font-medium"
>{$t('transportation.type')}<span class="text-error ml-1">*</span></span
>
</label>
<select
class="select select-bordered w-full bg-base-100/80 focus:bg-base-100"
name="type"
id="type"
bind:value={transportation.type}
>
{$t('adventures.remove')}
</button>
{/if}
<option disabled selected>{$t('transportation.select_type')}</option>
<option value="car">{$t('transportation.modes.car')}</option>
<option value="plane">{$t('transportation.modes.plane')}</option>
<option value="train">{$t('transportation.modes.train')}</option>
<option value="bus">{$t('transportation.modes.bus')}</option>
<option value="boat">{$t('transportation.modes.boat')}</option>
<option value="bike">{$t('transportation.modes.bike')}</option>
<option value="walking">{$t('transportation.modes.walking')}</option>
<option value="other">{$t('transportation.modes.other')}</option>
</select>
</div>
<!-- Rating Field -->
<div class="form-control">
<label class="label" for="rating">
<span class="label-text font-medium">{$t('adventures.rating')}</span>
</label>
<input
type="number"
min="0"
max="5"
hidden
bind:value={transportation.rating}
id="rating"
name="rating"
class="input input-bordered w-full max-w-xs"
/>
<div
class="flex items-center gap-4 p-4 bg-base-100/80 border border-base-300 rounded-xl"
>
<div class="rating">
<input
type="radio"
name="rating-2"
class="rating-hidden"
checked={Number.isNaN(transportation.rating)}
/>
<input
type="radio"
name="rating-2"
class="mask mask-star-2 bg-warning"
on:click={() => (transportation.rating = 1)}
checked={transportation.rating === 1}
/>
<input
type="radio"
name="rating-2"
class="mask mask-star-2 bg-warning"
on:click={() => (transportation.rating = 2)}
checked={transportation.rating === 2}
/>
<input
type="radio"
name="rating-2"
class="mask mask-star-2 bg-warning"
on:click={() => (transportation.rating = 3)}
checked={transportation.rating === 3}
/>
<input
type="radio"
name="rating-2"
class="mask mask-star-2 bg-warning"
on:click={() => (transportation.rating = 4)}
checked={transportation.rating === 4}
/>
<input
type="radio"
name="rating-2"
class="mask mask-star-2 bg-warning"
on:click={() => (transportation.rating = 5)}
checked={transportation.rating === 5}
/>
</div>
{#if transportation.rating}
<button
type="button"
class="btn btn-error btn-sm"
on:click={() => (transportation.rating = NaN)}
>
{$t('adventures.remove')}
</button>
{/if}
</div>
</div>
</div>
<!-- Right Column -->
<div class="space-y-4">
<!-- Link Field -->
<div class="form-control">
<label class="label" for="link">
<span class="label-text font-medium">{$t('adventures.link')}</span>
</label>
<input
type="url"
id="link"
name="link"
bind:value={transportation.link}
class="input input-bordered w-full bg-base-100/80 focus:bg-base-100"
placeholder={$t('transportation.enter_link')}
/>
</div>
<!-- Description Field -->
<div class="form-control">
<label class="label" for="description">
<span class="label-text font-medium">{$t('adventures.description')}</span>
</label>
<div class="bg-base-100/80 border border-base-300 rounded-xl p-2">
<MarkdownEditor bind:text={transportation.description} editor_height={'h-32'} />
</div>
</div>
</div>
</div>
<!-- Link -->
<div>
<label for="link">{$t('adventures.link')}</label>
<input
type="url"
id="link"
name="link"
bind:value={transportation.link}
class="input input-bordered w-full"
/>
</div>
</div>
</div>
<!-- Date Range Section -->
<DateRangeCollapse
type="transportation"
bind:utcStartDate={transportation.date}
@ -359,124 +540,193 @@
{collection}
/>
<!-- Flight Information -->
<div class="collapse collapse-plus bg-base-200 mb-4">
<!-- Location/Flight Information Section -->
<div
class="collapse collapse-plus bg-base-200/50 border border-base-300/50 mb-6 rounded-2xl overflow-hidden"
>
<input type="checkbox" checked />
<div class="collapse-title text-xl font-medium">
{#if transportation?.type == 'plane'}
{$t('adventures.flight_information')}
{:else}
{$t('adventures.location_information')}
{/if}
<div
class="collapse-title text-xl font-semibold bg-gradient-to-r from-primary/10 to-primary/5"
>
<div class="flex items-center gap-3">
<div class="p-2 bg-primary/10 rounded-lg">
<svg
class="w-5 h-5 text-primary"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
{#if transportation?.type == 'plane'}
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"
/>
{:else}
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
/>
{/if}
</svg>
</div>
{#if transportation?.type == 'plane'}
{$t('adventures.flight_information')}
{:else}
{$t('adventures.location_information')}
{/if}
</div>
</div>
<div class="collapse-content">
<div class="collapse-content bg-base-100/50 pt-4 p-6">
{#if transportation?.type == 'plane'}
<!-- Flight Number -->
<div class="mb-4">
<label for="flight_number" class="label">
<span class="label-text">{$t('transportation.flight_number')}</span>
</label>
<input
type="text"
id="flight_number"
name="flight_number"
bind:value={transportation.flight_number}
class="input input-bordered w-full"
/>
<!-- Flight-specific fields -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<!-- Flight Number -->
<div class="form-control">
<label class="label" for="flight_number">
<span class="label-text font-medium">{$t('transportation.flight_number')}</span>
</label>
<input
type="text"
id="flight_number"
name="flight_number"
bind:value={transportation.flight_number}
class="input input-bordered w-full bg-base-100/80 focus:bg-base-100"
placeholder={$t('transportation.enter_flight_number')}
/>
</div>
</div>
<!-- Starting Airport -->
<!-- Airport Fields (if locations not set) -->
{#if !transportation.from_location || !transportation.to_location}
<div class="mb-4">
<label for="starting_airport" class="label">
<span class="label-text">{$t('adventures.starting_airport')}</span>
</label>
<input
type="text"
id="starting_airport"
bind:value={starting_airport}
name="starting_airport"
class="input input-bordered w-full"
placeholder={$t('transportation.starting_airport_desc')}
/>
<label for="ending_airport" class="label">
<span class="label-text">{$t('adventures.ending_airport')}</span>
</label>
<input
type="text"
id="ending_airport"
bind:value={ending_airport}
name="ending_airport"
class="input input-bordered w-full"
placeholder={$t('transportation.ending_airport_desc')}
/>
<button type="button" class="btn btn-primary mt-2" on:click={geocode}>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<div class="form-control">
<label class="label" for="starting_airport">
<span class="label-text font-medium">{$t('adventures.starting_airport')}</span
>
</label>
<input
type="text"
id="starting_airport"
bind:value={starting_airport}
name="starting_airport"
class="input input-bordered w-full bg-base-100/80 focus:bg-base-100"
placeholder={$t('transportation.starting_airport_desc')}
/>
</div>
<div class="form-control">
<label class="label" for="ending_airport">
<span class="label-text font-medium">{$t('adventures.ending_airport')}</span>
</label>
<input
type="text"
id="ending_airport"
bind:value={ending_airport}
name="ending_airport"
class="input input-bordered w-full bg-base-100/80 focus:bg-base-100"
placeholder={$t('transportation.ending_airport_desc')}
/>
</div>
</div>
<div class="flex justify-start mb-6">
<button type="button" class="btn btn-primary gap-2" on:click={geocode}>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
{$t('transportation.fetch_location_information')}
</button>
</div>
{/if}
{/if}
{#if transportation.from_location && transportation.to_location}
<!-- Location Fields (for all types or when flight locations are set) -->
{#if transportation?.type != 'plane' || (transportation.from_location && transportation.to_location)}
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<!-- From Location -->
<div class="mb-4">
<label for="from_location" class="label">
<span class="label-text">{$t('transportation.from_location')}</span>
<div class="form-control">
<label class="label" for="from_location">
<span class="label-text font-medium">{$t('transportation.from_location')}</span>
</label>
<input
type="text"
id="from_location"
name="from_location"
bind:value={transportation.from_location}
class="input input-bordered w-full"
class="input input-bordered w-full bg-base-100/80 focus:bg-base-100"
placeholder={$t('transportation.enter_from_location')}
/>
</div>
<!-- To Location -->
<div class="mb-4">
<label for="to_location" class="label">
<span class="label-text">{$t('transportation.to_location')}</span>
<div class="form-control">
<label class="label" for="to_location">
<span class="label-text font-medium">{$t('transportation.to_location')}</span>
</label>
<input
type="text"
id="to_location"
name="to_location"
bind:value={transportation.to_location}
class="input input-bordered w-full"
class="input input-bordered w-full bg-base-100/80 focus:bg-base-100"
placeholder={$t('transportation.enter_to_location')}
/>
</div>
</div>
{#if transportation?.type != 'plane'}
<div class="flex justify-start mb-6">
<button type="button" class="btn btn-primary gap-2" on:click={geocode}>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
{$t('transportation.fetch_location_information')}
</button>
</div>
{/if}
{:else}
<!-- From Location -->
<div class="mb-4">
<label for="from_location" class="label">
<span class="label-text">{$t('transportation.from_location')}</span>
</label>
<input
type="text"
id="from_location"
name="from_location"
bind:value={transportation.from_location}
class="input input-bordered w-full"
/>
</div>
<!-- To Location -->
<div class="mb-4">
<label for="to_location" class="label">
<span class="label-text">{$t('transportation.to_location')}</span>
</label>
<input
type="text"
id="to_location"
name="to_location"
bind:value={transportation.to_location}
class="input input-bordered w-full"
/>
</div>
<button type="button" class="btn btn-primary mt-2" on:click={geocode}>
Fetch Location Information
</button>
{/if}
<div class="mt-4">
<!-- Map Section -->
<div class="bg-base-100/80 border border-base-300 rounded-xl p-4 mb-6">
<div class="mb-4">
<h4 class="font-semibold text-base-content flex items-center gap-2">
<svg
class="w-5 h-5 text-primary"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-1.447-.894L15 4m0 13V4m0 0L9 7"
/>
</svg>
{$t('adventures.route_map')}
</h4>
</div>
<MapLibre
style={getBasemapUrl()}
class="relative aspect-[9/16] max-h-[70vh] w-full sm:aspect-video sm:max-h-full rounded-lg"
@ -497,35 +747,169 @@
{/if}
</MapLibre>
</div>
<!-- Clear Location Button -->
{#if transportation.from_location || transportation.to_location}
<button
type="button"
class="btn btn-error btn-sm mt-2"
on:click={() => {
transportation.from_location = '';
transportation.to_location = '';
starting_airport = '';
ending_airport = '';
transportation.origin_latitude = NaN;
transportation.origin_longitude = NaN;
transportation.destination_latitude = NaN;
transportation.destination_longitude = NaN;
}}
>
{$t('adventures.clear_location')}
</button>
<div class="flex justify-start">
<button
type="button"
class="btn btn-error btn-sm gap-2"
on:click={() => {
transportation.from_location = '';
transportation.to_location = '';
starting_airport = '';
ending_airport = '';
transportation.origin_latitude = NaN;
transportation.origin_longitude = NaN;
transportation.destination_latitude = NaN;
transportation.destination_longitude = NaN;
}}
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
{$t('adventures.clear_location')}
</button>
</div>
{/if}
</div>
</div>
<!-- images section -->
<div
class="collapse collapse-plus bg-base-200/50 border border-base-300/50 mb-6 rounded-2xl overflow-hidden"
>
<input type="checkbox" checked />
<div
class="collapse-title text-xl font-semibold bg-gradient-to-r from-primary/10 to-primary/5"
>
<div class="flex items-center gap-3">
<div class="p-2 bg-primary/10 rounded-lg">
<FileImage class="w-5 h-5 text-primary" />
</div>
{$t('adventures.images')}
</div>
</div>
<div class="collapse-content bg-base-100/50 pt-4 p-6">
<div class="form-control">
<label class="label" for="image">
<span class="label-text font-medium">{$t('adventures.upload_image')}</span>
</label>
<input
type="file"
id="image"
name="image"
accept="image/*"
multiple
bind:this={imageInput}
on:change={handleImageChange}
class="file-input file-input-bordered file-input-primary w-full bg-base-100/80 focus:bg-base-100"
/>
</div>
<p class="text-sm text-base-content/60 mt-2">
{$t('adventures.image_upload_desc')}
</p>
{#if imageFiles.length > 0 && !transportation.id}
<div class="mt-4">
<h4 class="font-semibold text-base-content mb-2">
{$t('adventures.selected_images')}
</h4>
<ul class="list-disc pl-5 space-y-1">
{#each imageFiles as file}
<li>{file.name} ({Math.round(file.size / 1024)} KB)</li>
{/each}
</ul>
</div>
{/if}
{#if transportation.id}
<div class="divider my-6"></div>
<!-- Current Images -->
<div class="space-y-4">
<h4 class="font-semibold text-lg">{$t('adventures.my_images')}</h4>
{#if transportation.images.length > 0}
<div class="grid gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
{#each transportation.images as image}
<div class="relative group">
<div class="aspect-square overflow-hidden rounded-lg bg-base-300">
<img
src={image.image}
alt={image.id}
class="w-full h-full object-cover transition-transform group-hover:scale-105"
/>
</div>
<!-- Image Controls -->
<div
class="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity rounded-lg flex items-center justify-center gap-2"
>
{#if !image.is_primary}
<button
type="button"
class="btn btn-success btn-sm"
on:click={() => makePrimaryImage(image.id)}
title="Make Primary"
>
<Star class="h-4 w-4" />
</button>
{/if}
<button
type="button"
class="btn btn-error btn-sm"
on:click={() => removeImage(image.id)}
title="Remove"
>
</button>
</div>
<!-- Primary Badge -->
{#if image.is_primary}
<div
class="absolute top-2 left-2 bg-warning text-warning-content rounded-full p-1"
>
<Crown class="h-4 w-4" />
</div>
{/if}
</div>
{/each}
</div>
{:else}
<div class="text-center py-8">
<div class="text-base-content/60 text-lg mb-2">
{$t('adventures.no_images')}
</div>
<p class="text-sm text-base-content/40">Upload images to get started</p>
</div>
{/if}
</div>
{/if}
</div>
</div>
<!-- Form Actions -->
<div class="mt-4">
<button type="submit" class="btn btn-primary">
{$t('notes.save')}
</button>
<button type="button" class="btn" on:click={close}>
<div class="flex justify-end gap-3 mt-8 pt-6 border-t border-base-300">
<button type="button" class="btn btn-ghost" on:click={close}>
{$t('about.close')}
</button>
<button type="submit" class="btn btn-primary gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3 3m0 0l-3-3m3 3V4"
/>
</svg>
{$t('notes.save')}
</button>
</div>
</form>
</div>

View file

@ -14,6 +14,13 @@ export type User = {
disable_password: boolean;
};
export type ContentImage = {
id: string;
image: string;
is_primary: boolean;
immich_id: string | null;
};
export type Location = {
id: string;
name: string;
@ -22,12 +29,7 @@ export type Location = {
description?: string | null;
rating?: number | null;
link?: string | null;
images: {
id: string;
image: string;
is_primary: boolean;
immich_id: string | null;
}[];
images: ContentImage[];
visits: {
id: string;
start_date: string;
@ -174,6 +176,7 @@ export type Transportation = {
collection: Collection | null | string;
created_at: string; // ISO 8601 date string
updated_at: string; // ISO 8601 date string
images: ContentImage[]; // Array of images associated with the transportation
};
export type Note = {
@ -302,4 +305,5 @@ export type Lodging = {
collection: string | null;
created_at: string; // ISO 8601 date string
updated_at: string; // ISO 8601 date string
images: ContentImage[]; // Array of images associated with the lodging
};