feat: implement lodging detail page with server-side loading and image modal functionality

- Added a new server-side load function to fetch lodging details by ID.
- Created a new Svelte component for the lodging detail page, including image carousel and map integration.
- Implemented a modal for displaying images with navigation.
- Enhanced URL handling in the locations page to only read parameters.
This commit is contained in:
Sean Morley
2025-12-26 13:21:03 -05:00
parent c8cedcd9db
commit b660f4f042
7 changed files with 1748 additions and 1126 deletions

View File

@@ -4,7 +4,7 @@ from django.db import transaction
from rest_framework import viewsets
from rest_framework.decorators import action
from rest_framework.response import Response
from adventures.models import Collection, Location, Transportation, Note, Checklist, CollectionInvite, ContentImage, CollectionItineraryItem
from adventures.models import Collection, Location, Transportation, Note, Checklist, CollectionInvite, ContentImage, CollectionItineraryItem, Lodging
from adventures.permissions import CollectionShared
from adventures.serializers import CollectionSerializer, CollectionInviteSerializer, UltraSlimCollectionSerializer, CollectionItineraryItemSerializer
from users.models import CustomUser as User
@@ -268,11 +268,12 @@ class CollectionViewSet(viewsets.ModelViewSet):
# Bulk update those locations
Location.objects.filter(id__in=location_ids_to_set_private).update(is_public=False)
# Update transportations, notes, and checklists related to this collection
# Update transportations, notes, checklists, and lodgings related to this collection
# These still use direct ForeignKey relationships
Transportation.objects.filter(collection=instance).update(is_public=new_public_status)
Note.objects.filter(collection=instance).update(is_public=new_public_status)
Checklist.objects.filter(collection=instance).update(is_public=new_public_status)
Lodging.objects.filter(collection=instance).update(is_public=new_public_status)
# Log the action (optional)
action = "public" if new_public_status else "private"

View File

