1
0
Fork 0
mirror of https://github.com/seanmorley15/AdventureLog.git synced 2025-08-05 13:15:18 +02:00

feat: add LocationQuickStart and LocationVisits components for enhanced location selection and visit management

- Implemented LocationQuickStart.svelte for searching and selecting locations on a map with reverse geocoding.
- Created LocationVisits.svelte to manage visit dates and notes for locations, including timezone handling and validation.
- Updated types to remove location property from Attachment type.
- Modified locations page to integrate NewLocationModal for creating and editing locations, syncing updates with adventures.
This commit is contained in:
Sean Morley 2025-07-29 22:37:50 -04:00
parent 31eb7fb734
commit 707c99651f
13 changed files with 3224 additions and 185 deletions

View file

@ -230,7 +230,7 @@ class LocationSerializer(CustomModelSerializer):
return obj.is_visited_status()
def create(self, validated_data):
visits_data = validated_data.pop('visits', None)
visits_data = validated_data.pop('visits', [])
category_data = validated_data.pop('category', None)
collections_data = validated_data.pop('collections', [])

View file

@ -262,25 +262,39 @@ class ContentImageViewSet(viewsets.ModelViewSet):
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
def _create_standard_image(self, request, content_object, content_type, object_id):
"""Handle standard image creation"""
# Add content type and object ID to request data
request_data = request.data.copy()
request_data['content_type'] = content_type.id
request_data['object_id'] = object_id
"""Handle standard image creation without deepcopy issues"""
# Create serializer with modified data
# Get uploaded image file safely
image_file = request.FILES.get('image')
if not image_file:
return Response({"error": "No image uploaded"}, status=status.HTTP_400_BAD_REQUEST)
# Build a clean dict for serializer input
request_data = {
'content_type': content_type.id,
'object_id': object_id,
}
# Optionally add other fields (e.g., caption, alt text) from request.data
for key in ['caption', 'alt_text', 'description']: # update as needed
if key in request.data:
request_data[key] = request.data[key]
# Create and validate serializer
serializer = self.get_serializer(data=request_data)
serializer.is_valid(raise_exception=True)
# Save the image
# Save with image passed explicitly
serializer.save(
user=content_object.user if hasattr(content_object, 'user') else request.user,
user=getattr(content_object, 'user', request.user),
content_type=content_type,
object_id=object_id
object_id=object_id,
image=image_file
)
return Response(serializer.data, status=status.HTTP_201_CREATED)
def perform_create(self, serializer):
# The content_type and object_id are already set in the create method
# Just ensure the user is set correctly

View file

@ -3,7 +3,6 @@
import type { Category } from '$lib/types';
import { t } from 'svelte-i18n';
export let categories: Category[] = [];
export let selected_category: Category | null = null;
export let searchTerm: string = '';
let new_category: Category = {
@ -15,6 +14,12 @@
num_locations: 0
};
$: {
console.log('Selected category changed:', selected_category);
}
let categories: Category[] = [];
let isOpen: boolean = false;
let isEmojiPickerVisible: boolean = false;
@ -45,7 +50,15 @@
let dropdownRef: HTMLDivElement;
onMount(() => {
const loadData = async () => {
await import('emoji-picker-element');
let res = await fetch('/api/categories');
categories = await res.json();
categories = categories.sort((a, b) => (b.num_locations || 0) - (a.num_locations || 0));
};
loadData();
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef && !dropdownRef.contains(event.target as Node)) {
isOpen = false;
@ -56,9 +69,6 @@
document.removeEventListener('click', handleClickOutside);
};
});
onMount(async () => {
await import('emoji-picker-element');
});
</script>
<div class="dropdown w-full" bind:this={dropdownRef}>

View file

