Bug Fixes + Duplicate Support (#1016)
* Update README.md supporter list * Fix: Multiple bug fixes and features bundle (#888, #991, #617, #984) (#1007) * fix: resolve location creation failures, broken image uploads, and invalid URL handling - Add missing addToast import in LocationDetails.svelte for proper error feedback - Add objectId check and error response handling in ImageManagement.svelte to prevent ghost images - Add Content-Type check in +page.server.ts image action to handle non-JSON backend responses - Add client-side URL validation in LocationDetails.svelte (invalid URLs → null) - Improve Django field error extraction for user-friendly toast messages - Clean up empty description fields (whitespace → null) - Update BUGFIX_DOCUMENTATION.md with detailed fix descriptions * feat: bug fixes and new features bundle Bug fixes: - fix: resolve PATCH location with visits (#888) - fix: Wikipedia/URL image upload via server-side proxy (#991) - fix: private/public toggle race condition (#617) - fix: location creation feedback (addToast import) - fix: invalid URL handling for locations and collections - fix: world map country highlighting (bg-*-200 -> bg-*-400) - fix: clipboard API polyfill for HTTP contexts - fix: MultipleObjectsReturned for duplicate images - fix: SvelteKit proxy sessionid cookie forwarding Features: - feat: duplicate location button (list + detail view) - feat: duplicate collection button - feat: i18n translations for 19 languages - feat: improved error handling and user feedback Technical: - Backend: fetch_from_url endpoint with SSRF protection - Backend: validate_link() for collections - Backend: file_permissions filter() instead of get() - Frontend: copyToClipboard() helper function - Frontend: clipboard polyfill via server-side injection * chore: switch docker-compose from image to build Use local source code builds instead of upstream :latest images to preserve our custom patches and fixes. * fix: lodging save errors, AI language support, and i18n improvements - Fix Lodging save: add res.ok checks, error toasts, isSaving state (#984) - Fix URL validation: silently set invalid URLs to null (Lodging, Transportation) - Fix AI description language: pass user locale to Wikipedia API - Fix missing i18n keys: Strava toggle buttons (show/hide) - Add CHANGELOG.md - Remove internal documentation from public tracking - Update .gitignore for Cursor IDE and internal docs Co-authored-by: Cursor <cursoragent@cursor.com> * feat: update location duplication handling, improve UI feedback, and enhance localization support --------- Co-authored-by: AdventureLog Bugfix <bugfix@adventurelog.local> Co-authored-by: madmp87 <info@so-pa.de> Co-authored-by: Mathias Ponnwitz <devuser@dockge-dev.fritz.box> Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Sean Morley <mail@seanmorley.com> * Enhance duplication functionality for collections and locations; update UI to reflect changes * Potential fix for code scanning alert no. 49: Information exposure through an exception Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> * Update Django and Pillow versions in requirements.txt * Fix error logging for image fetch timeout in ContentImageViewSet * Update requirements.txt to include jaraco.context and wheel for security fixes * Update app version and add security vulnerabilities to .trivyignore * Update backend/server/adventures/views/collection_view.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update frontend/src/lib/types.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Reorder build and image directives in docker-compose.yml for clarity * Refactor code structure for improved readability and maintainability * Remove inline clipboard polyfill script injection from server hooks (#1019) * Initial plan * Remove inline clipboard polyfill script injection from hooks.server.ts Co-authored-by: seanmorley15 <98704938+seanmorley15@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: seanmorley15 <98704938+seanmorley15@users.noreply.github.com> * Fix unhandled promise rejections in copyToClipboard click handlers (#1018) * Initial plan * Fix: make copyToClipboard handlers async with try/catch error toast Co-authored-by: seanmorley15 <98704938+seanmorley15@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: seanmorley15 <98704938+seanmorley15@users.noreply.github.com> * Harden `fetch_from_url` image proxy: require auth, rate-limit, and strengthen SSRF protections (#1017) * Initial plan * Harden fetch_from_url: require auth, rate-limit, block non-standard ports, check all IPs, re-validate redirects Co-authored-by: seanmorley15 <98704938+seanmorley15@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: seanmorley15 <98704938+seanmorley15@users.noreply.github.com> * Fix subregion filtering in world travel page to exclude null values * Update package.json to use caret (^) for versioning in overrides * fix: update package dependencies for compatibility and stability - Added cookie dependency with version constraint <0.7.0 - Updated svelte dependency to allow versions <=5.51.4 - Updated @sveltejs/adapter-vercel dependency to allow versions <6.3.2 * Refactor code structure for improved readability and maintainability --------- Co-authored-by: madmp87 <79420509+madmp87@users.noreply.github.com> Co-authored-by: AdventureLog Bugfix <bugfix@adventurelog.local> Co-authored-by: madmp87 <info@so-pa.de> Co-authored-by: Mathias Ponnwitz <devuser@dockge-dev.fritz.box> Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -4,6 +4,7 @@
|
||||
import { t } from 'svelte-i18n';
|
||||
import MarkdownEditor from './MarkdownEditor.svelte';
|
||||
import { addToast } from '$lib/toasts';
|
||||
import { copyToClipboard } from '$lib/index';
|
||||
import type { Collection, ContentImage, SlimCollection } from '$lib/types';
|
||||
|
||||
// Icons
|
||||
@@ -158,7 +159,7 @@
|
||||
collection.end_date = null;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
const payload: any = {
|
||||
name: collection.name,
|
||||
description: collection.description,
|
||||
start_date: collection.start_date,
|
||||
@@ -168,6 +169,17 @@
|
||||
primary_image_id: coverImageId
|
||||
};
|
||||
|
||||
// Clean up link: empty/whitespace → null, invalid URL → null
|
||||
if (!payload.link || !payload.link.trim()) {
|
||||
payload.link = null;
|
||||
} else {
|
||||
try {
|
||||
new URL(payload.link);
|
||||
} catch {
|
||||
payload.link = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (collection.id === '') {
|
||||
let res = await fetch('/api/collections', {
|
||||
method: 'POST',
|
||||
@@ -186,7 +198,12 @@
|
||||
dispatch('save', toSlimCollection(collection));
|
||||
} else {
|
||||
console.error(data);
|
||||
addToast('error', $t('collection.error_creating_collection'));
|
||||
// Extract field-level errors from Django response
|
||||
const fieldErrors = Object.entries(data)
|
||||
.filter(([_, v]) => Array.isArray(v))
|
||||
.map(([k, v]) => `${k}: ${(v as string[]).join(', ')}`)
|
||||
.join('; ');
|
||||
addToast('error', fieldErrors || $t('collection.error_creating_collection'));
|
||||
}
|
||||
} else {
|
||||
let res = await fetch(`/api/collections/${collection.id}`, {
|
||||
@@ -205,7 +222,12 @@
|
||||
addToast('success', $t('collection.collection_edit_success'));
|
||||
dispatch('save', toSlimCollection(collection));
|
||||
} else {
|
||||
addToast('error', $t('collection.error_editing_collection'));
|
||||
// Extract field-level errors from Django response
|
||||
const fieldErrors = Object.entries(data)
|
||||
.filter(([_, v]) => Array.isArray(v))
|
||||
.map(([k, v]) => `${k}: ${(v as string[]).join(', ')}`)
|
||||
.join('; ');
|
||||
addToast('error', fieldErrors || $t('collection.error_editing_collection'));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -506,11 +528,15 @@
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => {
|
||||
navigator.clipboard.writeText(
|
||||
`${window.location.origin}/collections/${collection.id}`
|
||||
);
|
||||
addToast('success', $t('adventures.link_copied'));
|
||||
on:click={async () => {
|
||||
try {
|
||||
await copyToClipboard(
|
||||
`${window.location.origin}/collections/${collection.id}`
|
||||
);
|
||||
addToast('success', $t('adventures.link_copied'));
|
||||
} catch {
|
||||
addToast('error', $t('adventures.copy_failed') || 'Copy failed');
|
||||
}
|
||||
}}
|
||||
class="btn btn-primary gap-2"
|
||||
>
|
||||
|
||||
47
frontend/src/lib/components/ImageManagement.svelte
Normal file → Executable file
47
frontend/src/lib/components/ImageManagement.svelte
Normal file → Executable file
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import type { ContentImage } from '$lib/types';
|
||||
import { createEventDispatcher, onMount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { t, locale } from 'svelte-i18n';
|
||||
import { deserialize } from '$app/forms';
|
||||
|
||||
// Icons
|
||||
@@ -65,6 +65,12 @@
|
||||
|
||||
// API calls
|
||||
async function uploadImageToServer(file: File) {
|
||||
if (!objectId) {
|
||||
console.error('Cannot upload image: objectId is not set');
|
||||
addToast('error', 'Cannot upload image: location must be saved first');
|
||||
return null;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('image', file);
|
||||
formData.append('object_id', objectId);
|
||||
@@ -78,7 +84,20 @@
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const newData = deserialize(await res.text()) as { data: { id: string; image: string } };
|
||||
const newData = deserialize(await res.text()) as {
|
||||
data: { id: string; image: string; error?: string };
|
||||
};
|
||||
// Check if the server action returned an error
|
||||
if (newData.data && newData.data.error) {
|
||||
console.error('Image upload server error:', newData.data.error);
|
||||
addToast('error', String(newData.data.error));
|
||||
return null;
|
||||
}
|
||||
if (!newData.data || !newData.data.id || !newData.data.image) {
|
||||
console.error('Image upload returned incomplete data:', newData.data);
|
||||
addToast('error', 'Image upload failed - incomplete response');
|
||||
return null;
|
||||
}
|
||||
return createImageFromData(newData.data);
|
||||
} else {
|
||||
throw new Error('Upload failed');
|
||||
@@ -133,8 +152,24 @@
|
||||
|
||||
async function fetchImageFromUrl(imageUrl: string): Promise<Blob | null> {
|
||||
try {
|
||||
const res = await fetch(imageUrl);
|
||||
if (!res.ok) throw new Error('Failed to fetch image');
|
||||
// Use backend proxy to avoid CORS issues with external URLs (Wikipedia, etc.)
|
||||
const res = await fetch('/api/images/fetch_from_url/', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ url: imageUrl })
|
||||
});
|
||||
if (!res.ok) {
|
||||
let errorMsg = 'Failed to fetch image';
|
||||
try {
|
||||
const errorData = await res.json();
|
||||
errorMsg = errorData.error || errorMsg;
|
||||
} catch {
|
||||
// Response wasn't JSON (e.g. timeout), use default message
|
||||
}
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
return await res.blob();
|
||||
} catch (error) {
|
||||
console.error('Fetch error:', error);
|
||||
@@ -205,7 +240,9 @@
|
||||
wikiImageError = '';
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/generate/img/?name=${encodeURIComponent(imageSearch)}`);
|
||||
const res = await fetch(
|
||||
`/api/generate/img/?name=${encodeURIComponent(imageSearch)}&lang=${$locale || 'en'}`
|
||||
);
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok || !data.images || data.images.length === 0) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { addToast } from '$lib/toasts';
|
||||
import { copyToClipboard as clipboardCopy } from '$lib/index';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
const dispatch = createEventDispatcher();
|
||||
import { onMount } from 'svelte';
|
||||
@@ -111,16 +112,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
function copyToClipboard(copyText: string | null) {
|
||||
async function copyToClipboard(copyText: string | null) {
|
||||
if (copyText) {
|
||||
navigator.clipboard.writeText(copyText).then(
|
||||
() => {
|
||||
addToast('success', $t('adventures.copied_to_clipboard'));
|
||||
},
|
||||
() => {
|
||||
addToast('error', $t('adventures.copy_failed'));
|
||||
}
|
||||
);
|
||||
try {
|
||||
await clipboardCopy(copyText);
|
||||
addToast('success', $t('adventures.copied_to_clipboard'));
|
||||
} catch {
|
||||
addToast('error', $t('adventures.copy_failed'));
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
import type { Location, Collection, User, SlimCollection, ContentImage } from '$lib/types';
|
||||
import { addToast } from '$lib/toasts';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { copyToClipboard } from '$lib/index';
|
||||
|
||||
import Plus from '~icons/mdi/plus';
|
||||
import Minus from '~icons/mdi/minus';
|
||||
@@ -27,6 +28,7 @@
|
||||
import MapMarker from '~icons/mdi/map-marker-multiple';
|
||||
import LinkIcon from '~icons/mdi/link';
|
||||
import DownloadIcon from '~icons/mdi/download';
|
||||
import ContentCopy from '~icons/mdi/content-copy';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
@@ -39,7 +41,7 @@
|
||||
async function copyLink() {
|
||||
try {
|
||||
const url = `${location.origin}/collections/${collection.id}`;
|
||||
await navigator.clipboard.writeText(url);
|
||||
await copyToClipboard(url);
|
||||
copied = true;
|
||||
setTimeout(() => (copied = false), 2000);
|
||||
} catch (e) {
|
||||
@@ -47,6 +49,30 @@
|
||||
}
|
||||
}
|
||||
|
||||
let isDuplicating = false;
|
||||
|
||||
async function duplicateCollection() {
|
||||
if (isDuplicating) return;
|
||||
isDuplicating = true;
|
||||
try {
|
||||
const res = await fetch(`/api/collections/${collection.id}/duplicate/`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
if (res.ok) {
|
||||
const newCollection = await res.json();
|
||||
addToast('success', $t('adventures.collection_duplicate_success'));
|
||||
dispatch('duplicate', newCollection);
|
||||
} else {
|
||||
addToast('error', $t('adventures.collection_duplicate_error'));
|
||||
}
|
||||
} catch (e) {
|
||||
addToast('error', $t('adventures.collection_duplicate_error'));
|
||||
} finally {
|
||||
isDuplicating = false;
|
||||
}
|
||||
}
|
||||
|
||||
function editAdventure() {
|
||||
dispatch('edit', collection);
|
||||
}
|
||||
@@ -368,6 +394,16 @@
|
||||
{$t('adventures.export_zip')}
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button
|
||||
class="flex items-center gap-2"
|
||||
on:click={duplicateCollection}
|
||||
disabled={isDuplicating}
|
||||
>
|
||||
<ContentCopy class="w-4 h-4" />
|
||||
{isDuplicating ? '...' : $t('adventures.duplicate')}
|
||||
</button>
|
||||
</li>
|
||||
<div class="divider my-1"></div>
|
||||
<li>
|
||||
<button
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import Launch from '~icons/mdi/launch';
|
||||
import FileDocumentEdit from '~icons/mdi/file-document-edit';
|
||||
import ContentCopy from '~icons/mdi/content-copy';
|
||||
import TrashCan from '~icons/mdi/trash-can-outline';
|
||||
import Calendar from '~icons/mdi/calendar';
|
||||
import Clock from '~icons/mdi/clock-outline';
|
||||
@@ -13,6 +14,7 @@
|
||||
import LinkIcon from '~icons/mdi/link-variant';
|
||||
import Check from '~icons/mdi/check';
|
||||
import { addToast } from '$lib/toasts';
|
||||
import { copyToClipboard } from '$lib/index';
|
||||
import Link from '~icons/mdi/link-variant';
|
||||
import LinkVariantRemove from '~icons/mdi/link-variant-remove';
|
||||
import Plus from '~icons/mdi/plus';
|
||||
@@ -69,7 +71,7 @@
|
||||
async function copyLink() {
|
||||
try {
|
||||
const url = `${location.origin}/locations/${adventure.id}`;
|
||||
await navigator.clipboard.writeText(url);
|
||||
await copyToClipboard(url);
|
||||
copied = true;
|
||||
setTimeout(() => (copied = false), 2000);
|
||||
} catch (e) {
|
||||
@@ -206,6 +208,43 @@
|
||||
}
|
||||
}
|
||||
|
||||
let isDuplicating = false;
|
||||
|
||||
async function duplicateAdventure() {
|
||||
if (isDuplicating) return;
|
||||
isDuplicating = true;
|
||||
try {
|
||||
const duplicatePayload = collection?.id ? { collection_id: collection.id } : null;
|
||||
const res = await fetch(`/api/locations/${adventure.id}/duplicate/`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(duplicatePayload ?? {})
|
||||
});
|
||||
if (res.ok) {
|
||||
const newLocation = await res.json();
|
||||
|
||||
// Keep local UI in sync immediately in collection context.
|
||||
if (collection?.id) {
|
||||
const nextCollections = Array.isArray(newLocation.collections)
|
||||
? newLocation.collections
|
||||
: [];
|
||||
if (!nextCollections.includes(collection.id)) {
|
||||
newLocation.collections = [...nextCollections, collection.id];
|
||||
}
|
||||
}
|
||||
|
||||
addToast('success', $t('adventures.location_duplicate_success'));
|
||||
dispatch('duplicate', newLocation);
|
||||
} else {
|
||||
addToast('error', $t('adventures.location_duplicate_error'));
|
||||
}
|
||||
} catch (e) {
|
||||
addToast('error', $t('adventures.location_duplicate_error'));
|
||||
} finally {
|
||||
isDuplicating = false;
|
||||
}
|
||||
}
|
||||
|
||||
function editAdventure() {
|
||||
dispatch('edit', adventure);
|
||||
}
|
||||
@@ -388,6 +427,21 @@
|
||||
{$t('adventures.edit_location')}
|
||||
</button>
|
||||
</li>
|
||||
{#if user?.uuid == adventure.user?.uuid}
|
||||
<li>
|
||||
<button
|
||||
on:click={() => {
|
||||
isActionsMenuOpen = false;
|
||||
duplicateAdventure();
|
||||
}}
|
||||
class="flex items-center gap-2"
|
||||
disabled={isDuplicating}
|
||||
>
|
||||
<ContentCopy class="w-4 h-4" />
|
||||
{isDuplicating ? '...' : $t('adventures.duplicate')}
|
||||
</button>
|
||||
</li>
|
||||
{/if}
|
||||
{#if user?.uuid == adventure.user?.uuid}
|
||||
<li>
|
||||
<button
|
||||
|
||||
@@ -128,6 +128,30 @@
|
||||
// Bubble up so parent can open edit modals
|
||||
dispatch('openEdit', { type, item: updated });
|
||||
}
|
||||
|
||||
function handleLocationDuplicate(detail: any) {
|
||||
if (!detail || !detail.id) return;
|
||||
|
||||
const collectionId = collection?.id ? String(collection.id) : null;
|
||||
const duplicated = { ...detail };
|
||||
|
||||
if (collectionId) {
|
||||
const existingCollections = Array.isArray(duplicated.collections)
|
||||
? duplicated.collections.map((id: string) => String(id))
|
||||
: [];
|
||||
if (!existingCollections.includes(collectionId)) {
|
||||
duplicated.collections = [...existingCollections, collectionId];
|
||||
}
|
||||
}
|
||||
|
||||
collection = {
|
||||
...collection,
|
||||
locations: [
|
||||
duplicated,
|
||||
...(collection.locations || []).filter((loc) => String(loc.id) !== String(duplicated.id))
|
||||
]
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Show each section as its own card so transportations and others
|
||||
@@ -177,6 +201,7 @@
|
||||
{collection}
|
||||
on:delete={(e) => handleItemDelete('locations', e.detail)}
|
||||
on:edit={(e) => handleItemEdit('locations', e.detail)}
|
||||
on:duplicate={(e) => handleLocationDuplicate(e.detail)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -135,6 +135,32 @@
|
||||
isLocationModalOpen = true;
|
||||
}
|
||||
|
||||
function handleDuplicateLocation(event: CustomEvent<Location>) {
|
||||
const duplicated = event.detail;
|
||||
if (!duplicated || !duplicated.id) return;
|
||||
|
||||
const collectionId = collection?.id ? String(collection.id) : null;
|
||||
if (collectionId) {
|
||||
const existingCollections = Array.isArray((duplicated as any).collections)
|
||||
? (duplicated as any).collections.map((id: string) => String(id))
|
||||
: [];
|
||||
if (!existingCollections.includes(collectionId)) {
|
||||
(duplicated as any).collections = [...existingCollections, collectionId];
|
||||
}
|
||||
}
|
||||
|
||||
collection = {
|
||||
...collection,
|
||||
locations: [
|
||||
duplicated,
|
||||
...(collection.locations || []).filter((loc) => String(loc.id) !== String(duplicated.id))
|
||||
]
|
||||
};
|
||||
|
||||
days = groupItemsByDay(collection);
|
||||
unscheduledItems = getUnscheduledItems(collection);
|
||||
}
|
||||
|
||||
let lodgingToEdit: Lodging | null = null;
|
||||
let isLodgingModalOpen: boolean = false;
|
||||
function handleEditLodging(event: CustomEvent<Lodging>) {
|
||||
@@ -1731,6 +1757,7 @@
|
||||
adventure={resolvedObj}
|
||||
on:edit={handleEditLocation}
|
||||
on:delete={handleItemDelete}
|
||||
on:duplicate={handleDuplicateLocation}
|
||||
itineraryItem={item}
|
||||
on:removeFromItinerary={handleRemoveItineraryItem}
|
||||
on:moveToGlobal={(e) => moveItemToGlobal(e.detail.type, e.detail.id)}
|
||||
@@ -2141,6 +2168,7 @@
|
||||
adventure={resolvedObj}
|
||||
on:edit={handleEditLocation}
|
||||
on:delete={handleItemDelete}
|
||||
on:duplicate={handleDuplicateLocation}
|
||||
itineraryItem={item}
|
||||
on:removeFromItinerary={handleRemoveItineraryItem}
|
||||
on:moveToGlobal={(e) => moveItemToGlobal(e.detail.type, e.detail.id)}
|
||||
@@ -2450,6 +2478,7 @@
|
||||
adventure={item}
|
||||
on:edit={handleEditLocation}
|
||||
on:delete={handleItemDelete}
|
||||
on:duplicate={handleDuplicateLocation}
|
||||
{user}
|
||||
{collection}
|
||||
compact={true}
|
||||
|
||||
57
frontend/src/lib/components/locations/LocationDetails.svelte
Normal file → Executable file
57
frontend/src/lib/components/locations/LocationDetails.svelte
Normal file → Executable file
@@ -1,12 +1,13 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher, onMount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { t, locale } from 'svelte-i18n';
|
||||
import CategoryDropdown from '../CategoryDropdown.svelte';
|
||||
import LocationSearchMap from '../shared/LocationSearchMap.svelte';
|
||||
import MoneyInput from '../shared/MoneyInput.svelte';
|
||||
import MarkdownEditor from '../MarkdownEditor.svelte';
|
||||
import TagComplete from '../TagComplete.svelte';
|
||||
import { DEFAULT_CURRENCY, normalizeMoneyPayload, toMoneyValue } from '$lib/money';
|
||||
import { addToast } from '$lib/toasts';
|
||||
import type { Category, Collection, Location, MoneyValue, User } from '$lib/types';
|
||||
import MapIcon from '~icons/mdi/map';
|
||||
import InfoIcon from '~icons/mdi/information';
|
||||
@@ -116,7 +117,9 @@
|
||||
wikiError = '';
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/generate/desc/?name=${encodeURIComponent(location.name)}`);
|
||||
const response = await fetch(
|
||||
`/api/generate/desc/?name=${encodeURIComponent(location.name)}&lang=${$locale || 'en'}`
|
||||
);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
location.description = data.extract || '';
|
||||
@@ -132,6 +135,7 @@
|
||||
|
||||
async function handleSave() {
|
||||
if (!location.name || !location.category) {
|
||||
addToast('warning', 'Name and category are required');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -146,6 +150,22 @@
|
||||
}
|
||||
|
||||
let payload: any = { ...location };
|
||||
|
||||
// Clean up link: empty/whitespace → null, invalid URL → null
|
||||
if (!payload.link || !payload.link.trim()) {
|
||||
payload.link = null;
|
||||
} else {
|
||||
try {
|
||||
new URL(payload.link);
|
||||
} catch {
|
||||
// Not a valid URL — clear it so Django doesn't reject it
|
||||
payload.link = null;
|
||||
}
|
||||
}
|
||||
if (!payload.description || !payload.description.trim()) {
|
||||
payload.description = null;
|
||||
}
|
||||
|
||||
if (location.price === null) {
|
||||
payload.price = null;
|
||||
payload.price_currency = null;
|
||||
@@ -153,34 +173,49 @@
|
||||
payload = normalizeMoneyPayload(payload, 'price', 'price_currency', defaultCurrency);
|
||||
}
|
||||
|
||||
let res: Response;
|
||||
if (locationToEdit && locationToEdit.id) {
|
||||
if (
|
||||
(!payload.collections || payload.collections.length === 0) &&
|
||||
locationToEdit.collections &&
|
||||
locationToEdit.collections.length > 0
|
||||
) {
|
||||
// Only include collections if explicitly set via a collection context;
|
||||
// otherwise remove them from the PATCH payload to avoid triggering the
|
||||
// m2m_changed signal which can override is_public.
|
||||
if (!collection || !collection.id) {
|
||||
delete payload.collections;
|
||||
}
|
||||
|
||||
const res = await fetch(`/api/locations/${locationToEdit.id}`, {
|
||||
res = await fetch(`/api/locations/${locationToEdit.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
location = await res.json();
|
||||
} else {
|
||||
const res = await fetch(`/api/locations`, {
|
||||
res = await fetch(`/api/locations`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
location = await res.json();
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const errorData = await res.json().catch(() => ({}));
|
||||
// Extract error message from Django field errors (e.g. {"link": ["Enter a valid URL."]})
|
||||
let errorMsg = errorData?.detail || errorData?.name?.[0] || '';
|
||||
if (!errorMsg) {
|
||||
const fieldErrors = Object.entries(errorData)
|
||||
.filter(([_, v]) => Array.isArray(v))
|
||||
.map(([k, v]) => `${k}: ${(v as string[]).join(', ')}`)
|
||||
.join('; ');
|
||||
errorMsg = fieldErrors || 'Failed to save location';
|
||||
}
|
||||
addToast('error', String(errorMsg));
|
||||
return;
|
||||
}
|
||||
|
||||
location = await res.json();
|
||||
|
||||
dispatch('save', {
|
||||
...location
|
||||
});
|
||||
|
||||
@@ -312,7 +312,12 @@
|
||||
didSave = true;
|
||||
|
||||
steps[1].selected = false;
|
||||
steps[2].selected = true;
|
||||
if (location.id) {
|
||||
steps[2].selected = true;
|
||||
} else {
|
||||
// Stay on details if save failed (no ID returned)
|
||||
steps[1].selected = true;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher, onMount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { t, locale } from 'svelte-i18n';
|
||||
import { updateLocalDate, updateUTCDate, validateDateRange } from '$lib/dateUtils';
|
||||
import type { Collection, Lodging, MoneyValue } from '$lib/types';
|
||||
import LocationSearchMap from '../shared/LocationSearchMap.svelte';
|
||||
@@ -20,9 +20,11 @@
|
||||
// @ts-ignore
|
||||
import { DateTime } from 'luxon';
|
||||
import { isAllDay } from '$lib';
|
||||
import { addToast } from '$lib/toasts';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
let isSaving = false;
|
||||
let isReverseGeocoding = false;
|
||||
|
||||
let initialSelection: {
|
||||
@@ -249,7 +251,9 @@
|
||||
|
||||
try {
|
||||
// Mock Wikipedia API call - replace with actual implementation
|
||||
const response = await fetch(`/api/generate/desc/?name=${encodeURIComponent(lodging.name)}`);
|
||||
const response = await fetch(
|
||||
`/api/generate/desc/?name=${encodeURIComponent(lodging.name)}&lang=${$locale || 'en'}`
|
||||
);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
lodging.description = data.extract || '';
|
||||
@@ -268,6 +272,9 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// Prevent double-clicks while saving
|
||||
if (isSaving) return;
|
||||
|
||||
// Ensure timezone is only persisted for timed stays
|
||||
lodging.timezone = allDay ? null : selectedTimezone;
|
||||
|
||||
@@ -297,47 +304,83 @@
|
||||
payload = normalizeMoneyPayload(payload, 'price', 'price_currency', preferredCurrency);
|
||||
}
|
||||
|
||||
// Remove empty link to avoid URL validation errors
|
||||
if (!payload.link || payload.link.trim() === '') {
|
||||
delete payload.link;
|
||||
// Clean up link: empty/whitespace → null, invalid URL → null
|
||||
if (!payload.link || !payload.link.trim()) {
|
||||
payload.link = null;
|
||||
} else {
|
||||
try {
|
||||
new URL(payload.link);
|
||||
} catch {
|
||||
// Not a valid URL — clear it so Django doesn't reject it
|
||||
payload.link = null;
|
||||
}
|
||||
}
|
||||
|
||||
// If we're editing and the original location had collection, but the form's collection
|
||||
// is empty (i.e. user didn't modify collection), omit collection from payload so the
|
||||
// server doesn't clear them unintentionally.
|
||||
if (lodgingToEdit && lodgingToEdit.id) {
|
||||
if (
|
||||
(!payload.collection || payload.collection.length === 0) &&
|
||||
lodgingToEdit.collection &&
|
||||
lodgingToEdit.collection.length > 0
|
||||
) {
|
||||
delete payload.collection;
|
||||
isSaving = true;
|
||||
|
||||
try {
|
||||
// If we're editing and the original location had collection, but the form's collection
|
||||
// is empty (i.e. user didn't modify collection), omit collection from payload so the
|
||||
// server doesn't clear them unintentionally.
|
||||
if (lodgingToEdit && lodgingToEdit.id) {
|
||||
if (
|
||||
(!payload.collection || payload.collection.length === 0) &&
|
||||
lodgingToEdit.collection &&
|
||||
lodgingToEdit.collection.length > 0
|
||||
) {
|
||||
delete payload.collection;
|
||||
}
|
||||
|
||||
let res = await fetch(`/api/lodging/${lodgingToEdit.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errorData = await res.json().catch(() => null);
|
||||
const errorMsg = errorData
|
||||
? Object.values(errorData).flat().join(', ')
|
||||
: `Server error (${res.status})`;
|
||||
addToast('error', errorMsg);
|
||||
return;
|
||||
}
|
||||
|
||||
let updatedLocation = await res.json();
|
||||
lodging = updatedLocation;
|
||||
} else {
|
||||
let res = await fetch(`/api/lodging`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errorData = await res.json().catch(() => null);
|
||||
const errorMsg = errorData
|
||||
? Object.values(errorData).flat().join(', ')
|
||||
: `Server error (${res.status})`;
|
||||
addToast('error', errorMsg);
|
||||
return;
|
||||
}
|
||||
|
||||
let newLodging = await res.json();
|
||||
lodging = newLodging;
|
||||
}
|
||||
|
||||
let res = await fetch(`/api/lodging/${lodgingToEdit.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
dispatch('save', {
|
||||
...lodging
|
||||
});
|
||||
let updatedLocation = await res.json();
|
||||
lodging = updatedLocation;
|
||||
} else {
|
||||
let res = await fetch(`/api/lodging`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
let newLodging = await res.json();
|
||||
lodging = newLodging;
|
||||
} catch (err) {
|
||||
console.error('Error saving lodging:', err);
|
||||
addToast('error', $t('lodging.save_failed') || 'Failed to save lodging. Please try again.');
|
||||
} finally {
|
||||
isSaving = false;
|
||||
}
|
||||
|
||||
dispatch('save', {
|
||||
...lodging
|
||||
});
|
||||
}
|
||||
|
||||
function handleBack() {
|
||||
@@ -753,10 +796,13 @@
|
||||
<div class="flex gap-3 justify-end pt-4">
|
||||
<button
|
||||
class="btn btn-primary gap-2"
|
||||
disabled={!lodging.name || !lodging.type || isReverseGeocoding}
|
||||
disabled={!lodging.name || !lodging.type || isReverseGeocoding || isSaving}
|
||||
on:click={handleSave}
|
||||
>
|
||||
{#if isReverseGeocoding}
|
||||
{#if isSaving}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
{$t('adventures.saving') || 'Saving...'}
|
||||
{:else if isReverseGeocoding}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
{$t('adventures.processing')}...
|
||||
{:else}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher, onMount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { t, locale } from 'svelte-i18n';
|
||||
import { updateLocalDate, updateUTCDate, validateDateRange } from '$lib/dateUtils';
|
||||
import type { Collection, Lodging, Transportation, MoneyValue } from '$lib/types';
|
||||
import LocationSearchMap from '../shared/LocationSearchMap.svelte';
|
||||
@@ -328,7 +328,7 @@
|
||||
try {
|
||||
// Mock Wikipedia API call - replace with actual implementation
|
||||
const response = await fetch(
|
||||
`/api/generate/desc/?name=${encodeURIComponent(transportation.name)}`
|
||||
`/api/generate/desc/?name=${encodeURIComponent(transportation.name)}&lang=${$locale || 'en'}`
|
||||
);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
@@ -408,9 +408,16 @@
|
||||
payload = normalizeMoneyPayload(payload, 'price', 'price_currency', preferredCurrency);
|
||||
}
|
||||
|
||||
// Remove empty link to avoid URL validation errors
|
||||
if (!payload.link || payload.link.trim() === '') {
|
||||
delete payload.link;
|
||||
// Clean up link: empty/whitespace → null, invalid URL → null
|
||||
if (!payload.link || !payload.link.trim()) {
|
||||
payload.link = null;
|
||||
} else {
|
||||
try {
|
||||
new URL(payload.link);
|
||||
} catch {
|
||||
// Not a valid URL — clear it so Django doesn't reject it
|
||||
payload.link = null;
|
||||
}
|
||||
}
|
||||
|
||||
// If we're editing and the original location had collection, but the form's collection
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export let appVersion = 'v0.12.0-pre-main-011426';
|
||||
export let appVersion = 'v0.12.0-pre-main-021926';
|
||||
export let versionChangelog = 'https://github.com/seanmorley15/AdventureLog/releases/tag/v0.11.0';
|
||||
export let appTitle = 'AdventureLog';
|
||||
export let copyrightYear = '2023-2026';
|
||||
|
||||
@@ -1120,3 +1120,27 @@ export function getActivityIcon(activityType: string) {
|
||||
const activity = SPORT_TYPE_CHOICES.find((a) => a.key === activityType);
|
||||
return activity ? activity.icon : '🏅'; // Default medal if not found
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy text to clipboard with fallback for non-HTTPS contexts.
|
||||
* navigator.clipboard.writeText() requires a secure context (HTTPS or localhost).
|
||||
* On plain HTTP (e.g. LAN IP), we fall back to the legacy execCommand approach.
|
||||
*/
|
||||
export async function copyToClipboard(text: string): Promise<void> {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} else {
|
||||
// Fallback for non-secure contexts (HTTP on LAN, etc.)
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = text;
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.left = '-9999px';
|
||||
textarea.style.top = '-9999px';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.focus();
|
||||
textarea.select();
|
||||
const ok = document.execCommand('copy');
|
||||
document.body.removeChild(textarea);
|
||||
if (!ok) throw new Error('execCommand copy failed');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,6 +80,7 @@ export type Country = {
|
||||
id: number;
|
||||
name: string;
|
||||
country_code: string;
|
||||
subregion: string | null;
|
||||
flag_url: string;
|
||||
capital: string;
|
||||
num_regions: number;
|
||||
|
||||
Reference in New Issue
Block a user