mirror of
https://github.com/seanmorley15/AdventureLog.git
synced 2026-05-18 11:47:04 -04:00
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:
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
492
frontend/src/lib/components/ImageManagement.svelte
Normal file
492
frontend/src/lib/components/ImageManagement.svelte
Normal 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>
|
||||
@@ -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}
|
||||
|
||||
@@ -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 +
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user