fix: update LocationCard props and enhance restore data functionality

- Changed the user prop to null in LocationCard component on the dashboard page.
- Added isRestoring state to manage loading state during data restoration in settings.
- Updated the restore button to show a loading spinner when a restore operation is in progress.
This commit is contained in:
Sean Morley
2025-12-16 11:19:05 -05:00
parent eaac14a6f5
commit 682dc1abe8
21 changed files with 6348 additions and 3079 deletions

View File

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
import os
from django.contrib import admin
from django.utils.html import mark_safe
from .models import Location, Checklist, ChecklistItem, Collection, Transportation, Note, ContentImage, Visit, Category, ContentAttachment, Lodging, CollectionInvite, Trail, Activity
from .models import Location, Checklist, ChecklistItem, Collection, Transportation, Note, ContentImage, Visit, Category, ContentAttachment, Lodging, CollectionInvite, Trail, Activity, CollectionItineraryItem
from worldtravel.models import Country, Region, VisitedRegion, City, VisitedCity
from allauth.account.decorators import secure_admin_login
@@ -166,6 +166,7 @@ admin.site.register(Lodging)
admin.site.register(CollectionInvite, CollectionInviteAdmin)
admin.site.register(Trail)
admin.site.register(Activity, ActivityAdmin)
admin.site.register(CollectionItineraryItem)
admin.site.site_header = 'AdventureLog Admin'
admin.site.site_title = 'AdventureLog Admin Site'

View File

@@ -0,0 +1,32 @@
# Generated by Django 5.2.6 on 2025-12-15 16:46
import django.db.models.deletion
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('adventures', '0063_alter_activity_timezone_alter_lodging_timezone_and_more'),
('contenttypes', '0002_remove_content_type_name'),
]
operations = [
migrations.CreateModel(
name='CollectionItineraryItem',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('object_id', models.UUIDField()),
('date', models.DateField(blank=True, null=True)),
('order', models.PositiveIntegerField(help_text='Manual order within a day')),
('created_at', models.DateTimeField(auto_now_add=True)),
('collection', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='itinerary_items', to='adventures.collection')),
('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')),
],
options={
'ordering': ['date', 'order'],
'unique_together': {('collection', 'date', 'order')},
},
),
]

View File