@@ -18,6 +18,8 @@
import MapMarker from '~icons/mdi/map-marker';
import DotsHorizontal from '~icons/mdi/dots-horizontal';
import CalendarRemove from '~icons/mdi/calendar-remove';
import Launch from '~icons/mdi/launch';
import { goto } from '$app/navigation';
import type { CollectionItineraryItem } from '$lib/types';
const dispatch = createEventDispatcher();
@@ -132,47 +134,62 @@
<div class="card-body p-4 space-y-3">
<!-- Header -->
<div class="flex items-start justify-between gap-3">
<h2 class="text-lg font-semibold line-clamp-2">{lodging.name}</h2>
<a
href="/lodging/{lodging.id}"
class="hover:text-primary transition-colors duration-200 line-clamp-2 text-lg font-semibold"
>
{lodging.name}
</a>
{#if !readOnly && (lodging.user == user?.uuid || (collection && user && collection.shared_with?.includes(user.uuid)))}
<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" />
</summary>
<ul
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">
<FileDocumentEdit class="w-4 h-4" />
{$t('transportation.edit')}
</button>
</li>
{#if itineraryItem && itineraryItem.id}
<div class="flex items-center gap-2">
<button
class="btn btn-sm p-1 text-base-content"
aria-label="open-details"
on:click={() => goto(`/lodging/${lodging.id}`)}
>
<Launch class="w-4 h-4" />
</button>
{#if !readOnly && (lodging.user == user?.uuid || (collection && user && collection.shared_with?.includes(user.uuid)))}
<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" />
</summary>
<ul
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">
<FileDocumentEdit class="w-4 h-4" />
{$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
on:click={() => removeFromItinerary()}
class="text-error flex items-center gap-2"
on:click={() => (isWarningModalOpen = true)}
>
<CalendarRemove class="w-4 h-4 text-error" />
{$t('itinerary.remove_from_itinerary')}
<TrashCanOutline class="w-4 h-4" />
{$t('adventures.delete')}
</button>
</li>
{/if}
<div class="divider my-1"></div>
<li>
<button
class="text-error flex items-center gap-2"
on:click={() => (isWarningModalOpen = true)}
>
<TrashCanOutline class="w-4 h-4" />
{$t('adventures.delete')}
</button>
</li>
</ul>
</details>
{/if}
</ul>
</details>
{/if}
</div>
</div>
<!-- Location Info (Compact) -->

View File

@@ -521,7 +521,7 @@
}
// Optionally show success feedback
console.log('Itinerary order saved successfully');
// console.log('Itinerary order saved successfully');
// Make sure to sync the collection.itinerary with the new order
const updatedItinerary = collection.itinerary?.map((it) => {
const updatedItem = itemsToUpdate.find((upd) => upd.id === it.id);
@@ -899,64 +899,101 @@
{#if canModify}
<div class="dropdown z-[9999]">
<label tabindex="0" class="btn btn-sm btn-outline gap-2">Add</label>
<button
type="button"
class="btn btn-sm btn-outline gap-2"
aria-haspopup="menu"
aria-expanded="false"
>
Add
</button>
<ul
tabindex="0"
class="dropdown-content menu p-2 shadow bg-base-300 rounded-box w-56"
role="menu"
>
<li>
<a
<button
type="button"
role="menuitem"
class="w-full text-left"
on:click={() => {
linkModalTargetDate = day.date;
linkModalDisplayDate = day.displayDate;
isItineraryLinkModalOpen = true;
}}>Link existing item</a
}}
>
Link existing item
</button>
</li>
<li class="menu-title">Create new</li>
<li>
<a
<button
type="button"
role="menuitem"
class="w-full text-left"
on:click={() => {
pendingAddDate = day.date;
locationToEdit = null;
locationBeingUpdated = null;
isLocationModalOpen = true;
}}>Location</a
}}
>
Location
</button>
</li>
<li>
<a
<button
type="button"
role="menuitem"
class="w-full text-left"
on:click={() => {
pendingAddDate = day.date;
lodgingToEdit = null;
lodgingBeingUpdated = null;
isLodgingModalOpen = true;
}}>Lodging</a
}}
>
Lodging
</button>
</li>
<li>
<a
<button
type="button"
role="menuitem"
class="w-full text-left"
on:click={() => {
pendingAddDate = day.date;
isTransportationModalOpen = true;
}}>Transportation</a
}}
>
Transportation
</button>
</li>
<li>
<a
<button
type="button"
role="menuitem"
class="w-full text-left"
on:click={() => {
pendingAddDate = day.date;
isNoteModalOpen = true;
}}>Note</a
}}
>
Note
</button>
</li>
<li>
<a
<button
type="button"
role="menuitem"
class="w-full text-left"
on:click={() => {
pendingAddDate = day.date;
isChecklistModalOpen = true;
}}>Checklist</a
}}
>
Checklist
</button>
</li>
</ul>
</div>

View File

File diff suppressed because it is too large Load Diff

View File

@@ -58,25 +58,15 @@
let isLocationModalOpen: boolean = false;
let sidebarOpen = false;
// Reactive statements
$: {
if (typeof window !== 'undefined') {
let url = new URL(window.location.href);
if (typeString) {
url.searchParams.set('types', typeString);
} else {
url.searchParams.delete('types');
}
goto(url.toString(), { invalidateAll: true, replaceState: true });
}
}
// Reactive statements - Only read from URL, don't write
$: {
if (typeof window !== 'undefined') {
let url = new URL(window.location.href);
let types = url.searchParams.get('types');
if (types) {
typeString = types;
} else {
typeString = '';
}
}
}

View File

