{/if}
+ {#if adventure.user && adventure.user.uuid == user?.uuid}
+
+
+
+ {/if}
+
{#if itineraryItem && itineraryItem.id}
{#if !itineraryItem.is_global}
diff --git a/frontend/src/lib/shareMeta.server.ts b/frontend/src/lib/shareMeta.server.ts
new file mode 100644
index 00000000..ab9f3050
--- /dev/null
+++ b/frontend/src/lib/shareMeta.server.ts
@@ -0,0 +1,115 @@
+const MARKDOWN_PATTERNS: Array<[RegExp, string]> = [
+ [/!\[.*?\]\(.*?\)/g, ''],
+ [/\[([^\]]+)\]\([^)]+\)/g, '$1'],
+ [/#{1,6}\s*/g, ''],
+ [/\*\*([^*]+)\*\*/g, '$1'],
+ [/\*([^*]+)\*/g, '$1'],
+ [/`([^`]+)`/g, '$1'],
+];
+
+export function stripMarkdown(value: string | null | undefined, maxLength = 200): string {
+ if (!value) return '';
+ let text = String(value);
+ for (const [pattern, replacement] of MARKDOWN_PATTERNS) {
+ text = text.replace(pattern, replacement);
+ }
+ text = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
+ text = text.replace(/\n{3,}/g, '\n\n').trim();
+ if (text.length > maxLength) {
+ return `${text.slice(0, maxLength - 3)}...`;
+ }
+ return text;
+}
+
+export type ShareMeta = {
+ title: string;
+ description: string;
+ imageUrl: string;
+ pageUrl: string;
+};
+
+function formatShortDate(value: string | null | undefined): string {
+ if (!value) return '';
+ const date = new Date(value);
+ if (Number.isNaN(date.getTime())) return '';
+ return date.toLocaleDateString('en-US', { month: 'short', year: 'numeric' });
+}
+
+export function buildCollectionShareMeta(
+ collection: {
+ id: string;
+ name: string;
+ description?: string | null;
+ is_public?: boolean;
+ start_date?: string | null;
+ end_date?: string | null;
+ locations?: unknown[] | null;
+ },
+ origin: string
+): ShareMeta | null {
+ if (!collection?.is_public) return null;
+
+ const locCount = collection.locations?.length ?? 0;
+ let description = stripMarkdown(collection.description);
+ if (!description) {
+ const parts: string[] = [];
+ if (locCount) {
+ parts.push(`${locCount} location${locCount === 1 ? '' : 's'}`);
+ }
+ const start = formatShortDate(collection.start_date);
+ const end = formatShortDate(collection.end_date);
+ if (start && end && start !== end) {
+ parts.push(`${start} – ${end}`);
+ } else if (start) {
+ parts.push(start);
+ }
+ description = parts.join(' · ') || collection.name;
+ }
+
+ return {
+ title: collection.name,
+ description,
+ imageUrl: `${origin}/api/collections/${collection.id}/share-image/landscape/`,
+ pageUrl: `${origin}/collections/${collection.id}`,
+ };
+}
+
+export function buildLocationShareMeta(
+ location: {
+ id: string;
+ name: string;
+ description?: string | null;
+ is_public?: boolean;
+ location?: string | null;
+ rating?: number | null;
+ tags?: string[] | null;
+ city?: { name?: string } | null;
+ region?: { name?: string } | null;
+ country?: { name?: string } | null;
+ },
+ origin: string
+): ShareMeta | null {
+ if (!location?.is_public) return null;
+
+ let description = stripMarkdown(location.description);
+ if (!description) {
+ const parts: string[] = [];
+ const place =
+ location.city?.name ||
+ location.region?.name ||
+ location.country?.name ||
+ location.location ||
+ '';
+ if (place) parts.push(place);
+ if (location.rating != null) parts.push(`Rated ${location.rating}`);
+ if (location.tags?.length) parts.push(location.tags.slice(0, 3).join(', '));
+ description = parts.join(' · ') || location.name;
+ }
+
+ return {
+ title: location.name,
+ description,
+ imageUrl: `${origin}/api/locations/${location.id}/share-image/landscape/`,
+ pageUrl: `${origin}/locations/${location.id}`,
+ };
+}
diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json
index c9900dde..be1d92f6 100644
--- a/frontend/src/locales/en.json
+++ b/frontend/src/locales/en.json
@@ -490,6 +490,9 @@
"import_failed": "Import Failed",
"import_from_file": "Import from file",
"export_zip": "Export ZIP",
+ "export_pdf": "Download PDF",
+ "export_pdf_success": "Collection PDF downloaded",
+ "export_pdf_failed": "Failed to download collection PDF",
"export_failed": "Export failed",
"export_success": "Exported collection",
"location_actions": "Location actions",
@@ -968,6 +971,24 @@
"invite_failed": "Invite Failed",
"invite_sent": "Invite Sent"
},
+ "social_share": {
+ "share_externally": "Share Externally",
+ "modal_desc": "Create a shareable image for social media.",
+ "aspect_square": "Square",
+ "aspect_story": "Story",
+ "aspect_landscape": "Landscape",
+ "download_image": "Download Image",
+ "copy_image": "Copy Image",
+ "copy_image_success": "Image copied to clipboard",
+ "copy_image_failed": "Failed to copy image",
+ "share_native": "Share…",
+ "share_native_failed": "Sharing failed",
+ "preview_loading": "Generating preview…",
+ "share_image_failed": "Failed to generate share image",
+ "download_success": "Share image downloaded",
+ "private_notice_collection": "This collection is not public. The QR code and link only work for you and people you've shared the collection with.",
+ "private_notice_location": "This location is not public. The QR code and link only work for you and users with access."
+ },
"languages": {},
"profile": {
"member_since": "Member since",
diff --git a/frontend/src/routes/collections/[id]/+page.server.ts b/frontend/src/routes/collections/[id]/+page.server.ts
index 02e659ce..1ecce247 100644
--- a/frontend/src/routes/collections/[id]/+page.server.ts
+++ b/frontend/src/routes/collections/[id]/+page.server.ts
@@ -2,6 +2,7 @@ import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
const PUBLIC_SERVER_URL = process.env['PUBLIC_SERVER_URL'];
import type { Location, Collection } from '$lib/types';
+import { buildCollectionShareMeta } from '$lib/shareMeta.server';
const endpoint = PUBLIC_SERVER_URL || 'http://localhost:8000';
export const load = (async (event) => {
@@ -17,7 +18,8 @@ export const load = (async (event) => {
return {
props: {
adventure: null
- }
+ },
+ shareMeta: null
};
} else {
let collection = (await request.json()) as Collection;
@@ -25,7 +27,8 @@ export const load = (async (event) => {
return {
props: {
adventure: collection
- }
+ },
+ shareMeta: buildCollectionShareMeta(collection, event.url.origin)
};
}
}) satisfies PageServerLoad;
diff --git a/frontend/src/routes/collections/[id]/+page.svelte b/frontend/src/routes/collections/[id]/+page.svelte
index e184a225..58704c2e 100644
--- a/frontend/src/routes/collections/[id]/+page.svelte
+++ b/frontend/src/routes/collections/[id]/+page.svelte
@@ -31,6 +31,9 @@
import Lightbulb from '~icons/mdi/lightbulb';
import ChartBar from '~icons/mdi/chart-bar';
import Plus from '~icons/mdi/plus';
+ import FilePdfBox from '~icons/mdi/file-pdf-box';
+ import ImageOutline from '~icons/mdi/image-outline';
+ import SocialShareModal from '$lib/components/SocialShareModal.svelte';
import { addToast } from '$lib/toasts';
import NoteModal from '$lib/components/NoteModal.svelte';
import ChecklistModal from '$lib/components/ChecklistModal.svelte';
@@ -255,6 +258,37 @@
// Enforce recommendations visibility only for owner/shared users
$: availableViews.recommendations = !!canModifyCollection;
+ let isExportingPdf = false;
+ let isSocialShareModalOpen = false;
+
+ async function exportCollectionPdf() {
+ if (!collection || isExportingPdf) return;
+ isExportingPdf = true;
+ try {
+ const res = await fetch(`/api/collections/${collection.id}/export-pdf/`);
+ if (!res.ok) {
+ addToast('error', $t('adventures.export_pdf_failed'));
+ return;
+ }
+ const blob = await res.blob();
+ const safeName =
+ String(collection.name)
+ .replace(/[^\w\s-]/g, '')
+ .replace(/\s+/g, '_') || 'collection';
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `${safeName}_itinerary.pdf`;
+ a.click();
+ URL.revokeObjectURL(url);
+ addToast('success', $t('adventures.export_pdf_success'));
+ } catch {
+ addToast('error', $t('adventures.export_pdf_failed'));
+ } finally {
+ isExportingPdf = false;
+ }
+ }
+
// Build calendar events from collection visits
type TimezoneMode = 'event' | 'local';
@@ -816,6 +850,16 @@
/>
{/if}
+{#if isSocialShareModalOpen && collection}
+
(isSocialShareModalOpen = false)}
+ />
+{/if}
+
{#if isLocationLinkModalOpen && collection}
-
+
{#if availableViews.all}
+
+
+
+
@@ -1609,5 +1671,18 @@
{collection && collection.name ? `${collection.name}` : 'Collection'}
-
+ {#if data.shareMeta}
+
+
+
+
+
+
+
+
+
+
+ {:else}
+
+ {/if}
diff --git a/frontend/src/routes/locations/[id]/+page.server.ts b/frontend/src/routes/locations/[id]/+page.server.ts
index 194770bb..2c3dc9bb 100644
--- a/frontend/src/routes/locations/[id]/+page.server.ts
+++ b/frontend/src/routes/locations/[id]/+page.server.ts
@@ -1,6 +1,7 @@
import type { PageServerLoad } from './$types';
const PUBLIC_SERVER_URL = process.env['PUBLIC_SERVER_URL'];
import type { AdditionalLocation, Location, Collection } from '$lib/types';
+import { buildLocationShareMeta } from '$lib/shareMeta.server';
const endpoint = PUBLIC_SERVER_URL || 'http://localhost:8000';
export const load = (async (event) => {
@@ -16,7 +17,8 @@ export const load = (async (event) => {
return {
props: {
adventure: null
- }
+ },
+ shareMeta: null
};
} else {
let adventure = (await request.json()) as AdditionalLocation;
@@ -24,7 +26,8 @@ export const load = (async (event) => {
return {
props: {
adventure
- }
+ },
+ shareMeta: buildLocationShareMeta(adventure, event.url.origin)
};
}
}) satisfies PageServerLoad;
diff --git a/frontend/src/routes/locations/[id]/+page.svelte b/frontend/src/routes/locations/[id]/+page.svelte
index 7d6fe6f2..ba37eca7 100644
--- a/frontend/src/routes/locations/[id]/+page.svelte
+++ b/frontend/src/routes/locations/[id]/+page.svelte
@@ -29,6 +29,8 @@
import ExternalMapLinks from '$lib/components/shared/ExternalMapLinks.svelte';
import MapFloatingControls from '$lib/components/map/MapFloatingControls.svelte';
import MapTrackLayerControls from '$lib/components/map/MapTrackLayerControls.svelte';
+ import ImageOutline from '~icons/mdi/image-outline';
+ import SocialShareModal from '$lib/components/SocialShareModal.svelte';
const renderMarkdown = (markdown: string) => {
return marked(markdown) as string;
@@ -57,6 +59,7 @@
let notFound: boolean = false;
let isEditModalOpen: boolean = false;
+ let isSocialShareModalOpen: boolean = false;
let adventure_images: { image: string; adventure: AdditionalLocation | null }[] = [];
let modalInitialIndex: number = 0;
let isImageModalOpen: boolean = false;
@@ -210,6 +213,16 @@
/>
{/if}
+{#if isSocialShareModalOpen && adventure}
+
(isSocialShareModalOpen = false)}
+ />
+{/if}
+
{#if !adventure && !notFound}
@@ -244,6 +257,18 @@
{$t('adventures.edit_location')}
+
+
+