@@ -673,4 +673,55 @@ class Activity(models.Model):
class Meta:
verbose_name = "Activity"
verbose_name_plural = "Activities"
verbose_name_plural = "Activities"
class CollectionItineraryItem(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
collection = models.ForeignKey(
Collection,
on_delete=models.CASCADE,
related_name="itinerary_items"
)
# Generic reference to Visit, Transportation, Lodging, Note, etc
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
object_id = models.UUIDField()
item = GenericForeignKey("content_type", "object_id")
# Placement (planning concern, not content concern)
date = models.DateField(blank=True, null=True)
order = models.PositiveIntegerField(help_text="Manual order within a day")
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["date", "order"]
unique_together = ("collection", "date", "order")
def __str__(self):
return f"{self.collection.name} - {self.content_type.model} - {self.date} ({self.order})"
@property
def start_datetime(self):
obj = self.item
for field in ("start_date", "check_in", "date"):
if hasattr(obj, field):
value = getattr(obj, field)
if value:
return value
return None
@property
def end_datetime(self):
obj = self.item
for field in ("end_date", "check_out"):
if hasattr(obj, field):
value = getattr(obj, field)
if value:
return value
return None

View File

@@ -1,5 +1,5 @@
import os
from .models import Location, ContentImage, ChecklistItem, Collection, Note, Transportation, Checklist, Visit, Category, ContentAttachment, Lodging, CollectionInvite, Trail, Activity
from .models import Location, ContentImage, ChecklistItem, Collection, Note, Transportation, Checklist, Visit, Category, ContentAttachment, Lodging, CollectionInvite, Trail, Activity, CollectionItineraryItem
from rest_framework import serializers
from main.utils import CustomModelSerializer
from users.serializers import CustomUserDetailsSerializer
@@ -700,4 +700,47 @@ class UltraSlimCollectionSerializer(serializers.ModelSerializer):
shared_uuids.append(str(user.uuid))
representation['shared_with'] = shared_uuids
return representation
class CollectionItineraryItemSerializer(CustomModelSerializer):
item = serializers.SerializerMethodField()
start_datetime = serializers.ReadOnlyField()
end_datetime = serializers.ReadOnlyField()
object_name = serializers.ReadOnlyField(source='content_type.model')
class Meta:
model = CollectionItineraryItem
fields = ['id', 'collection', 'content_type', 'object_id', 'item', 'date', 'order', 'start_datetime', 'end_datetime', 'created_at', 'object_name']
read_only_fields = ['id', 'created_at', 'start_datetime', 'end_datetime', 'item', 'object_name']
def get_item(self, obj):
"""Return serialized data for the linked item"""
if not obj.item:
return None
# Get the appropriate serializer based on the content type
from django.contrib.contenttypes.models import ContentType
content_type = obj.content_type
item = obj.item
# Map content types to their serializers
serializer_mapping = {
'visit': VisitSerializer,
'transportation': TransportationSerializer,
'lodging': LodgingSerializer,
'note': NoteSerializer,
'checklist': ChecklistSerializer,
}
model_name = content_type.model
serializer_class = serializer_mapping.get(model_name)
if serializer_class:
return serializer_class(item, context=self.context).data
# Fallback for unknown content types
return {
'id': str(item.id),
'type': model_name,
}

View File

@@ -4,9 +4,9 @@ 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
from adventures.models import Collection, Location, Transportation, Note, Checklist, CollectionInvite, ContentImage, CollectionItineraryItem
from adventures.permissions import CollectionShared
from adventures.serializers import CollectionSerializer, CollectionInviteSerializer, UltraSlimCollectionSerializer
from adventures.serializers import CollectionSerializer, CollectionInviteSerializer, UltraSlimCollectionSerializer, CollectionItineraryItemSerializer
from users.models import CustomUser as User
from adventures.utils import pagination
from users.serializers import CustomUserDetailsSerializer as UserSerializer
@@ -184,6 +184,14 @@ class CollectionViewSet(viewsets.ModelViewSet):
return Response(serializer.data)
# get view to get all the itinerary items for the collection
@action(detail=True, methods=['get'])
def itinerary(self, request, pk=None):
collection = self.get_object()
itinerary_items = CollectionItineraryItem.objects.filter(collection=collection)
serializer = CollectionItineraryItemSerializer(itinerary_items, many=True)
return Response(serializer.data)
# this make the is_public field of the collection cascade to the locations
@transaction.atomic
def update(self, request, *args, **kwargs):

View File

@@ -6,5 +6,6 @@ def get_user_uuid(user):
class CustomModelSerializer(serializers.ModelSerializer):
def to_representation(self, instance):
representation = super().to_representation(instance)
representation['user'] = get_user_uuid(instance.user)
if hasattr(instance, 'user') and instance.user:
representation['user'] = get_user_uuid(instance.user)
return representation

View File

@@ -2,8 +2,8 @@
In addition to the primary configuration variables listed above, there are several optional environment variables that can be set to further customize your AdventureLog instance. These variables are not required for a basic setup but can enhance functionality and security.
| Name | Required | Description | Default Value |
| ---------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------- |
| `ACCOUNT_EMAIL_VERIFICATION` | No | Enable email verification for new accounts. Options are `none`, `optional`, or `mandatory` | `none` |
| `FORCE_SOCIALACCOUNT_LOGIN` | No | When set to `True`, only social login is allowed (no password login). The login page will show only social providers or redirect directly to the first provider if only one is configured. | `False` |
| `SOCIALACCOUNT_ALLOW_SIGNUP` | No | When set to `True`, signup will be allowed via social providers even if registration is disabled. | `False` |
| Name | Required | Description | Default Value | Variable Location |
| ---------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------- | ----------------- |
| `ACCOUNT_EMAIL_VERIFICATION` | No | Enable email verification for new accounts. Options are `none`, `optional`, or `mandatory` | `none` | Backend |
| `FORCE_SOCIALACCOUNT_LOGIN` | No | When set to `True`, only social login is allowed (no password login). The login page will show only social providers or redirect directly to the first provider if only one is configured. | `False` | Backend |
| `SOCIALACCOUNT_ALLOW_SIGNUP` | No | When set to `True`, signup will be allowed via social providers even if registration is disabled. | `False` | Backend |

View File

@@ -0,0 +1,143 @@
# Pocket ID OIDC Authentication
<img src="https://pocket-id.org/logo.png" alt="Pocket ID Logo" width="400" />
Pocket ID is a lightweight, self-hosted OpenID Connect (OIDC) identity provider. AdventureLog can be configured to use Pocket ID for social authentication using its built-in OpenID Connect support.
Once Pocket ID is configured by an administrator, users can sign in to AdventureLog using their Pocket ID account and optionally link it to an existing AdventureLog account.
---
# Configuration
To enable Pocket ID as an identity provider, both Pocket ID and AdventureLog must be configured correctly. The most important (and least obvious) part of this setup is the **callback URL**, which must match AdventureLogs internal OIDC routing.
---
## Pocket ID Configuration
1. Log in to your Pocket ID admin interface.
2. Navigate to **Clients** and create a new client.
3. Name the client something like `AdventureLog`.
4. Set the **Redirect / Callback URL** to:
```
https://<adventurelog-backend.example.com>/accounts/oidc/<CLIENT_ID>/login/callback/
```
- Replace `<adventurelog-backend.example.com>` with the **backend** URL of your AdventureLog instance.
- Replace `<CLIENT_ID>` with the **Pocket ID client ID** exactly as generated.
- This path is required and currently not auto-documented by Pocket ID or AdventureLog.
5. Ensure the client type is **Confidential**.
6. Copy the generated **Client ID** and **Client Secret** — you will need both for AdventureLog.
---
## AdventureLog Configuration
This configuration is done in the [Admin Panel](../../guides/admin_panel.md). You can launch it from the `Settings` page or navigate directly to `/admin` on your AdventureLog server.
1. Log in to AdventureLog as an administrator.
2. Navigate to **Settings** → **Administration Settings** and launch the admin panel.
3. Go to **Social Accounts**.
4. Under **Social applications**, click **Add**.
5. Fill in the fields as follows:
### Social Application Settings
- **Provider**: `OpenID Connect`
- **Provider ID**: Pocket ID Client ID
- **Name**: `Pocket ID`
- **Client ID**: Pocket ID Client ID
- **Secret Key**: Pocket ID Client Secret
- **Key**: _(leave blank)_
- **Settings**:
```json
{
"server_url": "https://<pocketid-url>/.well-known/openid-configuration"
}
```
- Replace `<pocketid-url>` with the base URL of your Pocket ID instance.
::: warning
Do **not** use `localhost` unless Pocket ID is running on the same machine and is resolvable from inside the AdventureLog container or service. Use a domain name or LAN IP instead.
:::
- **Sites**: Move the sites you want Pocket ID enabled on (usually `example.com` and `www.example.com`).
6. Save the configuration.
Ensure Pocket ID is running and reachable by AdventureLog.
---
## What It Should Look Like
Once configured correctly:
- Pocket ID appears as a login option on the AdventureLog login screen.
- Logging in redirects to Pocket ID, then back to AdventureLog without errors.
---
## Linking to an Existing Account
If a user already has an AdventureLog account:
1. Log in to AdventureLog normally.
2. Go to **Settings**.
3. Click **Launch Account Connections**.
4. Choose **Pocket ID** to link the identity to the existing account.
This allows future logins using Pocket ID without creating a duplicate account.
---
## Troubleshooting
### 404 Error After Login
Ensure that:
- `/accounts` routes are handled by the **backend**, not the frontend.
- Your reverse proxy (Nginx, Traefik, Caddy, etc.) forwards `/accounts/*` correctly.
---
### Invalid Redirect URI
- Double-check that the callback URL in Pocket ID exactly matches:
```
/accounts/oidc/<CLIENT_ID>/login/callback/
```
- The `<CLIENT_ID>` must match the value used in the AdventureLog social application.
---
### Cannot Reach Pocket ID
- Verify that the `.well-known/openid-configuration` endpoint is accessible from the AdventureLog server.
- Test by opening:
```
https://<pocketid-url>/.well-known/openid-configuration
```
in a browser.
---
## Notes
- Pocket ID configuration is very similar to Authentik.
- The main difference is the **explicit callback URL requirement** and the use of the `.well-known/openid-configuration` endpoint as the `server_url`.
- This setup works with Docker, Docker Compose, and bare-metal deployments as long as networking i

View File

@@ -9,6 +9,10 @@
import TrashCan from '~icons/mdi/trash-can';
import Calendar from '~icons/mdi/calendar';
import DeleteWarning from './DeleteWarning.svelte';
import DotsHorizontal from '~icons/mdi/dots-horizontal';
import FileDocumentEdit from '~icons/mdi/file-document-edit';
import CheckCircle from '~icons/mdi/check-circle';
import CheckboxBlankCircleOutline from '~icons/mdi/checkbox-blank-circle-outline';
import { isEntityOutsideCollectionDateRange } from '$lib/dateUtils';
export let checklist: Checklist;
@@ -52,53 +56,107 @@
/>
{/if}
<div
class="card w-full max-w-md bg-base-300 text-base-content shadow-2xl hover:shadow-3xl transition-all duration-300 border border-base-300 hover:border-primary/20 group"
class="card w-full max-w-md bg-base-300 shadow hover:shadow-md transition-all duration-200 border border-base-300 group"
aria-label="checklist-card"
>
<div class="card-body p-6 space-y-4">
<div class="card-body p-4 space-y-3">
<!-- Header -->
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
<h2 class="text-xl font-bold break-words">{checklist.name}</h2>
<div class="flex flex-wrap gap-2">
<div class="badge badge-primary">{$t('adventures.checklist')}</div>
{#if outsideCollectionRange}
<div class="badge badge-error">{$t('adventures.out_of_range')}</div>
{/if}
<div class="flex items-start justify-between gap-3">
<div class="flex-1 min-w-0">
<h2 class="text-lg font-semibold line-clamp-2">{checklist.name}</h2>
<div class="flex flex-wrap items-center gap-2 mt-2">
<div class="badge badge-primary badge-sm">{$t('adventures.checklist')}</div>
{#if outsideCollectionRange}
<div class="badge badge-error badge-xs">{$t('adventures.out_of_range')}</div>
{/if}
</div>
</div>
{#if checklist.user == user?.uuid || (collection && user && collection.shared_with?.includes(user.uuid))}
<div class="dropdown dropdown-end">
<div tabindex="0" role="button" class="btn btn-square btn-sm p-1 text-base-content">
<DotsHorizontal class="w-5 h-5" />
</div>
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
<ul
tabindex="0"
class="dropdown-content menu bg-base-100 rounded-box z-[1] w-52 p-2 shadow-lg border border-base-300"
>
<li>
<button on:click={editChecklist} class="flex items-center gap-2">
<FileDocumentEdit class="w-4 h-4" />
{$t('notes.open')}
</button>
</li>
<div class="divider my-1"></div>
<li>
<button
class="text-error flex items-center gap-2"
on:click={() => (isWarningModalOpen = true)}
>
<TrashCan class="w-4 h-4" />
{$t('adventures.delete')}
</button>
</li>
</ul>
</div>
{/if}
</div>
<!-- Checklist Stats -->
<!-- Checklist Items Preview -->
{#if checklist.items.length > 0}
<p class="text-sm">
{checklist.items.length}
{checklist.items.length > 1 ? $t('checklist.items') : $t('checklist.item')}
</p>
{/if}
<!-- Date -->
{#if checklist.date && checklist.date !== ''}
<div class="inline-flex items-center gap-2 text-sm">
<Calendar class="w-5 h-5 text-primary" />
<p>{new Date(checklist.date).toLocaleDateString(undefined, { timeZone: 'UTC' })}</p>
<div class="space-y-2">
{#each checklist.items.slice(0, 3) as item}
<div class="flex items-center gap-2 text-sm text-base-content/70">
{#if item.is_checked}
<CheckCircle class="w-4 h-4 text-success flex-shrink-0" />
{:else}
<CheckboxBlankCircleOutline class="w-4 h-4 flex-shrink-0" />
{/if}
<span
class="truncate"
class:line-through={item.is_checked}
class:opacity-60={item.is_checked}
>
{item.name}
</span>
</div>
{/each}
{#if checklist.items.length > 3}
<div class="text-sm text-base-content/60 pl-6">
+{checklist.items.length - 3}
{$t('checklist.more_items')}
</div>
{/if}
</div>
{/if}
<!-- Actions -->
<div class="pt-4 border-t border-base-300 flex justify-end gap-2">
<button class="btn btn-neutral btn-sm flex items-center gap-1" on:click={editChecklist}>
<Launch class="w-5 h-5" />
{$t('notes.open')}
</button>
{#if checklist.user == user?.uuid || (collection && user && collection.shared_with?.includes(user.uuid))}
<button
id="delete_adventure"
data-umami-event="Delete Checklist"
class="btn btn-secondary btn-sm flex items-center gap-1"
on:click={() => (isWarningModalOpen = true)}
>
<TrashCan class="w-5 h-5" />
{$t('adventures.delete')}
</button>
<!-- Inline Stats -->
<div class="flex flex-wrap items-center gap-3 text-sm text-base-content/70">
{#if checklist.date && checklist.date !== ''}
<div class="flex items-center gap-1">
<Calendar class="w-4 h-4 text-primary" />
<span>{new Date(checklist.date).toLocaleDateString(undefined, { timeZone: 'UTC' })}</span>
</div>
{/if}
{#if checklist.items.length > 0}
{@const completedCount = checklist.items.filter((item) => item.is_checked).length}
<div class="badge badge-ghost badge-sm">
{completedCount}/{checklist.items.length}
{$t('checklist.completed')}
</div>
{/if}
</div>
</div>
</div>
<style>
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>

View File

@@ -0,0 +1,307 @@
<script lang="ts">
import type { Collection } from '$lib/types';
import LocationCard from '$lib/components/LocationCard.svelte';
import TransportationCard from '$lib/components/TransportationCard.svelte';
import LodgingCard from '$lib/components/LodgingCard.svelte';
import NoteCard from '$lib/components/NoteCard.svelte';
import ChecklistCard from '$lib/components/ChecklistCard.svelte';
import Magnify from '~icons/mdi/magnify';
import ClipboardList from '~icons/mdi/clipboard-list';
export let collection: Collection;
export let user: any;
export let isFolderView: boolean = false;
// Exported so a parent can bind to them if desired
export let locationSearch: string = '';
export let locationSort:
| 'alphabetical-asc'
| 'alphabetical-desc'
| 'visited'
| 'date-asc'
| 'date-desc' = 'alphabetical-asc';
$: sortedLocations = (() => {
if (!collection?.locations) return [];
let filtered = collection.locations.filter(
(loc) =>
loc.name.toLowerCase().includes(locationSearch.toLowerCase()) ||
loc.location?.toLowerCase().includes(locationSearch.toLowerCase())
);
switch (locationSort) {
case 'alphabetical-asc':
return filtered.sort((a, b) => a.name.localeCompare(b.name));
case 'alphabetical-desc':
return filtered.sort((a, b) => b.name.localeCompare(a.name));
case 'visited':
return filtered.sort((a, b) => {
const aVisited = a.visits && a.visits.length > 0 ? 1 : 0;
const bVisited = b.visits && b.visits.length > 0 ? 1 : 0;
return bVisited - aVisited;
});
case 'date-asc':
return filtered.sort((a, b) => {
const aDate = a.visits?.[0]?.start_date || '';
const bDate = b.visits?.[0]?.start_date || '';
return aDate.localeCompare(bDate);
});
case 'date-desc':
return filtered.sort((a, b) => {
const aDate = a.visits?.[0]?.start_date || '';
const bDate = b.visits?.[0]?.start_date || '';
return bDate.localeCompare(aDate);
});
default:
return filtered;
}
})();
// Transportations
export let transportationSearch: string = '';
$: filteredTransportations = (() => {
if (!collection?.transportations) return [];
return collection.transportations.filter((t) =>
t.name.toLowerCase().includes(transportationSearch.toLowerCase())
);
})();
// Lodging
export let lodgingSearch: string = '';
$: filteredLodging = (() => {
if (!collection?.lodging) return [];
return collection.lodging.filter((l) =>
l.name.toLowerCase().includes(lodgingSearch.toLowerCase())
);
})();
// Notes
export let noteSearch: string = '';
$: filteredNotes = (() => {
if (!collection?.notes) return [];
return collection.notes.filter((n) => n.name.toLowerCase().includes(noteSearch.toLowerCase()));
})();
// Checklists
export let checklistSearch: string = '';
$: filteredChecklists = (() => {
if (!collection?.checklists) return [];
return collection.checklists.filter((c) =>
c.name.toLowerCase().includes(checklistSearch.toLowerCase())
);
})();
</script>
{#if collection.locations && collection.locations.length > 0}
<div class="card bg-base-200 shadow-xl">
<div class="card-body">
<div class="flex flex-wrap justify-between items-center gap-4 mb-6">
<h2 class="card-title text-2xl">
📍 Locations ({sortedLocations.length}/{collection.locations.length})
</h2>
{#if isFolderView}
<div class="flex flex-wrap gap-2">
<!-- Search -->
<div class="join">
<input
type="text"
placeholder="Search locations..."
class="input input-sm input-bordered join-item w-48"
bind:value={locationSearch}
/>
</div>
<!-- Sort dropdown -->
<select class="select select-sm select-bordered" bind:value={locationSort}>
<option value="alphabetical-asc">A → Z</option>
<option value="alphabetical-desc">Z → A</option>
<option value="visited">Visited First</option>
<option value="date-asc">Oldest First</option>
<option value="date-desc">Newest First</option>
</select>
</div>
{/if}
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 auto-rows-fr items-stretch">
{#each sortedLocations as location}
<LocationCard adventure={location} {user} {collection} />
{/each}
</div>
{#if sortedLocations.length === 0}
<div class="text-center py-8 opacity-70">
<p>No locations match your search</p>
</div>
{/if}
<!-- Transportations Section -->
{#if collection.transportations && collection.transportations.length > 0}
<div class="card bg-base-200 shadow-xl mt-6">
<div class="card-body">
<div class="flex flex-wrap justify-between items-center gap-4 mb-6">
<h2 class="card-title text-2xl">
✈️ Transportation ({filteredTransportations.length}/{collection.transportations
.length})
</h2>
{#if isFolderView}
<div class="join">
<input
type="text"
placeholder="Search transportation..."
class="input input-sm input-bordered join-item w-48"
bind:value={transportationSearch}
/>
<button class="btn btn-sm btn-square join-item">
<Magnify class="w-4 h-4" />
</button>
</div>
{/if}
</div>
<div
class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 auto-rows-fr items-stretch"
>
{#each filteredTransportations as transport}
<TransportationCard transportation={transport} {user} {collection} />
{/each}
</div>
{#if filteredTransportations.length === 0}
<div class="text-center py-8 opacity-70">
<p>No transportation matches your search</p>
</div>
{/if}
</div>
</div>
{/if}
<!-- Lodging Section -->
{#if collection.lodging && collection.lodging.length > 0}
<div class="card bg-base-200 shadow-xl mt-6">
<div class="card-body">
<div class="flex flex-wrap justify-between items-center gap-4 mb-6">
<h2 class="card-title text-2xl">
🏨 Lodging ({filteredLodging.length}/{collection.lodging.length})
</h2>
{#if isFolderView}
<div class="join">
<input
type="text"
placeholder="Search lodging..."
class="input input-sm input-bordered join-item w-48"
bind:value={lodgingSearch}
/>
<button class="btn btn-sm btn-square join-item">
<Magnify class="w-4 h-4" />
</button>
</div>
{/if}
</div>
<div
class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 auto-rows-fr items-stretch"
>
{#each filteredLodging as lodging}
<LodgingCard {lodging} {user} {collection} />
{/each}
</div>
{#if filteredLodging.length === 0}
<div class="text-center py-8 opacity-70">
<p>No lodging matches your search</p>
</div>
{/if}
</div>
</div>
{/if}
<!-- Notes Section -->
{#if collection.notes && collection.notes.length > 0}
<div class="card bg-base-200 shadow-xl mt-6">
<div class="card-body">
<div class="flex flex-wrap justify-between items-center gap-4 mb-6">
<h2 class="card-title text-2xl">
📝 Notes ({filteredNotes.length}/{collection.notes.length})
</h2>
{#if isFolderView}
<div class="join">
<input
type="text"
placeholder="Search notes..."
class="input input-sm input-bordered join-item w-48"
bind:value={noteSearch}
/>
<button class="btn btn-sm btn-square join-item">
<Magnify class="w-4 h-4" />
</button>
</div>
{/if}
</div>
<div
class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 auto-rows-fr items-stretch"
>
{#each filteredNotes as note}
<NoteCard {note} {user} {collection} />
{/each}
</div>
{#if filteredNotes.length === 0}
<div class="text-center py-8 opacity-70">
<p>No notes match your search</p>
</div>
{/if}
</div>
</div>
{/if}
<!-- Checklists Section -->
{#if collection.checklists && collection.checklists.length > 0}
<div class="card bg-base-200 shadow-xl mt-6">
<div class="card-body">
<div class="flex flex-wrap justify-between items-center gap-4 mb-6">
<h2 class="card-title text-2xl">
<ClipboardList class="w-6 h-6" />
Checklists ({filteredChecklists.length}/{collection.checklists.length})
</h2>
{#if isFolderView}
<div class="join">
<input
type="text"
placeholder="Search checklists..."
class="input input-sm input-bordered join-item w-48"
bind:value={checklistSearch}
/>
<button class="btn btn-sm btn-square join-item">
<Magnify class="w-4 h-4" />
</button>
</div>
{/if}
</div>
<div
class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 auto-rows-fr items-stretch"
>
{#each filteredChecklists as checklist}
<ChecklistCard {checklist} {user} {collection} />
{/each}
</div>
{#if filteredChecklists.length === 0}
<div class="text-center py-8 opacity-70">
<p>No checklists match your search</p>
</div>
{/if}
</div>
</div>
{/if}
</div>
</div>
{/if}

View File

@@ -8,7 +8,10 @@
import FileDocumentEdit from '~icons/mdi/file-document-edit';
import TrashCan from '~icons/mdi/trash-can-outline';
import Calendar from '~icons/mdi/calendar';
import Clock from '~icons/mdi/clock-outline';
import MapMarker from '~icons/mdi/map-marker';
import LinkIcon from '~icons/mdi/link-variant';
import Check from '~icons/mdi/check';
import { addToast } from '$lib/toasts';
import Link from '~icons/mdi/link-variant';
import LinkVariantRemove from '~icons/mdi/link-variant-remove';
@@ -31,6 +34,18 @@
let isCollectionModalOpen: boolean = false;
let isWarningModalOpen: boolean = false;
let copied = false;
async function copyLink() {
try {
const url = `${location.origin}/locations/${adventure.id}`;
await navigator.clipboard.writeText(url);
copied = true;
setTimeout(() => (copied = false), 2000);
} catch (e) {
addToast('error', $t('adventures.copy_failed') || 'Copy failed');
}
}
export let adventure: Location;
let displayActivityTypes: string[] = [];
@@ -72,10 +87,6 @@
: adventure.user?.username || 'Unknown User';
// Helper functions for display
function formatVisitCount() {
const count = adventure.visits.length;
return count > 1 ? `${count} ${$t('adventures.visits')}` : `${count} ${$t('adventures.visit')}`;
}
function renderStars(rating: number) {
const stars = [];
@@ -185,32 +196,44 @@
{/if}
<div
class="card w-full max-w-md bg-base-300 shadow-2xl hover:shadow-3xl transition-all duration-300 border border-base-300 hover:border-primary/20 group"
class="card w-full max-w-md bg-base-300 shadow hover:shadow-md transition-all duration-200 border border-base-300 group"
aria-label="location-card"
>
<!-- Image Section with Overlay -->
<div class="relative overflow-hidden rounded-t-2xl">
<CardCarousel images={adventure.images} icon={adventure.category?.icon} name={adventure.name} />
<!-- Status Overlay -->
<div class="absolute top-4 left-4 flex flex-col gap-2">
<!-- Status Overlay (icon-only) -->
<div class="absolute top-2 left-4 flex items-center gap-3">
<div
class="badge badge-sm {adventure.is_visited ? 'badge-success' : 'badge-warning'} shadow-lg"
class="tooltip tooltip-right"
data-tip={adventure.is_visited ? $t('adventures.visited') : $t('adventures.not_visited')}
>
{adventure.is_visited ? $t('adventures.visited') : $t('adventures.planned')}
{#if adventure.is_visited}
<div class="badge badge-sm badge-success p-1 rounded-full shadow-sm">
<Calendar class="w-4 h-4" />
</div>
{:else}
<div class="badge badge-sm badge-warning p-1 rounded-full shadow-sm">
<Clock class="w-4 h-4" />
</div>
{/if}
</div>
{#if outsideCollectionRange}
<div class="badge badge-sm badge-error shadow-lg">{$t('adventures.out_of_range')}</div>
<div class="badge badge-xs badge-error shadow">{$t('adventures.out_of_range')}</div>
{/if}
</div>
<!-- Privacy Indicator -->
<div class="absolute top-4 right-4">
<div class="absolute top-2 right-4">
<div
class="tooltip tooltip-left"
data-tip={adventure.is_public ? $t('adventures.public') : $t('adventures.private')}
>
<div
class="btn btn-circle btn-sm btn-ghost bg-black/20 backdrop-blur-sm border-0 text-white"
class="badge badge-sm p-1 rounded-full text-base-content shadow-sm"
role="img"
aria-label={adventure.is_public ? $t('adventures.public') : $t('adventures.private')}
>
{#if adventure.is_public}
<Eye class="w-4 h-4" />
@@ -224,10 +247,13 @@
<!-- Category Badge -->
{#if adventure.category}
<div class="absolute bottom-4 left-4">
<div class="badge badge-primary shadow-lg font-medium">
<a
href="/locations?types={adventure.category.name}"
class="badge badge-primary shadow-lg font-medium cursor-pointer hover:brightness-110 transition-all"
>
{adventure.category.display_name}
{adventure.category.icon}
</div>
</a>
</div>
{/if}
@@ -236,7 +262,7 @@
<div class="absolute bottom-4 right-4">
<div class="tooltip tooltip-left" data-tip={creatorDisplayName}>
<div class="avatar">
<div class="w-8 h-8 rounded-full ring-2 ring-white/50 shadow-lg">
<div class="w-7 h-7 rounded-full ring-2 ring-white/40 shadow">
{#if adventure.user.profile_pic}
<img
src={adventure.user.profile_pic}
@@ -245,7 +271,7 @@
/>
{:else}
<div
class="w-8 h-8 bg-gradient-to-br from-primary to-secondary rounded-full flex items-center justify-center text-primary-content font-semibold text-xs shadow-lg"
class="w-7 h-7 bg-gradient-to-br from-primary to-secondary rounded-full flex items-center justify-center text-primary-content font-semibold text-xs"
>
{creatorInitials.toUpperCase()}
</div>
@@ -258,28 +284,110 @@
</div>
<!-- Content Section -->
<div class="card-body p-6 space-y-4">
<!-- Header Section -->
<div class="space-y-3">
<div class="card-body p-4 space-y-3">
<!-- Header: title + compact actions -->
<div class="flex items-start justify-between gap-3">
<a
href="/locations/{adventure.id}"
class="text-xl font-bold text-left hover:text-primary transition-colors duration-200 line-clamp-2 group-hover:underline block"
class="text-lg font-semibold hover:text-primary transition-colors duration-200 line-clamp-2"
>
{adventure.name}
</a>
<!-- Location -->
<div class="flex items-center gap-2">
<button
class="btn btn-sm p-1 text-base-content"
aria-label="open-details"
on:click={() => goto(`/locations/${adventure.id}`)}
>
<Launch class="w-4 h-4" />
</button>
{#if (adventure.user && adventure.user.uuid == user?.uuid) || (collection && user && collection.shared_with?.includes(user.uuid)) || (collection && user && collection.user == user.uuid)}
<div class="dropdown dropdown-end">
<div tabindex="0" role="button" class="btn btn-square btn-sm p-1 text-base-content">
<DotsHorizontal class="w-5 h-5" />
</div>
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
<ul
tabindex="0"
class="dropdown-content menu bg-base-100 rounded-box z-[1] w-52 p-2 shadow-lg border border-base-300"
>
<li>
<button on:click={editAdventure} class="flex items-center gap-2">
<FileDocumentEdit class="w-4 h-4" />
{$t('adventures.edit_location')}
</button>
</li>
{#if user?.uuid == adventure.user?.uuid}
<li>
<button
on:click={() => (isCollectionModalOpen = true)}
class="flex items-center gap-2"
>
<Plus class="w-4 h-4" />
{$t('collection.manage_collections')}
</button>
</li>
{:else if collection && user && collection.user == user.uuid}
<li>
<button
on:click={() =>
removeFromCollection(new CustomEvent('unlink', { detail: collection.id }))}
class="flex items-center gap-2"
>
<LinkVariantRemove class="w-4 h-4" />
{$t('adventures.remove_from_collection')}
</button>
</li>
{/if}
{#if adventure.is_public}
<li>
<button on:click={copyLink} class="flex items-center gap-2">
{#if copied}
<Check class="w-4 h-4 text-success" />
<span>{$t('adventures.link_copied')}</span>
{:else}
<LinkIcon class="w-4 h-4" />
{$t('adventures.copy_link')}
{/if}
</button>
</li>
{/if}
{#if user.uuid == adventure.user?.uuid}
<div class="divider my-1"></div>
<li>
<button
id="delete_adventure"
data-umami-event="Delete Adventure"
class="text-error flex items-center gap-2"
on:click={() => (isWarningModalOpen = true)}
>
<TrashCan class="w-4 h-4" />
{$t('adventures.delete')}
</button>
</li>
{/if}
</ul>
</div>
{/if}
</div>
</div>
<!-- Inline stats: location, rating, visits -->
<div class="flex flex-wrap items-center gap-3 text-sm text-base-content/70 min-w-0">
{#if adventure.location}
<div class="flex items-center gap-2 text-base-content/70">
<div class="flex items-center gap-1 min-w-0">
<MapMarker class="w-4 h-4 text-primary" />
<span class="text-sm font-medium truncate">{adventure.location}</span>
<span class="truncate max-w-[18rem]">{adventure.location}</span>
</div>
{/if}
<!-- Rating -->
{#if adventure.rating}
<div class="flex items-center gap-2">
<div class="flex">
<div class="flex items-center gap-1">
<div class="flex -ml-1">
{#each renderStars(adventure.rating) as filled}
{#if filled}
<Star class="w-4 h-4 text-warning fill-current" />
@@ -288,100 +396,34 @@
{/if}
{/each}
</div>
<span class="text-sm text-base-content/60">({adventure.rating}/5)</span>
<span class="text-xs text-base-content/60">({adventure.rating}/5)</span>
</div>
{/if}
</div>
<!-- Stats Section -->
{#if adventure.visits.length > 0}
<div class="flex items-center gap-2 p-3 bg-base-200 rounded-lg">
<Calendar class="w-4 h-4 text-primary" />
<span class="text-sm font-medium">{formatVisitCount()}</span>
</div>
{/if}
<!-- Actions Section -->
{#if !readOnly}
<div class="pt-4 border-t border-base-300">
{#if type != 'link'}
<div class="flex justify-between items-center">
<button
class="btn btn-base-300 btn-sm flex-1 mr-2"
on:click={() => goto(`/locations/${adventure.id}`)}
>
<Launch class="w-4 h-4" />
{$t('adventures.open_details')}
</button>
{#if (adventure.user && adventure.user.uuid == user?.uuid) || (collection && user && collection.shared_with?.includes(user.uuid)) || (collection && user && collection.user == user.uuid)}
<div class="dropdown dropdown-end">
<div tabindex="0" role="button" class="btn btn-square btn-sm btn-base-300">
<DotsHorizontal class="w-5 h-5" />
</div>
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
<ul
tabindex="0"
class="dropdown-content menu bg-base-100 rounded-box z-[1] w-56 p-2 shadow-xl border border-base-300"
>
<li>
<button on:click={editAdventure} class="flex items-center gap-2">
<FileDocumentEdit class="w-4 h-4" />
{$t('adventures.edit_location')}
</button>
</li>
{#if user?.uuid == adventure.user?.uuid}
<li>
<button
on:click={() => (isCollectionModalOpen = true)}
class="flex items-center gap-2"
>
<Plus class="w-4 h-4" />
{$t('collection.manage_collections')}
</button>
</li>
{:else if collection && user && collection.user == user.uuid}
<li>
<button
on:click={() =>
removeFromCollection(
new CustomEvent('unlink', { detail: collection.id })
)}
class="flex items-center gap-2"
>
<LinkVariantRemove class="w-4 h-4" />
{$t('adventures.remove_from_collection')}
</button>
</li>
{/if}
{#if user.uuid == adventure.user?.uuid}
<div class="divider my-1"></div>
<li>
<button
id="delete_adventure"
data-umami-event="Delete Adventure"
class="text-error flex items-center gap-2"
on:click={() => (isWarningModalOpen = true)}
>
<TrashCan class="w-4 h-4" />
{$t('adventures.delete')}
</button>
</li>
{/if}
</ul>
</div>
{/if}
</div>
{:else}
<button class="btn btn-primary btn-block" on:click={link}>
<Link class="w-4 h-4" />
Link Adventure
</button>
<!-- Tags (compact) -->
{#if displayActivityTypes.length > 0}
<div class="flex flex-wrap gap-2">
{#each displayActivityTypes as tag}
<span class="badge badge-ghost badge-sm">{tag}</span>
{/each}
{#if remainingCount > 0}
<span class="badge badge-ghost badge-sm">+{remainingCount}</span>
{/if}
</div>
{/if}
</div>
{#if !readOnly}
{#if type == 'link'}
<div class="card-body p-4 pt-0">
<button class="btn btn-primary btn-block btn-sm" on:click={link}>
<Link class="w-4 h-4 mr-2" />
Link Adventure
</button>
</div>
{/if}
{/if}
</div>
<style>

View File

@@ -11,6 +11,12 @@
import { formatAllDayDate } from '$lib/dateUtils';
import { isAllDay } from '$lib';
import CardCarousel from './CardCarousel.svelte';
import Eye from '~icons/mdi/eye';
import EyeOff from '~icons/mdi/eye-off';
import Star from '~icons/mdi/star';
import StarOutline from '~icons/mdi/star-outline';
import MapMarker from '~icons/mdi/map-marker';
import DotsHorizontal from '~icons/mdi/dots-horizontal';
const dispatch = createEventDispatcher();
@@ -21,6 +27,15 @@
return '🏨';
}
}
function renderStars(rating: number) {
const stars = [];
for (let i = 1; i <= 5; i++) {
stars.push(i <= rating);
}
return stars;
}
export let lodging: Lodging;
export let user: User | null = null;
export let collection: Collection | null = null;
@@ -68,12 +83,40 @@
{/if}
<div
class="card w-full max-w-md bg-base-300 text-base-content shadow-2xl hover:shadow-3xl transition-all duration-300 border border-base-300 hover:border-primary/20 group"
class="card w-full max-w-md bg-base-300 shadow hover:shadow-md transition-all duration-200 border border-base-300 group"
aria-label="lodging-card"
>
<!-- Image Section with Overlay -->
<div class="relative overflow-hidden rounded-t-2xl">
<CardCarousel images={lodging.images} icon={getLodgingIcon(lodging.type)} name={lodging.name} />
<!-- Privacy Indicator -->
<div class="absolute top-2 right-4">
<div
class="tooltip tooltip-left"
data-tip={lodging.is_public ? $t('adventures.public') : $t('adventures.private')}
>
<div
class="badge badge-sm p-1 rounded-full text-base-content shadow-sm"
role="img"
aria-label={lodging.is_public ? $t('adventures.public') : $t('adventures.private')}
>
{#if lodging.is_public}
<Eye class="w-4 h-4" />
{:else}
<EyeOff class="w-4 h-4" />
{/if}
</div>
</div>
</div>
<!-- Out of Range Badge -->
{#if outsideCollectionRange}
<div class="absolute top-2 left-4">
<div class="badge badge-xs badge-error shadow">{$t('adventures.out_of_range')}</div>
</div>
{/if}
<!-- Category Badge -->
{#if lodging.type}
<div class="absolute bottom-4 left-4">
@@ -84,103 +127,117 @@
</div>
{/if}
</div>
<div class="card-body p-6 space-y-4">
<div class="card-body p-4 space-y-3">
<!-- Header -->
<div class="flex flex-col gap-3">
<h2 class="text-xl font-bold break-words">{lodging.name}</h2>
<div class="flex flex-wrap gap-2">
<div class="badge badge-secondary">
{$t(`lodging.${lodging.type}`)}
{getLodgingIcon(lodging.type)}
</div>
{#if outsideCollectionRange}
<div class="badge badge-error">{$t('adventures.out_of_range')}</div>
{/if}
</div>
</div>
<div class="flex items-start justify-between gap-3">
<h2 class="text-lg font-semibold line-clamp-2">{lodging.name}</h2>
<!-- Location Info -->
<div class="space-y-2">
{#if lodging.location}
<div class="flex items-center gap-2">
<span class="text-sm font-medium">{$t('adventures.location')}:</span>
<p class="text-sm break-words">{lodging.location}</p>
{#if lodging.user == user?.uuid || (collection && user && collection.shared_with?.includes(user.uuid))}
<div class="dropdown dropdown-end">
<div tabindex="0" role="button" class="btn btn-square btn-sm p-1 text-base-content">
<DotsHorizontal class="w-5 h-5" />
</div>
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
<ul
tabindex="0"
class="dropdown-content menu bg-base-100 rounded-box z-[1] 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>
<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>
</div>
{/if}
<div class="space-y-3">
{#if lodging.check_in}
<div class="flex gap-2 text-sm">
<span class="font-medium whitespace-nowrap">{$t('adventures.check_in')}:</span>
<span>
{#if isAllDay(lodging.check_in)}
{formatAllDayDate(lodging.check_in)}
{:else}
{formatDateInTimezone(lodging.check_in, lodging.timezone)}
{#if lodging.timezone}
<span class="ml-1 text-xs opacity-60">({lodging.timezone})</span>
{/if}
{/if}
</span>
</div>
{/if}
{#if lodging.check_out}
<div class="flex gap-2 text-sm">
<span class="font-medium whitespace-nowrap">{$t('adventures.check_out')}:</span>
<span>
{#if isAllDay(lodging.check_out)}
{formatAllDayDate(lodging.check_out)}
{:else}
{formatDateInTimezone(lodging.check_out, lodging.timezone)}
{#if lodging.timezone}
<span class="ml-1 text-xs opacity-60">({lodging.timezone})</span>
{/if}
{/if}
</span>
</div>
{/if}
</div>
</div>
<!-- Reservation Info -->
{#if lodging.user == user?.uuid || (collection && user && collection.shared_with?.includes(user.uuid))}
<div class="space-y-2">
{#if lodging.reservation_number}
<div class="flex items-center gap-2">
<span class="text-sm font-medium">{$t('adventures.reservation_number')}:</span>
<p class="text-sm break-all">{lodging.reservation_number}</p>
</div>
{/if}
{#if lodging.price}
<div class="flex items-center gap-2">
<span class="text-sm font-medium">{$t('adventures.price')}:</span>
<p class="text-sm">{lodging.price}</p>
</div>
{/if}
<!-- Location Info (Compact) -->
{#if lodging.location}
<div class="flex items-center gap-2 text-sm text-base-content/70">
<MapMarker class="w-4 h-4 text-primary" />
<span class="truncate max-w-[18rem]">{lodging.location}</span>
</div>
{/if}
<!-- Actions -->
<!-- Inline Stats -->
<div class="flex flex-wrap items-center gap-3 text-sm text-base-content/70">
{#if lodging.check_in}
<div class="flex items-center gap-1">
<span class="font-medium">
{#if isAllDay(lodging.check_in)}
{formatAllDayDate(lodging.check_in)}
{:else}
{formatDateInTimezone(lodging.check_in, lodging.timezone)}
{/if}
</span>
</div>
{/if}
{#if lodging.check_out && lodging.check_in}
<span class="text-base-content/40"></span>
<div class="flex items-center gap-1">
<span class="font-medium">
{#if isAllDay(lodging.check_out)}
{formatAllDayDate(lodging.check_out)}
{:else}
{formatDateInTimezone(lodging.check_out, lodging.timezone)}
{/if}
</span>
</div>
{/if}
{#if lodging.rating}
<div class="flex items-center gap-1">
<div class="flex -ml-1">
{#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-base-content/30" />
{/if}
{/each}
</div>
<span class="text-xs text-base-content/60">({lodging.rating}/5)</span>
</div>
{/if}
</div>
<!-- Additional Info (for owner only) -->
{#if lodging.user == user?.uuid || (collection && user && collection.shared_with?.includes(user.uuid))}
<div class="pt-4 border-t border-base-300 flex justify-end gap-2">
<button
class="btn btn-neutral btn-sm flex items-center gap-1"
on:click={editTransportation}
title={$t('transportation.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={$t('adventures.delete')}
>
<TrashCanOutline class="w-5 h-5" />
<span>{$t('adventures.delete')}</span>
</button>
<div class="flex flex-wrap gap-2">
{#if lodging.reservation_number}
<div class="badge badge-ghost badge-sm">
{$t('adventures.reservation')}: {lodging.reservation_number}
</div>
{/if}
{#if lodging.price}
<div class="badge badge-ghost badge-sm">
{lodging.price}
</div>
{/if}
</div>
{/if}
</div>
</div>
<style>
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>

View File

@@ -15,6 +15,9 @@
import TrashCan from '~icons/mdi/trash-can';
import Calendar from '~icons/mdi/calendar';
import DeleteWarning from './DeleteWarning.svelte';
import DotsHorizontal from '~icons/mdi/dots-horizontal';
import FileDocumentEdit from '~icons/mdi/file-document-edit';
import LinkVariant from '~icons/mdi/link-variant';
import { isEntityOutsideCollectionDateRange } from '$lib/dateUtils';
export let note: Note;
@@ -60,76 +63,116 @@
{/if}
<div
class="card w-full max-w-md bg-base-300 text-base-content shadow-2xl hover:shadow-3xl transition-all duration-300 border border-base-300 hover:border-primary/20 group"
class="card w-full max-w-md bg-base-300 shadow hover:shadow-md transition-all duration-200 border border-base-300 group"
aria-label="note-card"
>
<div class="card-body p-6 space-y-4">
<div class="card-body p-4 space-y-3">
<!-- Header -->
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
<h2 class="text-xl font-bold break-words">{note.name}</h2>
<div class="flex flex-wrap gap-2">
<div class="badge badge-primary">{$t('adventures.note')}</div>
{#if outsideCollectionRange}
<div class="badge badge-error">{$t('adventures.out_of_range')}</div>
{/if}
<div class="flex items-start justify-between gap-3">
<div class="flex-1 min-w-0">
<h2 class="text-lg font-semibold line-clamp-2">{note.name}</h2>
<div class="flex flex-wrap items-center gap-2 mt-2">
<div class="badge badge-primary badge-sm">{$t('adventures.note')}</div>
{#if outsideCollectionRange}
<div class="badge badge-error badge-xs">{$t('adventures.out_of_range')}</div>
{/if}
</div>
</div>
{#if note.user == user?.uuid || (collection && user && collection.shared_with?.includes(user.uuid))}
<div class="dropdown dropdown-end">
<div tabindex="0" role="button" class="btn btn-square btn-sm p-1 text-base-content">
<DotsHorizontal class="w-5 h-5" />
</div>
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
<ul
tabindex="0"
class="dropdown-content menu bg-base-100 rounded-box z-[1] w-52 p-2 shadow-lg border border-base-300"
>
<li>
<button on:click={editNote} class="flex items-center gap-2">
<FileDocumentEdit class="w-4 h-4" />
{$t('notes.open')}
</button>
</li>
<div class="divider my-1"></div>
<li>
<button
class="text-error flex items-center gap-2"
on:click={() => (isWarningModalOpen = true)}
>
<TrashCan class="w-4 h-4" />
{$t('adventures.delete')}
</button>
</li>
</ul>
</div>
{/if}
</div>
<!-- Note Content -->
<!-- Note Content Preview -->
{#if note.content && note.content?.length > 0}
<article
class="prose overflow-auto max-h-72 max-w-full p-4 border border-base-300 bg-base-100 rounded-lg"
class="prose prose-sm max-w-none overflow-hidden max-h-32 text-sm text-base-content/70 line-clamp-4"
>
{@html renderMarkdown(note.content || '')}
</article>
{/if}
<!-- Links -->
{#if note.links && note.links?.length > 0}
<div class="space-y-1">
<p class="text-sm font-medium">
<!-- Inline Stats -->
<div class="flex flex-wrap items-center gap-3 text-sm text-base-content/70">
{#if note.date && note.date !== ''}
<div class="flex items-center gap-1">
<Calendar class="w-4 h-4 text-primary" />
<span>{new Date(note.date).toLocaleDateString(undefined, { timeZone: 'UTC' })}</span>
</div>
{/if}
{#if note.links && note.links?.length > 0}
<div class="badge badge-ghost badge-sm">
<LinkVariant class="w-3 h-3 mr-1" />
{note.links.length}
{note.links.length > 1 ? $t('adventures.links') : $t('adventures.link')}
</p>
<ul class="list-disc pl-5 text-sm">
{#each note.links.slice(0, 3) as link}
<li>
<a class="link link-primary" href={link} target="_blank" rel="noopener noreferrer">
{link.split('//')[1]?.split('/', 1)[0]}
</a>
</li>
{/each}
{#if note.links.length > 3}
<li></li>
{/if}
</ul>
</div>
{/if}
<!-- Date -->
{#if note.date && note.date !== ''}
<div class="inline-flex items-center gap-2 text-sm">
<Calendar class="w-5 h-5 text-primary" />
<p>{new Date(note.date).toLocaleDateString(undefined, { timeZone: 'UTC' })}</p>
</div>
{/if}
<!-- Actions -->
<div class="pt-4 border-t border-base-300 flex justify-end gap-2">
<button class="btn btn-neutral btn-sm flex items-center gap-1" on:click={editNote}>
<Launch class="w-5 h-5" />
{$t('notes.open')}
</button>
{#if note.user == user?.uuid || (collection && user && collection.shared_with?.includes(user.uuid))}
<button
id="delete_adventure"
data-umami-event="Delete Adventure"
class="btn btn-secondary btn-sm flex items-center gap-1"
on:click={() => (isWarningModalOpen = true)}
>
<TrashCan class="w-5 h-5" />
{$t('adventures.delete')}
</button>
</div>
{/if}
</div>
<!-- Links Preview (compact) -->
{#if note.links && note.links?.length > 0}
<div class="flex flex-wrap gap-2">
{#each note.links.slice(0, 2) as link}
<a
class="badge badge-outline badge-sm hover:badge-primary transition-colors"
href={link}
target="_blank"
rel="noopener noreferrer"
>
<LinkVariant class="w-3 h-3 mr-1" />
{link.split('//')[1]?.split('/', 1)[0]}
</a>
{/each}
{#if note.links.length > 2}
<span class="badge badge-ghost badge-sm">+{note.links.length - 2}</span>
{/if}
</div>
{/if}
</div>
</div>
<style>
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.line-clamp-4 {
display: -webkit-box;
-webkit-line-clamp: 4;
line-clamp: 4;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>

View File

@@ -18,6 +18,9 @@
import Eye from '~icons/mdi/eye';
import EyeOff from '~icons/mdi/eye-off';
import Star from '~icons/mdi/star';
import StarOutline from '~icons/mdi/star-outline';
import DotsHorizontal from '~icons/mdi/dots-horizontal';
function getTransportationIcon(type: string) {
if (type in TRANSPORTATION_TYPES_ICONS) {
@@ -26,6 +29,15 @@
return '🚗';
}
}
function renderStars(rating: number) {
const stars = [];
for (let i = 1; i <= 5; i++) {
stars.push(i <= rating);
}
return stars;
}
const dispatch = createEventDispatcher();
export let transportation: Transportation;
@@ -77,7 +89,8 @@
{/if}
<div
class="card w-full max-w-md bg-base-300 text-base-content shadow-2xl hover:shadow-3xl transition-all duration-300 border border-base-300 hover:border-primary/20 group"
class="card w-full max-w-md bg-base-300 shadow hover:shadow-md transition-all duration-200 border border-base-300 group"
aria-label="transportation-card"
>
<!-- Image Section with Overlay -->
<div class="relative overflow-hidden rounded-t-2xl">
@@ -88,13 +101,15 @@
/>
<!-- Privacy Indicator -->
<div class="absolute top-4 right-4">
<div class="absolute top-2 right-4">
<div
class="tooltip tooltip-left"
data-tip={transportation.is_public ? $t('adventures.public') : $t('adventures.private')}
>
<div
class="btn btn-circle btn-sm btn-ghost bg-black/20 backdrop-blur-sm border-0 text-white"
class="badge badge-sm p-1 rounded-full text-base-content shadow-sm"
role="img"
aria-label={transportation.is_public ? $t('adventures.public') : $t('adventures.private')}
>
{#if transportation.is_public}
<Eye class="w-4 h-4" />
@@ -105,6 +120,13 @@
</div>
</div>
<!-- Out of Range Badge -->
{#if outsideCollectionRange}
<div class="absolute top-2 left-4">
<div class="badge badge-xs badge-error shadow">{$t('adventures.out_of_range')}</div>
</div>
{/if}
<!-- Category Badge -->
{#if transportation.type}
<div class="absolute bottom-4 left-4">
@@ -116,105 +138,116 @@
{/if}
</div>
<div class="card-body p-6 space-y-6">
<div class="card-body p-4 space-y-3">
<!-- Header -->
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
<h2 class="text-xl font-bold truncate">{transportation.name}</h2>
<div class="flex flex-wrap gap-2">
<div class="badge badge-secondary">
{$t(`transportation.modes.${transportation.type}`)}
{getTransportationIcon(transportation.type)}
</div>
{#if transportation.type === 'plane' && transportation.flight_number}
<div class="badge badge-neutral">{transportation.flight_number}</div>
{/if}
{#if outsideCollectionRange}
<div class="badge badge-error">{$t('adventures.out_of_range')}</div>
{/if}
</div>
</div>
<div class="flex items-start justify-between gap-3">
<h2 class="text-lg font-semibold line-clamp-2">{transportation.name}</h2>
<!-- Route Info -->
<div class="space-y-3">
{#if transportation.from_location}
<div class="flex gap-2 text-sm">
<span class="font-medium whitespace-nowrap">{$t('adventures.from')}:</span>
<span class="break-words">{transportation.from_location}</span>
</div>
{/if}
{#if transportation.to_location}
<div class="flex gap-2 text-sm">
<span class="font-medium whitespace-nowrap">{$t('adventures.to')}:</span>
<span class="break-words">{transportation.to_location}</span>
</div>
{/if}
{#if transportation.distance && !isNaN(+transportation.distance)}
<div class="flex gap-2 text-sm">
<span class="font-medium whitespace-nowrap">{$t('adventures.distance')}:</span>
<span>
{(+transportation.distance).toFixed(1)} km / {toMiles(transportation.distance)} mi
</span>
{#if transportation.user === user?.uuid || (collection && user && collection.shared_with?.includes(user.uuid))}
<div class="dropdown dropdown-end">
<div tabindex="0" role="button" class="btn btn-square btn-sm p-1 text-base-content">
<DotsHorizontal class="w-5 h-5" />
</div>
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
<ul
tabindex="0"
class="dropdown-content menu bg-base-100 rounded-box z-[1] 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>
<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>
</div>
{/if}
</div>
<!-- Time Info -->
<div class="space-y-3">
<!-- Route Info (Compact) -->
<div class="space-y-2">
{#if transportation.from_location && transportation.to_location}
<div class="flex items-center gap-2 text-sm text-base-content/70">
<span class="font-medium">{$t('adventures.route')}:</span>
<span class="truncate">{transportation.from_location} → {transportation.to_location}</span
>
</div>
{:else if transportation.from_location}
<div class="flex items-center gap-2 text-sm text-base-content/70">
<span class="font-medium">{$t('adventures.from')}:</span>
<span class="truncate">{transportation.from_location}</span>
</div>
{:else if transportation.to_location}
<div class="flex items-center gap-2 text-sm text-base-content/70">
<span class="font-medium">{$t('adventures.to')}:</span>
<span class="truncate">{transportation.to_location}</span>
</div>
{/if}
{#if transportation.type === 'plane' && transportation.flight_number}
<div class="flex items-center gap-2 text-sm text-base-content/70">
<span class="font-medium">{$t('adventures.flight')}:</span>
<span>{transportation.flight_number}</span>
</div>
{/if}
</div>
<!-- Inline Stats -->
<div class="flex flex-wrap items-center gap-3 text-sm text-base-content/70">
{#if transportation.date}
<div class="flex gap-2 text-sm">
<span class="font-medium whitespace-nowrap">{$t('adventures.start')}:</span>
<span>
<div class="flex items-center gap-1">
<span class="font-medium">
{#if isAllDay(transportation.date) && (!transportation.end_date || isAllDay(transportation.end_date))}
{formatAllDayDate(transportation.date)}
{:else}
{formatDateInTimezone(transportation.date, transportation.start_timezone)}
{#if transportation.start_timezone}
<span class="ml-1 text-xs opacity-60">({transportation.start_timezone})</span>
{/if}
{/if}
</span>
</div>
{/if}
{#if transportation.end_date}
<div class="flex gap-2 text-sm">
<span class="font-medium whitespace-nowrap">{$t('adventures.end')}:</span>
<span>
{#if isAllDay(transportation.end_date) && (!transportation.date || isAllDay(transportation.date))}
{formatAllDayDate(transportation.end_date)}
{:else}
{formatDateInTimezone(transportation.end_date, transportation.end_timezone)}
{#if transportation.end_timezone}
<span class="ml-1 text-xs opacity-60">({transportation.end_timezone})</span>
{#if transportation.distance && !isNaN(+transportation.distance)}
<div class="badge badge-ghost badge-sm">
{user?.measurement_system === 'imperial'
? `${toMiles(transportation.distance)} mi`
: `${(+transportation.distance).toFixed(1)} km`}
</div>
{/if}
{#if transportation.rating}
<div class="flex items-center gap-1">
<div class="flex -ml-1">
{#each renderStars(transportation.rating) as filled}
{#if filled}
<Star class="w-4 h-4 text-warning fill-current" />
{:else}
<StarOutline class="w-4 h-4 text-base-content/30" />
{/if}
{/if}
</span>
{/each}
</div>
<span class="text-xs text-base-content/60">({transportation.rating}/5)</span>
</div>
{/if}
</div>
<!-- Actions -->
{#if transportation.user === user?.uuid || (collection && user && collection.shared_with?.includes(user.uuid))}
<div class="pt-4 border-t border-base-300 flex justify-end gap-2">
<button
class="btn btn-neutral btn-sm flex items-center gap-1"
on:click={editTransportation}
title={$t('transportation.edit')}
>
<FileDocumentEdit class="w-5 h-5" />
<span>{$t('transportation.edit')}</span>
</button>
<button
class="btn btn-secondary btn-sm flex items-center gap-1"
on:click={() => (isWarningModalOpen = true)}
title={$t('adventures.delete')}
>
<TrashCanOutline class="w-5 h-5" />
<span>{$t('adventures.delete')}</span>
</button>
</div>
{/if}
</div>
</div>
<style>
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>

View File

File diff suppressed because it is too large Load Diff

View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,83 @@
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';
const endpoint = PUBLIC_SERVER_URL || 'http://localhost:8000';
export const load = (async (event) => {
const id = event.params as { id: string };
let sessionid = event.cookies.get('sessionid');
let request = await fetch(`${endpoint}/api/collections/${id.id}/`, {
headers: {
Cookie: `sessionid=${sessionid}`
}
});
if (!request.ok) {
console.error('Failed to fetch adventure ' + id.id);
return {
props: {
adventure: null
}
};
} else {
let collection = (await request.json()) as Collection;
return {
props: {
adventure: collection
}
};
}
}) satisfies PageServerLoad;
import 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 adventureId = id.id;
if (!adventureId) {
return {
status: 400,
error: new Error('Bad request')
};
}
let sessionId = event.cookies.get('sessionid');
if (!sessionId) {
return {
status: 401,
error: new Error('Unauthorized')
};
}
let csrfToken = await fetchCSRFToken();
let res = await fetch(`${serverEndpoint}/api/collections/${event.params.id}`, {
method: 'DELETE',
headers: {
Cookie: `sessionid=${sessionId}; csrftoken=${csrfToken}`,
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken,
Referer: event.url.origin // Include Referer header
},
credentials: 'include'
});
if (!res.ok) {
return {
status: res.status,
error: new Error('Failed to delete collection')
};
} else {
return {
status: 204
};
}
}
};

View File

File diff suppressed because it is too large Load Diff

View File

@@ -178,7 +178,7 @@
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
{#each recentAdventures as adventure}
<div class="adventure-card">
<LocationCard {adventure} user={data.user} readOnly />
<LocationCard {adventure} readOnly user={null} />
</div>
{/each}
</div>

View File

@@ -58,6 +58,8 @@
let acknowledgeRestoreOverride: boolean = false;
// Indicates restore operation in progress to disable button and show loader
let isRestoring: boolean = false;
let newImmichIntegration: ImmichIntegration = {
server_url: '',
api_key: '',
@@ -102,6 +104,11 @@
if (browser && $page.form?.error) {
addToast('error', $t('settings.update_error'));
}
// Stop any restoring loader when a form result (success or error) is present
if (browser && $page.form) {
isRestoring = false;
}
}
async function checkVisitedRegions() {
@@ -1384,6 +1391,7 @@
method="post"
action="?/restoreData"
use:enhance
on:submit={() => (isRestoring = true)}
enctype="multipart/form-data"
class="space-y-4"
>
@@ -1448,8 +1456,11 @@
<button
type="submit"
class="btn btn-warning"
disabled={!acknowledgeRestoreOverride}
disabled={!acknowledgeRestoreOverride || isRestoring}
>
{#if isRestoring}
<span class="loading loading-spinner loading-sm mr-2"></span>
{/if}
🚀 {$t('settings.restore_data')}
</button>
</div>