@@ -0,0 +1,76 @@
import type { PageServerLoad } from './$types';
const PUBLIC_SERVER_URL = process.env['PUBLIC_SERVER_URL'];
import type { Lodging } from '$lib/types';
const endpoint = PUBLIC_SERVER_URL || 'http://localhost:8000';
export const load = (async (event) => {
const id = event.params as { id: string };
let request = await fetch(`${endpoint}/api/lodging/${id.id}/`, {
headers: {
Cookie: `sessionid=${event.cookies.get('sessionid')}`
},
credentials: 'include'
});
if (!request.ok) {
console.error('Failed to fetch lodging ' + id.id);
return {
props: {
lodging: null
}
};
} else {
let lodging = (await request.json()) as Lodging;
return {
props: {
lodging
}
};
}
}) satisfies PageServerLoad;
import { redirect, type Actions } from '@sveltejs/kit';
import { fetchCSRFToken } from '$lib/index.server';
const serverEndpoint = PUBLIC_SERVER_URL || 'http://localhost:8000';
export const actions: Actions = {
delete: async (event) => {
const id = event.params as { id: string };
const lodgingId = id.id;
if (!event.locals.user) {
return redirect(302, '/login');
}
if (!lodgingId) {
return {
status: 400,
error: new Error('Bad request')
};
}
let csrfToken = await fetchCSRFToken();
let res = await fetch(`${serverEndpoint}/api/lodging/${event.params.id}`, {
method: 'DELETE',
headers: {
Referer: event.url.origin,
Cookie: `sessionid=${event.cookies.get('sessionid')};
csrftoken=${csrfToken}`,
'X-CSRFToken': csrfToken
},
credentials: 'include'
});
console.log(res);
if (!res.ok) {
return {
status: res.status,
error: new Error('Failed to delete lodging')
};
} else {
return {
status: 204
};
}
}
};

View File

