feat: add itinerary removal functionality to various cards and update UI components

- Implemented `removeFromItinerary` function in `LodgingCard`, `NoteCard`, and `TransportationCard` to allow users to remove items from their itinerary.
- Replaced the trash icon with a calendar remove icon in `LocationCard`, `LodgingCard`, `NoteCard`, and `TransportationCard` for better visual representation.
- Updated the dropdown menus in `LodgingCard`, `NoteCard`, and `TransportationCard` to include the new remove from itinerary option.
- Enhanced `CollectionItineraryPlanner` to pass itinerary items to the respective cards.
- Removed `PointSelectionModal.svelte` as it is no longer needed.
- Refactored `LocationMedia.svelte` to integrate `ImageManagement` component and clean up unused code related to image handling.
This commit is contained in:
Sean Morley
2025-12-22 11:54:34 -05:00
parent ace2a45cc9
commit 09f8cd4a8c
13 changed files with 855 additions and 841 deletions

View File

@@ -1,7 +1,8 @@
from adventures.models import Location, Collection, CollectionItineraryItem, Transportation, Note, Lodging, Visit
from adventures.models import Location, Collection, CollectionItineraryItem, Transportation, Note, Lodging, Visit, Checklist, Note
import datetime
from django.utils.dateparse import parse_date, parse_datetime
from django.contrib.contenttypes.models import ContentType
from django.db import models
from adventures.serializers import CollectionItineraryItemSerializer
from adventures.utils.itinerary import reorder_itinerary_items
from adventures.utils.autogenerate_itinerary import auto_generate_itinerary
@@ -66,6 +67,8 @@ class ItineraryViewSet(viewsets.ModelViewSet):
'note': Note,
'lodging': Lodging,
'visit': Visit,
'checklist': Checklist,
'note': Note,
}
if content_type_val not in content_map:
@@ -171,6 +174,23 @@ class ItineraryViewSet(viewsets.ModelViewSet):
setattr(content_object, date_field, clean_date)
content_object.save(update_fields=[date_field])
# Ensure order is unique for this collection+date combination
collection_id = data.get('collection')
item_date = data.get('date')
item_order = data.get('order', 0)
if collection_id and item_date:
# Find the maximum order for this collection+date
existing_max = CollectionItineraryItem.objects.filter(
collection_id=collection_id,
date=item_date
).aggregate(max_order=models.Max('order'))['max_order']
# Check if the requested order conflicts with existing items
if existing_max is not None and item_order <= existing_max:
# Assign next available order
data['order'] = existing_max + 1
# Proceed with normal serializer flow using modified data
serializer = self.get_serializer(data=data)
serializer.is_valid(raise_exception=True)

View File

