1
0
Fork 0
mirror of https://github.com/seanmorley15/AdventureLog.git synced 2025-07-19 12:59:36 +02:00

feat: Refactor hotel terminology to lodging and update related components

This commit is contained in:
Sean Morley 2025-02-08 16:10:01 -05:00
parent d2cb862103
commit 68924d7ecc
17 changed files with 510 additions and 135 deletions

View file

@ -0,0 +1,23 @@
# Generated by Django 5.0.8 on 2025-02-08 01:52
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('achievements', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='achievement',
name='key',
field=models.CharField(default='achievements.other', max_length=255, unique=True),
),
migrations.AddField(
model_name='achievement',
name='type',
field=models.CharField(choices=[('adventure_count', 'adventure_count'), ('country_count', 'country_count')], default='adventure_count', max_length=255),
),
]

View file

@ -1,3 +1,4 @@
import uuid
from django.db import models
from django.contrib.auth import get_user_model
@ -11,8 +12,8 @@ VALID_ACHIEVEMENT_TYPES = [
class Achievement(models.Model):
"""Stores all possible achievements"""
name = models.CharField(max_length=255, unique=True)
key = models.CharField(max_length=255, unique=True) # Used for frontend lookups, e.g. "achievements.first_adventure"
type = models.CharField(max_length=255) # adventure_count, country_count, etc.
key = models.CharField(max_length=255, unique=True, default='achievements.other') # Used for frontend lookups, e.g. "achievements.first_adventure"
type = models.CharField(max_length=255, choices=[(tag, tag) for tag in VALID_ACHIEVEMENT_TYPES], default='adventure_count') # adventure_count, country_count, etc.
description = models.TextField()
icon = models.ImageField(upload_to="achievements/", null=True, blank=True)
condition = models.JSONField() # Stores rules like {"type": "adventure_count", "value": 10}

View file

@ -1,7 +1,7 @@
import os
from django.contrib import admin
from django.utils.html import mark_safe
from .models import Adventure, Checklist, ChecklistItem, Collection, Transportation, Note, AdventureImage, Visit, Category, Attachment, Hotel
from .models import Adventure, Checklist, ChecklistItem, Collection, Transportation, Note, AdventureImage, Visit, Category, Attachment, Lodging
from worldtravel.models import Country, Region, VisitedRegion, City, VisitedCity
from allauth.account.decorators import secure_admin_login
@ -140,7 +140,7 @@ admin.site.register(Category, CategoryAdmin)
admin.site.register(City, CityAdmin)
admin.site.register(VisitedCity)
admin.site.register(Attachment)
admin.site.register(Hotel)
admin.site.register(Lodging)
admin.site.site_header = 'AdventureLog Admin'
admin.site.site_title = 'AdventureLog Admin Site'

View file

@ -0,0 +1,43 @@
# Generated by Django 5.0.8 on 2025-02-08 01:50
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('adventures', '0022_hotel'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Lodging',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
('name', models.CharField(max_length=200)),
('type', models.CharField(choices=[('hotel', 'Hotel'), ('hostel', 'Hostel'), ('resort', 'Resort'), ('bnb', 'Bed & Breakfast'), ('campground', 'Campground'), ('cabin', 'Cabin'), ('apartment', 'Apartment'), ('house', 'House'), ('villa', 'Villa'), ('motel', 'Motel'), ('other', 'Other')], default='other', max_length=100)),
('description', models.TextField(blank=True, null=True)),
('rating', models.FloatField(blank=True, null=True)),
('link', models.URLField(blank=True, max_length=2083, null=True)),
('check_in', models.DateTimeField(blank=True, null=True)),
('check_out', models.DateTimeField(blank=True, null=True)),
('reservation_number', models.CharField(blank=True, max_length=100, null=True)),
('price', models.DecimalField(blank=True, decimal_places=2, max_digits=9, null=True)),
('latitude', models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True)),
('longitude', models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True)),
('location', models.CharField(blank=True, max_length=200, null=True)),
('is_public', models.BooleanField(default=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('collection', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='adventures.collection')),
('user_id', models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
migrations.DeleteModel(
name='Hotel',
),
]

View file

@ -36,6 +36,20 @@ ADVENTURE_TYPES = [
('other', 'Other')
]
LODGING_TYPES = [
('hotel', 'Hotel'),
('hostel', 'Hostel'),
('resort', 'Resort'),
('bnb', 'Bed & Breakfast'),
('campground', 'Campground'),
('cabin', 'Cabin'),
('apartment', 'Apartment'),
('house', 'House'),
('villa', 'Villa'),
('motel', 'Motel'),
('other', 'Other')
]
TRANSPORTATION_TYPES = [
('car', 'Car'),
('plane', 'Plane'),
@ -320,11 +334,12 @@ class Category(models.Model):
def __str__(self):
return self.name + ' - ' + self.display_name + ' - ' + self.icon
class Hotel(models.Model):
class Lodging(models.Model):
id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True, primary_key=True)
user_id = models.ForeignKey(
User, on_delete=models.CASCADE, default=default_user_id)
name = models.CharField(max_length=200)
type = models.CharField(max_length=100, choices=LODGING_TYPES, default='other')
description = models.TextField(blank=True, null=True)
rating = models.FloatField(blank=True, null=True)
link = models.URLField(blank=True, null=True, max_length=2083)
@ -346,9 +361,9 @@ class Hotel(models.Model):
if self.collection:
if self.collection.is_public and not self.is_public:
raise ValidationError('Hotels associated with a public collection must be public. Collection: ' + self.collection.name + ' Hotel: ' + self.name)
raise ValidationError('Lodging associated with a public collection must be public. Collection: ' + self.collection.name + ' Loging: ' + self.name)
if self.user_id != self.collection.user_id:
raise ValidationError('Hotels must be associated with collections owned by the same user. Collection owner: ' + self.collection.user_id.username + ' Hotel owner: ' + self.user_id.username)
raise ValidationError('Lodging must be associated with collections owned by the same user. Collection owner: ' + self.collection.user_id.username + ' Lodging owner: ' + self.user_id.username)
def __str__(self):
return self.name

View file

@ -1,6 +1,6 @@
from django.utils import timezone
import os
from .models import Adventure, AdventureImage, ChecklistItem, Collection, Note, Transportation, Checklist, Visit, Category, Attachment, Hotel
from .models import Adventure, AdventureImage, ChecklistItem, Collection, Note, Transportation, Checklist, Visit, Category, Attachment, Lodging
from rest_framework import serializers
from main.utils import CustomModelSerializer
from users.serializers import CustomUserDetailsSerializer
@ -203,14 +203,14 @@ class TransportationSerializer(CustomModelSerializer):
]
read_only_fields = ['id', 'created_at', 'updated_at', 'user_id']
class HotelSerializer(CustomModelSerializer):
class LodgingSerializer(CustomModelSerializer):
class Meta:
model = Hotel
model = Lodging
fields = [
'id', 'user_id', 'name', 'description', 'rating', 'link', 'check_in', 'check_out',
'reservation_number', 'price', 'latitude', 'longitude', 'location', 'is_public',
'collection', 'created_at', 'updated_at'
'collection', 'created_at', 'updated_at', 'type'
]
read_only_fields = ['id', 'created_at', 'updated_at', 'user_id']
@ -300,11 +300,11 @@ class CollectionSerializer(CustomModelSerializer):
transportations = TransportationSerializer(many=True, read_only=True, source='transportation_set')
notes = NoteSerializer(many=True, read_only=True, source='note_set')
checklists = ChecklistSerializer(many=True, read_only=True, source='checklist_set')
hotels = HotelSerializer(many=True, read_only=True, source='hotel_set')
lodging = LodgingSerializer(many=True, read_only=True, source='lodging_set')
class Meta:
model = Collection
fields = ['id', 'description', 'user_id', 'name', 'is_public', 'adventures', 'created_at', 'start_date', 'end_date', 'transportations', 'notes', 'updated_at', 'checklists', 'is_archived', 'shared_with', 'link', 'hotels']
fields = ['id', 'description', 'user_id', 'name', 'is_public', 'adventures', 'created_at', 'start_date', 'end_date', 'transportations', 'notes', 'updated_at', 'checklists', 'is_archived', 'shared_with', 'link', 'lodging']
read_only_fields = ['id', 'created_at', 'updated_at', 'user_id']
def to_representation(self, instance):