@@ -0,0 +1,500 @@
<script lang="ts">
import type { Lodging } from '$lib/types';
import { onMount } from 'svelte';
import type { PageData } from './$types';
import { goto } from '$app/navigation';
import Lost from '$lib/assets/undraw_lost.svg';
import { DefaultMarker, MapLibre, Popup } from 'svelte-maplibre';
import { t } from 'svelte-i18n';
import { marked } from 'marked';
import DOMPurify from 'dompurify';
// @ts-ignore
import { DateTime } from 'luxon';
import ClipboardList from '~icons/mdi/clipboard-list';
import ImageDisplayModal from '$lib/components/ImageDisplayModal.svelte';
import AttachmentCard from '$lib/components/cards/AttachmentCard.svelte';
import { getBasemapUrl, isAllDay, LODGING_TYPES_ICONS } from '$lib';
import Star from '~icons/mdi/star';
import StarOutline from '~icons/mdi/star-outline';
import MapMarker from '~icons/mdi/map-marker';
import CalendarRange from '~icons/mdi/calendar-range';
import Eye from '~icons/mdi/eye';
import EyeOff from '~icons/mdi/eye-off';
import OpenInNew from '~icons/mdi/open-in-new';
import CashMultiple from '~icons/mdi/cash-multiple';
import CardAccountDetails from '~icons/mdi/card-account-details';
import CardCarousel from '$lib/components/CardCarousel.svelte';
import { formatDateInTimezone, formatAllDayDate } from '$lib/dateUtils';
const renderMarkdown = (markdown: string) => {
return marked(markdown) as string;
};
export let data: PageData;
console.log(data);
let lodging: Lodging;
let currentSlide = 0;
function goToSlide(index: number) {
currentSlide = index;
}
let notFound: boolean = false;
let lodging_images: { image: string; lodging: Lodging | null }[] = [];
let modalInitialIndex: number = 0;
let isImageModalOpen: boolean = false;
function getLodgingIcon(type: string) {
if (type in LODGING_TYPES_ICONS) {
return LODGING_TYPES_ICONS[type as keyof typeof LODGING_TYPES_ICONS];
} else {
return '🏨';
}
}
function renderStars(rating: number) {
const stars = [];
for (let i = 1; i <= 5; i++) {
stars.push(i <= rating);
}
return stars;
}
onMount(async () => {
if (data.props.lodging) {
lodging = data.props.lodging;
lodging.images.sort((a, b) => {
if (a.is_primary && !b.is_primary) {
return -1;
} else if (!a.is_primary && b.is_primary) {
return 1;
} else {
return 0;
}
});
} else {
notFound = true;
}
});
function closeImageModal() {
isImageModalOpen = false;
}
function openImageModal(imageIndex: number) {
lodging_images = lodging.images.map((img) => ({
image: img.image,
lodging: lodging
}));
modalInitialIndex = imageIndex;
isImageModalOpen = true;
}
function formatCheckInOut(
checkIn: string | null,
checkOut: string | null,
timezone: string | null
) {
if (!checkIn && !checkOut) return null;
const formatDate = (date: string | null) => {
if (!date) return '';
if (isAllDay(date)) {
return formatAllDayDate(date);
} else {
return formatDateInTimezone(date, timezone);
}
};
if (checkIn && checkOut) {
return `${formatDate(checkIn)}${formatDate(checkOut)}`;
} else if (checkIn) {
return `Check-in: ${formatDate(checkIn)}`;
} else if (checkOut) {
return `Check-out: ${formatDate(checkOut)}`;
}
return null;
}
function calculateNights(checkIn: string | null, checkOut: string | null): number | null {
if (!checkIn || !checkOut) return null;
const start = DateTime.fromISO(checkIn);
const end = DateTime.fromISO(checkOut);
if (!start.isValid || !end.isValid) return null;
return Math.ceil(end.diff(start, 'days').days);
}
</script>
{#if notFound}
<div class="hero min-h-screen bg-gradient-to-br from-base-200 to-base-300 overflow-x-hidden">
<div class="hero-content text-center">
<div class="max-w-md">
<img src={Lost} alt="Lost" class="w-64 mx-auto mb-8 opacity-80" />
<h1 class="text-5xl font-bold text-primary mb-4">{$t('adventures.lodging_not_found')}</h1>
<p class="text-lg opacity-70 mb-8">{$t('adventures.location_not_found_desc')}</p>
<button class="btn btn-primary btn-lg" on:click={() => goto('/')}>
{$t('adventures.homepage')}
</button>
</div>
</div>
</div>
{/if}
{#if isImageModalOpen}
<ImageDisplayModal
images={lodging.images}
initialIndex={modalInitialIndex}
location={lodging.location ?? ''}
on:close={closeImageModal}
/>
{/if}
{#if !lodging && !notFound}
<div class="hero min-h-screen overflow-x-hidden">
<div class="hero-content">
<span class="loading loading-spinner w-24 h-24 text-primary"></span>
</div>
</div>
{/if}
{#if lodging}
<!-- Hero Section -->
<div class="relative">
<div
class="hero min-h-[60vh] relative overflow-hidden"
class:min-h-[30vh]={!lodging.images || lodging.images.length === 0}
>
<!-- Background: Images or Gradient -->
{#if lodging.images && lodging.images.length > 0}
<div class="hero-overlay bg-gradient-to-t from-black/70 via-black/20 to-transparent"></div>
{#each lodging.images as image, i}
<div
class="absolute inset-0 transition-opacity duration-500"
class:opacity-100={i === currentSlide}
class:opacity-0={i !== currentSlide}
>
<button
class="w-full h-full p-0 bg-transparent border-0"
on:click={() => openImageModal(i)}
aria-label={`View full image of ${lodging.name}`}
>
<img src={image.image} class="w-full h-full object-cover" alt={lodging.name} />
</button>
</div>
{/each}
{:else}
<div class="absolute inset-0 bg-gradient-to-br from-primary/20 to-secondary/20"></div>
{/if}
<!-- Content -->
<div
class="hero-content relative z-10 text-center"
class:text-white={lodging.images?.length > 0}
>
<div class="max-w-4xl">
<div class="flex justify-center items-center gap-3 mb-4">
<span class="text-5xl">{getLodgingIcon(lodging.type)}</span>
<h1 class="text-6xl font-bold drop-shadow-lg">{lodging.name}</h1>
</div>
<!-- Rating -->
{#if lodging.rating !== undefined && lodging.rating !== null}
<div class="flex justify-center mb-6">
<div class="rating rating-lg">
{#each Array.from({ length: 5 }, (_, i) => i + 1) as star}
<input
type="radio"
name="rating-hero"
class="mask mask-star-2 bg-warning"
checked={star <= lodging.rating}
disabled
/>
{/each}
</div>
</div>
{/if}
<!-- Quick Info Badges -->
<div class="flex flex-wrap justify-center gap-4 mb-6">
{#if lodging.type}
<div class="badge badge-lg badge-primary font-semibold px-4 py-3">
{$t(`lodging.${lodging.type}`)}
</div>
{/if}
{#if lodging.location}
<div class="badge badge-lg badge-secondary font-semibold px-4 py-3">
📍 {lodging.location}
</div>
{/if}
{#if lodging.is_public}
<div class="badge badge-lg badge-accent font-semibold px-4 py-3">
👁️ {$t('adventures.public')}
</div>
{:else}
<div class="badge badge-lg badge-ghost font-semibold px-4 py-3">
🔒 {$t('adventures.private')}
</div>
{/if}
</div>
<!-- Image Navigation (only shown when multiple images exist) -->
{#if lodging.images && lodging.images.length > 1}
<div class="w-full max-w-md mx-auto">
<!-- Navigation arrows and current position -->
<div class="flex items-center justify-center gap-4 mb-3">
<button
on:click={() =>
goToSlide(currentSlide > 0 ? currentSlide - 1 : lodging.images.length - 1)}
class="btn btn-circle btn-sm btn-primary"
aria-label={$t('adventures.previous_image')}
>
</button>
<div class="text-sm font-medium bg-black/50 px-3 py-1 rounded-full">
{currentSlide + 1} / {lodging.images.length}
</div>
<button
on:click={() =>
goToSlide(currentSlide < lodging.images.length - 1 ? currentSlide + 1 : 0)}
class="btn btn-circle btn-sm btn-primary"
aria-label={$t('adventures.next_image')}
>
</button>
</div>
<!-- Dot navigation -->
{#if lodging.images.length <= 12}
<div class="flex justify-center gap-2 flex-wrap">
{#each lodging.images as _, i}
<button
on:click={() => goToSlide(i)}
class="btn btn-circle btn-xs transition-all duration-200"
class:btn-primary={i === currentSlide}
class:btn-outline={i !== currentSlide}
class:opacity-50={i !== currentSlide}
>
{i + 1}
</button>
{/each}
</div>
{:else}
<div class="relative">
<div
class="absolute left-0 top-0 bottom-2 w-4 bg-gradient-to-r from-black/30 to-transparent pointer-events-none"
></div>
<div
class="absolute right-0 top-0 bottom-2 w-4 bg-gradient-to-l from-black/30 to-transparent pointer-events-none"
></div>
</div>
{/if}
</div>
{/if}
</div>
</div>
</div>
</div>
<!-- Main Content -->
<div class="container mx-auto px-2 sm:px-4 py-6 sm:py-8 max-w-7xl">
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4 sm:gap-8">
<!-- Left Column - Main Content -->
<div class="lg:col-span-2 space-y-6 sm:space-y-8">
<!-- Description Card -->
{#if lodging.description}
<div class="card bg-base-200 shadow-xl">
<div class="card-body">
<h2 class="card-title text-2xl mb-4">📝 {$t('adventures.description')}</h2>
<article class="prose max-w-none">
{@html DOMPurify.sanitize(renderMarkdown(lodging.description))}
</article>
</div>
</div>
{/if}
<!-- Map Section -->
{#if lodging.latitude && lodging.longitude}
<div class="card bg-base-200 shadow-xl">
<div class="card-body">
<h2 class="card-title text-2xl mb-4">🗺️ {$t('adventures.location')}</h2>
<div class="rounded-lg overflow-hidden shadow-lg">
<MapLibre
style={getBasemapUrl()}
class="w-full h-96"
standardControls
center={[lodging.longitude, lodging.latitude]}
zoom={13}
>
<DefaultMarker lngLat={[lodging.longitude, lodging.latitude]}>
<Popup openOn="click" offset={[0, -10]}>
<div class="p-2">
<div class="text-lg font-bold text-black mb-1">{lodging.name}</div>
<p class="font-semibold text-black text-sm mb-2">
{$t(`lodging.${lodging.type}`)}
{getLodgingIcon(lodging.type)}
</p>
{#if lodging.rating}
<div class="flex items-center gap-1 mb-2">
{#each renderStars(lodging.rating) as filled}
{#if filled}
<Star class="w-4 h-4 text-warning fill-current" />
{:else}
<StarOutline class="w-4 h-4 text-gray-400" />
{/if}
{/each}
<span class="text-xs text-black ml-1">({lodging.rating}/5)</span>
</div>
{/if}
{#if lodging.location}
<div class="text-xs text-black">
📍 {lodging.location}
</div>
{/if}
</div>
</Popup>
</DefaultMarker>
</MapLibre>
</div>
{#if lodging.location}
<p class="mt-4 text-base-content/70 flex items-center gap-2">
<MapMarker class="w-5 h-5" />
{lodging.location}
</p>
{/if}
</div>
</div>
{/if}
</div>
<!-- Right Column - Sidebar -->
<div class="space-y-4 sm:space-y-6">
<!-- Quick Info Card -->
<div class="card bg-base-200 shadow-xl">
<div class="card-body">
<h2 class="card-title text-xl mb-4"> {$t('adventures.details')}</h2>
<div class="space-y-4">
<!-- Check-in/Check-out -->
{#if lodging.check_in || lodging.check_out}
<div class="flex items-start gap-3">
<CalendarRange class="w-5 h-5 text-primary mt-1 flex-shrink-0" />
<div>
<p class="font-semibold text-sm opacity-70">{$t('adventures.stay_dates')}</p>
<p class="text-base">
{formatCheckInOut(lodging.check_in, lodging.check_out, lodging.timezone)}
</p>
{#if calculateNights(lodging.check_in, lodging.check_out)}
<p class="text-sm opacity-70 mt-1">
{calculateNights(lodging.check_in, lodging.check_out)}
{$t('adventures.nights')}
</p>
{/if}
</div>
</div>
{/if}
<!-- Type -->
<div class="flex items-start gap-3">
<span class="text-xl mt-1 flex-shrink-0">{getLodgingIcon(lodging.type)}</span>
<div>
<p class="font-semibold text-sm opacity-70">{$t('transportation.type')}</p>
<p class="text-base">{$t(`lodging.${lodging.type}`)}</p>
</div>
</div>
<!-- Reservation Number -->
{#if lodging.reservation_number}
<div class="flex items-start gap-3">
<CardAccountDetails class="w-5 h-5 text-primary mt-1 flex-shrink-0" />
<div>
<p class="font-semibold text-sm opacity-70">{$t('adventures.reservation')}</p>
<p class="text-base font-mono">{lodging.reservation_number}</p>
</div>
</div>
{/if}
<!-- Price -->
{#if lodging.price}
<div class="flex items-start gap-3">
<CashMultiple class="w-5 h-5 text-primary mt-1 flex-shrink-0" />
<div>
<p class="font-semibold text-sm opacity-70">{$t('adventures.price')}</p>
<p class="text-base">{lodging.price}</p>
</div>
</div>
{/if}
<!-- Link -->
{#if lodging.link}
<div class="flex items-start gap-3">
<OpenInNew class="w-5 h-5 text-primary mt-1 flex-shrink-0" />
<div class="flex-1">
<p class="font-semibold text-sm opacity-70 mb-1">{$t('adventures.link')}</p>
<a
href={lodging.link}
target="_blank"
rel="noopener noreferrer"
class="link link-primary text-base break-all"
>
{lodging.link}
</a>
</div>
</div>
{/if}
</div>
</div>
</div>
<!-- Additional Images -->
{#if lodging.images && lodging.images.length > 0}
<div class="card bg-base-200 shadow-xl">
<div class="card-body">
<h2 class="card-title text-xl mb-4">🖼️ {$t('adventures.images')}</h2>
<div class="grid grid-cols-2 gap-2">
{#each lodging.images as image, i}
<button
class="aspect-square rounded-lg overflow-hidden hover:opacity-80 transition-opacity"
on:click={() => openImageModal(i)}
>
<img
src={image.image}
alt={`${lodging.name} - ${i + 1}`}
class="w-full h-full object-cover"
/>
</button>
{/each}
</div>
</div>
</div>
{/if}
<!-- Attachments -->
{#if lodging.attachments && lodging.attachments.length > 0}
<div class="card bg-base-200 shadow-xl">
<div class="card-body">
<h2 class="card-title text-xl mb-4">📎 {$t('adventures.attachments')}</h2>
<div class="space-y-2">
{#each lodging.attachments as attachment}
<AttachmentCard {attachment} />
{/each}
</div>
</div>
</div>
{/if}
</div>
</div>
</div>
{/if}
<svelte:head>
<title>
{data.props.lodging && data.props.lodging.name ? `${data.props.lodging.name}` : 'Lodging'}
</title>
<meta name="description" content="View lodging details" />
</svelte:head>