@@ -203,6 +203,10 @@ export default defineConfig({
text: "Authelia",
link: "https://www.authelia.com/integration/openid-connect/adventure-log/",
},
{
text: "Pocket ID",
link: "/docs/configuration/social_auth/pocket_id",
},
{
text: "Open ID Connect",
link: "/docs/configuration/social_auth/oidc",

View File

@@ -13,11 +13,14 @@
import FileDocumentEdit from '~icons/mdi/file-document-edit';
import CheckCircle from '~icons/mdi/check-circle';
import CheckboxBlankCircleOutline from '~icons/mdi/checkbox-blank-circle-outline';
import CalendarRemove from '~icons/mdi/calendar-remove';
import type { CollectionItineraryItem } from '$lib/types';
export let checklist: Checklist;
export let user: User | null = null;
export let collection: Collection;
export let readOnly: boolean = false;
export let itineraryItem: CollectionItineraryItem | null = null;
let isWarningModalOpen: boolean = false;
@@ -37,6 +40,19 @@
addToast($t('checklist.checklist_delete_error'), 'error');
}
}
async function removeFromItinerary() {
let itineraryItemId = itineraryItem?.id;
let res = await fetch(`/api/itineraries/${itineraryItemId}`, {
method: 'DELETE'
});
if (res.ok) {
addToast('info', $t('itinerary.item_remove_success'));
dispatch('removeFromItinerary', itineraryItem);
} else {
addToast('error', $t('itinerary.item_remove_error'));
}
}
</script>
{#if isWarningModalOpen}
@@ -64,14 +80,12 @@
</div>
{#if !readOnly && (checklist.user == user?.uuid || (collection && user && collection.shared_with?.includes(user.uuid)))}
<div class="dropdown dropdown-end">
<div tabindex="0" role="button" class="btn btn-square btn-sm p-1 text-base-content">
<details class="dropdown dropdown-end relative z-50">
<summary class="btn btn-square btn-sm p-1 text-base-content">
<DotsHorizontal class="w-5 h-5" />
</div>
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
</summary>
<ul
tabindex="0"
class="dropdown-content menu bg-base-100 rounded-box z-[1] w-52 p-2 shadow-lg border border-base-300"
class="dropdown-content menu bg-base-100 rounded-box z-[9999] w-52 p-2 shadow-lg border border-base-300"
>
<li>
<button on:click={editChecklist} class="flex items-center gap-2">
@@ -79,6 +93,18 @@
{$t('notes.open')}
</button>
</li>
{#if itineraryItem && itineraryItem.id}
<div class="divider my-1"></div>
<li>
<button
on:click={() => removeFromItinerary()}
class="text-error flex items-center gap-2"
>
<CalendarRemove class="w-4 h-4 text-error" />
{$t('itinerary.remove_from_itinerary')}
</button>
</li>
{/if}
<div class="divider my-1"></div>
<li>
<button
@@ -90,7 +116,7 @@
</button>
</li>
</ul>
</div>
</details>
{/if}
</div>

View File

@@ -0,0 +1,492 @@
<script lang="ts">
import type { ContentImage } from '$lib/types';
import { createEventDispatcher } from 'svelte';
import { t } from 'svelte-i18n';
import { deserialize } from '$app/forms';
// Icons
import Star from '~icons/mdi/star';
import Crown from '~icons/mdi/crown';
import TrashIcon from '~icons/mdi/delete';
import CheckIcon from '~icons/mdi/check';
import CloseIcon from '~icons/mdi/close';
import ImageIcon from '~icons/mdi/image';
import { addToast } from '$lib/toasts';
import ImmichSelect from './ImmichSelect.svelte';
// Props
export let images: ContentImage[] = [];
export let objectId: string = '';
export let contentType: string = 'location'; // 'location', 'adventure', 'collection', etc.
export let defaultSearchTerm: string = '';
export let immichIntegration: boolean = false;
export let copyImmichLocally: boolean = false;
// Component state
let fileInput: HTMLInputElement;
let url: string = '';
let imageSearch: string = defaultSearchTerm;
let imageError: string = '';
let wikiImageError: string = '';
let isLoading: boolean = false;
// Wikipedia image selection
let wikiImageResults: Array<{
source: string;
width: number;
height: number;
title: string;
type: string;
}> = [];
const dispatch = createEventDispatcher<{
imagesUpdated: ContentImage[];
}>();
// 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];
dispatch('imagesUpdated', images);
}
// API calls
async function uploadImageToServer(file: File) {
const formData = new FormData();
formData.append('image', file);
formData.append('object_id', objectId);
formData.append('content_type', contentType);
try {
const res = await fetch(`/${contentType}s?/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.images || data.images.length === 0) {
wikiImageError = $t('adventures.image_fetch_failed');
return;
}
// Store results to display inline
wikiImageResults = data.images;
} catch (error) {
wikiImageError = $t('adventures.wiki_image_error');
addToast('error', $t('adventures.image_upload_error'));
} finally {
isLoading = false;
}
}
async function selectWikiImage(imageUrl: string) {
isLoading = true;
try {
const blob = await fetchImageFromUrl(imageUrl);
if (!blob) {
wikiImageError = $t('adventures.image_fetch_failed');
isLoading = false;
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'));
// Keep results open to allow adding multiple images
} 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
}));
dispatch('imagesUpdated', images);
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);
dispatch('imagesUpdated', images);
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');
}
}
function handleImmichImageSaved(event: CustomEvent) {
const newImage = createImageFromData(event.detail);
updateImagesList(newImage);
addToast('success', $t('adventures.image_upload_success'));
}
// Watch for defaultSearchTerm changes
$: if (defaultSearchTerm && !imageSearch) {
imageSearch = defaultSearchTerm;
}
</script>
<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">{$t('adventures.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('navbar.search')}
</button>
</div>
{#if wikiImageError}
<div class="alert alert-error mt-2 py-2">
<span class="text-sm">{wikiImageError}</span>
</div>
{/if}
<!-- Wikipedia Image Results (Inside Box) -->
{#if wikiImageResults.length > 0}
<div class="mt-4">
<div class="flex items-center justify-between mb-3">
<span class="text-sm text-base-content/70">
{$t('adventures.wiki_results_found', {
values: { count: wikiImageResults.length, query: imageSearch }
})}
</span>
<button
class="btn btn-ghost btn-xs"
on:click={() => {
wikiImageResults = [];
imageSearch = defaultSearchTerm;
}}
>
<CloseIcon class="h-4 w-4" />
</button>
</div>
<div class="grid grid-cols-2 sm:grid-cols-3 gap-2 max-h-96 overflow-y-auto">
{#each wikiImageResults as result (result.source)}
<button
type="button"
class="card bg-base-100 border border-base-300 hover:border-primary hover:shadow-lg transition-all duration-200 cursor-pointer group"
on:click={() => selectWikiImage(result.source)}
disabled={isLoading}
>
<figure class="aspect-square bg-base-200 overflow-hidden">
<img
src={result.source}
alt={result.title}
class="w-full h-full object-cover transition-transform group-hover:scale-105"
loading="lazy"
/>
</figure>
<div class="card-body p-2">
<h4 class="text-xs font-medium line-clamp-1 text-left" title={result.title}>
{result.title}
</h4>
<div
class="text-xs text-base-content/60 flex items-center justify-between gap-1"
>
<span class="truncate">{result.width} × {result.height}</span>
</div>
</div>
<div
class="absolute inset-0 bg-primary/10 opacity-0 group-hover:opacity-100 transition-opacity duration-200 flex items-center justify-center rounded-2xl"
>
<div class="btn btn-primary btn-sm gap-2">
<CheckIcon class="h-4 w-4" />
{$t('adventures.select')}
</div>
</div>
</button>
{/each}
</div>
</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">
{$t('immich.immich')}
</h4>
<ImmichSelect
{objectId}
{contentType}
{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">{$t('adventures.no_images_uploaded_yet')}</div>
<div class="text-sm text-base-content/40">
{$t('adventures.upload_first_image')}
</div>
</div>
{/if}
</div>
</div>

View File

@@ -1,8 +1,8 @@
<script lang="ts">
import { createEventDispatcher, onMount } from 'svelte';
import { t } from 'svelte-i18n';
import ImmichLogo from '$lib/assets/immich.svg';
import Upload from '~icons/mdi/upload';
import CheckIcon from '~icons/mdi/check';
import CloseIcon from '~icons/mdi/close';
import type { ImmichAlbum } from '$lib/types';
import { debounce } from '$lib';
@@ -195,181 +195,145 @@
});
</script>
<div class="space-y-4">
<!-- Header -->
<div class="flex items-center gap-2 mb-4">
<h4 class="font-medium text-lg">
{$t('immich.immich')}
</h4>
<img src={ImmichLogo} alt="Immich Logo" class="h-6 w-6" />
</div>
<!-- Search Category Tabs -->
<div class="flex gap-2 mb-3">
<button
class="btn btn-sm"
class:btn-primary={searchCategory === 'search'}
class:btn-ghost={searchCategory !== 'search'}
on:click={() => handleSearchCategoryChange('search')}
>
{$t('navbar.search')}
</button>
<button
class="btn btn-sm"
class:btn-primary={searchCategory === 'date'}
class:btn-ghost={searchCategory !== 'date'}
on:click={() => handleSearchCategoryChange('date')}
>
{$t('immich.by_date')}
</button>
<button
class="btn btn-sm"
class:btn-primary={searchCategory === 'album'}
class:btn-ghost={searchCategory !== 'album'}
on:click={() => handleSearchCategoryChange('album')}
>
{$t('immich.by_album')}
</button>
</div>
<!-- Search Category Tabs -->
<div class="tabs tabs-boxed w-fit">
<!-- Search Controls -->
{#if searchCategory === 'search'}
<div class="flex gap-2">
<input
type="text"
placeholder={$t('immich.image_search_placeholder') + '...'}
bind:value={immichSearchValue}
class="input input-bordered flex-1"
disabled={loading}
/>
<button
class="tab"
class:tab-active={searchCategory === 'search'}
on:click={() => handleSearchCategoryChange('search')}
type="submit"
class="btn btn-primary btn-sm"
class:loading
disabled={loading || !immichSearchValue.trim()}
on:click={searchImmich}
>
{$t('navbar.search')}
</button>
<button
class="tab"
class:tab-active={searchCategory === 'date'}
on:click={() => handleSearchCategoryChange('date')}
>
{$t('immich.by_date')}
</button>
<button
class="tab"
class:tab-active={searchCategory === 'album'}
on:click={() => handleSearchCategoryChange('album')}
>
{$t('immich.by_album')}
</button>
</div>
<!-- Search Controls -->
<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
type="text"
placeholder={$t('immich.image_search_placeholder') + '...'}
bind:value={immichSearchValue}
class="input input-bordered flex-1"
disabled={loading}
/>
<button
type="submit"
class="btn btn-primary"
class:loading
disabled={loading || !immichSearchValue.trim()}
>
{$t('navbar.search')}
</button>
</form>
{:else if searchCategory === 'date'}
<div class="flex items-center gap-2">
<label class="label" for="date-picker">
<span class="label-text">{$t('immich.select_date')}</span>
</label>
<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" for="album-select">
<span class="label-text">{$t('immich.select_album')}</span>
</label>
<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') : $t('immich.loading_albums')}
</option>
{#each albums as album (album.id)}
<option value={album.id}>{album.albumName}</option>
{/each}
</select>
</div>
{/if}
{:else if searchCategory === 'date'}
<div class="flex gap-2 items-center">
<input
id="date-picker"
type="date"
bind:value={selectedDate}
class="input input-bordered flex-1"
disabled={loading}
/>
</div>
{:else if searchCategory === 'album'}
<select
id="album-select"
class="select select-bordered w-full"
bind:value={currentAlbum}
disabled={loading}
>
<option value="" disabled>
{albums.length > 0 ? $t('immich.select_album') : $t('immich.loading_albums')}
</option>
{#each albums as album (album.id)}
<option value={album.id}>{album.albumName}</option>
{/each}
</select>
{/if}
<!-- Error Message -->
{#if immichError}
<div class="alert alert-error py-2">
<svg
xmlns="http://www.w3.org/2000/svg"
class="stroke-current shrink-0 h-5 w-5"
fill="none"
viewBox="0 0 24 24"
aria-hidden="true"
<!-- Error Message -->
{#if immichError}
<div class="alert alert-error mt-2 py-2">
<span class="text-sm">{immichError}</span>
</div>
{/if}
<!-- Images Results (Inline) -->
{#if immichImages.length > 0}
<div class="mt-4">
<div class="flex items-center justify-between mb-3">
<span class="text-sm text-base-content/70">
{immichImages.length}
{immichImages.length === 1 ? 'image' : 'images'} found
</span>
<button
class="btn btn-ghost btn-xs"
on:click={() => {
immichImages = [];
immichSearchValue = '';
immichNextURL = '';
}}
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span class="text-sm">{immichError}</span>
<CloseIcon class="h-4 w-4" />
</button>
</div>
{/if}
<!-- Images Grid -->
<div class="relative">
<!-- Loading Overlay -->
{#if loading}
<div
class="absolute inset-0 bg-base-200/50 backdrop-blur-sm z-10 flex items-center justify-center rounded-lg"
>
<div class="flex flex-col items-center gap-2">
<span class="loading loading-spinner loading-lg"></span>
<span class="text-sm text-base-content/70">{$t('immich.loading')}</span>
</div>
</div>
{/if}
<!-- Images Grid -->
{#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="grid grid-cols-2 sm:grid-cols-3 gap-2 max-h-96 overflow-y-auto">
{#each immichImages as image (image.id)}
<button
type="button"
class="card bg-base-100 border border-base-300 hover:border-primary hover:shadow-lg transition-all duration-200 cursor-pointer group relative"
on:click={() => handleImageSelect(image)}
disabled={loading}
>
<figure class="aspect-square bg-base-200 overflow-hidden">
<img
src={image.image_url}
alt="Image from Immich"
class="w-full h-full object-cover transition-transform group-hover:scale-105"
loading="lazy"
/>
</figure>
<div
class="card bg-base-100 shadow-sm hover:shadow-md transition-all duration-200 border border-base-200"
class="absolute inset-0 bg-primary/10 opacity-0 group-hover:opacity-100 transition-opacity duration-200 flex items-center justify-center rounded-2xl"
>
<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 class="btn btn-primary btn-sm gap-2">
<CheckIcon class="h-4 w-4" />
{$t('adventures.select')}
</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>
</div>
{/if}
</button>
{/each}
</div>
<!-- Load More Button -->
{#if immichNextURL && !loading}
<div class="flex justify-center mt-6">
<button class="btn btn-outline btn-wide" on:click={loadMoreImmich} disabled={loading}>
{$t('immich.load_more')}
{#if immichNextURL}
<div class="flex justify-center mt-3">
<button
class="btn btn-outline btn-sm btn-wide"
on:click={loadMoreImmich}
disabled={loading}
>
{loading ? $t('immich.loading') : $t('immich.load_more')}
</button>
</div>
{/if}
</div>
</div>
{/if}

View File

@@ -114,7 +114,9 @@
scheduledOnThisDay.push({ type: 'note', item: note, dates });
else scheduledOtherDays.push({ type: 'note', item: note, dates });
} else {
otherDays.push({ type: 'note', item: note });
const itemDate = note.date ? note.date.split('T')[0] : null;
if (itemDate === targetDate) onThisDay.push({ type: 'note', item: note });
else otherDays.push({ type: 'note', item: note });
}
});
@@ -126,7 +128,9 @@
scheduledOnThisDay.push({ type: 'checklist', item: checklist, dates });
else scheduledOtherDays.push({ type: 'checklist', item: checklist, dates });
} else {
otherDays.push({ type: 'checklist', item: checklist });
const itemDate = checklist.date ? checklist.date.split('T')[0] : null;
if (itemDate === targetDate) onThisDay.push({ type: 'checklist', item: checklist });
else otherDays.push({ type: 'checklist', item: checklist });
}
});
@@ -171,14 +175,14 @@
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="p-2 bg-primary/10 rounded-xl">
<CalendarBlank class="w-8 h-8 text-primary" />
<div class="p-1 bg-primary/10 rounded-xl">
<CalendarBlank class="w-6 h-6 text-primary" />
</div>
<div>
<h1 class="text-3xl font-bold text-primary bg-clip-text">
<h1 class="text-2xl font-bold text-primary bg-clip-text">
Link Items to {displayDate}
</h1>
<p class="text-sm text-base-content/60">
<p class="text-xs text-base-content/60">
{groupedItems.scheduledOnThisDay.length +
groupedItems.onThisDay.length +
groupedItems.scheduledOtherDays.length +
@@ -195,18 +199,18 @@
</div>
</div>
<div class="px-6">
<div class="px-4">
<!-- Items on this day -->
{#if groupedItems.onThisDay.length > 0}
<div class="mb-6">
<div class="mb-4">
<h4 class="text-lg font-semibold mb-3 flex items-center gap-2">
<CalendarBlank class="w-5 h-5 text-primary" />
Items on this day
</h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="grid grid-cols-1 md:grid-cols-3 gap-2">
{#each groupedItems.onThisDay as { type, item }}
<div class="card bg-base-100 border border-base-300 shadow-sm">
<div class="card-body p-4">
<div class="card-body p-2 text-sm">
<div class="mb-3">
{#if type === 'location'}
<LocationCard
@@ -232,7 +236,7 @@
{/if}
</div>
<button
class="btn btn-primary btn-sm w-full"
class="btn btn-primary btn-xs w-full"
on:click={() => handleAddItem(type, item.id, false)}
>
Add to Itinerary
@@ -245,15 +249,15 @@
{/if}
{#if groupedItems.scheduledOnThisDay.length > 0}
<div class="mb-6">
<div class="mb-4">
<h4 class="text-lg font-semibold mb-3 flex items-center gap-2">
<CalendarBlank class="w-5 h-5 text-primary" />
Already added on this day
</h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="grid grid-cols-1 md:grid-cols-3 gap-2">
{#each groupedItems.scheduledOnThisDay as { type, item, dates }}
<div class="card bg-base-100 border border-base-300 shadow-sm">
<div class="card-body p-4">
<div class="card-body p-2 text-sm">
<div class="mb-3">
{#if type === 'location'}
<LocationCard
@@ -278,8 +282,8 @@
<ChecklistCard checklist={item} {user} {collection} readOnly={true} />
{/if}
</div>
<div class="text-sm opacity-70 mb-2">Already on: {(dates || []).join(', ')}</div>
<div class="text-xs opacity-50">
<div class="text-xs opacity-70 mb-2">Already on: {(dates || []).join(', ')}</div>
<div class="text-2xs opacity-50">
Use "Items on other days" or "Add as is" to duplicate onto this date.
</div>
</div>
@@ -294,16 +298,16 @@
{/if}
{#if groupedItems.scheduledOtherDays.length > 0}
<div class="mb-6">
<div class="mb-4">
<h4 class="text-lg font-semibold mb-3 opacity-70">Already added on other days</h4>
<p class="text-sm opacity-60 mb-4">
These items are already in your itinerary on a different date — you can add them again
to this day or update their date.
</p>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="grid grid-cols-1 md:grid-cols-3 gap-2">
{#each groupedItems.scheduledOtherDays as { type, item, dates }}
<div class="card bg-base-100 border border-base-300 shadow-sm">
<div class="card-body p-4">
<div class="card-body p-2 text-sm">
<div class="mb-3">
{#if type === 'location'}
<LocationCard
@@ -328,14 +332,14 @@
<ChecklistCard checklist={item} {user} {collection} readOnly={true} />
{/if}
</div>
<div class="text-sm opacity-70 mb-2">On: {(dates || []).join(', ')}</div>
<div class="text-xs opacity-70 mb-2">On: {(dates || []).join(', ')}</div>
<div class="flex gap-2">
<button
class="btn btn-outline btn-sm flex-1"
class="btn btn-outline btn-xs flex-1"
on:click={() => handleAddItem(type, item.id, false)}>Add to This Day</button
>
<button
class="btn btn-primary btn-sm flex-1"
class="btn btn-primary btn-xs flex-1"
on:click={() => handleAddItem(type, item.id, true)}>Add & Update Date</button
>
</div>
@@ -348,16 +352,16 @@
<!-- Items on other days -->
{#if groupedItems.otherDays.length > 0}
<div class="mb-6">
<div class="mb-4">
<h4 class="text-lg font-semibold mb-3 opacity-70">Items on other days</h4>
<p class="text-sm opacity-60 mb-4">
These items have different dates. You can add them and optionally update their date to
match.
</p>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="grid grid-cols-1 md:grid-cols-3 gap-2">
{#each groupedItems.otherDays as { type, item }}
<div class="card bg-base-100 border border-base-300 shadow-sm">
<div class="card-body p-4">
<div class="card-body p-2 text-sm">
<div class="mb-3">
{#if type === 'location'}
<LocationCard
@@ -384,13 +388,13 @@
</div>
<div class="flex gap-2">
<button
class="btn btn-outline btn-sm flex-1"
class="btn btn-outline btn-xs flex-1"
on:click={() => handleAddItem(type, item.id, false)}
>
Add as is
</button>
<button
class="btn btn-primary btn-sm flex-1"
class="btn btn-primary btn-xs flex-1"
on:click={() => handleAddItem(type, item.id, true)}
>
Add & Update Date
@@ -405,10 +409,10 @@
{#if groupedItems.scheduledOnThisDay.length + groupedItems.onThisDay.length + groupedItems.scheduledOtherDays.length + groupedItems.otherDays.length === 0}
<div class="card bg-base-200">
<div class="card-body text-center py-12">
<CalendarBlank class="w-16 h-16 mx-auto mb-4 opacity-30" />
<p class="text-lg font-semibold opacity-70">No unscheduled items available</p>
<p class="text-sm opacity-60 mt-2">
<div class="card-body text-center py-8">
<CalendarBlank class="w-12 h-12 mx-auto mb-3 opacity-30" />
<p class="text-md font-semibold opacity-70">No unscheduled items available</p>
<p class="text-xs opacity-60 mt-1">
All items have been added to the itinerary or there are no items to add.
</p>
</div>
@@ -418,10 +422,10 @@
<!-- Footer Actions -->
<div
class="sticky bottom-0 bg-base-100/90 backdrop-blur-lg border-t border-base-300 -mx-6 -mb-6 px-6 py-4 mt-6"
class="sticky bottom-0 bg-base-100/90 backdrop-blur-lg border-t border-base-300 -mx-6 -mb-6 px-4 py-3 mt-4"
>
<div class="flex items-center justify-between">
<div class="text-sm text-base-content/60">
<div class="text-xs text-base-content/60">
{groupedItems.scheduledOnThisDay.length +
groupedItems.onThisDay.length +
groupedItems.scheduledOtherDays.length +

View File

@@ -26,6 +26,7 @@
import Eye from '~icons/mdi/eye';
import EyeOff from '~icons/mdi/eye-off';
import CollectionItineraryPlanner from './locations/CollectionItineraryPlanner.svelte';
import CalendarRemove from '~icons/mdi/calendar-remove';
export let type: string | null = null;
export let user: User | null;
@@ -371,7 +372,7 @@
on:click={() => removeFromItinerary()}
class="text-error flex items-center gap-2"
>
<TrashCan class="w-4 h-4 text-error" />
<CalendarRemove class="w-4 h-4 text-error" />
{$t('itinerary.remove_from_itinerary')}
</button>
</li>

View File

@@ -17,6 +17,8 @@
import StarOutline from '~icons/mdi/star-outline';
import MapMarker from '~icons/mdi/map-marker';
import DotsHorizontal from '~icons/mdi/dots-horizontal';
import CalendarRemove from '~icons/mdi/calendar-remove';
import type { CollectionItineraryItem } from '$lib/types';
const dispatch = createEventDispatcher();
@@ -40,6 +42,7 @@
export let user: User | null = null;
export let collection: Collection | null = null;
export let readOnly: boolean = false;
export let itineraryItem: CollectionItineraryItem | null = null;
let isWarningModalOpen: boolean = false;
@@ -62,6 +65,19 @@
dispatch('delete', lodging.id);
}
}
async function removeFromItinerary() {
let itineraryItemId = itineraryItem?.id;
let res = await fetch(`/api/itineraries/${itineraryItemId}`, {
method: 'DELETE'
});
if (res.ok) {
addToast('info', $t('itinerary.item_remove_success'));
dispatch('removeFromItinerary', itineraryItem);
} else {
addToast('error', $t('itinerary.item_remove_error'));
}
}
</script>
{#if isWarningModalOpen}
@@ -119,14 +135,12 @@
<h2 class="text-lg font-semibold line-clamp-2">{lodging.name}</h2>
{#if !readOnly && (lodging.user == user?.uuid || (collection && user && collection.shared_with?.includes(user.uuid)))}
<div class="dropdown dropdown-end">
<div tabindex="0" role="button" class="btn btn-square btn-sm p-1 text-base-content">
<details class="dropdown dropdown-end relative z-50">
<summary class="btn btn-square btn-sm p-1 text-base-content">
<DotsHorizontal class="w-5 h-5" />
</div>
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
</summary>
<ul
tabindex="0"
class="dropdown-content menu bg-base-100 rounded-box z-[1] w-52 p-2 shadow-lg border border-base-300"
class="dropdown-content menu bg-base-100 rounded-box z-[9999] w-52 p-2 shadow-lg border border-base-300"
>
<li>
<button on:click={editTransportation} class="flex items-center gap-2">
@@ -134,6 +148,18 @@
{$t('transportation.edit')}
</button>
</li>
{#if itineraryItem && itineraryItem.id}
<div class="divider my-1"></div>
<li>
<button
on:click={() => removeFromItinerary()}
class="text-error flex items-center gap-2"
>
<CalendarRemove class="w-4 h-4 text-error" />
{$t('itinerary.remove_from_itinerary')}
</button>
</li>
{/if}
<div class="divider my-1"></div>
<li>
<button
@@ -145,7 +171,7 @@
</button>
</li>
</ul>
</div>
</details>
{/if}
</div>

View File

@@ -17,11 +17,14 @@
import DotsHorizontal from '~icons/mdi/dots-horizontal';
import FileDocumentEdit from '~icons/mdi/file-document-edit';
import LinkVariant from '~icons/mdi/link-variant';
import CalendarRemove from '~icons/mdi/calendar-remove';
import type { CollectionItineraryItem } from '$lib/types';
export let note: Note;
export let user: User | null = null;
export let collection: Collection | null = null;
export let readOnly: boolean = false;
export let itineraryItem: CollectionItineraryItem | null = null;
let isWarningModalOpen: boolean = false;
@@ -41,6 +44,19 @@
addToast($t('notes.note_delete_error'), 'error');
}
}
async function removeFromItinerary() {
let itineraryItemId = itineraryItem?.id;
let res = await fetch(`/api/itineraries/${itineraryItemId}`, {
method: 'DELETE'
});
if (res.ok) {
addToast('info', $t('itinerary.item_remove_success'));
dispatch('removeFromItinerary', itineraryItem);
} else {
addToast('error', $t('itinerary.item_remove_error'));
}
}
</script>
{#if isWarningModalOpen}
@@ -69,14 +85,12 @@
</div>
{#if !readOnly && (note.user == user?.uuid || (collection && user && collection.shared_with?.includes(user.uuid)))}
<div class="dropdown dropdown-end">
<div tabindex="0" role="button" class="btn btn-square btn-sm p-1 text-base-content">
<details class="dropdown dropdown-end relative z-50">
<summary class="btn btn-square btn-sm p-1 text-base-content">
<DotsHorizontal class="w-5 h-5" />
</div>
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
</summary>
<ul
tabindex="0"
class="dropdown-content menu bg-base-100 rounded-box z-[1] w-52 p-2 shadow-lg border border-base-300"
class="dropdown-content menu bg-base-100 rounded-box z-[9999] w-52 p-2 shadow-lg border border-base-300"
>
<li>
<button on:click={editNote} class="flex items-center gap-2">
@@ -84,6 +98,18 @@
{$t('notes.open')}
</button>
</li>
{#if itineraryItem && itineraryItem.id}
<div class="divider my-1"></div>
<li>
<button
on:click={() => removeFromItinerary()}
class="text-error flex items-center gap-2"
>
<CalendarRemove class="w-4 h-4 text-error" />
{$t('itinerary.remove_from_itinerary')}
</button>
</li>
{/if}
<div class="divider my-1"></div>
<li>
<button
@@ -95,7 +121,7 @@
</button>
</li>
</ul>
</div>
</details>
{/if}
</div>

View File

@@ -1,157 +0,0 @@
<script lang="ts">
// @ts-nocheck
import type { Location, GeocodeSearchResult, Point } from '$lib/types';
import { createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher();
import { onMount } from 'svelte';
let modal: HTMLDialogElement;
import { appVersion } from '$lib/config';
import { DefaultMarker, MapEvents, MapLibre, Popup } from 'svelte-maplibre';
import { getBasemapUrl } from '$lib';
let markers: Point[] = [];
export let query: string | null = null;
export let adventure: Location;
if (query) {
geocode();
}
function addMarker(e: CustomEvent<MouseEvent>) {
markers = [];
markers = [...markers, { lngLat: e.detail.lngLat, name: '' }];
console.log(markers);
}
onMount(() => {
modal = document.getElementById('my_modal_1') as HTMLDialogElement;
if (modal) {
modal.showModal();
}
if (adventure.longitude && adventure.latitude) {
markers = [
{
lngLat: { lng: adventure.longitude, lat: adventure.latitude },
name: adventure.name,
location: adventure.location
}
];
}
});
function close() {
dispatch('close');
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
dispatch('close');
}
}
let places: GeocodeSearchResult[] = [];
async function geocode(e: Event | null) {
if (e) {
e.preventDefault();
}
if (!query) {
alert('Please enter a location');
return;
}
let res = await fetch(`/api/reverse-geocode/search/?query=${query}`);
console.log(res);
let data = (await res.json()) as GeocodeSearchResult[];
places = data;
}
function submit() {
if (markers.length === 0) {
alert('Please select a point on the map');
return;
}
console.log(markers[0]);
adventure.longitude = markers[0].lngLat.lng;
adventure.latitude = markers[0].lngLat.lat;
if (!adventure.location) {
adventure.location = markers[0].location;
}
if (!adventure.name) {
adventure.name = markers[0].name;
}
if (adventure.type == 'visited' || adventure.type == 'planned') {
adventure.tags = [...adventure.tags, markers[0].activity_type];
}
dispatch('submit', adventure);
close();
}
</script>
<dialog id="my_modal_1" class="modal">
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
<div class="modal-box w-11/12 max-w-4xl" role="dialog" on:keydown={handleKeydown} tabindex="0">
<form on:submit={geocode}>
<input
type="text"
placeholder="Seach for a location"
class="input input-bordered w-full max-w-xs"
id="search"
name="search"
bind:value={query}
/>
<button type="submit">Search</button>
</form>
<h3 class="font-bold text-lg mb-4">Choose a Point</h3>
<MapLibre
style={getBasemapUrl()}
class="relative aspect-[9/16] max-h-[70vh] w-full sm:aspect-video sm:max-h-full rounded-lg"
standardControls
>
<!-- MapEvents gives you access to map events even from other components inside the map,
where you might not have access to the top-level `MapLibre` component. In this case
it would also work to just use on:click on the MapLibre component itself. -->
<MapEvents on:click={addMarker} />
{#each markers as marker}
<DefaultMarker lngLat={marker.lngLat} />
{/each}
</MapLibre>
{#if places.length > 0}
<div class="mt-4">
<h3 class="font-bold text-lg mb-4">Search Results</h3>
<ul>
{#each places as place}
<li>
<button
class="btn btn-neutral mb-2"
on:click={() => {
markers = [
{
lngLat: { lng: Number(place.lon), lat: Number(place.lat) },
location: place.display_name,
name: place.name,
activity_type: place.type
}
];
}}
>
{place.display_name}
</button>
</li>
{/each}
</ul>
</div>
{:else}
<p class="text-error text-lg">No results found</p>
{/if}
<div class="mb-4 mt-4"></div>
<button class="btn btn-primary" on:click={submit}>Submit</button>
<button class="btn btn-neutral" on:click={close}>Close</button>
</div>
</dialog>

View File

@@ -17,6 +17,8 @@
import Star from '~icons/mdi/star';
import StarOutline from '~icons/mdi/star-outline';
import DotsHorizontal from '~icons/mdi/dots-horizontal';
import CalendarRemove from '~icons/mdi/calendar-remove';
import type { CollectionItineraryItem } from '$lib/types';
function getTransportationIcon(type: string) {
if (type in TRANSPORTATION_TYPES_ICONS) {
@@ -40,6 +42,7 @@
export let user: User | null = null;
export let collection: Collection | null = null;
export let readOnly: boolean = false;
export let itineraryItem: CollectionItineraryItem | null = null;
const toMiles = (km: any) => (Number(km) * 0.621371).toFixed(1);
@@ -64,6 +67,19 @@
dispatch('delete', transportation.id);
}
}
async function removeFromItinerary() {
let itineraryItemId = itineraryItem?.id;
let res = await fetch(`/api/itineraries/${itineraryItemId}`, {
method: 'DELETE'
});
if (res.ok) {
addToast('info', $t('itinerary.item_remove_success'));
dispatch('removeFromItinerary', itineraryItem);
} else {
addToast('error', $t('itinerary.item_remove_error'));
}
}
</script>
{#if isWarningModalOpen}
@@ -126,14 +142,12 @@
<h2 class="text-lg font-semibold line-clamp-2">{transportation.name}</h2>
{#if !readOnly && (transportation.user === user?.uuid || (collection && user && collection.shared_with?.includes(user.uuid)))}
<div class="dropdown dropdown-end">
<div tabindex="0" role="button" class="btn btn-square btn-sm p-1 text-base-content">
<details class="dropdown dropdown-end relative z-50">
<summary class="btn btn-square btn-sm p-1 text-base-content">
<DotsHorizontal class="w-5 h-5" />
</div>
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
</summary>
<ul
tabindex="0"
class="dropdown-content menu bg-base-100 rounded-box z-[1] w-52 p-2 shadow-lg border border-base-300"
class="dropdown-content menu bg-base-100 rounded-box z-[9999] w-52 p-2 shadow-lg border border-base-300"
>
<li>
<button on:click={editTransportation} class="flex items-center gap-2">
@@ -141,6 +155,18 @@
{$t('transportation.edit')}
</button>
</li>
{#if itineraryItem && itineraryItem.id}
<div class="divider my-1"></div>
<li>
<button
on:click={() => removeFromItinerary()}
class="text-error flex items-center gap-2"
>
<CalendarRemove class="w-4 h-4 text-error" />
{$t('itinerary.remove_from_itinerary')}
</button>
</li>
{/if}
<div class="divider my-1"></div>
<li>
<button
@@ -152,7 +178,7 @@
</button>
</li>
</ul>
</div>
</details>
{/if}
</div>

View File

@@ -824,7 +824,7 @@
<div
class="group relative transition-all duration-200 pointer-events-auto h-full {isDraggingShadow
? 'opacity-40 scale-95'
: 'hover:shadow-lg'}"
: ''}"
animate:flip={{ duration: flipDurationMs }}
>
{#if resolvedObj}
@@ -906,15 +906,39 @@
compact={true}
/>
{:else if objectType === 'transportation'}
<TransportationCard transportation={resolvedObj} {user} {collection} />
<TransportationCard
transportation={resolvedObj}
{user}
{collection}
itineraryItem={item}
on:removeFromItinerary={handleRemoveItineraryItem}
/>
{:else if objectType === 'lodging'}
<LodgingCard lodging={resolvedObj} {user} {collection} />
<LodgingCard
lodging={resolvedObj}
{user}
{collection}
itineraryItem={item}
on:removeFromItinerary={handleRemoveItineraryItem}
/>
{:else if objectType === 'note'}
<!-- @ts-ignore - TypeScript can't narrow union type properly -->
<NoteCard note={resolvedObj} {user} {collection} />
<NoteCard
note={resolvedObj}
{user}
{collection}
itineraryItem={item}
on:removeFromItinerary={handleRemoveItineraryItem}
/>
{:else if objectType === 'checklist'}
<!-- @ts-ignore - TypeScript can't narrow union type properly -->
<ChecklistCard checklist={resolvedObj} {user} {collection} />
<ChecklistCard
checklist={resolvedObj}
{user}
{collection}
itineraryItem={item}
on:removeFromItinerary={handleRemoveItineraryItem}
/>
{/if}
</div>
{:else}

View File

@@ -5,17 +5,15 @@
import { deserialize } from '$app/forms';
// Icons
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 AttachmentIcon from '~icons/mdi/attachment';
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 Star from '~icons/mdi/star';
import SwapHorizontalVariantIcon from '~icons/mdi/swap-horizontal-variant';
import LinkIcon from '~icons/mdi/link';
import PlusIcon from '~icons/mdi/plus';
@@ -27,7 +25,7 @@
import Camera from '~icons/mdi/camera';
import { addToast } from '$lib/toasts';
import ImmichSelect from '../ImmichSelect.svelte';
import ImageManagement from '../ImageManagement.svelte';
import WandererCard from '../WandererCard.svelte';
// Props
@@ -39,16 +37,10 @@
export let measurementSystem: 'metric' | 'imperial' = 'metric';
export let userIsOwner: boolean = false;
// 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
@@ -75,15 +67,6 @@
let wandererFetchedTrails: WandererTrail[] = [];
// Wikipedia image selection
let wikiImageResults: Array<{
source: string;
width: number;
height: number;
title: string;
type: string;
}> = [];
// Allowed file types for attachments
const allowedFileTypes = [
'.gpx',
@@ -103,23 +86,6 @@
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];
}
@@ -128,191 +94,6 @@
trails = [...trails, newTrail];
}
// 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.images || data.images.length === 0) {
wikiImageError = $t('adventures.image_fetch_failed');
return;
}
// Store results to display inline
wikiImageResults = data.images;
} catch (error) {
wikiImageError = $t('adventures.wiki_image_error');
addToast('error', $t('adventures.image_upload_error'));
} finally {
isLoading = false;
}
}
async function selectWikiImage(imageUrl: string) {
isLoading = true;
try {
const blob = await fetchImageFromUrl(imageUrl);
if (!blob) {
wikiImageError = $t('adventures.image_fetch_failed');
isLoading = false;
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'));
// Keep results open to allow adding multiple images
} 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;
@@ -677,10 +458,8 @@
dispatch('next');
}
function handleImmichImageSaved(event: CustomEvent) {
const newImage = createImageFromData(event.detail);
updateImagesList(newImage);
addToast('success', $t('adventures.image_upload_success'));
function handleImagesUpdated(event: CustomEvent<ContentImage[]>) {
images = event.detail;
}
// Lifecycle
@@ -709,242 +488,21 @@
} catch (error) {
console.error('Error checking integrations:', error);
}
if (itemName) {
imageSearch = itemName;
}
});
</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">{$t('adventures.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>
<!-- 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>
<!-- Wikipedia Search (Full Width) -->
<div class="bg-base-50 p-4 rounded-lg border border-base-200 mb-6">
<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('navbar.search')}
</button>
</div>
{#if wikiImageError}
<div class="alert alert-error mt-2 py-2">
<span class="text-sm">{wikiImageError}</span>
</div>
{/if}
<!-- Wikipedia Image Results -->
{#if wikiImageResults.length > 0}
<div class="mt-4">
<div class="flex items-center justify-between mb-3">
<span class="text-sm text-base-content/70">
{$t('adventures.wiki_results_found', {
values: { count: wikiImageResults.length, query: imageSearch }
})}
</span>
<button
class="btn btn-ghost btn-xs"
on:click={() => {
wikiImageResults = [];
imageSearch = '';
}}
>
<CloseIcon class="h-4 w-4" />
</button>
</div>
<div
class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-3"
>
{#each wikiImageResults as result (result.source)}
<button
type="button"
class="card bg-base-100 border border-base-300 hover:border-primary hover:shadow-lg transition-all duration-200 cursor-pointer group"
on:click={() => selectWikiImage(result.source)}
disabled={isLoading}
>
<figure class="aspect-square bg-base-200 overflow-hidden">
<img
src={result.source}
alt={result.title}
class="w-full h-full object-cover transition-transform group-hover:scale-105"
loading="lazy"
/>
</figure>
<div class="card-body p-2">
<h4 class="text-xs font-medium line-clamp-1 text-left" title={result.title}>
{result.title}
</h4>
<div
class="text-xs text-base-content/60 flex items-center justify-between gap-1"
>
<span class="truncate">{result.width} × {result.height}</span>
</div>
</div>
<div
class="absolute inset-0 bg-primary/10 opacity-0 group-hover:opacity-100 transition-opacity duration-200 flex items-center justify-center rounded-2xl"
>
<div class="btn btn-primary btn-sm gap-2">
<CheckIcon class="h-4 w-4" />
{$t('adventures.select')}
</div>
</div>
</button>
{/each}
</div>
</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">{$t('adventures.no_images_uploaded_yet')}</div>
<div class="text-sm text-base-content/40">
{$t('adventures.upload_first_image')}
</div>
</div>
{/if}
</div>
</div>
<ImageManagement
bind:images
objectId={itemId}
contentType="location"
defaultSearchTerm={itemName}
{immichIntegration}
{copyImmichLocally}
on:imagesUpdated={handleImagesUpdated}
/>
<!-- Attachment Management Section -->
<div class="card bg-base-100 border border-base-300 shadow-lg">