View file

@ -18,7 +18,7 @@ router.register(r'ics-calendar', IcsCalendarGeneratorViewSet, basename='ics-cale
router.register(r'overpass', OverpassViewSet, basename='overpass')
router.register(r'search', GlobalSearchView, basename='search')
router.register(r'attachments', AttachmentViewSet, basename='attachments')
router.register(r'hotels', HotelViewSet, basename='hotels')
router.register(r'lodging', LodgingViewSet, basename='lodging')
urlpatterns = [

View file

@ -13,4 +13,4 @@ from .stats_view import *
from .transportation_view import *
from .global_search_view import *
from .attachment_view import *
from .hotel_view import *
from .lodging_view import *

View file

@ -2,21 +2,21 @@ from rest_framework import viewsets, status
from rest_framework.decorators import action
from rest_framework.response import Response
from django.db.models import Q
from adventures.models import Hotel
from adventures.serializers import HotelSerializer
from adventures.models import Lodging
from adventures.serializers import LodgingSerializer
from rest_framework.exceptions import PermissionDenied
from adventures.permissions import IsOwnerOrSharedWithFullAccess
from rest_framework.permissions import IsAuthenticated
class HotelViewSet(viewsets.ModelViewSet):
queryset = Hotel.objects.all()
serializer_class = HotelSerializer
class LodgingViewSet(viewsets.ModelViewSet):
queryset = Lodging.objects.all()
serializer_class = LodgingSerializer
permission_classes = [IsOwnerOrSharedWithFullAccess]
def list(self, request, *args, **kwargs):
if not request.user.is_authenticated:
return Response(status=status.HTTP_403_FORBIDDEN)
queryset = Hotel.objects.filter(
queryset = Lodging.objects.filter(
Q(user_id=request.user.id)
)
serializer = self.get_serializer(queryset, many=True)
@ -26,11 +26,11 @@ class HotelViewSet(viewsets.ModelViewSet):
user = self.request.user
if self.action == 'retrieve':
# For individual adventure retrieval, include public adventures, user's own adventures and shared adventures
return Hotel.objects.filter(
return Lodging.objects.filter(
Q(is_public=True) | Q(user_id=user.id) | Q(collection__shared_with=user.id)
).distinct().order_by('-updated_at')
# For other actions, include user's own adventures and shared adventures
return Hotel.objects.filter(
return Lodging.objects.filter(
Q(user_id=user.id) | Q(collection__shared_with=user.id)
).distinct().order_by('-updated_at')

View file

@ -178,6 +178,8 @@ export default defineConfig({
{ icon: "github", link: "https://github.com/seanmorley15/AdventureLog" },
{ icon: "discord", link: "https://discord.gg/wRbQ9Egr8C" },
{ icon: "buymeacoffee", link: "https://buymeacoffee.com/seanmorley15" },
{ icon: "x", link: "https://x.com/AdventureLogApp" },
{ icon: "mastodon", link: "https://mastodon.social/@adventurelog" },
],
},
});

View file

@ -1,11 +1,11 @@
<script lang="ts">
import { appVersion } from '$lib/config';
import { addToast } from '$lib/toasts';
import type { Adventure, Hotel, OpenStreetMapPlace, Point, ReverseGeocode } from '$lib/types';
import type { Adventure, Lodging, OpenStreetMapPlace, Point, ReverseGeocode } from '$lib/types';
import { t } from 'svelte-i18n';
import { DefaultMarker, MapEvents, MapLibre } from 'svelte-maplibre';
export let item: Adventure | Hotel;
export let item: Adventure | Lodging;
export let triggerMarkVisted: boolean = false;
let reverseGeocodePlace: ReverseGeocode | null = null;
@ -279,40 +279,39 @@ it would also work to just use on:click on the MapLibre component itself. -->
{/each}
</MapLibre>
{#if reverseGeocodePlace}
<div class="mt-2">
<p>
<div class="mt-2 p-4 bg-neutral rounded-lg shadow-md">
<h3 class="text-lg font-bold mb-2">{$t('adventures.location_details')}</h3>
<p class="mb-1">
<span class="font-semibold">{$t('adventures.display_name')}:</span>
{reverseGeocodePlace.city
? reverseGeocodePlace.city + ', '
: ''}{reverseGeocodePlace.region},
{reverseGeocodePlace.country}
: ''}{reverseGeocodePlace.region}, {reverseGeocodePlace.country}
</p>
<p>
{reverseGeocodePlace.region}:
{reverseGeocodePlace.region_visited
? $t('adventures.visited')
: $t('adventures.not_visited')}
<p class="mb-1">
<span class="font-semibold">{$t('adventures.region')}:</span>
{reverseGeocodePlace.region}
{reverseGeocodePlace.region_visited ? '✅' : '❌'}
</p>
{#if reverseGeocodePlace.city}
<p>
{reverseGeocodePlace.city}:
{reverseGeocodePlace.city_visited
? $t('adventures.visited')
: $t('adventures.not_visited')}
<p class="mb-1">
<span class="font-semibold">{$t('adventures.city')}:</span>
{reverseGeocodePlace.city}
{reverseGeocodePlace.city_visited ? '✅' : '❌'}
</p>
{/if}
</div>
{#if !reverseGeocodePlace.region_visited || (!reverseGeocodePlace.city_visited && !willBeMarkedVisited)}
<button type="button" class="btn btn-neutral" on:click={markVisited}>
<button type="button" class="btn btn-primary mt-2" on:click={markVisited}>
{$t('adventures.mark_visited')}
</button>
{/if}
{#if (willBeMarkedVisited && !reverseGeocodePlace.region_visited && reverseGeocodePlace.region_id) || (!reverseGeocodePlace.city_visited && willBeMarkedVisited && reverseGeocodePlace.city_id)}
<div role="alert" class="alert alert-info mt-2">
<div role="alert" class="alert alert-info mt-2 flex items-center">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
class="h-6 w-6 shrink-0 stroke-current"
class="h-6 w-6 shrink-0 stroke-current mr-2"
>
<path
stroke-linecap="round"
@ -321,13 +320,12 @@ it would also work to just use on:click on the MapLibre component itself. -->
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
></path>
</svg>
<span
>{reverseGeocodePlace.city
<span>
{reverseGeocodePlace.city
? reverseGeocodePlace.city + ', '
: ''}{reverseGeocodePlace.region},
{reverseGeocodePlace.country}
{$t('adventures.will_be_marked')}</span
>
: ''}{reverseGeocodePlace.region}, {reverseGeocodePlace.country}
{$t('adventures.will_be_marked')}
</span>
</div>
{/if}
{/if}

View file

@ -0,0 +1,176 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import TrashCanOutline from '~icons/mdi/trash-can-outline';
import FileDocumentEdit from '~icons/mdi/file-document-edit';
import type { Collection, Lodging, User } from '$lib/types';
import { addToast } from '$lib/toasts';
import { t } from 'svelte-i18n';
import DeleteWarning from './DeleteWarning.svelte';
// import ArrowDownThick from '~icons/mdi/arrow-down-thick';
const dispatch = createEventDispatcher();
export let lodging: Lodging;
export let user: User | null = null;
export let collection: Collection | null = null;
let isWarningModalOpen: boolean = false;
function editTransportation() {
dispatch('edit', lodging);
}
let unlinked: boolean = false;
$: {
if (collection?.start_date && collection.end_date) {
// Parse transportation dates
let transportationStartDate = lodging.check_in
? new Date(lodging.check_in.split('T')[0]) // Ensure proper date parsing
: null;
let transportationEndDate = lodging.check_out
? new Date(lodging.check_out.split('T')[0])
: null;
// Parse collection dates
let collectionStartDate = new Date(collection.start_date);
let collectionEndDate = new Date(collection.end_date);
// // Debugging outputs
// console.log(
// 'Transportation Start Date:',
// transportationStartDate,
// 'Transportation End Date:',
// transportationEndDate
// );
// console.log(
// 'Collection Start Date:',
// collectionStartDate,
// 'Collection End Date:',
// collectionEndDate
// );
// Check if the collection range is outside the transportation range
const startOutsideRange =
transportationStartDate &&
collectionStartDate < transportationStartDate &&
collectionEndDate < transportationStartDate;
const endOutsideRange =
transportationEndDate &&
collectionStartDate > transportationEndDate &&
collectionEndDate > transportationEndDate;
unlinked = !!(
startOutsideRange ||
endOutsideRange ||
(!transportationStartDate && !transportationEndDate)
);
}
}
async function deleteTransportation() {
let res = await fetch(`/api/lodging/${lodging.id}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json'
}
});
if (!res.ok) {
console.log($t('transportation.transportation_delete_error'));
} else {
addToast('info', $t('transportation.transportation_deleted'));
isWarningModalOpen = false;
dispatch('delete', lodging.id);
}
}
</script>
{#if isWarningModalOpen}
<DeleteWarning
title={$t('adventures.delete_transportation')}
button_text="Delete"
description={$t('adventures.transportation_delete_confirm')}
is_warning={false}
on:close={() => (isWarningModalOpen = false)}
on:confirm={deleteTransportation}
/>
{/if}
<div
class="card w-full max-w-xs sm:max-w-sm md:max-w-md lg:max-w-md xl:max-w-md bg-neutral text-neutral-content shadow-xl"
>
<div class="card-body space-y-4">
<!-- Title and Type -->
<div class="flex items-center justify-between">
<h2 class="card-title text-lg font-semibold truncate">{lodging.name}</h2>
<div class="flex items-center gap-2">
<div class="badge badge-secondary">
{lodging.type}
</div>
<!-- {#if hotel.type == 'plane' && hotel.flight_number}
<div class="badge badge-neutral-200">{hotel.flight_number}</div>
{/if} -->
</div>
</div>
{#if unlinked}
<div class="badge badge-error">{$t('adventures.out_of_range')}</div>
{/if}
<!-- Locations -->
<div class="space-y-2">
{#if lodging.location}
<div class="flex items-center gap-2">
<span class="font-medium text-sm">{$t('adventures.from')}:</span>
<p class="break-words">{lodging.location}</p>
</div>
{/if}
{#if lodging.check_in && lodging.check_out}
<div class="flex items-center gap-2">
<span class="font-medium text-sm">{$t('adventures.start')}:</span>
<p>{new Date(lodging.check_in).toLocaleDateString(undefined, { timeZone: 'UTC' })}</p>
</div>
{/if}
</div>
<!-- Dates -->
<div class="space-y-2">
{#if lodging.location}
<!-- <ArrowDownThick class="w-4 h-4" /> -->
<div class="flex items-center gap-2">
<span class="font-medium text-sm">{$t('adventures.to')}:</span>
<p class="break-words">{lodging.location}</p>
</div>
{/if}
{#if lodging.check_out}
<div class="flex items-center gap-2">
<span class="font-medium text-sm">{$t('adventures.end')}:</span>
<p>{new Date(lodging.check_out).toLocaleDateString(undefined, { timeZone: 'UTC' })}</p>
</div>
{/if}
</div>
<!-- Actions -->
{#if lodging.user_id == user?.uuid || (collection && user && collection.shared_with && collection.shared_with.includes(user.uuid))}
<div class="card-actions justify-end">
<button
class="btn btn-primary btn-sm flex items-center gap-1"
on:click={editTransportation}
title="Edit"
>
<FileDocumentEdit class="w-5 h-5" />
<span>{$t('transportation.edit')}</span>
</button>
<button
on:click={() => (isWarningModalOpen = true)}
class="btn btn-secondary btn-sm flex items-center gap-1"
title="Delete"
>
<TrashCanOutline class="w-5 h-5" />
<span>{$t('adventures.delete')}</span>
</button>
</div>
{/if}
</div>
</div>

View file

@ -3,27 +3,19 @@
import { addToast } from '$lib/toasts';
import { t } from 'svelte-i18n';
import MarkdownEditor from './MarkdownEditor.svelte';
import { appVersion } from '$lib/config';
import { DefaultMarker, MapEvents, MapLibre } from 'svelte-maplibre';
import type { Collection, Hotel, ReverseGeocode, OpenStreetMapPlace, Point } from '$lib/types';
import type { Collection, Lodging } from '$lib/types';
import LocationDropdown from './LocationDropdown.svelte';
const dispatch = createEventDispatcher();
export let collection: Collection;
export let hotelToEdit: Hotel | null = null;
export let lodgingToEdit: Lodging | null = null;
let modal: HTMLDialogElement;
let constrainDates: boolean = false;
let hotel: Hotel = { ...initializeHotel(hotelToEdit) };
let lodging: Lodging = { ...initializeLodging(lodgingToEdit) };
let fullStartDate: string = '';
let fullEndDate: string = '';
let reverseGeocodePlace: any | null = null;
let query: string = '';
let places: OpenStreetMapPlace[] = [];
let noPlaces: boolean = false;
let is_custom_location: boolean = false;
let markers: Point[] = [];
// Format date as local datetime
function toLocalDatetime(value: string | null): string {
@ -32,12 +24,32 @@
return date.toISOString().slice(0, 16); // Format: YYYY-MM-DDTHH:mm
}
type LodgingType = {
value: string;
label: string;
};
const LODGING_TYPES: LodgingType[] = [
{ value: 'hotel', label: 'Hotel' },
{ value: 'hostel', label: 'Hostel' },
{ value: 'resort', label: 'Resort' },
{ value: 'bnb', label: 'Bed & Breakfast' },
{ value: 'campground', label: 'Campground' },
{ value: 'cabin', label: 'Cabin' },
{ value: 'apartment', label: 'Apartment' },
{ value: 'house', label: 'House' },
{ value: 'villa', label: 'Villa' },
{ value: 'motel', label: 'Motel' },
{ value: 'other', label: 'Other' }
];
// Initialize hotel with values from hotelToEdit or default values
function initializeHotel(hotelToEdit: Hotel | null): Hotel {
function initializeLodging(hotelToEdit: Lodging | null): Lodging {
return {
id: hotelToEdit?.id || '',
user_id: hotelToEdit?.user_id || '',
name: hotelToEdit?.name || '',
type: hotelToEdit?.type || 'other',
description: hotelToEdit?.description || '',
rating: hotelToEdit?.rating || NaN,
link: hotelToEdit?.link || '',
@ -49,7 +61,7 @@
longitude: hotelToEdit?.longitude || null,
location: hotelToEdit?.location || '',
is_public: hotelToEdit?.is_public || false,
collection: hotelToEdit?.collection || '',
collection: hotelToEdit?.collection || collection.id,
created_at: hotelToEdit?.created_at || '',
updated_at: hotelToEdit?.updated_at || ''
};
@ -63,8 +75,8 @@
// Handle rating change
$: {
if (!hotel.rating) {
hotel.rating = NaN;
if (!lodging.rating) {
lodging.rating = NaN;
}
}
@ -88,35 +100,37 @@
async function handleSubmit(event: Event) {
event.preventDefault();
if (hotel.check_in && !hotel.check_out) {
const checkInDate = new Date(hotel.check_in);
if (lodging.check_in && !lodging.check_out) {
const checkInDate = new Date(lodging.check_in);
checkInDate.setDate(checkInDate.getDate() + 1);
hotel.check_out = checkInDate.toISOString();
lodging.check_out = checkInDate.toISOString();
}
if (hotel.check_in && hotel.check_out && hotel.check_in > hotel.check_out) {
if (lodging.check_in && lodging.check_out && lodging.check_in > lodging.check_out) {
addToast('error', $t('adventures.start_before_end_error'));
return;
}
// Create or update hotel
const url = hotel.id === '' ? '/api/hotels' : `/api/hotels/${hotel.id}`;
const method = hotel.id === '' ? 'POST' : 'PATCH';
const url = lodging.id === '' ? '/api/lodging' : `/api/lodging/${lodging.id}`;
const method = lodging.id === '' ? 'POST' : 'PATCH';
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(hotel)
body: JSON.stringify(lodging)
});
const data = await res.json();
if (data.id) {
hotel = data as Hotel;
lodging = data as Lodging;
const toastMessage =
hotel.id === '' ? 'adventures.adventure_created' : 'adventures.adventure_updated';
lodging.id === '' ? 'adventures.adventure_created' : 'adventures.adventure_updated';
addToast('success', $t(toastMessage));
dispatch('save', hotel);
dispatch('save', lodging);
} else {
const errorMessage =
hotel.id === '' ? 'adventures.adventure_create_error' : 'adventures.adventure_update_error';
lodging.id === ''
? 'adventures.adventure_create_error'
: 'adventures.adventure_update_error';
addToast('error', $t(errorMessage));
}
}
@ -127,9 +141,7 @@
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<div class="modal-box w-11/12 max-w-3xl" role="dialog" on:keydown={handleKeydown} tabindex="0">
<h3 class="font-bold text-2xl">
{hotelToEdit
? $t('transportation.edit_transportation')
: $t('transportation.new_transportation')}
{lodgingToEdit ? $t('lodging.edit_lodging') : $t('lodging.new_lodging')}
</h3>
<div class="modal-action items-center">
<form method="post" style="width: 100%;" on:submit={handleSubmit}>
@ -149,7 +161,7 @@
type="text"
id="name"
name="name"
bind:value={hotel.name}
bind:value={lodging.name}
class="input input-bordered w-full"
required
/>
@ -157,7 +169,7 @@
<!-- Description -->
<div>
<label for="description">{$t('adventures.description')}</label><br />
<MarkdownEditor bind:text={hotel.description} editor_height={'h-32'} />
<MarkdownEditor bind:text={lodging.description} editor_height={'h-32'} />
</div>
<!-- Rating -->
<div>
@ -167,7 +179,7 @@
min="0"
max="5"
hidden
bind:value={hotel.rating}
bind:value={lodging.rating}
id="rating"
name="rating"
class="input input-bordered w-full max-w-xs mt-1"
@ -177,48 +189,48 @@
type="radio"
name="rating-2"
class="rating-hidden"
checked={Number.isNaN(hotel.rating)}
checked={Number.isNaN(lodging.rating)}
/>
<input
type="radio"
name="rating-2"
class="mask mask-star-2 bg-orange-400"
on:click={() => (hotel.rating = 1)}
checked={hotel.rating === 1}
on:click={() => (lodging.rating = 1)}
checked={lodging.rating === 1}
/>
<input
type="radio"
name="rating-2"
class="mask mask-star-2 bg-orange-400"
on:click={() => (hotel.rating = 2)}
checked={hotel.rating === 2}
on:click={() => (lodging.rating = 2)}
checked={lodging.rating === 2}
/>
<input
type="radio"
name="rating-2"
class="mask mask-star-2 bg-orange-400"
on:click={() => (hotel.rating = 3)}
checked={hotel.rating === 3}
on:click={() => (lodging.rating = 3)}
checked={lodging.rating === 3}
/>
<input
type="radio"
name="rating-2"
class="mask mask-star-2 bg-orange-400"
on:click={() => (hotel.rating = 4)}
checked={hotel.rating === 4}
on:click={() => (lodging.rating = 4)}
checked={lodging.rating === 4}
/>
<input
type="radio"
name="rating-2"
class="mask mask-star-2 bg-orange-400"
on:click={() => (hotel.rating = 5)}
checked={hotel.rating === 5}
on:click={() => (lodging.rating = 5)}
checked={lodging.rating === 5}
/>
{#if hotel.rating}
{#if lodging.rating}
<button
type="button"
class="btn btn-sm btn-error ml-2"
on:click={() => (hotel.rating = NaN)}
on:click={() => (lodging.rating = NaN)}
>
{$t('adventures.remove')}
</button>
@ -232,7 +244,7 @@
type="url"
id="link"
name="link"
bind:value={hotel.link}
bind:value={lodging.link}
class="input input-bordered w-full"
/>
</div>
@ -247,7 +259,7 @@
<!-- Start Date -->
<div>
<label for="date">
{$t('adventures.start_date')}
{$t('lodging.check_in')}
</label>
{#if collection && collection.start_date && collection.end_date}<label
@ -268,7 +280,7 @@
type="datetime-local"
id="date"
name="date"
bind:value={hotel.check_in}
bind:value={lodging.check_in}
min={constrainDates ? fullStartDate : ''}
max={constrainDates ? fullEndDate : ''}
class="input input-bordered w-full max-w-xs mt-1"
@ -276,19 +288,19 @@
</div>
</div>
<!-- End Date -->
{#if hotel.check_in}
{#if lodging.check_out}
<div>
<label for="end_date">
{$t('adventures.end_date')}
{$t('lodging.check_out')}
</label>
<div>
<input
type="datetime-local"
id="end_date"
name="end_date"
min={constrainDates ? hotel.check_in : ''}
min={constrainDates ? lodging.check_in : ''}
max={constrainDates ? fullEndDate : ''}
bind:value={hotel.check_out}
bind:value={lodging.check_out}
class="input input-bordered w-full max-w-xs mt-1"
/>
</div>
@ -298,7 +310,7 @@
</div>
<!-- Location Information -->
<LocationDropdown bind:item={hotel} />
<LocationDropdown bind:item={lodging} />
<!-- Form Actions -->
<div class="mt-4">

View file

@ -113,7 +113,7 @@ export type Collection = {
end_date: string | null;
transportations?: Transportation[];
notes?: Note[];
hotels?: Hotel[];
lodging?: Lodging[];
checklists?: Checklist[];
is_archived?: boolean;
shared_with: string[] | undefined;
@ -264,10 +264,11 @@ export type Attachment = {
name: string;
};
export type Hotel = {
export type Lodging = {
id: string;
user_id: string;
name: string;
type: string;
description: string | null;
rating: number | null;
link: string | null;

View file

@ -235,7 +235,12 @@
"primary": "Primär",
"upload": "Hochladen",
"view_attachment": "Anhang anzeigen",
"of": "von"
"of": "von",
"city": "Stadt",
"display_name": "Anzeigename",
"location_details": "Standortdetails",
"lodging": "Unterkunft",
"region": "Region"
},
"home": {
"desc_1": "Entdecken, planen und erkunden Sie mit Leichtigkeit",

View file

@ -191,7 +191,7 @@
"no_description_found": "No description found",
"adventure_created": "Adventure created",
"adventure_create_error": "Failed to create adventure",
"hotel": "Hotel",
"lodging": "Lodging",
"create_adventure": "Create Adventure",
"adventure_updated": "Adventure updated",
"adventure_update_error": "Failed to update adventure",
@ -200,6 +200,7 @@
"new_adventure": "New Adventure",
"basic_information": "Basic Information",
"no_adventures_to_recommendations": "No adventures found. Add at leat one adventure to get recommendations.",
"display_name": "Display Name",
"adventure_not_found": "There are no adventures to display. Add some using the plus button at the bottom right or try changing filters!",
"no_adventures_found": "No adventures found",
"mark_region_as_visited": "Mark region {region}, {country} as visited?",
@ -250,6 +251,9 @@
"out_of_range": "Not in itinerary date range",
"preview": "Preview",
"finding_recommendations": "Discovering hidden gems for your next adventure",
"location_details": "Location Details",
"city": "City",
"region": "Region",
"md_instructions": "Write your markdown here...",
"days": "days",
"attachment_upload_success": "Attachment uploaded successfully!",
@ -489,6 +493,30 @@
"start": "Start",
"date_and_time": "Date & Time"
},
"lodging": {
"lodging_deleted": "Lodging deleted successfully!",
"lodging_delete_error": "Error deleting lodging",
"provide_start_date": "Please provide a start date",
"lodging_type": "Lodging Type",
"type": "Type",
"lodging_added": "Lodging added successfully!",
"error_editing_lodging": "Error editing lodging",
"new_lodging": "New Lodging",
"check_in": "Check In",
"check_out": "Check Out",
"edit": "Edit",
"modes": {
"hotel": "Hotel",
"hostel": "Hostel",
"airbnb": "Airbnb",
"camping": "Camping",
"other": "Other"
},
"lodging_edit_success": "Lodging edited successfully!",
"edit_lodging": "Edit Lodging",
"start": "Start",
"date_and_time": "Date & Time"
},
"search": {
"adventurelog_results": "AdventureLog Results",
"public_adventures": "Public Adventures",

View file

@ -1,5 +1,5 @@
<script lang="ts">
import type { Adventure, Checklist, Collection, Hotel, Note, Transportation } from '$lib/types';
import type { Adventure, Checklist, Collection, Lodging, Note, Transportation } from '$lib/types';
import { onMount } from 'svelte';
import type { PageData } from './$types';
import { marked } from 'marked'; // Import the markdown parser
@ -35,7 +35,8 @@
import TransportationModal from '$lib/components/TransportationModal.svelte';
import CardCarousel from '$lib/components/CardCarousel.svelte';
import { goto } from '$app/navigation';
import HotelModal from '$lib/components/HotelModal.svelte';
import LodgingModal from '$lib/components/LodgingModal.svelte';
import LodgingCard from '$lib/components/LodgingCard.svelte';
export let data: PageData;
console.log(data);
@ -104,6 +105,19 @@
);
}
if (lodging) {
dates = dates.concat(
lodging
.filter((i) => i.check_in)
.map((lodging) => ({
id: lodging.id,
start: lodging.check_in || '', // Ensure it's a string
end: lodging.check_out || lodging.check_in || '', // Ensure it's a string
title: lodging.name
}))
);
}
// Update `options.events` when `dates` changes
options = { ...options, events: dates };
}
@ -116,7 +130,7 @@
let numAdventures: number = 0;
let transportations: Transportation[] = [];
let hotels: Hotel[] = [];
let lodging: Lodging[] = [];
let notes: Note[] = [];
let checklists: Checklist[] = [];
@ -176,8 +190,8 @@
if (collection.transportations) {
transportations = collection.transportations;
}
if (collection.hotels) {
hotels = collection.hotels;
if (collection.lodging) {
lodging = collection.lodging;
}
if (collection.notes) {
notes = collection.notes;
@ -248,8 +262,8 @@
let adventureToEdit: Adventure | null = null;
let transportationToEdit: Transportation | null = null;
let isShowingHotelModal: boolean = false;
let hotelToEdit: Hotel | null = null;
let isShowingLodgingModal: boolean = false;
let lodgingToEdit: Lodging | null = null;
let isAdventureModalOpen: boolean = false;
let isNoteModalOpen: boolean = false;
let noteToEdit: Note | null;
@ -267,9 +281,9 @@
isShowingTransportationModal = true;
}
function editHotel(event: CustomEvent<Hotel>) {
hotelToEdit = event.detail;
isShowingHotelModal = true;
function editLodging(event: CustomEvent<Lodging>) {
lodgingToEdit = event.detail;
isShowingLodgingModal = true;
}
function saveOrCreateAdventure(event: CustomEvent<Adventure>) {
@ -368,20 +382,20 @@
isShowingTransportationModal = false;
}
function saveOrCreateHotel(event: CustomEvent<Hotel>) {
if (hotels.find((hotel) => hotel.id === event.detail.id)) {
function saveOrCreateLodging(event: CustomEvent<Lodging>) {
if (lodging.find((lodging) => lodging.id === event.detail.id)) {
// Update existing hotel
hotels = hotels.map((hotel) => {
if (hotel.id === event.detail.id) {
lodging = lodging.map((lodging) => {
if (lodging.id === event.detail.id) {
return event.detail;
}
return hotel;
return lodging;
});
} else {
// Create new hotel
hotels = [event.detail, ...hotels];
// Create new lodging
lodging = [event.detail, ...lodging];
}
isShowingHotelModal = false;
isShowingLodgingModal = false;
}
</script>
@ -404,11 +418,11 @@
/>
{/if}
{#if isShowingHotelModal}
<HotelModal
{hotelToEdit}
on:close={() => (isShowingHotelModal = false)}
on:save={saveOrCreateHotel}
{#if isShowingLodgingModal}
<LodgingModal
{lodgingToEdit}
on:close={() => (isShowingLodgingModal = false)}
on:save={saveOrCreateLodging}
{collection}
/>
{/if}
@ -541,12 +555,12 @@
<button
class="btn btn-primary"
on:click={() => {
isShowingHotelModal = true;
isShowingLodgingModal = true;
newType = '';
hotelToEdit = null;
lodgingToEdit = null;
}}
>
{$t('adventures.hotel')}</button
{$t('adventures.lodging')}</button
>
<!-- <button
@ -589,7 +603,7 @@
</div>
{/if}
{#if collection && !collection.start_date && adventures.length == 0 && transportations.length == 0 && notes.length == 0 && checklists.length == 0}
{#if collection && !collection.start_date && adventures.length == 0 && transportations.length == 0 && notes.length == 0 && checklists.length == 0 && lodging.length == 0}
<NotFound error={undefined} />
{/if}
@ -701,6 +715,63 @@
</div>
{/if}
{#if lodging.length > 0}
<h1 class="text-center font-bold text-4xl mt-4 mb-4">{$t('adventures.lodging')}</h1>
<div class="flex flex-wrap gap-4 mr-4 justify-center content-center">
{#each lodging as hotel}
<LodgingCard
lodging={hotel}
user={data?.user}
on:delete={(event) => {
lodging = lodging.filter((t) => t.id != event.detail);
}}
on:edit={editLodging}
{collection}
/>
{/each}
</div>
{/if}
{#if notes.length > 0}
<h1 class="text-center font-bold text-4xl mt-4 mb-4">{$t('adventures.notes')}</h1>
<div class="flex flex-wrap gap-4 mr-4 justify-center content-center">
{#each notes as note}
<NoteCard
{note}
user={data.user || null}
on:edit={(event) => {
noteToEdit = event.detail;
isNoteModalOpen = true;
}}
on:delete={(event) => {
notes = notes.filter((n) => n.id != event.detail);
}}
{collection}
/>
{/each}
</div>
{/if}
{#if checklists.length > 0}
<h1 class="text-center font-bold text-4xl mt-4 mb-4">{$t('adventures.checklists')}</h1>
<div class="flex flex-wrap gap-4 mr-4 justify-center content-center">
{#each checklists as checklist}
<ChecklistCard
{checklist}
user={data.user || null}
on:delete={(event) => {
checklists = checklists.filter((n) => n.id != event.detail);
}}
on:edit={(event) => {
checklistToEdit = event.detail;
isShowingChecklistModal = true;
}}
{collection}
/>
{/each}
</div>
{/if}
{#if notes.length > 0}
<h1 class="text-center font-bold text-4xl mt-4 mb-4">{$t('adventures.notes')}</h1>
<div class="flex flex-wrap gap-4 mr-4 justify-center content-center">
@ -742,7 +813,7 @@
{/if}
<!-- if none found -->
{#if adventures.length == 0 && transportations.length == 0 && notes.length == 0 && checklists.length == 0}
{#if adventures.length == 0 && transportations.length == 0 && notes.length == 0 && checklists.length == 0 && lodging.length == 0}
<NotFound error={undefined} />
{/if}
{/if}