mirror of
https://github.com/seanmorley15/AdventureLog.git
synced 2026-03-30 12:11:53 -04:00
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:
1852
.github/instructions/daisyui.instructions.md
vendored
Normal file
1852
.github/instructions/daisyui.instructions.md
vendored
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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'
|
||||
|
||||
@@ -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')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
@@ -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 |
|
||||
|
||||
143
documentation/docs/configuration/social_auth/pocket_id.md
Normal file
143
documentation/docs/configuration/social_auth/pocket_id.md
Normal 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 AdventureLog’s 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
|
||||
@@ -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>
|
||||
|
||||
307
frontend/src/lib/components/CollectionAllItems.svelte
Normal file
307
frontend/src/lib/components/CollectionAllItems.svelte
Normal 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}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
83
frontend/src/routes/collections/old/[id]/+page.server.ts
Normal file
83
frontend/src/routes/collections/old/[id]/+page.server.ts
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
1690
frontend/src/routes/collections/old/[id]/+page.svelte
Normal file
1690
frontend/src/routes/collections/old/[id]/+page.svelte
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user