mirror of
https://github.com/seanmorley15/AdventureLog.git
synced 2025-08-05 05:05:17 +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:
parent
31eb7fb734
commit
707c99651f
13 changed files with 3224 additions and 185 deletions
|
@ -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', [])
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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(() => {
|
||||
categories = categories.sort((a, b) => (b.num_locations || 0) - (a.num_locations || 0));
|
||||
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}>
|
||||
|
|
|
@ -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
|
||||
if (currentAlbum) {
|
||||
currentAlbum = '';
|
||||
}
|
||||
// Search will be triggered by the form submission or debounced search
|
||||
clearAlbumSelection();
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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');
|
||||
// Helper functions
|
||||
function clearAlbumSelection() {
|
||||
if (currentAlbum) {
|
||||
currentAlbum = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchAssets(url: string, usingNext = false) {
|
||||
loading = true;
|
||||
try {
|
||||
let res = await fetch(url);
|
||||
immichError = '';
|
||||
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();
|
||||
if (data.results && data.results.length > 0) {
|
||||
if (usingNext) {
|
||||
immichImages = [...immichImages, ...data.results];
|
||||
} else {
|
||||
immichImages = data.results;
|
||||
}
|
||||
} else {
|
||||
immichError = $t('immich.no_items_found');
|
||||
}
|
||||
function buildQueryParams(): string {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
immichNextURL = data.next || '';
|
||||
}
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchAlbumAssets(album_id: string) {
|
||||
return fetchAssets(`/api/integrations/immich/albums/${album_id}`);
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
let res = await fetch('/api/integrations/immich/albums');
|
||||
if (res.ok) {
|
||||
let data = await res.json();
|
||||
albums = data;
|
||||
}
|
||||
});
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
// API functions
|
||||
async function fetchAssets(url: string, usingNext = false): Promise<void> {
|
||||
loading = true;
|
||||
immichError = '';
|
||||
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
|
||||
if (!res.ok) {
|
||||
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 = '';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching assets:', error);
|
||||
immichError = $t('immich.fetch_error');
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchAlbumAssets(albumId: string): Promise<void> {
|
||||
return fetchAssets(`/api/integrations/immich/albums/${albumId}`);
|
||||
}
|
||||
|
||||
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
|
||||
})
|
||||
});
|
||||
|
||||
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,42 +314,61 @@
|
|||
<div
|
||||
class="absolute inset-0 bg-base-200/50 backdrop-blur-sm z-10 flex items-center justify-center rounded-lg"
|
||||
>
|
||||
<span class="loading loading-spinner loading-lg"></span>
|
||||
<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" />
|
||||
</figure>
|
||||
<div class="card-body p-2">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary btn-sm max-w-full"
|
||||
on:click={() => {
|
||||
let currentDomain = window.location.origin;
|
||||
let fullUrl = `${currentDomain}/immich/${image.id}`;
|
||||
if (copyImmichLocally) {
|
||||
dispatch('fetchImage', fullUrl);
|
||||
} else {
|
||||
saveImmichRemoteUrl(image.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Upload class="w-4 h-4" />
|
||||
</button>
|
||||
{#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-3">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary btn-sm w-full gap-2"
|
||||
disabled={loading}
|
||||
on:click={() => handleImageSelect(image)}
|
||||
>
|
||||
<Upload class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</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>
|
||||
{/each}
|
||||
</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>
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
289
frontend/src/lib/components/NewLocationModal.svelte
Normal file
289
frontend/src/lib/components/NewLocationModal.svelte
Normal 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>
|
|
@ -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
|
799
frontend/src/lib/components/locations/LocationDetails.svelte
Normal file
799
frontend/src/lib/components/locations/LocationDetails.svelte
Normal 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>
|
761
frontend/src/lib/components/locations/LocationMedia.svelte
Normal file
761
frontend/src/lib/components/locations/LocationMedia.svelte
Normal 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>
|
462
frontend/src/lib/components/locations/LocationQuickStart.svelte
Normal file
462
frontend/src/lib/components/locations/LocationQuickStart.svelte
Normal 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>
|
602
frontend/src/lib/components/locations/LocationVisits.svelte
Normal file
602
frontend/src/lib/components/locations/LocationVisits.svelte
Normal 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>
|
|
@ -279,7 +279,6 @@ export type ImmichAlbum = {
|
|||
export type Attachment = {
|
||||
id: string;
|
||||
file: string;
|
||||
location: string;
|
||||
extension: string;
|
||||
user: string;
|
||||
name: string;
|
||||
|
|
|
@ -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}
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue