mirror of
https://github.com/seanmorley15/AdventureLog.git
synced 2026-05-08 23:15:11 -04:00
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:
@@ -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"
|
||||
|
||||
@@ -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) -->
|
||||
|
||||
@@ -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>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
76
frontend/src/routes/lodging/[id]/+page.server.ts
Normal file
76
frontend/src/routes/lodging/[id]/+page.server.ts
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
500
frontend/src/routes/lodging/[id]/+page.svelte
Normal file
500
frontend/src/routes/lodging/[id]/+page.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user