@ -3,150 +3,196 @@
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 type { ImmichAlbum } from '$lib/types';
import { debounce } from '$lib';
// Props
export let copyImmichLocally: boolean = false;
export let objectId: string = '';
export let contentType: string = 'location';
export let defaultDate: string = '';
// Component state
let immichImages: any[] = [];
let immichSearchValue: string = '';
let searchCategory: 'search' | 'date' | 'album' = 'date';
let immichError: string = '';
let immichNextURL: string = '';
let loading = false;
export let location: Location | null = null;
export let copyImmichLocally: boolean = false;
let albums: ImmichAlbum[] = [];
let currentAlbum: string = '';
let selectedDate: string = defaultDate || new Date().toISOString().split('T')[0];
const dispatch = createEventDispatcher();
let albums: ImmichAlbum[] = [];
let currentAlbum: string = '';
let selectedDate: string =
(location as Location | null)?.visits
.map((v) => new Date(v.end_date || v.start_date))
.sort((a, b) => +b - +a)[0]
?.toISOString()
?.split('T')[0] || '';
if (!selectedDate) {
selectedDate = new Date().toISOString().split('T')[0];
}
// Reactive statements
$: {
if (searchCategory === 'album' && currentAlbum) {
immichImages = [];
fetchAlbumAssets(currentAlbum);
} else if (searchCategory === 'date' && selectedDate) {
// Clear album selection when switching to date mode
if (currentAlbum) {
currentAlbum = '';
}
clearAlbumSelection();
searchImmich();
} else if (searchCategory === 'search') {
// Clear album selection when switching to search mode
clearAlbumSelection();
}
}
// Helper functions
function clearAlbumSelection() {
if (currentAlbum) {
currentAlbum = '';
}
// Search will be triggered by the form submission or debounced search
}
}
async function loadMoreImmich() {
// The next URL returned by our API is a absolute url to API, but we need to use the relative path, to use the frontend api proxy.
const url = new URL(immichNextURL);
immichNextURL = url.pathname + url.search;
return fetchAssets(immichNextURL, true);
function buildQueryParams(): string {
const params = new URLSearchParams();
if (immichSearchValue && searchCategory === 'search') {
params.append('query', immichSearchValue);
} else if (selectedDate && searchCategory === 'date') {
params.append('date', selectedDate);
}
async function saveImmichRemoteUrl(imageId: string) {
if (!location) {
console.error('No location provided to save the image URL');
return;
}
let res = await fetch('/api/images', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
immich_id: imageId,
object_id: location.id,
content_type: 'location'
})
});
if (res.ok) {
let data = await res.json();
if (!data.image) {
console.error('No image data returned from the server');
immichError = $t('immich.error_saving_image');
return;
}
dispatch('remoteImmichSaved', data);
} else {
let errorData = await res.json();
console.error('Error saving image URL:', errorData);
immichError = $t(errorData.message || 'immich.error_saving_image');
}
return params.toString();
}
async function fetchAssets(url: string, usingNext = false) {
// API functions
async function fetchAssets(url: string, usingNext = false): Promise<void> {
loading = true;
try {
let res = await fetch(url);
immichError = '';
try {
const res = await fetch(url);
if (!res.ok) {
let data = await res.json();
let errorMessage = data.message;
console.error('Error in handling fetchAsstes', errorMessage);
immichError = $t(data.code);
} else {
let data = await res.json();
const data = await res.json();
console.error('Error in fetchAssets:', data.message);
immichError = $t(data.code || 'immich.fetch_error');
return;
}
const data = await res.json();
if (data.results && data.results.length > 0) {
if (usingNext) {
immichImages = [...immichImages, ...data.results];
} else {
immichImages = data.results;
}
immichNextURL = data.next || '';
} else {
immichError = $t('immich.no_items_found');
immichNextURL = '';
}
immichNextURL = data.next || '';
}
} catch (error) {
console.error('Error fetching assets:', error);
immichError = $t('immich.fetch_error');
} finally {
loading = false;
}
}
async function fetchAlbumAssets(album_id: string) {
return fetchAssets(`/api/integrations/immich/albums/${album_id}`);
async function fetchAlbumAssets(albumId: string): Promise<void> {
return fetchAssets(`/api/integrations/immich/albums/${albumId}`);
}
onMount(async () => {
let res = await fetch('/api/integrations/immich/albums');
if (res.ok) {
let data = await res.json();
albums = data;
async function loadMoreImmich(): Promise<void> {
if (!immichNextURL) return;
// Convert absolute URL to relative path for frontend API proxy
const url = new URL(immichNextURL);
const relativePath = url.pathname + url.search;
return fetchAssets(relativePath, true);
}
async function saveImmichRemoteUrl(imageId: string): Promise<void> {
if (!objectId) {
console.error('No object ID provided to save the image URL');
immichError = $t('immich.error_no_object_id');
return;
}
try {
const res = await fetch('/api/images', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
immich_id: imageId,
object_id: objectId,
content_type: contentType
})
});
function buildQueryParams() {
let params = new URLSearchParams();
if (immichSearchValue && searchCategory === 'search') {
params.append('query', immichSearchValue);
} else if (selectedDate && searchCategory === 'date') {
params.append('date', selectedDate);
}
return params.toString();
if (res.ok) {
const data = await res.json();
if (!data.image) {
console.error('No image data returned from the server');
immichError = $t('immich.error_saving_image');
return;
}
dispatch('remoteImmichSaved', data);
} else {
const errorData = await res.json();
console.error('Error saving image URL:', errorData);
immichError = $t(errorData.message || 'immich.error_saving_image');
}
} catch (error) {
console.error('Error in saveImmichRemoteUrl:', error);
immichError = $t('immich.error_saving_image');
}
}
// Event handlers
const searchImmich = debounce(() => {
_searchImmich();
}, 500); // Debounce the search function to avoid multiple requests on every key press
}, 500);
async function _searchImmich() {
async function _searchImmich(): Promise<void> {
immichImages = [];
return fetchAssets(`/api/integrations/immich/search/?${buildQueryParams()}`);
}
function handleSearchCategoryChange(category: 'search' | 'date' | 'album') {
searchCategory = category;
immichError = '';
if (category !== 'album') {
clearAlbumSelection();
}
}
function handleImageSelect(image: any) {
const currentDomain = window.location.origin;
const fullUrl = `${currentDomain}/immich/${image.id}`;
if (copyImmichLocally) {
dispatch('fetchImage', fullUrl);
} else {
saveImmichRemoteUrl(image.id);
}
}
// Lifecycle
onMount(async () => {
try {
const res = await fetch('/api/integrations/immich/albums');
if (res.ok) {
const data = await res.json();
albums = data;
} else {
console.warn('Failed to fetch Immich albums');
}
} catch (error) {
console.error('Error fetching albums:', error);
}
});
</script>
<div class="space-y-4">
@ -163,31 +209,28 @@
<button
class="tab"
class:tab-active={searchCategory === 'search'}
on:click={() => {
searchCategory = 'search';
currentAlbum = '';
}}
on:click={() => handleSearchCategoryChange('search')}
>
{$t('immich.search')}
</button>
<button
class="tab"
class:tab-active={searchCategory === 'date'}
on:click={() => (searchCategory = 'date')}
on:click={() => handleSearchCategoryChange('date')}
>
{$t('immich.by_date')}
</button>
<button
class="tab"
class:tab-active={searchCategory === 'album'}
on:click={() => (searchCategory = 'album')}
on:click={() => handleSearchCategoryChange('album')}
>
{$t('immich.by_album')}
</button>
</div>
<!-- Search Controls -->
<div class="bg-base-100 p-4 rounded-lg border border-base-300">
<div class="bg-base-50 p-4 rounded-lg border border-base-200">
{#if searchCategory === 'search'}
<form on:submit|preventDefault={searchImmich} class="flex gap-2">
<input
@ -195,26 +238,47 @@
placeholder={$t('immich.search_placeholder')}
bind:value={immichSearchValue}
class="input input-bordered flex-1"
disabled={loading}
/>
<button type="submit" class="btn btn-primary">
<button
type="submit"
class="btn btn-primary"
class:loading
disabled={loading || !immichSearchValue.trim()}
>
{$t('immich.search')}
</button>
</form>
{:else if searchCategory === 'date'}
<div class="flex items-center gap-2">
<label class="label">
<label class="label" for="date-picker">
<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" />
<input
id="date-picker"
type="date"
bind:value={selectedDate}
class="input input-bordered w-full max-w-xs"
disabled={loading}
/>
</div>
{:else if searchCategory === 'album'}
<div class="flex items-center gap-2">
<label class="label">
<label class="label" for="album-select">
<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}
<select
id="album-select"
class="select select-bordered w-full max-w-xs"
bind:value={currentAlbum}
disabled={loading}
>
<option value="" disabled>
{albums.length > 0
? $t('immich.select_album_placeholder')
: $t('immich.loading_albums')}
</option>
{#each albums as album (album.id)}
<option value={album.id}>{album.albumName}</option>
{/each}
</select>
@ -224,12 +288,13 @@
<!-- Error Message -->
{#if immichError}
<div class="alert alert-error">
<div class="alert alert-error py-2">
<svg
xmlns="http://www.w3.org/2000/svg"
class="stroke-current shrink-0 h-6 w-6"
class="stroke-current shrink-0 h-5 w-5"
fill="none"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
stroke-linecap="round"
@ -249,30 +314,37 @@
<div
class="absolute inset-0 bg-base-200/50 backdrop-blur-sm z-10 flex items-center justify-center rounded-lg"
>
<div class="flex flex-col items-center gap-2">
<span class="loading loading-spinner loading-lg"></span>
<span class="text-sm text-base-content/70">{$t('immich.loading')}</span>
</div>
</div>
{/if}
<!-- 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" />
{#if immichImages.length > 0}
<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 (image.id)}
<div
class="card bg-base-100 shadow-sm hover:shadow-md transition-all duration-200 border border-base-200"
>
<figure class="aspect-square overflow-hidden">
<img
src={image.image_url}
alt="Image from Immich"
class="w-full h-full object-cover hover:scale-105 transition-transform duration-200"
loading="lazy"
/>
</figure>
<div class="card-body p-2">
<div class="card-body p-3">
<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);
}
}}
class="btn btn-primary btn-sm w-full gap-2"
disabled={loading}
on:click={() => handleImageSelect(image)}
>
<Upload class="w-4 h-4" />
</button>
@ -280,11 +352,23 @@
</div>
{/each}
</div>
{:else if !loading && searchCategory !== 'search'}
<div class="bg-base-200/50 rounded-lg p-8 text-center">
<div class="text-base-content/60 mb-2">{$t('immich.no_images')}</div>
<div class="text-sm text-base-content/40">
{#if searchCategory === 'date'}
{$t('immich.try_different_date')}
{:else if searchCategory === 'album'}
{$t('immich.select_album_first')}
{/if}
</div>
</div>
{/if}
<!-- Load More Button -->
{#if immichNextURL}
{#if immichNextURL && !loading}
<div class="flex justify-center mt-6">
<button class="btn btn-outline btn-wide" on:click={loadMoreImmich}>
<button class="btn btn-outline btn-wide" on:click={loadMoreImmich} disabled={loading}>
{$t('immich.load_more')}
</button>
</div>

View file

@ -83,7 +83,7 @@
let immichIntegration: boolean = false;
let copyImmichLocally: boolean = false;
import ActivityComplete from './ActivityComplete.svelte';
import ActivityComplete from './TagComplete.svelte';
import CategoryDropdown from './CategoryDropdown.svelte';
import { findFirstValue, isAllDay } from '$lib';
import MarkdownEditor from './MarkdownEditor.svelte';
@ -879,7 +879,7 @@
bind:value={location.tags}
class="input input-bordered w-full"
/>
<ActivityComplete bind:activities={location.tags} />
<ActivityComplete bind:tags={location.tags} />
</div>
</div>

View file

@ -0,0 +1,289 @@
<script lang="ts">
import { createEventDispatcher, onMount } from 'svelte';
import type { Location, Attachment, Category, Collection, User } from '$lib/types';
import { addToast } from '$lib/toasts';
import { t } from 'svelte-i18n';
import LocationQuickStart from './locations/LocationQuickStart.svelte';
import LocationDetails from './locations/LocationDetails.svelte';
import LocationMedia from './locations/LocationMedia.svelte';
import LocationVisits from './locations/LocationVisits.svelte';
export let user: User | null = null;
const dispatch = createEventDispatcher();
let modal: HTMLDialogElement;
let steps = [
{
name: $t('adventures.quick_start'),
selected: true
},
{
name: $t('adventures.details'),
selected: false
},
{
name: $t('settings.media'),
selected: false
},
{
name: $t('adventures.visits'),
selected: false
}
];
export let location: Location = {
id: '',
name: '',
visits: [],
link: null,
description: null,
tags: [],
rating: NaN,
is_public: false,
latitude: NaN,
longitude: NaN,
location: null,
images: [],
user: null,
category: {
id: '',
name: '',
display_name: '',
icon: '',
user: ''
},
attachments: []
};
export let locationToEdit: Location | null = null;
location = {
id: locationToEdit?.id || '',
name: locationToEdit?.name || '',
link: locationToEdit?.link || null,
description: locationToEdit?.description || null,
tags: locationToEdit?.tags || [],
rating: locationToEdit?.rating || NaN,
is_public: locationToEdit?.is_public || false,
latitude: locationToEdit?.latitude || NaN,
longitude: locationToEdit?.longitude || NaN,
location: locationToEdit?.location || null,
images: locationToEdit?.images || [],
user: locationToEdit?.user || null,
visits: locationToEdit?.visits || [],
is_visited: locationToEdit?.is_visited || false,
collections: locationToEdit?.collections || [],
category: locationToEdit?.category || {
id: '',
name: '',
display_name: '',
icon: '',
user: ''
},
attachments: locationToEdit?.attachments || []
};
onMount(async () => {
modal = document.getElementById('my_modal_1') as HTMLDialogElement;
modal.showModal();
// Skip the quick start step if editing an existing location
if (!locationToEdit) {
steps[0].selected = true;
steps[1].selected = false;
} else {
steps[0].selected = false;
steps[1].selected = true;
}
});
function close() {
dispatch('close');
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
close();
}
}
</script>
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
<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-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="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>
<div>
<h1 class="text-3xl font-bold text-primary bg-clip-text">
{locationToEdit ? $t('adventures.edit_location') : $t('adventures.new_location')}
</h1>
<p class="text-sm text-base-content/60">
{locationToEdit
? $t('adventures.update_location_details')
: $t('adventures.create_new_location')}
</p>
</div>
</div>
<ul class="timeline">
{#each steps as step, index}
<li>
{#if index > 0}
<hr />
{/if}
<div class="timeline-middle">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="h-5 w-5 {step.selected ? 'text-primary' : 'text-base-content/40'}"
>
<path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z"
clip-rule="evenodd"
/>
</svg>
</div>
<div
class="timeline-end timeline-box {step.selected
? 'bg-primary text-primary-content'
: 'bg-base-200'}"
>
{step.name}
</div>
{#if index < steps.length - 1}
<hr />
{/if}
</li>
{/each}
</ul>
<!-- Close Button -->
{#if !location.id}
<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>
{:else}
<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>
{/if}
</div>
</div>
{#if steps[0].selected}
<!-- Main Content -->
<LocationQuickStart
on:locationSelected={(e) => {
location.name = e.detail.name;
location.location = e.detail.location;
location.latitude = e.detail.latitude;
location.longitude = e.detail.longitude;
steps[0].selected = false;
steps[1].selected = true;
console.log('Location selected:', e.detail);
}}
on:cancel={() => close()}
on:next={() => {
steps[0].selected = false;
steps[1].selected = true;
}}
/>
{/if}
{#if steps[1].selected}
<LocationDetails
currentUser={user}
initialLocation={location}
bind:editingLocation={location}
on:back={() => {
steps[1].selected = false;
steps[0].selected = true;
}}
on:save={(e) => {
location.name = e.detail.name;
location.category = e.detail.category;
location.rating = e.detail.rating;
location.is_public = e.detail.is_public;
location.link = e.detail.link;
location.description = e.detail.description;
location.latitude = e.detail.latitude;
location.longitude = e.detail.longitude;
location.location = e.detail.location;
location.tags = e.detail.tags;
location.user = e.detail.user;
steps[1].selected = false;
steps[2].selected = true;
}}
/>
{/if}
{#if steps[2].selected}
<LocationMedia
bind:images={location.images}
bind:attachments={location.attachments}
on:back={() => {
steps[2].selected = false;
steps[1].selected = true;
}}
itemId={location.id}
on:next={() => {
steps[2].selected = false;
steps[3].selected = true;
}}
/>
{/if}
{#if steps[3].selected}
<LocationVisits
bind:visits={location.visits}
objectId={location.id}
type="location"
on:back={() => {
steps[3].selected = false;
steps[2].selected = true;
}}
/>
{/if}
</div>
</dialog>

View file

@ -2,13 +2,13 @@
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
export let activities: string[] | undefined | null;
export let tags: string[] | undefined | null;
let allActivities: string[] = [];
let allTags: string[] = [];
let inputVal: string = '';
if (activities == null || activities == undefined) {
activities = [];
if (tags == null || tags == undefined) {
tags = [];
}
onMount(async () => {
@ -20,30 +20,29 @@
});
let data = await res.json();
if (data && data.activities) {
allActivities = data.activities;
allTags = data.activities;
}
});
function addActivity() {
if (inputVal && activities) {
if (inputVal && tags) {
const trimmedInput = inputVal.trim().toLocaleLowerCase();
if (trimmedInput && !activities.includes(trimmedInput)) {
activities = [...activities, trimmedInput];
if (trimmedInput && !tags.includes(trimmedInput)) {
tags = [...tags, trimmedInput];
inputVal = '';
}
}
}
function removeActivity(item: string) {
if (activities) {
activities = activities.filter((activity) => activity !== item);
if (tags) {
tags = tags.filter((activity) => activity !== item);
}
}
$: filteredItems = allActivities.filter(function (activity) {
$: filteredItems = allTags.filter(function (activity) {
return (
activity.toLowerCase().includes(inputVal.toLowerCase()) &&
(!activities || !activities.includes(activity))
activity.toLowerCase().includes(inputVal.toLowerCase()) && (!tags || !tags.includes(activity))
);
});
</script>
@ -88,8 +87,8 @@
<div class="mt-2">
<ul class="space-y-2">
{#if activities}
{#each activities as activity}
{#if tags}
{#each tags as activity}
<li class="flex items-center justify-between bg-base-200 p-2 rounded">
{activity}
<button

View file

@ -0,0 +1,799 @@
<script lang="ts">
import { createEventDispatcher, onMount } from 'svelte';
import { MapLibre, Marker, MapEvents } from 'svelte-maplibre';
import { t } from 'svelte-i18n';
import { getBasemapUrl } from '$lib';
import CategoryDropdown from '../CategoryDropdown.svelte';
import type { Collection, Location } from '$lib/types';
// Icons
import SearchIcon from '~icons/mdi/magnify';
import LocationIcon from '~icons/mdi/crosshairs-gps';
import MapIcon from '~icons/mdi/map';
import CheckIcon from '~icons/mdi/check';
import ClearIcon from '~icons/mdi/close';
import PinIcon from '~icons/mdi/map-marker';
import InfoIcon from '~icons/mdi/information';
import StarIcon from '~icons/mdi/star';
import LinkIcon from '~icons/mdi/link';
import TextIcon from '~icons/mdi/text';
import CategoryIcon from '~icons/mdi/tag';
import PublicIcon from '~icons/mdi/earth';
import GenerateIcon from '~icons/mdi/lightning-bolt';
import ArrowLeftIcon from '~icons/mdi/arrow-left';
import SaveIcon from '~icons/mdi/content-save';
import type { Category, User } from '$lib/types';
import MarkdownEditor from '../MarkdownEditor.svelte';
import TagComplete from '../TagComplete.svelte';
const dispatch = createEventDispatcher();
// Location selection properties
let searchQuery = '';
let searchResults: any[] = [];
let selectedLocation: any = null;
let mapCenter: [number, number] = [-74.5, 40];
let mapZoom = 2;
let isSearching = false;
let isReverseGeocoding = false;
let searchTimeout: ReturnType<typeof setTimeout>;
let mapComponent: any;
let selectedMarker: { lng: number; lat: number } | null = null;
// Enhanced location data
let locationData: {
city?: { name: string; id: string; visited: boolean };
region?: { name: string; id: string; visited: boolean };
country?: { name: string; country_code: string; visited: boolean };
display_name?: string;
location_name?: string;
} | null = null;
// Form data properties
let location: {
name: string;
category: Category | null;
rating: number;
is_public: boolean;
link: string;
description: string;
latitude: number | null;
longitude: number | null;
location: string;
tags: string[];
} = {
name: '',
category: null,
rating: NaN,
is_public: false,
link: '',
description: '',
latitude: null,
longitude: null,
location: '',
tags: []
};
let user: User | null = null;
let locationToEdit: Location | null = null;
let wikiError = '';
let isGeneratingDesc = false;
let ownerUser: User | null = null;
// Props (would be passed in from parent component)
export let initialLocation: any = null;
export let currentUser: any = null;
export let editingLocation: any = null;
$: user = currentUser;
$: locationToEdit = editingLocation;
// Location selection functions
async function searchLocations(query: string) {
if (!query.trim() || query.length < 3) {
searchResults = [];
return;
}
isSearching = true;
try {
const response = await fetch(
`/api/reverse-geocode/search/?query=${encodeURIComponent(query)}`
);
const results = await response.json();
searchResults = results.map((result: any) => ({
id: result.name + result.lat + result.lon,
name: result.name,
lat: parseFloat(result.lat),
lng: parseFloat(result.lon),
type: result.type,
category: result.category,
location: result.display_name,
importance: result.importance,
powered_by: result.powered_by
}));
} catch (error) {
console.error('Search error:', error);
searchResults = [];
} finally {
isSearching = false;
}
}
function handleSearchInput() {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
searchLocations(searchQuery);
}, 300);
}
async function selectSearchResult(searchResult: any) {
selectedLocation = searchResult;
selectedMarker = { lng: searchResult.lng, lat: searchResult.lat };
mapCenter = [searchResult.lng, searchResult.lat];
mapZoom = 14;
searchResults = [];
searchQuery = searchResult.name;
// Update form data
if (!location.name) location.name = searchResult.name;
location.latitude = searchResult.lat;
location.longitude = searchResult.lng;
location.name = searchResult.name;
await performDetailedReverseGeocode(searchResult.lat, searchResult.lng);
}
async function handleMapClick(e: { detail: { lngLat: { lng: number; lat: number } } }) {
selectedMarker = {
lng: e.detail.lngLat.lng,
lat: e.detail.lngLat.lat
};
await reverseGeocode(e.detail.lngLat.lng, e.detail.lngLat.lat);
}
async function reverseGeocode(lng: number, lat: number) {
isReverseGeocoding = true;
try {
const response = await fetch(`/api/reverse-geocode/search/?query=${lat},${lng}`);
const results = await response.json();
if (results && results.length > 0) {
const result = results[0];
selectedLocation = {
name: result.name,
lat: lat,
lng: lng,
location: result.display_name,
type: result.type,
category: result.category
};
searchQuery = result.name;
if (!location.name) location.name = result.name;
} else {
selectedLocation = {
name: `Location at ${lat.toFixed(4)}, ${lng.toFixed(4)}`,
lat: lat,
lng: lng,
location: `${lat.toFixed(4)}, ${lng.toFixed(4)}`
};
searchQuery = selectedLocation.name;
if (!location.name) location.name = selectedLocation.name;
}
location.latitude = lat;
location.longitude = lng;
location.location = selectedLocation.location;
await performDetailedReverseGeocode(lat, lng);
} catch (error) {
console.error('Reverse geocoding error:', error);
selectedLocation = {
name: `Location at ${lat.toFixed(4)}, ${lng.toFixed(4)}`,
lat: lat,
lng: lng,
location: `${lat.toFixed(4)}, ${lng.toFixed(4)}`
};
searchQuery = selectedLocation.name;
if (!location.name) location.name = selectedLocation.name;
location.latitude = lat;
location.longitude = lng;
location.location = selectedLocation.location;
locationData = null;
} finally {
isReverseGeocoding = false;
}
}
async function performDetailedReverseGeocode(lat: number, lng: number) {
try {
const response = await fetch(
`/api/reverse-geocode/reverse_geocode/?lat=${lat}&lon=${lng}&format=json`
);
if (response.ok) {
const data = await response.json();
locationData = {
city: data.city
? {
name: data.city,
id: data.city_id,
visited: data.city_visited || false
}
: undefined,
region: data.region
? {
name: data.region,
id: data.region_id,
visited: data.region_visited || false
}
: undefined,
country: data.country
? {
name: data.country,
country_code: data.country_id,
visited: false
}
: undefined,
display_name: data.display_name,
location_name: data.location_name
};
location.location = data.display_name;
} else {
locationData = null;
}
} catch (error) {
console.error('Detailed reverse geocoding error:', error);
locationData = null;
}
}
function useCurrentLocation() {
if ('geolocation' in navigator) {
navigator.geolocation.getCurrentPosition(
async (position) => {
const lat = position.coords.latitude;
const lng = position.coords.longitude;
selectedMarker = { lng, lat };
mapCenter = [lng, lat];
mapZoom = 14;
await reverseGeocode(lng, lat);
},
(error) => {
console.error('Geolocation error:', error);
}
);
}
}
function clearLocationSelection() {
selectedLocation = null;
selectedMarker = null;
locationData = null;
searchQuery = '';
searchResults = [];
location.latitude = null;
location.longitude = null;
location.location = '';
mapCenter = [-74.5, 40];
mapZoom = 2;
}
async function generateDesc() {
if (!location.name) return;
isGeneratingDesc = true;
wikiError = '';
try {
// Mock Wikipedia API call - replace with actual implementation
const response = await fetch(`/api/generate/desc/?name=${encodeURIComponent(location.name)}`);
if (response.ok) {
const data = await response.json();
location.description = data.extract || '';
} else {
wikiError = 'Failed to generate description from Wikipedia';
}
} catch (error) {
wikiError = 'Error connecting to Wikipedia service';
} finally {
isGeneratingDesc = false;
}
}
async function handleSave() {
if (!location.name || !location.category) {
return;
}
// round latitude and longitude to 6 decimal places
if (location.latitude !== null && typeof location.latitude === 'number') {
location.latitude = parseFloat(location.latitude.toFixed(6));
}
if (location.longitude !== null && typeof location.longitude === 'number') {
location.longitude = parseFloat(location.longitude.toFixed(6));
}
// either a post or a patch depending on whether we're editing or creating
if (locationToEdit && locationToEdit.id) {
let res = await fetch(`/api/locations/${locationToEdit.id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(location)
});
let updatedLocation = await res.json();
location = updatedLocation;
} else {
let res = await fetch(`/api/locations`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(location)
});
let newLocation = await res.json();
location = newLocation;
}
dispatch('save', {
...location
});
}
function handleBack() {
dispatch('back');
}
onMount(() => {
if (initialLocation && typeof initialLocation === 'object') {
// Only update location properties if they don't already have values
// This prevents overwriting user selections
if (!location.name) location.name = initialLocation.name || '';
if (!location.link) location.link = initialLocation.link || '';
if (!location.description) location.description = initialLocation.description || '';
if (Number.isNaN(location.rating)) location.rating = initialLocation.rating || NaN;
if (location.is_public === false) location.is_public = initialLocation.is_public || false;
// Only set category if location doesn't have one or if initialLocation has a valid category
if (!location.category || !location.category.id) {
if (initialLocation.category && initialLocation.category.id) {
location.category = initialLocation.category;
}
}
if (initialLocation.latitude && initialLocation.longitude) {
selectedMarker = {
lng: initialLocation.longitude,
lat: initialLocation.latitude
};
location.latitude = initialLocation.latitude;
location.longitude = initialLocation.longitude;
mapCenter = [initialLocation.longitude, initialLocation.latitude];
mapZoom = 14;
}
if (initialLocation.tags && Array.isArray(initialLocation.tags)) {
location.tags = initialLocation.tags;
}
if (initialLocation.location) {
location.location = initialLocation.location;
}
if (initialLocation.user) {
ownerUser = initialLocation.user;
}
}
searchQuery = initialLocation.name || '';
return () => {
clearTimeout(searchTimeout);
};
});
</script>
<div class="min-h-screen bg-gradient-to-br from-base-200/30 via-base-100 to-primary/5 p-6">
<div class="max-w-full mx-auto space-y-6">
<!-- Basic Information Section -->
<div class="card bg-base-100 border border-base-300 shadow-lg">
<div class="card-body p-6">
<div class="flex items-center gap-3 mb-6">
<div class="p-2 bg-primary/10 rounded-lg">
<InfoIcon class="w-5 h-5 text-primary" />
</div>
<h2 class="text-xl font-bold">Basic Information</h2>
</div>
<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">
Name <span class="text-error">*</span>
</span>
</label>
<input
type="text"
id="name"
bind:value={location.name}
class="input input-bordered bg-base-100/80 focus:bg-base-100"
placeholder="Enter location name"
required
/>
</div>
<!-- Category Field -->
<div class="form-control">
<label class="label" for="category">
<span class="label-text font-medium">
Category <span class="text-error">*</span>
</span>
</label>
{#if (user && ownerUser && user.uuid == ownerUser.uuid) || !ownerUser}
<CategoryDropdown bind:selected_category={location.category} />
{:else}
<div
class="flex items-center gap-3 p-3 bg-base-100/80 border border-base-300 rounded-lg"
>
{#if location.category?.icon}
<span class="text-xl flex-shrink-0">{location.category.icon}</span>
{/if}
<span class="font-medium">
{location.category?.display_name || location.category?.name}
</span>
</div>
{/if}
</div>
<!-- Rating Field -->
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Rating</span>
</label>
<div
class="flex items-center gap-4 p-3 bg-base-100/80 border border-base-300 rounded-lg"
>
<div class="rating">
<input
type="radio"
name="rating"
class="rating-hidden"
checked={Number.isNaN(location.rating)}
/>
{#each [1, 2, 3, 4, 5] as star}
<input
type="radio"
name="rating"
class="mask mask-star-2 bg-warning"
on:click={() => (location.rating = star)}
checked={location.rating === star}
/>
{/each}
</div>
{#if !Number.isNaN(location.rating)}
<button
type="button"
class="btn btn-sm btn-error btn-outline gap-2"
on:click={() => (location.rating = NaN)}
>
<ClearIcon class="w-4 h-4" />
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">Link</span>
</label>
<input
type="url"
id="link"
bind:value={location.link}
class="input input-bordered bg-base-100/80 focus:bg-base-100"
placeholder="https://example.com"
/>
</div>
<!-- Public Toggle -->
{#if !locationToEdit || (locationToEdit.collections && locationToEdit.collections.length === 0)}
<div class="form-control">
<label class="label cursor-pointer justify-start gap-4" for="is_public">
<input
type="checkbox"
class="toggle toggle-primary"
id="is_public"
bind:checked={location.is_public}
/>
<div>
<span class="label-text font-medium">Public Location</span>
<p class="text-sm text-base-content/60">
Make this location visible to other users
</p>
</div>
</label>
</div>
{/if}
<!-- Description Field -->
<div class="form-control">
<label class="label" for="description">
<span class="label-text font-medium">Description</span>
</label>
<MarkdownEditor bind:text={location.description} editor_height="h-32" />
<div class="flex items-center gap-4 mt-3">
<button
type="button"
class="btn btn-neutral btn-sm gap-2"
on:click={generateDesc}
disabled={!location.name || isGeneratingDesc}
>
{#if isGeneratingDesc}
<span class="loading loading-spinner loading-xs"></span>
{:else}
<GenerateIcon class="w-4 h-4" />
{/if}
Generate Description
</button>
{#if wikiError}
<div class="alert alert-error alert-sm">
<InfoIcon class="w-4 h-4" />
<span class="text-sm">{wikiError}</span>
</div>
{/if}
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Tags Section -->
<div class="card bg-base-100 border border-base-300 shadow-lg">
<div class="card-body p-6">
<div class="flex items-center gap-3 mb-6">
<div class="p-2 bg-warning/10 rounded-lg">
<CategoryIcon class="w-5 h-5 text-warning" />
</div>
<h2 class="text-xl font-bold">{$t('adventures.tags')} ({location.tags?.length || 0})</h2>
</div>
<div class="space-y-4">
<!-- Hidden input for form submission (same as old version) -->
<input
type="text"
id="tags"
name="tags"
hidden
bind:value={location.tags}
class="input input-bordered w-full"
/>
<!-- Use the same ActivityComplete component as the old version -->
<TagComplete bind:tags={location.tags} />
</div>
</div>
</div>
<!-- Location Selection Section -->
<div class="card bg-base-100 border border-base-300 shadow-lg">
<div class="card-body p-6">
<div class="flex items-center gap-3 mb-6">
<div class="p-2 bg-secondary/10 rounded-lg">
<MapIcon class="w-5 h-5 text-secondary" />
</div>
<h2 class="text-xl font-bold">Location & Map</h2>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Search & Controls -->
<div class="space-y-4">
<!-- Location Display Name Input -->
<div class="form-control">
<label class="label" for="location-display">
<span class="label-text font-medium">Location Display Name</span>
</label>
<input
type="text"
id="location-display"
bind:value={location.location}
class="input input-bordered bg-base-100/80 focus:bg-base-100"
placeholder="Enter location display name"
/>
</div>
<!-- Search Input -->
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Search Location</span>
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<SearchIcon class="w-4 h-4 text-base-content/40" />
</div>
<input
type="text"
bind:value={searchQuery}
on:input={handleSearchInput}
placeholder="Enter city, location, or landmark..."
class="input input-bordered w-full pl-10 pr-4 bg-base-100/80 focus:bg-base-100"
class:input-primary={selectedLocation}
/>
{#if searchQuery && !selectedLocation}
<button
class="absolute inset-y-0 right-0 pr-3 flex items-center"
on:click={clearLocationSelection}
>
<ClearIcon class="w-4 h-4 text-base-content/40 hover:text-base-content" />
</button>
{/if}
</div>
</div>
<!-- Search Results -->
{#if isSearching}
<div class="flex items-center justify-center py-4">
<span class="loading loading-spinner loading-sm"></span>
<span class="ml-2 text-sm text-base-content/60">Searching...</span>
</div>
{:else if searchResults.length > 0}
<div class="space-y-2">
<label class="label">
<span class="label-text text-sm font-medium">Search Results</span>
</label>
<div class="max-h-48 overflow-y-auto space-y-1">
{#each searchResults as result}
<button
class="w-full text-left p-3 rounded-lg border border-base-300 hover:bg-base-100 hover:border-primary/50 transition-colors"
on:click={() => selectSearchResult(result)}
>
<div class="flex items-start gap-3">
<PinIcon class="w-4 h-4 text-primary mt-1 flex-shrink-0" />
<div class="min-w-0 flex-1">
<div class="font-medium text-sm truncate">{result.name}</div>
<div class="text-xs text-base-content/60 truncate">{result.location}</div>
{#if result.category}
<div class="text-xs text-primary/70 capitalize">{result.category}</div>
{/if}
</div>
</div>
</button>
{/each}
</div>
</div>
{/if}
<!-- Current Location Button -->
<div class="flex items-center gap-2">
<div class="divider divider-horizontal text-xs">OR</div>
</div>
<button class="btn btn-outline gap-2 w-full" on:click={useCurrentLocation}>
<LocationIcon class="w-4 h-4" />
Use Current Location
</button>
<!-- Selected Location Display -->
{#if selectedLocation && selectedMarker}
<div class="card bg-success/10 border border-success/30">
<div class="card-body p-4">
<div class="flex items-start gap-3">
<div class="p-2 bg-success/20 rounded-lg">
<CheckIcon class="w-4 h-4 text-success" />
</div>
<div class="flex-1 min-w-0">
<h4 class="font-semibold text-success mb-1">Location Selected</h4>
<p class="text-sm text-base-content/80 truncate">{selectedLocation.name}</p>
<p class="text-xs text-base-content/60 mt-1">
{selectedMarker.lat.toFixed(6)}, {selectedMarker.lng.toFixed(6)}
</p>
<!-- Geographic Tags -->
{#if locationData?.city || locationData?.region || locationData?.country}
<div class="flex flex-wrap gap-2 mt-3">
{#if locationData.city}
<div class="badge badge-info badge-sm gap-1">
🏙️ {locationData.city.name}
</div>
{/if}
{#if locationData.region}
<div class="badge badge-warning badge-sm gap-1">
🗺️ {locationData.region.name}
</div>
{/if}
{#if locationData.country}
<div class="badge badge-success badge-sm gap-1">
🌎 {locationData.country.name}
</div>
{/if}
</div>
{/if}
</div>
<button class="btn btn-ghost btn-sm" on:click={clearLocationSelection}>
<ClearIcon class="w-4 h-4" />
</button>
</div>
</div>
</div>
{/if}
</div>
<!-- Map -->
<div class="space-y-4">
<div class="flex items-center justify-between">
<label class="label">
<span class="label-text font-medium">Interactive Map</span>
</label>
{#if isReverseGeocoding}
<div class="flex items-center gap-2">
<span class="loading loading-spinner loading-sm"></span>
<span class="text-sm text-base-content/60">Getting location details...</span>
</div>
{/if}
</div>
<div class="relative">
<MapLibre
bind:this={mapComponent}
style={getBasemapUrl()}
class="w-full h-80 rounded-lg border border-base-300"
center={mapCenter}
zoom={mapZoom}
standardControls
>
<MapEvents on:click={handleMapClick} />
{#if selectedMarker}
<Marker
lngLat={[selectedMarker.lng, selectedMarker.lat]}
class="grid h-8 w-8 place-items-center rounded-full border-2 border-white bg-primary shadow-lg cursor-pointer"
>
<PinIcon class="w-5 h-5 text-primary-content" />
</Marker>
{/if}
</MapLibre>
</div>
{#if !selectedMarker}
<p class="text-sm text-base-content/60 text-center">
Click on the map to select a location
</p>
{/if}
</div>
</div>
</div>
</div>
<!-- Action Buttons -->
<div class="flex gap-3 justify-end pt-4">
<button class="btn btn-ghost gap-2" on:click={handleBack}>
<ArrowLeftIcon class="w-5 h-5" />
Back
</button>
<button
class="btn btn-primary gap-2"
disabled={!location.name || !location.category || isReverseGeocoding}
on:click={handleSave}
>
{#if isReverseGeocoding}
<span class="loading loading-spinner loading-sm"></span>
Processing...
{:else}
<SaveIcon class="w-5 h-5" />
Next
{/if}
</button>
</div>
</div>
</div>

View file

@ -0,0 +1,761 @@
<script lang="ts">
import type { Attachment, ContentImage } from '$lib/types';
import { createEventDispatcher, onMount } from 'svelte';
import { t } from 'svelte-i18n';
import { deserialize } from '$app/forms';
// Icons
import InfoIcon from '~icons/mdi/information';
import Star from '~icons/mdi/star';
import Crown from '~icons/mdi/crown';
import SaveIcon from '~icons/mdi/content-save';
import ArrowLeftIcon from '~icons/mdi/arrow-left';
import TrashIcon from '~icons/mdi/delete';
import EditIcon from '~icons/mdi/pencil';
import FileIcon from '~icons/mdi/file-document';
import CheckIcon from '~icons/mdi/check';
import CloseIcon from '~icons/mdi/close';
import ImageIcon from '~icons/mdi/image';
import AttachmentIcon from '~icons/mdi/attachment';
import SwapHorizontalVariantIcon from '~icons/mdi/swap-horizontal-variant';
import { addToast } from '$lib/toasts';
import ImmichSelect from '../ImmichSelect.svelte';
// Props
export let images: ContentImage[] = [];
export let attachments: Attachment[] = [];
export let itemId: string = '';
// Component state
let fileInput: HTMLInputElement;
let attachmentFileInput: HTMLInputElement;
let url: string = '';
let imageSearch: string = '';
let imageError: string = '';
let wikiImageError: string = '';
let attachmentError: string = '';
let immichIntegration: boolean = false;
let copyImmichLocally: boolean = false;
let isLoading: boolean = false;
let isAttachmentLoading: boolean = false;
// Attachment state
let selectedFile: File | null = null;
let attachmentName: string = '';
let attachmentToEdit: Attachment | null = null;
let editingAttachmentName: string = '';
// Allowed file types for attachments
const allowedFileTypes = [
'.gpx',
'.kml',
'.kmz',
'.pdf',
'.doc',
'.docx',
'.txt',
'.md',
'.json',
'.xml',
'.csv',
'.xlsx'
];
const dispatch = createEventDispatcher();
// Helper functions
function createImageFromData(data: {
id: string;
image: string;
immich_id?: string | null;
}): ContentImage {
return {
id: data.id,
image: data.image,
is_primary: false,
immich_id: data.immich_id || null
};
}
function updateImagesList(newImage: ContentImage) {
images = [...images, newImage];
}
function updateAttachmentsList(newAttachment: Attachment) {
attachments = [...attachments, newAttachment];
}
// API calls
async function uploadImageToServer(file: File) {
const formData = new FormData();
formData.append('image', file);
formData.append('object_id', itemId);
formData.append('content_type', 'location');
try {
const res = await fetch(`/locations?/image`, {
method: 'POST',
body: formData
});
if (res.ok) {
const newData = deserialize(await res.text()) as { data: { id: string; image: string } };
return createImageFromData(newData.data);
} else {
throw new Error('Upload failed');
}
} catch (error) {
console.error('Upload error:', error);
return null;
}
}
async function fetchImageFromUrl(imageUrl: string): Promise<Blob | null> {
try {
const res = await fetch(imageUrl);
if (!res.ok) throw new Error('Failed to fetch image');
return await res.blob();
} catch (error) {
console.error('Fetch error:', error);
return null;
}
}
// Image event handlers
async function handleMultipleFiles(event: Event) {
const files = (event.target as HTMLInputElement).files;
if (!files) return;
isLoading = true;
imageError = '';
try {
for (const file of files) {
const newImage = await uploadImageToServer(file);
if (newImage) {
updateImagesList(newImage);
}
}
addToast('success', $t('adventures.image_upload_success'));
} catch (error) {
addToast('error', $t('adventures.image_upload_error'));
imageError = $t('adventures.image_upload_error');
} finally {
isLoading = false;
if (fileInput) fileInput.value = '';
}
}
async function handleUrlUpload() {
if (!url.trim()) return;
isLoading = true;
imageError = '';
try {
const blob = await fetchImageFromUrl(url);
if (!blob) {
imageError = $t('adventures.no_image_url');
return;
}
const file = new File([blob], 'image.jpg', { type: 'image/jpeg' });
const newImage = await uploadImageToServer(file);
if (newImage) {
updateImagesList(newImage);
addToast('success', $t('adventures.image_upload_success'));
url = '';
} else {
throw new Error('Upload failed');
}
} catch (error) {
imageError = $t('adventures.image_fetch_failed');
addToast('error', $t('adventures.image_upload_error'));
} finally {
isLoading = false;
}
}
async function handleWikiImageSearch() {
if (!imageSearch.trim()) return;
isLoading = true;
wikiImageError = '';
try {
const res = await fetch(`/api/generate/img/?name=${encodeURIComponent(imageSearch)}`);
const data = await res.json();
if (!res.ok || !data.source) {
wikiImageError = $t('adventures.image_fetch_failed');
return;
}
const blob = await fetchImageFromUrl(data.source);
if (!blob) {
wikiImageError = $t('adventures.image_fetch_failed');
return;
}
const file = new File([blob], `${imageSearch}.jpg`, { type: 'image/jpeg' });
const newImage = await uploadImageToServer(file);
if (newImage) {
updateImagesList(newImage);
addToast('success', $t('adventures.image_upload_success'));
imageSearch = '';
} else {
throw new Error('Upload failed');
}
} catch (error) {
wikiImageError = $t('adventures.wiki_image_error');
addToast('error', $t('adventures.image_upload_error'));
} finally {
isLoading = false;
}
}
async function makePrimaryImage(imageId: string) {
try {
const res = await fetch(`/api/images/${imageId}/toggle_primary`, {
method: 'POST'
});
if (res.ok) {
images = images.map((image) => ({
...image,
is_primary: image.id === imageId
}));
addToast('success', 'Primary image updated');
} else {
throw new Error('Failed to update primary image');
}
} catch (error) {
console.error('Error in makePrimaryImage:', error);
addToast('error', 'Failed to update primary image');
}
}
async function removeImage(imageId: string) {
try {
const res = await fetch(`/api/images/${imageId}/image_delete`, {
method: 'POST'
});
if (res.status === 204) {
images = images.filter((image) => image.id !== imageId);
addToast('success', 'Image removed');
} else {
throw new Error('Failed to remove image');
}
} catch (error) {
console.error('Error removing image:', error);
addToast('error', 'Failed to remove image');
}
}
// Attachment event handlers
function handleAttachmentFileChange(event: Event) {
const files = (event.target as HTMLInputElement).files;
if (files && files.length > 0) {
selectedFile = files[0];
// Auto-fill attachment name if empty
if (!attachmentName.trim()) {
attachmentName = selectedFile.name.split('.')[0];
}
} else {
selectedFile = null;
}
attachmentError = '';
}
async function uploadAttachment() {
if (!selectedFile) {
attachmentError = $t('adventures.no_file_selected');
return;
}
if (!attachmentName.trim()) {
attachmentError = $t('adventures.attachment_name_required');
return;
}
isAttachmentLoading = true;
attachmentError = '';
const formData = new FormData();
formData.append('file', selectedFile);
formData.append('location', itemId);
formData.append('name', attachmentName.trim());
try {
const res = await fetch('/locations?/attachment', {
method: 'POST',
body: formData
});
if (res.ok) {
const newData = deserialize(await res.text()) as { data: Attachment };
updateAttachmentsList(newData.data);
addToast('success', $t('adventures.attachment_upload_success'));
// Reset form
attachmentName = '';
selectedFile = null;
if (attachmentFileInput) {
attachmentFileInput.value = '';
}
} else {
throw new Error('Upload failed');
}
} catch (error) {
console.error('Attachment upload error:', error);
attachmentError = $t('adventures.attachment_upload_error');
addToast('error', $t('adventures.attachment_upload_error'));
} finally {
isAttachmentLoading = false;
}
}
function startEditingAttachment(attachment: Attachment) {
attachmentToEdit = attachment;
editingAttachmentName = attachment.name;
}
function cancelEditingAttachment() {
attachmentToEdit = null;
editingAttachmentName = '';
}
async function saveAttachmentEdit() {
if (!attachmentToEdit || !editingAttachmentName.trim()) return;
try {
const res = await fetch(`/api/attachments/${attachmentToEdit.id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
name: editingAttachmentName.trim()
})
});
if (res.ok) {
attachments = attachments.map((att) =>
att.id === attachmentToEdit!.id ? { ...att, name: editingAttachmentName.trim() } : att
);
addToast('success', $t('adventures.attachment_updated'));
cancelEditingAttachment();
} else {
throw new Error('Failed to update attachment');
}
} catch (error) {
console.error('Error updating attachment:', error);
addToast('error', $t('adventures.attachment_update_error'));
}
}
async function removeAttachment(attachmentId: string) {
try {
const res = await fetch(`/api/attachments/${attachmentId}`, {
method: 'DELETE'
});
if (res.status === 204) {
attachments = attachments.filter((attachment) => attachment.id !== attachmentId);
addToast('success', $t('adventures.attachment_removed'));
} else {
throw new Error('Failed to remove attachment');
}
} catch (error) {
console.error('Error removing attachment:', error);
addToast('error', $t('adventures.attachment_remove_error'));
}
}
// Navigation handlers
function handleBack() {
dispatch('back');
}
function handleNext() {
dispatch('next');
}
function handleImmichImageSaved(event: CustomEvent) {
const newImage = createImageFromData(event.detail);
updateImagesList(newImage);
addToast('success', $t('adventures.image_upload_success'));
}
// Lifecycle
onMount(async () => {
try {
const res = await fetch('/api/integrations/immich/');
if (res.ok) {
const data = await res.json();
if (data.id) {
immichIntegration = true;
copyImmichLocally = data.copy_locally || false;
}
} else if (res.status !== 404) {
addToast('error', $t('immich.integration_fetch_error'));
}
} catch (error) {
console.error('Error checking Immich integration:', error);
}
});
</script>
<div class="min-h-screen bg-gradient-to-br from-base-200/30 via-base-100 to-primary/5 p-6">
<div class="max-w-full mx-auto space-y-6">
<!-- Image Management Section -->
<div class="card bg-base-100 border border-base-300 shadow-lg">
<div class="card-body p-6">
<div class="flex items-center gap-3 mb-6">
<div class="p-2 bg-primary/10 rounded-lg">
<ImageIcon class="w-5 h-5 text-primary" />
</div>
<h2 class="text-xl font-bold">Image Management</h2>
</div>
<!-- Upload Options Grid -->
<div class="grid gap-4 lg:grid-cols-2 mb-6">
<!-- File Upload -->
<div class="bg-base-50 p-4 rounded-lg border border-base-200">
<h4 class="font-medium mb-3 text-base-content/80">
{$t('adventures.upload_from_device')}
</h4>
<input
type="file"
bind:this={fileInput}
class="file-input file-input-bordered w-full"
accept="image/*"
multiple
disabled={isLoading}
on:change={handleMultipleFiles}
/>
</div>
<!-- URL Upload -->
<div class="bg-base-50 p-4 rounded-lg border border-base-200">
<h4 class="font-medium mb-3 text-base-content/80">
{$t('adventures.upload_from_url')}
</h4>
<div class="flex gap-2">
<input
type="url"
bind:value={url}
class="input input-bordered flex-1"
placeholder="https://example.com/image.jpg"
disabled={isLoading}
/>
<button
class="btn btn-primary btn-sm"
class:loading={isLoading}
disabled={isLoading || !url.trim()}
on:click={handleUrlUpload}
>
{$t('adventures.fetch_image')}
</button>
</div>
{#if imageError}
<div class="alert alert-error mt-2 py-2">
<span class="text-sm">{imageError}</span>
</div>
{/if}
</div>
<!-- Wikipedia Search -->
<div class="bg-base-50 p-4 rounded-lg border border-base-200">
<h4 class="font-medium mb-3 text-base-content/80">
{$t('adventures.wikipedia')}
</h4>
<div class="flex gap-2">
<input
type="text"
bind:value={imageSearch}
class="input input-bordered flex-1"
placeholder="Search Wikipedia for images"
disabled={isLoading}
/>
<button
class="btn btn-primary btn-sm"
class:loading={isLoading}
disabled={isLoading || !imageSearch.trim()}
on:click={handleWikiImageSearch}
>
{$t('adventures.fetch_image')}
</button>
</div>
{#if wikiImageError}
<div class="alert alert-error mt-2 py-2">
<span class="text-sm">{wikiImageError}</span>
</div>
{/if}
</div>
<!-- Immich Integration -->
{#if immichIntegration}
<div class="bg-base-50 p-4 rounded-lg border border-base-200">
<h4 class="font-medium mb-3 text-base-content/80">Immich Integration</h4>
<ImmichSelect
objectId={itemId}
contentType="location"
{copyImmichLocally}
on:fetchImage={(e) => {
url = e.detail;
handleUrlUpload();
}}
on:remoteImmichSaved={handleImmichImageSaved}
/>
</div>
{/if}
</div>
<!-- Image Gallery -->
{#if images.length > 0}
<div class="divider">Current Images</div>
<div class="grid gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
{#each images as image (image.id)}
<div class="relative group">
<div
class="aspect-square overflow-hidden rounded-lg bg-base-200 border border-base-300"
>
<img
src={image.image}
alt="Uploaded content"
class="w-full h-full object-cover transition-transform group-hover:scale-105"
loading="lazy"
/>
</div>
<!-- Image Controls Overlay -->
<div
class="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-all duration-200 rounded-lg flex items-center justify-center gap-2"
>
{#if !image.is_primary}
<button
type="button"
class="btn btn-success btn-sm tooltip tooltip-top"
data-tip="Make Primary"
on:click={() => makePrimaryImage(image.id)}
>
<Star class="h-4 w-4" />
</button>
{/if}
<button
type="button"
class="btn btn-error btn-sm tooltip tooltip-top"
data-tip="Remove Image"
on:click={() => removeImage(image.id)}
>
<TrashIcon class="h-4 w-4" />
</button>
</div>
<!-- Primary Badge -->
{#if image.is_primary}
<div
class="absolute top-2 left-2 bg-warning text-warning-content rounded-full p-1 shadow-lg"
>
<Crown class="h-4 w-4" />
</div>
{/if}
</div>
{/each}
</div>
{:else}
<div class="bg-base-200/50 rounded-lg p-8 text-center">
<div class="text-base-content/60 mb-2">No images uploaded yet</div>
<div class="text-sm text-base-content/40">
Upload your first image using one of the options above
</div>
</div>
{/if}
</div>
</div>
<!-- Attachment Management Section -->
<div class="card bg-base-100 border border-base-300 shadow-lg">
<div class="card-body p-6">
<div class="flex items-center gap-3 mb-6">
<div class="p-2 bg-secondary/10 rounded-lg">
<AttachmentIcon class="w-5 h-5 text-secondary" />
</div>
<h2 class="text-xl font-bold">Attachment Management</h2>
</div>
<!-- Upload Options -->
<div class="grid gap-4 mb-6">
<!-- File Upload -->
<div class="bg-base-50 p-4 rounded-lg border border-base-200">
<h4 class="font-medium mb-3 text-base-content/80">
{$t('adventures.upload_attachment')}
</h4>
<div class="grid gap-3 md:grid-cols-3">
<input
type="file"
bind:this={attachmentFileInput}
class="file-input file-input-bordered col-span-2 md:col-span-1"
accept={allowedFileTypes.join(',')}
disabled={isAttachmentLoading}
on:change={handleAttachmentFileChange}
/>
<input
type="text"
bind:value={attachmentName}
class="input input-bordered"
placeholder={$t('adventures.attachment_name')}
disabled={isAttachmentLoading}
/>
<button
class="btn btn-secondary btn-sm md:btn-md"
class:loading={isAttachmentLoading}
disabled={isAttachmentLoading || !selectedFile || !attachmentName.trim()}
on:click={uploadAttachment}
>
{$t('adventures.upload')}
</button>
</div>
{#if attachmentError}
<div class="alert alert-error mt-2 py-2">
<span class="text-sm">{attachmentError}</span>
</div>
{/if}
</div>
<!-- File Type Info -->
<div class="alert alert-info">
<InfoIcon class="h-5 w-5" />
<div>
<div class="text-sm font-medium">Supported file types:</div>
<div class="text-xs text-base-content/70 mt-1">
GPX, KML, PDF, DOC, TXT, JSON, CSV, XLSX and more
</div>
</div>
</div>
</div>
<!-- Attachment Gallery -->
{#if attachments.length > 0}
<div class="divider">Current Attachments</div>
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each attachments as attachment (attachment.id)}
<div class="relative group">
{#if attachmentToEdit?.id === attachment.id}
<!-- Edit Mode -->
<div class="bg-warning/10 p-4 rounded-lg border border-warning/30">
<div class="flex items-center gap-2 mb-3">
<EditIcon class="w-4 h-4 text-warning" />
<span class="text-sm font-medium text-warning">Editing</span>
</div>
<input
type="text"
bind:value={editingAttachmentName}
class="input input-bordered input-sm w-full mb-3"
placeholder="Attachment name"
/>
<div class="flex gap-2">
<button class="btn btn-success btn-xs flex-1" on:click={saveAttachmentEdit}>
<CheckIcon class="w-3 h-3" />
Save
</button>
<button
class="btn btn-ghost btn-xs flex-1"
on:click={cancelEditingAttachment}
>
<CloseIcon class="w-3 h-3" />
Cancel
</button>
</div>
</div>
{:else}
<!-- Normal Display -->
<div
class="bg-base-50 p-4 rounded-lg border border-base-200 hover:border-base-300 transition-colors"
>
<div class="flex items-center gap-3 mb-2">
<div class="p-2 bg-secondary/10 rounded">
<FileIcon class="w-4 h-4 text-secondary" />
</div>
<div class="flex-1 min-w-0">
<div class="font-medium truncate">{attachment.name}</div>
<div class="text-xs text-base-content/60">
{attachment.extension.toUpperCase()}
</div>
</div>
</div>
<!-- Attachment Controls -->
<div class="flex gap-2 mt-3 justify-end">
<button
type="button"
class="btn btn-warning btn-xs btn-square tooltip tooltip-top"
data-tip="Edit Name"
on:click={() => startEditingAttachment(attachment)}
>
<EditIcon class="w-3 h-3" />
</button>
<button
type="button"
class="btn btn-error btn-xs btn-square tooltip tooltip-top"
data-tip="Remove Attachment"
on:click={() => removeAttachment(attachment.id)}
>
<TrashIcon class="w-3 h-3" />
</button>
</div>
</div>
{/if}
</div>
{/each}
</div>
{:else}
<div class="bg-base-200/50 rounded-lg p-8 text-center">
<div class="text-base-content/60 mb-2">No attachments uploaded yet</div>
<div class="text-sm text-base-content/40">
Upload your first attachment using the options above
</div>
</div>
{/if}
</div>
</div>
<!-- Trails Managment -->
<div class="card bg-base-100 border border-base-300 shadow-lg">
<div class="card-body p-6">
<div class="flex items-center gap-3 mb-6">
<div class="p-2 bg-accent/10 rounded-lg">
<SwapHorizontalVariantIcon class="w-5 h-5 text-accent" />
</div>
<h2 class="text-xl font-bold">Trails Management</h2>
</div>
<p class="text-base-content/70 mb-4">
You can manage trails associated with this location in the Trails section.
</p>
<p class="text-sm text-base-content/50">
Coming soon: Create, edit, and delete trails directly from this section.
</p>
</div>
</div>
<!-- Action Buttons -->
<div class="flex gap-3 justify-between pt-4">
<button class="btn btn-ghost gap-2" on:click={handleBack}>
<ArrowLeftIcon class="w-5 h-5" />
Back
</button>
<button class="btn btn-primary gap-2" on:click={handleNext}>
<SaveIcon class="w-5 h-5" />
Next
</button>
</div>
</div>
</div>

View file

@ -0,0 +1,462 @@
<script lang="ts">
import { createEventDispatcher, onMount } from 'svelte';
import { MapLibre, Marker, MapEvents } from 'svelte-maplibre';
import { t } from 'svelte-i18n';
import { getBasemapUrl } from '$lib';
// Icons
import SearchIcon from '~icons/mdi/magnify';
import LocationIcon from '~icons/mdi/crosshairs-gps';
import MapIcon from '~icons/mdi/map';
import CheckIcon from '~icons/mdi/check';
import ClearIcon from '~icons/mdi/close';
import PinIcon from '~icons/mdi/map-marker';
const dispatch = createEventDispatcher();
let searchQuery = '';
let searchResults: any[] = [];
let selectedLocation: any = null;
let mapCenter: [number, number] = [-74.5, 40]; // Default center
let mapZoom = 2;
let isSearching = false;
let isReverseGeocoding = false;
let searchTimeout: ReturnType<typeof setTimeout>;
let mapComponent: any;
let selectedMarker: { lng: number; lat: number } | null = null;
// Enhanced location data from reverse geocoding
let locationData: {
city?: { name: string; id: string; visited: boolean };
region?: { name: string; id: string; visited: boolean };
country?: { name: string; country_code: string; visited: boolean };
display_name?: string;
location_name?: string;
} | null = null;
// Search for locations using your custom API
async function searchLocations(query: string) {
if (!query.trim() || query.length < 3) {
searchResults = [];
return;
}
isSearching = true;
try {
const response = await fetch(
`/api/reverse-geocode/search/?query=${encodeURIComponent(query)}`
);
const results = await response.json();
searchResults = results.map((result: any) => ({
id: result.name + result.lat + result.lon, // Create a unique ID
name: result.name,
lat: parseFloat(result.lat),
lng: parseFloat(result.lon),
type: result.type,
category: result.category,
location: result.display_name,
importance: result.importance,
powered_by: result.powered_by
}));
} catch (error) {
console.error('Search error:', error);
searchResults = [];
} finally {
isSearching = false;
}
}
// Debounced search
function handleSearchInput() {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
searchLocations(searchQuery);
}, 300);
}
// Select a location from search results
async function selectSearchResult(location: any) {
selectedLocation = location;
selectedMarker = { lng: location.lng, lat: location.lat };
mapCenter = [location.lng, location.lat];
mapZoom = 14;
searchResults = [];
searchQuery = location.name;
// Perform detailed reverse geocoding
await performDetailedReverseGeocode(location.lat, location.lng);
}
// Handle map click to place marker
async function handleMapClick(e: { detail: { lngLat: { lng: number; lat: number } } }) {
selectedMarker = {
lng: e.detail.lngLat.lng,
lat: e.detail.lngLat.lat
};
// Reverse geocode to get location name and detailed data
await reverseGeocode(e.detail.lngLat.lng, e.detail.lngLat.lat);
}
// Reverse geocode coordinates to get location name using your API
async function reverseGeocode(lng: number, lat: number) {
isReverseGeocoding = true;
try {
// Using a coordinate-based search query for reverse geocoding
const response = await fetch(`/api/reverse-geocode/search/?query=${lat},${lng}`);
const results = await response.json();
if (results && results.length > 0) {
const result = results[0];
selectedLocation = {
name: result.name,
lat: lat,
lng: lng,
location: result.display_name,
type: result.type,
category: result.category
};
searchQuery = result.name;
} else {
// Fallback if no results from API
selectedLocation = {
name: `Location at ${lat.toFixed(4)}, ${lng.toFixed(4)}`,
lat: lat,
lng: lng,
location: `${lat.toFixed(4)}, ${lng.toFixed(4)}`
};
searchQuery = selectedLocation.name;
}
// Perform detailed reverse geocoding
await performDetailedReverseGeocode(lat, lng);
} catch (error) {
console.error('Reverse geocoding error:', error);
selectedLocation = {
name: `Location at ${lat.toFixed(4)}, ${lng.toFixed(4)}`,
lat: lat,
lng: lng,
location: `${lat.toFixed(4)}, ${lng.toFixed(4)}`
};
searchQuery = selectedLocation.name;
locationData = null;
} finally {
isReverseGeocoding = false;
}
}
// Perform detailed reverse geocoding to get city, region, country data
async function performDetailedReverseGeocode(lat: number, lng: number) {
try {
const response = await fetch(
`/api/reverse-geocode/reverse_geocode/?lat=${lat}&lon=${lng}&format=json`
);
if (response.ok) {
const data = await response.json();
locationData = {
city: data.city
? {
name: data.city,
id: data.city_id,
visited: data.city_visited || false
}
: undefined,
region: data.region
? {
name: data.region,
id: data.region_id,
visited: data.region_visited || false
}
: undefined,
country: data.country
? {
name: data.country,
country_code: data.country_id,
visited: false // You might want to check this from your backend
}
: undefined,
display_name: data.display_name,
location_name: data.location_name
};
selectedLocation.location = data.display_name || `${lat.toFixed(4)}, ${lng.toFixed(4)}`;
} else {
console.warn('Detailed reverse geocoding failed:', response.status);
locationData = null;
}
} catch (error) {
console.error('Detailed reverse geocoding error:', error);
locationData = null;
}
}
// Use current location
function useCurrentLocation() {
if ('geolocation' in navigator) {
navigator.geolocation.getCurrentPosition(
async (position) => {
const lat = position.coords.latitude;
const lng = position.coords.longitude;
selectedMarker = { lng, lat };
mapCenter = [lng, lat];
mapZoom = 14;
await reverseGeocode(lng, lat);
},
(error) => {
console.error('Geolocation error:', error);
}
);
}
}
$: {
console.log('Selected Location:', selectedLocation);
}
// Continue with selected location
function continueWithLocation() {
if (selectedLocation && selectedMarker) {
dispatch('locationSelected', {
name: selectedLocation.name,
latitude: selectedMarker.lat,
longitude: selectedMarker.lng,
location: selectedLocation.location,
type: selectedLocation.type,
category: selectedLocation.category,
// Include the enhanced geographical data
city: locationData?.city,
region: locationData?.region,
country: locationData?.country,
display_name: locationData?.display_name,
location_name: locationData?.location_name
});
} else {
dispatch('next');
}
}
// Clear selection
function clearSelection() {
selectedLocation = null;
selectedMarker = null;
locationData = null;
searchQuery = '';
searchResults = [];
mapCenter = [-74.5, 40];
mapZoom = 2;
}
onMount(() => {
return () => {
clearTimeout(searchTimeout);
};
});
</script>
<div class="space-y-6">
<!-- Search Section -->
<div class="card bg-base-200/50 border border-base-300">
<div class="card-body p-6">
<div class="space-y-4">
<!-- Search Input -->
<div class="form-control">
<!-- svelte-ignore a11y-label-has-associated-control -->
<label class="label">
<span class="label-text font-medium">
{$t('adventures.search_location') || 'Search for a location'}
</span>
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<SearchIcon class="w-5 h-5 text-base-content/40" />
</div>
<input
type="text"
bind:value={searchQuery}
on:input={handleSearchInput}
placeholder={$t('adventures.search_placeholder') ||
'Enter city, location, or landmark...'}
class="input input-bordered w-full pl-10 pr-4"
class:input-primary={selectedLocation}
/>
{#if searchQuery && !selectedLocation}
<button
class="absolute inset-y-0 right-0 pr-3 flex items-center"
on:click={clearSelection}
>
<ClearIcon class="w-4 h-4 text-base-content/40 hover:text-base-content" />
</button>
{/if}
</div>
</div>
<!-- Search Results -->
{#if isSearching}
<div class="flex items-center justify-center py-4">
<span class="loading loading-spinner loading-sm"></span>
<span class="ml-2 text-sm text-base-content/60">Searching...</span>
</div>
{:else if searchResults.length > 0}
<div class="space-y-2">
<!-- svelte-ignore a11y-label-has-associated-control -->
<label class="label">
<span class="label-text text-sm font-medium">Search Results</span>
</label>
<div class="max-h-48 overflow-y-auto space-y-1">
{#each searchResults as result}
<button
class="w-full text-left p-3 rounded-lg border border-base-300 hover:bg-base-100 hover:border-primary/50 transition-colors"
on:click={() => selectSearchResult(result)}
>
<div class="flex items-start gap-3">
<PinIcon class="w-4 h-4 text-primary mt-1 flex-shrink-0" />
<div class="min-w-0 flex-1">
<div class="font-medium text-sm truncate">{result.name}</div>
<div class="text-xs text-base-content/60 truncate">{result.location}</div>
{#if result.category}
<div class="text-xs text-primary/70 capitalize">{result.category}</div>
{/if}
</div>
</div>
</button>
{/each}
</div>
</div>
{/if}
<!-- Current Location Button -->
<div class="flex items-center gap-2">
<div class="divider divider-horizontal text-xs">OR</div>
</div>
<button class="btn btn-outline gap-2 w-full" on:click={useCurrentLocation}>
<LocationIcon class="w-4 h-4" />
{$t('adventures.use_current_location') || 'Use Current Location'}
</button>
</div>
</div>
</div>
<!-- Map Section -->
<div class="card bg-base-100 border border-base-300">
<div class="card-body p-4">
<div class="flex items-center justify-between mb-4">
<h3 class="font-semibold flex items-center gap-2">
<MapIcon class="w-5 h-5" />
{$t('adventures.select_on_map') || 'Select on Map'}
</h3>
{#if selectedMarker}
<button class="btn btn-ghost btn-sm gap-1" on:click={clearSelection}>
<ClearIcon class="w-4 h-4" />
Clear
</button>
{/if}
</div>
{#if !selectedMarker}
<p class="text-sm text-base-content/60 mb-4">
{$t('adventures.click_map') || 'Click on the map to select a location'}
</p>
{/if}
{#if isReverseGeocoding}
<div class="flex items-center justify-center py-2 mb-4">
<span class="loading loading-spinner loading-sm"></span>
<span class="ml-2 text-sm text-base-content/60">Getting location details...</span>
</div>
{/if}
<div class="relative">
<MapLibre
bind:this={mapComponent}
style={getBasemapUrl()}
class="w-full h-80 rounded-lg border border-base-300"
center={mapCenter}
zoom={mapZoom}
standardControls
>
<MapEvents on:click={handleMapClick} />
{#if selectedMarker}
<Marker
lngLat={[selectedMarker.lng, selectedMarker.lat]}
class="grid h-8 w-8 place-items-center rounded-full border-2 border-white bg-primary shadow-lg cursor-pointer"
>
<PinIcon class="w-5 h-5 text-primary-content" />
</Marker>
{/if}
</MapLibre>
</div>
</div>
</div>
<!-- Selected Location Display -->
{#if selectedLocation && selectedMarker}
<div class="card bg-success/10 border border-success/30">
<div class="card-body p-4">
<div class="flex items-start gap-3">
<div class="p-2 bg-success/20 rounded-lg">
<CheckIcon class="w-5 h-5 text-success" />
</div>
<div class="flex-1 min-w-0">
<h4 class="font-semibold text-success mb-1">Location Selected</h4>
<p class="text-sm text-base-content/80 truncate">{selectedLocation.name}</p>
<p class="text-xs text-base-content/60 mt-1">
{selectedMarker.lat.toFixed(6)}, {selectedMarker.lng.toFixed(6)}
</p>
{#if selectedLocation.category}
<p class="text-xs text-base-content/50 capitalize">
{selectedLocation.category}{selectedLocation.type || 'location'}
</p>
{/if}
<!-- Geographic Tags -->
{#if locationData?.city || locationData?.region || locationData?.country}
<div class="flex flex-wrap gap-2 mt-3">
{#if locationData.city}
<div class="badge badge-info badge-sm gap-1">
🏙️ {locationData.city.name}
</div>
{/if}
{#if locationData.region}
<div class="badge badge-warning badge-sm gap-1">
🗺️ {locationData.region.name}
</div>
{/if}
{#if locationData.country}
<div class="badge badge-success badge-sm gap-1">
🌎 {locationData.country.name}
</div>
{/if}
</div>
{/if}
{#if locationData?.display_name}
<p class="text-xs text-base-content/50 mt-2">
{locationData.display_name}
</p>
{/if}
</div>
</div>
</div>
</div>
{/if}
<!-- Action Buttons -->
<div class="flex gap-3 pt-4">
<button class="btn btn-ghost flex-1" on:click={() => dispatch('cancel')}>
{$t('common.cancel') || 'Cancel'}
</button>
<button class="btn btn-primary flex-1" on:click={continueWithLocation}>
{#if isReverseGeocoding}
<span class="loading loading-spinner loading-xs"></span>
Getting details...
{:else}
{$t('common.continue') || 'Continue with This Location'}
{/if}
</button>
</div>
</div>

View file

@ -0,0 +1,602 @@
<script lang="ts">
import type { Collection } from '$lib/types';
import TimezoneSelector from '../TimezoneSelector.svelte';
import { t } from 'svelte-i18n';
import { updateLocalDate, updateUTCDate, validateDateRange, formatUTCDate } from '$lib/dateUtils';
import { onMount } from 'svelte';
import { isAllDay } from '$lib';
// Icons
import CalendarIcon from '~icons/mdi/calendar';
import ClockIcon from '~icons/mdi/clock';
import MapMarkerIcon from '~icons/mdi/map-marker';
import PlusIcon from '~icons/mdi/plus';
import EditIcon from '~icons/mdi/pencil';
import TrashIcon from '~icons/mdi/delete';
import AlertIcon from '~icons/mdi/alert';
import CheckIcon from '~icons/mdi/check';
import SettingsIcon from '~icons/mdi/cog';
// Props
export let collection: Collection | null = null;
export let type: 'location' | 'transportation' | 'lodging' = 'location';
export let selectedStartTimezone: string = Intl.DateTimeFormat().resolvedOptions().timeZone;
export let selectedEndTimezone: string = Intl.DateTimeFormat().resolvedOptions().timeZone;
export let utcStartDate: string | null = null;
export let utcEndDate: string | null = null;
export let note: string | null = null;
export let visits: (Visit | TransportationVisit)[] | null = null;
export let objectId: string;
// Types
type Visit = {
id: string;
start_date: string;
end_date: string;
notes: string;
timezone: string | null;
};
type TransportationVisit = {
id: string;
start_date: string;
end_date: string;
notes: string;
start_timezone: string;
end_timezone: string;
};
// Component state
let allDay: boolean = false;
let localStartDate: string = '';
let localEndDate: string = '';
let fullStartDate: string = '';
let fullEndDate: string = '';
let constrainDates: boolean = false;
let isEditing = false;
let isExpanded = true;
// Reactive constraints
$: constraintStartDate = allDay
? fullStartDate && fullStartDate.includes('T')
? fullStartDate.split('T')[0]
: ''
: fullStartDate || '';
$: constraintEndDate = allDay
? fullEndDate && fullEndDate.includes('T')
? fullEndDate.split('T')[0]
: ''
: fullEndDate || '';
// Set the full date range for constraining purposes
$: if (collection && collection.start_date && collection.end_date) {
fullStartDate = `${collection.start_date}T00:00`;
fullEndDate = `${collection.end_date}T23:59`;
}
// Update local display dates whenever timezone or UTC dates change
$: if (!isEditing) {
if (allDay) {
localStartDate = utcStartDate?.substring(0, 10) ?? '';
localEndDate = utcEndDate?.substring(0, 10) ?? '';
} else {
const start = updateLocalDate({
utcDate: utcStartDate,
timezone: selectedStartTimezone
}).localDate;
const end = updateLocalDate({
utcDate: utcEndDate,
timezone: type === 'transportation' ? selectedEndTimezone : selectedStartTimezone
}).localDate;
localStartDate = start;
localEndDate = end;
}
}
// Helper functions
function formatDateInTimezone(utcDate: string, timezone: string): string {
try {
return new Intl.DateTimeFormat(undefined, {
timeZone: timezone,
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: true
}).format(new Date(utcDate));
} catch {
return new Date(utcDate).toLocaleString();
}
}
function getTypeConfig() {
switch (type) {
case 'transportation':
return {
startLabel: 'Departure Date',
endLabel: 'Arrival Date',
icon: MapMarkerIcon,
color: 'accent'
};
case 'lodging':
return {
startLabel: 'Check In',
endLabel: 'Check Out',
icon: CalendarIcon,
color: 'secondary'
};
default:
return {
startLabel: 'Start Date',
endLabel: 'End Date',
icon: CalendarIcon,
color: 'primary'
};
}
}
// Event handlers
function handleLocalDateChange() {
utcStartDate = updateUTCDate({
localDate: localStartDate,
timezone: selectedStartTimezone,
allDay
}).utcDate;
utcEndDate = updateUTCDate({
localDate: localEndDate,
timezone: type === 'transportation' ? selectedEndTimezone : selectedStartTimezone,
allDay
}).utcDate;
}
function handleAllDayToggle() {
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;
}
function createVisitObject(): Visit | TransportationVisit {
const uniqueId = Date.now().toString(36) + Math.random().toString(36).substring(2);
if (type === 'transportation') {
const transportVisit: TransportationVisit = {
id: uniqueId,
start_date: utcStartDate ?? '',
end_date: utcEndDate ?? utcStartDate ?? '',
notes: note ?? '',
start_timezone: selectedStartTimezone,
end_timezone: selectedEndTimezone
};
return transportVisit;
} else {
const regularVisit: Visit = {
id: uniqueId,
start_date: utcStartDate ?? '',
end_date: utcEndDate ?? utcStartDate ?? '',
notes: note ?? '',
timezone: selectedStartTimezone
};
return regularVisit;
}
}
async function addVisit() {
const newVisit = createVisitObject();
// Add new visit to the visits array
if (visits) {
visits = [...visits, newVisit];
} else {
visits = [newVisit];
}
// Reset form fields
note = '';
localStartDate = '';
localEndDate = '';
utcStartDate = null;
utcEndDate = null;
// Patch updated visits array to location
if (type === 'location' && objectId) {
try {
const response = await fetch(`/api/locations/${objectId}/`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ visits }) // Send updated visits array
});
if (!response.ok) {
console.error('Failed to patch visits:', await response.text());
}
} catch (error) {
console.error('Error patching visits:', error);
}
}
}
function editVisit(visit: Visit | TransportationVisit) {
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);
// Update the visits array in the parent component
if (type === 'location' && objectId) {
fetch(`/api/locations/${objectId}/`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ visits }) // Send updated visits array
});
}
}
function removeVisit(visitId: string) {
if (visits) {
visits = visits.filter((v) => v.id !== visitId);
}
// Patch updated visits array to location
if (type === 'location' && objectId) {
fetch(`/api/locations/${objectId}/`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ visits }) // Send updated visits array
});
}
}
// Lifecycle
onMount(async () => {
if ((type === 'transportation' || type === 'lodging') && utcStartDate) {
allDay = isAllDay(utcStartDate);
}
localStartDate = updateLocalDate({
utcDate: utcStartDate,
timezone: selectedStartTimezone
}).localDate;
localEndDate = updateLocalDate({
utcDate: utcEndDate,
timezone: type === 'transportation' ? selectedEndTimezone : selectedStartTimezone
}).localDate;
if (!selectedStartTimezone) {
selectedStartTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
}
if (!selectedEndTimezone) {
selectedEndTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
}
});
$: typeConfig = getTypeConfig();
$: isDateValid = validateDateRange(utcStartDate ?? '', utcEndDate ?? '').valid;
</script>
<div class="card bg-base-100 border border-base-300 shadow-lg">
<div class="card-body p-6">
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<div class="flex items-center gap-3">
<div class="p-2 bg-{typeConfig.color}/10 rounded-lg">
<svelte:component this={typeConfig.icon} class="w-5 h-5 text-{typeConfig.color}" />
</div>
<h2 class="text-xl font-bold">{$t('adventures.date_information')}</h2>
</div>
<button class="btn btn-ghost btn-sm" on:click={() => (isExpanded = !isExpanded)}>
{isExpanded ? 'Collapse' : 'Expand'}
</button>
</div>
{#if isExpanded}
<!-- Settings Section -->
<div class="bg-base-50 p-4 rounded-lg border border-base-200 mb-6">
<div class="flex items-center gap-2 mb-4">
<SettingsIcon class="w-4 h-4 text-base-content/70" />
<h3 class="font-medium text-base-content/80">Settings</h3>
</div>
<div class="space-y-4">
<!-- Timezone Selection -->
{#if type === 'transportation'}
<div class="grid grid-cols-1 md: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}
<!-- Toggles -->
<div class="flex flex-wrap gap-6">
<div class="flex items-center gap-3">
<ClockIcon class="w-4 h-4 text-base-content/70" />
<label class="label-text text-sm font-medium">All Day</label>
<input
type="checkbox"
class="toggle toggle-{typeConfig.color} toggle-sm"
bind:checked={allDay}
on:change={handleAllDayToggle}
/>
</div>
{#if collection?.start_date && collection?.end_date}
<div class="flex items-center gap-3">
<CalendarIcon class="w-4 h-4 text-base-content/70" />
<label class="label-text text-sm font-medium">Constrain to Collection Dates</label>
<input
type="checkbox"
class="toggle toggle-{typeConfig.color} toggle-sm"
bind:checked={constrainDates}
/>
</div>
{/if}
</div>
</div>
</div>
<!-- Date Selection Section -->
<div class="bg-base-50 p-4 rounded-lg border border-base-200 mb-6">
<h3 class="font-medium text-base-content/80 mb-4">Date Selection</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- Start Date -->
<div>
<label class="label-text text-sm font-medium">
{typeConfig.startLabel}
</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>
<!-- End Date -->
{#if localStartDate}
<div>
<label class="label-text text-sm font-medium">
{typeConfig.endLabel}
</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>
<!-- Notes (Location only) -->
{#if type === 'location'}
<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 about this visit..."
bind:value={note}
></textarea>
</div>
<!-- Add Visit Button -->
<div class="flex justify-end mt-4">
<button
class="btn btn-{typeConfig.color} btn-sm gap-2"
type="button"
disabled={!localStartDate || !isDateValid}
on:click={addVisit}
>
<PlusIcon class="w-4 h-4" />
Add Visit
</button>
</div>
{/if}
</div>
<!-- Validation Error -->
{#if !isDateValid}
<div class="alert alert-error mb-6">
<AlertIcon class="w-5 h-5" />
<span class="text-sm">Invalid date range - end date must be after start date</span>
</div>
{/if}
<!-- Visits List (Location only) -->
{#if type === 'location'}
<div class="bg-base-50 p-4 rounded-lg border border-base-200">
<h3 class="font-medium text-base-content/80 mb-4">
Visits ({visits?.length || 0})
</h3>
{#if !visits || visits.length === 0}
<div class="text-center py-8 text-base-content/60">
<CalendarIcon class="w-8 h-8 mx-auto mb-2 opacity-50" />
<p class="text-sm">No visits added yet</p>
<p class="text-xs text-base-content/40 mt-1">
Create your first visit by selecting dates above
</p>
</div>
{:else}
<div class="space-y-3">
{#each visits as visit (visit.id)}
<div
class="bg-base-100 p-4 rounded-lg border border-base-300 hover:border-base-400 transition-colors"
>
<div class="flex items-start justify-between">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-2">
{#if isAllDay(visit.start_date)}
<span class="badge badge-outline badge-sm">All Day</span>
{:else}
<ClockIcon class="w-3 h-3 text-base-content/50" />
{/if}
<div class="text-sm font-medium truncate">
{#if isAllDay(visit.start_date)}
{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>
</div>
{#if visit.notes}
<p class="text-xs text-base-content/70 bg-base-200/50 p-2 rounded">
"{visit.notes}"
</p>
{/if}
</div>
<!-- Visit Actions -->
<div class="flex gap-1 ml-4">
<button
class="btn btn-warning btn-xs tooltip tooltip-top"
data-tip="Edit Visit"
on:click={() => editVisit(visit)}
>
<EditIcon class="w-3 h-3" />
</button>
<button
class="btn btn-error btn-xs tooltip tooltip-top"
data-tip="Remove Visit"
on:click={() => removeVisit(visit.id)}
>
<TrashIcon class="w-3 h-3" />
</button>
</div>
</div>
</div>
{/each}
</div>
{/if}
</div>
{/if}
{/if}
</div>
</div>

View file

@ -279,7 +279,6 @@ export type ImmichAlbum = {
export type Attachment = {
id: string;
file: string;
location: string;
extension: string;
user: string;
name: string;

View file

@ -20,6 +20,7 @@
import Star from '~icons/mdi/star';
import Tag from '~icons/mdi/tag';
import Compass from '~icons/mdi/compass';
import NewLocationModal from '$lib/components/NewLocationModal.svelte';
export let data: any;
@ -34,6 +35,19 @@
is_visited: 'all'
};
let locationBeingUpdated: Location | undefined = undefined;
// Sync the locationBeingUpdated with the adventures array
$: {
if (locationBeingUpdated && locationBeingUpdated.id) {
const index = adventures.findIndex((adventure) => adventure.id === locationBeingUpdated!.id);
if (index !== -1) {
adventures[index] = { ...locationBeingUpdated };
adventures = adventures; // Trigger reactivity
}
}
}
let resultsPerPage: number = 25;
let count = data.props.count || 0;
let totalPages = Math.ceil(count / resultsPerPage);
@ -141,7 +155,6 @@
} else {
adventures = [event.detail, ...adventures];
}
isLocationModalOpen = false;
}
function editAdventure(event: CustomEvent<Location>) {
@ -168,11 +181,18 @@
</svelte:head>
{#if isLocationModalOpen}
<LocationModal
<!-- <LocationModal
locationToEdit={adventureToEdit}
on:close={() => (isLocationModalOpen = false)}
on:save={saveOrCreate}
user={data.user}
/> -->
<NewLocationModal
on:close={() => (isLocationModalOpen = false)}
on:save={saveOrCreate}
user={data.user}
locationToEdit={adventureToEdit}
bind:location={locationBeingUpdated}
/>
{/if}