diff --git a/backend/server/adventures/migrations/0072_transportation_collection_lodging_collection.py b/backend/server/adventures/migrations/0072_transportation_collection_lodging_collection.py new file mode 100644 index 000000000..f53f007aa --- /dev/null +++ b/backend/server/adventures/migrations/0072_transportation_collection_lodging_collection.py @@ -0,0 +1,25 @@ +# Generated manually for collection field on Transportation and Lodging + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('adventures', '0071_alter_collectionitineraryitem_unique_together_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='transportation', + name='collection', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='adventures.collection'), + ), + migrations.AddField( + model_name='lodging', + name='collection', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='adventures.collection'), + ), + # Note: note.collection and checklist.collection already exist in database + ] diff --git a/backend/server/adventures/migrations/0073_collectiontemplate.py b/backend/server/adventures/migrations/0073_collectiontemplate.py new file mode 100644 index 000000000..5f3e98333 --- /dev/null +++ b/backend/server/adventures/migrations/0073_collectiontemplate.py @@ -0,0 +1,30 @@ +# Generated manually for CollectionTemplate model + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('adventures', '0072_transportation_collection_lodging_collection'), + ] + + operations = [ + migrations.CreateModel( + name='CollectionTemplate', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), + ('name', models.CharField(max_length=255)), + ('description', models.TextField(blank=True, null=True)), + ('template_data', models.JSONField(default=dict)), + ('is_public', models.BooleanField(default=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='collection_templates', to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/backend/server/adventures/models.py b/backend/server/adventures/models.py index bdeaeac91..415d09aad 100644 --- a/backend/server/adventures/models.py +++ b/backend/server/adventures/models.py @@ -707,6 +707,25 @@ def __str__(self): self.collection.name} - {self.date} - {self.name or 'Unnamed Day'}" +class CollectionTemplate(models.Model): + """Reusable template for creating new collections with pre-defined structure""" + id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True, primary_key=True) + name = models.CharField(max_length=255) + description = models.TextField(blank=True, null=True) + template_data = models.JSONField(default=dict) + # Structure: {notes: [...], checklists: [...], transportations: [...], lodgings: [...]} + is_public = models.BooleanField(default=False) + user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='collection_templates') + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ['-created_at'] + + def __str__(self): + return f"{self.name} ({'Public' if self.is_public else 'Private'})" + + class CollectionItineraryItem(models.Model): id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) diff --git a/backend/server/adventures/serializers.py b/backend/server/adventures/serializers.py index 054357397..fe18d63a3 100644 --- a/backend/server/adventures/serializers.py +++ b/backend/server/adventures/serializers.py @@ -1,5 +1,5 @@ import os -from .models import Location, ContentImage, ChecklistItem, Collection, Note, Transportation, Checklist, Visit, Category, ContentAttachment, Lodging, CollectionInvite, Trail, Activity, CollectionItineraryItem, CollectionItineraryDay +from .models import Location, ContentImage, ChecklistItem, Collection, Note, Transportation, Checklist, Visit, Category, ContentAttachment, Lodging, CollectionInvite, Trail, Activity, CollectionItineraryItem, CollectionItineraryDay, CollectionTemplate from rest_framework import serializers from main.utils import CustomModelSerializer from users.serializers import CustomUserDetailsSerializer @@ -788,25 +788,37 @@ def get_transportations(self, obj): # Only include transportations if not in nested context if self.context.get('nested', False): return [] - return TransportationSerializer(obj.transportation_set.all(), many=True, context=self.context).data + try: + return TransportationSerializer(obj.transportation_set.all(), many=True, context=self.context).data + except Exception: + return [] # Handle missing column gracefully def get_notes(self, obj): # Only include notes if not in nested context if self.context.get('nested', False): return [] - return NoteSerializer(obj.note_set.all(), many=True, context=self.context).data + try: + return NoteSerializer(obj.note_set.all(), many=True, context=self.context).data + except Exception: + return [] # Handle missing column gracefully def get_checklists(self, obj): # Only include checklists if not in nested context if self.context.get('nested', False): return [] - return ChecklistSerializer(obj.checklist_set.all(), many=True, context=self.context).data + try: + return ChecklistSerializer(obj.checklist_set.all(), many=True, context=self.context).data + except Exception: + return [] # Handle missing column gracefully def get_lodging(self, obj): # Only include lodging if not in nested context if self.context.get('nested', False): return [] - return LodgingSerializer(obj.lodging_set.all(), many=True, context=self.context).data + try: + return LodgingSerializer(obj.lodging_set.all(), many=True, context=self.context).data + except Exception: + return [] # Handle missing column gracefully def get_status(self, obj): """Calculate the status of the collection based on dates""" @@ -1058,9 +1070,25 @@ def get_item(self, obj): """Return id and type for the linked item""" if not obj.item: return None - + return { 'id': str(obj.item.id), 'type': obj.content_type.model, } + + +class CollectionTemplateSerializer(CustomModelSerializer): + class Meta: + model = CollectionTemplate + fields = [ + 'id', 'name', 'description', 'template_data', 'is_public', + 'user', 'created_at', 'updated_at' + ] + read_only_fields = ['id', 'created_at', 'updated_at', 'user'] + + def to_representation(self, instance): + representation = super().to_representation(instance) + # Convert user to UUID string for consistency + representation['user'] = str(instance.user.uuid) + return representation \ No newline at end of file diff --git a/backend/server/adventures/urls.py b/backend/server/adventures/urls.py index 11e7e3d79..51dbd1354 100644 --- a/backend/server/adventures/urls.py +++ b/backend/server/adventures/urls.py @@ -25,6 +25,7 @@ router.register(r'visits', VisitViewSet, basename='visits') router.register(r'itineraries', ItineraryViewSet, basename='itineraries') router.register(r'itinerary-days', ItineraryDayViewSet, basename='itinerary-days') +router.register(r'collection-templates', CollectionTemplateViewSet, basename='collection-templates') urlpatterns = [ # Include the router under the 'api/' prefix diff --git a/backend/server/adventures/views/__init__.py b/backend/server/adventures/views/__init__.py index 168b7c507..ce7c36873 100644 --- a/backend/server/adventures/views/__init__.py +++ b/backend/server/adventures/views/__init__.py @@ -18,4 +18,5 @@ from .trail_view import * from .activity_view import * from .visit_view import * -from .itinerary_view import * \ No newline at end of file +from .itinerary_view import * +from .template_view import * \ No newline at end of file diff --git a/backend/server/adventures/views/collection_view.py b/backend/server/adventures/views/collection_view.py index b1917fcab..4ab86518c 100644 --- a/backend/server/adventures/views/collection_view.py +++ b/backend/server/adventures/views/collection_view.py @@ -13,9 +13,9 @@ import json import zipfile import tempfile -from adventures.models import Collection, Location, Transportation, Note, Checklist, CollectionInvite, ContentImage, CollectionItineraryItem, Lodging, CollectionItineraryDay, ContentAttachment, Category +from adventures.models import Collection, Location, Transportation, Note, Checklist, ChecklistItem, CollectionInvite, ContentImage, CollectionItineraryItem, Lodging, CollectionItineraryDay, ContentAttachment, Category, CollectionTemplate from adventures.permissions import CollectionShared -from adventures.serializers import CollectionSerializer, CollectionInviteSerializer, UltraSlimCollectionSerializer, CollectionItineraryItemSerializer, CollectionItineraryDaySerializer +from adventures.serializers import CollectionSerializer, CollectionInviteSerializer, UltraSlimCollectionSerializer, CollectionItineraryItemSerializer, CollectionItineraryDaySerializer, CollectionTemplateSerializer from users.models import CustomUser as User from adventures.utils import pagination from users.serializers import CustomUserDetailsSerializer as UserSerializer @@ -791,6 +791,214 @@ def _coords_close(lat1, lon1, lat2, lon2, threshold=0.02): serializer = self.get_serializer(new_collection) return Response(serializer.data, status=status.HTTP_201_CREATED) + @action(detail=True, methods=['post']) + def duplicate(self, request, pk=None): + """ + Duplicate a collection with all its content. + Creates deep copies of: Transportation, Lodging, Notes, Checklists, ChecklistItems. + Locations are linked (M2M) but not duplicated. + """ + collection = self.get_object() + + # Ensure user has permission (owner or shared with) + if collection.user != request.user and not collection.shared_with.filter(id=request.user.id).exists(): + return Response( + {"error": "You do not have permission to duplicate this collection"}, + status=status.HTTP_403_FORBIDDEN + ) + + # Create new collection with date/time in name for uniqueness + from django.utils import timezone + copy_time = timezone.now().strftime('%Y-%m-%d %H:%M') + new_collection = Collection.objects.create( + name=f"{collection.name} - Copy {copy_time}", + description=collection.description, + start_date=collection.start_date, + end_date=collection.end_date, + is_public=False, # Always private for duplicates + user=request.user, + link=collection.link, + ) + + # Link same locations (M2M - shared reference) + new_collection.locations.set(collection.locations.all()) + + # Deep copy Transportation (wrapped in try/except for migration compatibility) + try: + for transport in collection.transportation_set.all(): + Transportation.objects.create( + user=request.user, + collection=new_collection, + type=transport.type, + name=transport.name, + description=transport.description, + rating=transport.rating, + link=transport.link, + date=transport.date, + end_date=transport.end_date, + start_timezone=transport.start_timezone, + end_timezone=transport.end_timezone, + flight_number=transport.flight_number, + from_location=transport.from_location, + to_location=transport.to_location, + origin_latitude=transport.origin_latitude, + origin_longitude=transport.origin_longitude, + destination_latitude=transport.destination_latitude, + destination_longitude=transport.destination_longitude, + start_code=transport.start_code, + end_code=transport.end_code, + is_public=False, + ) + except Exception: + pass # Skip if collection field not yet migrated + + # Deep copy Lodging (wrapped in try/except for migration compatibility) + try: + for lodging in collection.lodging_set.all(): + Lodging.objects.create( + user=request.user, + collection=new_collection, + type=lodging.type, + name=lodging.name, + description=lodging.description, + rating=lodging.rating, + link=lodging.link, + check_in=lodging.check_in, + check_out=lodging.check_out, + timezone=lodging.timezone, + reservation_number=lodging.reservation_number, + latitude=lodging.latitude, + longitude=lodging.longitude, + location=lodging.location, + is_public=False, + ) + except Exception: + pass # Skip if collection field not yet migrated + + # Deep copy Notes (use reverse relation) + for note in collection.note_set.all(): + Note.objects.create( + user=request.user, + collection=new_collection, + name=note.name, + content=note.content, + links=note.links, + date=note.date, + is_public=False, + ) + + # Deep copy Checklists with their items (use reverse relation) + for checklist in collection.checklist_set.all(): + new_checklist = Checklist.objects.create( + user=request.user, + collection=new_collection, + name=checklist.name, + date=checklist.date, + is_public=False, + ) + # Copy checklist items + for item in checklist.checklistitem_set.all(): + ChecklistItem.objects.create( + user=request.user, + checklist=new_checklist, + name=item.name, + is_checked=item.is_checked, + ) + + serializer = CollectionSerializer(new_collection, context={'request': request}) + return Response(serializer.data, status=status.HTTP_201_CREATED) + + @action(detail=True, methods=['post'], url_path='save-as-template') + def save_as_template(self, request, pk=None): + """ + Save a collection's structure as a reusable template. + Template includes: notes, checklists, transportations, lodgings. + Does NOT include: locations, dates, images, attachments. + """ + collection = self.get_object() + + # Ensure user has permission (owner only for templates) + if collection.user != request.user: + return Response( + {"error": "Only the collection owner can save it as a template"}, + status=status.HTTP_403_FORBIDDEN + ) + + # Get template name and description from request or use collection's + template_name = request.data.get('name', collection.name) + template_description = request.data.get('description', collection.description) + is_public = request.data.get('is_public', False) + + # Build template data structure (includes locations, excludes dates) + template_data = { + 'notes': [], + 'checklists': [], + 'transportations': [], + 'lodgings': [], + 'locations': [], # Store location IDs + } + + # Extract location IDs + for location in collection.locations.all(): + template_data['locations'].append(str(location.id)) + + # Extract notes structure (use reverse relation) + for note in collection.note_set.all(): + template_data['notes'].append({ + 'name': note.name, + 'content': note.content, + 'links': note.links or [], + }) + + # Extract checklists with items (use reverse relation) + for checklist in collection.checklist_set.all(): + checklist_data = { + 'name': checklist.name, + 'items': [], + } + for item in checklist.checklistitem_set.all(): + checklist_data['items'].append({ + 'name': item.name, + }) + template_data['checklists'].append(checklist_data) + + # Extract transportations structure (wrapped in try/except for migration compatibility) + try: + for transport in collection.transportation_set.all(): + template_data['transportations'].append({ + 'type': transport.type, + 'name': transport.name, + 'description': transport.description, + 'from_location': transport.from_location, + 'to_location': transport.to_location, + }) + except Exception: + pass # Skip if collection field not yet migrated + + # Extract lodgings structure (wrapped in try/except for migration compatibility) + try: + for lodging in collection.lodging_set.all(): + template_data['lodgings'].append({ + 'type': lodging.type, + 'name': lodging.name, + 'description': lodging.description, + 'location': lodging.location, + }) + except Exception: + pass # Skip if collection field not yet migrated + + # Create the template + template = CollectionTemplate.objects.create( + name=template_name, + description=template_description, + template_data=template_data, + is_public=is_public, + user=request.user, + ) + + serializer = CollectionTemplateSerializer(template, context={'request': request}) + return Response(serializer.data, status=status.HTTP_201_CREATED) + def perform_create(self, serializer): # This is ok because you cannot share a collection when creating it serializer.save(user=self.request.user) diff --git a/backend/server/adventures/views/template_view.py b/backend/server/adventures/views/template_view.py new file mode 100644 index 000000000..147940ad5 --- /dev/null +++ b/backend/server/adventures/views/template_view.py @@ -0,0 +1,155 @@ +from django.db.models import Q +from rest_framework import viewsets, status +from rest_framework.decorators import action +from rest_framework.response import Response +from rest_framework.permissions import IsAuthenticated + +from adventures.models import ( + CollectionTemplate, Collection, Transportation, Note, Checklist, + ChecklistItem, Lodging, Location +) +from adventures.serializers import CollectionTemplateSerializer, CollectionSerializer + + +class CollectionTemplateViewSet(viewsets.ModelViewSet): + """ + ViewSet for managing collection templates. + + Supports: + - List own templates + public templates + - Retrieve template details + - Delete own templates + - Create collection from template + """ + serializer_class = CollectionTemplateSerializer + permission_classes = [IsAuthenticated] + + def get_queryset(self): + """Return user's own templates plus all public templates""" + user = self.request.user + return CollectionTemplate.objects.filter( + Q(user=user) | Q(is_public=True) + ).distinct() + + def perform_create(self, serializer): + serializer.save(user=self.request.user) + + def destroy(self, request, *args, **kwargs): + """Only allow deletion of own templates""" + instance = self.get_object() + if instance.user != request.user: + return Response( + {"error": "You can only delete your own templates"}, + status=status.HTTP_403_FORBIDDEN + ) + return super().destroy(request, *args, **kwargs) + + def update(self, request, *args, **kwargs): + """Only allow update of own templates""" + instance = self.get_object() + if instance.user != request.user: + return Response( + {"error": "You can only update your own templates"}, + status=status.HTTP_403_FORBIDDEN + ) + return super().update(request, *args, **kwargs) + + def partial_update(self, request, *args, **kwargs): + """Only allow partial update of own templates""" + instance = self.get_object() + if instance.user != request.user: + return Response( + {"error": "You can only update your own templates"}, + status=status.HTTP_403_FORBIDDEN + ) + return super().partial_update(request, *args, **kwargs) + + @action(detail=True, methods=['post'], url_path='create-collection') + def create_collection(self, request, pk=None): + """ + Create a new collection from a template. + + The template_data contains structure for notes, checklists, + transportations, and lodgings that will be created in the new collection. + """ + template = self.get_object() + user = request.user + + # Get optional name override from request + collection_name = request.data.get('name', template.name) + collection_description = request.data.get('description', template.description) + + # Create the new collection + new_collection = Collection.objects.create( + name=collection_name, + description=collection_description, + is_public=False, # New collections from templates are always private + user=user, + ) + + template_data = template.template_data or {} + + # Link locations from template (only those the user has access to) + location_ids = template_data.get('locations', []) + if location_ids: + # Get locations that the user owns or are public + accessible_locations = Location.objects.filter( + Q(id__in=location_ids) & (Q(user=user) | Q(is_public=True)) + ) + new_collection.locations.set(accessible_locations) + + # Create notes from template + for note_data in template_data.get('notes', []): + Note.objects.create( + user=user, + collection=new_collection, + name=note_data.get('name', 'Untitled Note'), + content=note_data.get('content', ''), + links=note_data.get('links', []), + is_public=False, + ) + + # Create checklists from template + for checklist_data in template_data.get('checklists', []): + checklist = Checklist.objects.create( + user=user, + collection=new_collection, + name=checklist_data.get('name', 'Untitled Checklist'), + is_public=False, + ) + # Create checklist items + for item_data in checklist_data.get('items', []): + ChecklistItem.objects.create( + user=user, + checklist=checklist, + name=item_data.get('name', ''), + is_checked=False, # Always start unchecked + ) + + # Create transportations from template + for transport_data in template_data.get('transportations', []): + Transportation.objects.create( + user=user, + collection=new_collection, + type=transport_data.get('type', 'other'), + name=transport_data.get('name', 'Untitled Transportation'), + description=transport_data.get('description', ''), + from_location=transport_data.get('from_location', ''), + to_location=transport_data.get('to_location', ''), + is_public=False, + ) + + # Create lodgings from template + for lodging_data in template_data.get('lodgings', []): + Lodging.objects.create( + user=user, + collection=new_collection, + type=lodging_data.get('type', 'other'), + name=lodging_data.get('name', 'Untitled Lodging'), + description=lodging_data.get('description', ''), + location=lodging_data.get('location', ''), + is_public=False, + ) + + serializer = CollectionSerializer(new_collection, context={'request': request}) + return Response(serializer.data, status=status.HTTP_201_CREATED) diff --git a/documentation/.vitepress/config.mts b/documentation/.vitepress/config.mts index 67351b16c..002a0316e 100644 --- a/documentation/.vitepress/config.mts +++ b/documentation/.vitepress/config.mts @@ -138,6 +138,7 @@ export default defineConfig({ link: "/docs/install/kustomize", }, { text: "Unraid 🧡", link: "/docs/install/unraid" }, + { text: "Clever Cloud ☁️", link: "/docs/install/clever_cloud" }, { text: "Dev Container + WSL 🧰", link: "/docs/install/dev_container_wsl", diff --git a/documentation/docs/install/clever_cloud.md b/documentation/docs/install/clever_cloud.md new file mode 100644 index 000000000..94c5fd660 --- /dev/null +++ b/documentation/docs/install/clever_cloud.md @@ -0,0 +1,384 @@ +# Clever Cloud + +Clever Cloud is a European PaaS (Platform as a Service) that allows you to deploy applications without managing servers. This guide walks you through deploying AdventureLog on Clever Cloud **without modifying the source code**. + +## Prerequisites + +- A [Clever Cloud](https://www.clever-cloud.com/) account +- [Clever Cloud CLI](https://www.clever-cloud.com/doc/cli/) installed +- Git installed +- **A custom domain** (required - see note below) + +Custom Domain Required + +The default `*.cleverapps.io` domains **will not work**. The `cleverapps.io` domain is on the [Public Suffix List](https://publicsuffix.org/), which prevents session cookies from being shared between applications. + +You must use a custom domain where both apps share a parent domain: +- `adventurelog.your-domain.com` (frontend) +- `api.adventurelog.your-domain.com` (backend) + +## Architecture + +| Component | Size | Description | +|-----------|------|-------------| +| Frontend | XS (runtime) / M (build) | SvelteKit application | +| Backend | XS | Django REST API | +| PostgreSQL | S | Database | +| FS Bucket | - | Media storage (persistent) | +| Mailpace | - | Transactional emails (optional) | + +## Step 1: Clone the Repository + +```bash +git clone https://github.com/seanmorley15/AdventureLog.git +cd AdventureLog +``` + +## Step 2: Create the Applications + +```bash +# Python backend +clever create --type python adventurelog-backend + +# Node.js frontend +clever create --type node adventurelog-frontend + +# Link applications +clever link adventurelog-backend --alias adventurelog-backend +clever link adventurelog-frontend --alias adventurelog-frontend +``` + +## Step 3: Create Add-ons + +```bash +# PostgreSQL +clever addon create postgresql-addon adventurelog-postgres --plan s_sml --link adventurelog-backend + +# FS Bucket for media files +clever addon create fs-bucket adventurelog-media --link adventurelog-backend + +# Mailpace for emails (optional) +clever addon create mailpace adventurelog-email --link adventurelog-backend +``` + +## Step 4: Configure Custom Domains + +```bash +# Replace with your domain +clever domain add adventurelog.your-domain.com --alias adventurelog-frontend +clever domain add api.adventurelog.your-domain.com --alias adventurelog-backend +``` + +Configure CNAME DNS records with your domain registrar. + +## Step 5: Configure Instance Sizes + +```bash +# Frontend: build on M, runtime on XS +clever scale --alias adventurelog-frontend --flavor XS --build-flavor M + +# Backend: XS is sufficient +clever scale --alias adventurelog-backend --flavor XS +``` + +## Step 6: Backend Environment Variables + +### Clever Cloud Configuration + +```bash +clever env set --alias adventurelog-backend APP_FOLDER "backend/server" +clever env set --alias adventurelog-backend CC_PYTHON_VERSION "3" +clever env set --alias adventurelog-backend CC_PYTHON_MODULE "main.wsgi:application" +clever env set --alias adventurelog-backend CC_PRE_RUN_HOOK "memcached -u nobody -m 64 -p 11211 -d" +clever env set --alias adventurelog-backend CC_PYTHON_MANAGE_TASKS "collectstatic --noinput, migrate --noinput, download-countries --force" +``` + +### Django Configuration + +```bash +clever env set --alias adventurelog-backend SECRET_KEY "$(openssl rand -base64 32)" +clever env set --alias adventurelog-backend DEBUG "False" +clever env set --alias adventurelog-backend DISABLE_REGISTRATION "False" + +# URLs (replace your-domain.com with your domain) +clever env set --alias adventurelog-backend PUBLIC_URL "https://api.your-domain.com" +clever env set --alias adventurelog-backend FRONTEND_URL "https://your-domain.com" +clever env set --alias adventurelog-backend CSRF_TRUSTED_ORIGINS "https://your-domain.com,https://api.your-domain.com" +``` + +### Automatic Add-on Variable Mapping + +These commands automatically retrieve values injected by Clever Cloud add-ons: + +```bash +# === PostgreSQL: map addon variables to PG* === +clever env set --alias adventurelog-backend PGHOST "$(clever env --alias adventurelog-backend --format json | jq -r '.env[] | select(.name=="POSTGRESQL_ADDON_HOST") | .value')" +clever env set --alias adventurelog-backend PGDATABASE "$(clever env --alias adventurelog-backend --format json | jq -r '.env[] | select(.name=="POSTGRESQL_ADDON_DB") | .value')" +clever env set --alias adventurelog-backend PGUSER "$(clever env --alias adventurelog-backend --format json | jq -r '.env[] | select(.name=="POSTGRESQL_ADDON_USER") | .value')" +clever env set --alias adventurelog-backend PGPASSWORD "$(clever env --alias adventurelog-backend --format json | jq -r '.env[] | select(.name=="POSTGRESQL_ADDON_PASSWORD") | .value')" +clever env set --alias adventurelog-backend PGPORT "$(clever env --alias adventurelog-backend --format json | jq -r '.env[] | select(.name=="POSTGRESQL_ADDON_PORT") | .value')" + +# === FS Bucket: build CC_FS_BUCKET with BUCKET_HOST === +clever env set --alias adventurelog-backend CC_FS_BUCKET "backend/server/media:$(clever env --alias adventurelog-backend --format json | jq -r '.env[] | select(.name=="BUCKET_HOST") | .value')" + +# === Nginx serves media files directly === +clever env set --alias adventurelog-backend STATIC_FILES_PATH "backend/server/media" +clever env set --alias adventurelog-backend STATIC_URL_PREFIX "/media" +``` + +## Step 7: Frontend Environment Variables + +```bash +clever env set --alias adventurelog-frontend APP_FOLDER "frontend" +clever env set --alias adventurelog-frontend CC_NODE_BUILD_TOOL "pnpm" +clever env set --alias adventurelog-frontend CC_NODE_DEV_DEPENDENCIES "install" +clever env set --alias adventurelog-frontend CC_POST_BUILD_HOOK "cd frontend && pnpm run build" +clever env set --alias adventurelog-frontend CC_RUN_COMMAND "cd frontend && node build" + +# Configuration (replace with your custom domain) +clever env set --alias adventurelog-frontend ORIGIN "https://adventurelog.your-domain.com" +clever env set --alias adventurelog-frontend PUBLIC_SERVER_URL "https://api.adventurelog.your-domain.com" +clever env set --alias adventurelog-frontend BODY_SIZE_LIMIT "Infinity" +``` + +## Step 8: Email Configuration (Optional) + +If you created the Mailpace add-on: + +```bash +clever env set --alias adventurelog-backend EMAIL_BACKEND "email" +clever env set --alias adventurelog-backend EMAIL_HOST "smtp.mailpace.com" +clever env set --alias adventurelog-backend EMAIL_PORT "587" +clever env set --alias adventurelog-backend EMAIL_USE_TLS "True" +clever env set --alias adventurelog-backend EMAIL_USE_SSL "False" + +# Automatically map Mailpace token +clever env set --alias adventurelog-backend EMAIL_HOST_USER "$(clever env --alias adventurelog-backend --format json | jq -r '.env[] | select(.name=="MAILPACE_API_TOKEN") | .value')" +clever env set --alias adventurelog-backend EMAIL_HOST_PASSWORD "$(clever env --alias adventurelog-backend --format json | jq -r '.env[] | select(.name=="MAILPACE_API_TOKEN") | .value')" + +# Replace with your verified domain in Mailpace +clever env set --alias adventurelog-backend DEFAULT_FROM_EMAIL "noreply@your-verified-domain.com" +``` + +## Step 9: Deploy + +```bash +# Backend first +clever deploy --alias adventurelog-backend + +# Then frontend +clever deploy --alias adventurelog-frontend +``` + +## Step 10: Create Superuser + +After the first deployment, manually create the admin account: + +```bash +clever ssh --alias adventurelog-backend +cd backend/server +python manage.py createsuperuser +``` + +## Verification + +```bash +clever status --alias adventurelog-backend +clever status --alias adventurelog-frontend +``` + +## Accessing the Application + +| URL | Description | +|-----|-------------| +| `https://adventurelog.your-domain.com` | Main application | +| `https://api.adventurelog.your-domain.com/admin` | Django admin panel | + +## Updating + +To update AdventureLog: + +```bash +git pull origin main +clever deploy --alias adventurelog-backend +clever deploy --alias adventurelog-frontend +``` + +No Git Conflicts + +This configuration does not modify the AdventureLog source code. You can update without conflicts. + +## Quick Reference - Environment Variables + +Two Methods Available +- **CLI (recommended)**: Use the `jq` commands from steps 6-8 to automatically map add-on variables. +- **Web Interface**: Copy the blocks below and manually replace `REPLACE_*` values in the Clever Cloud console. + +### Backend (Python) + +```bash +# ============================================ +# CLEVER CLOUD CONFIGURATION +# ============================================ + +# Django application folder +APP_FOLDER="backend/server" + +# Python version +CC_PYTHON_VERSION="3" + +# Django WSGI module +CC_PYTHON_MODULE="main.wsgi:application" + +# Start memcached before the app (required by AdventureLog) +CC_PRE_RUN_HOOK="memcached -u nobody -m 64 -p 11211 -d" + +# Django commands executed on deployment +CC_PYTHON_MANAGE_TASKS="collectstatic --noinput, migrate --noinput, download-countries" + +# ============================================ +# MEDIA STORAGE (FS BUCKET) +# ============================================ + +# Bucket mount for media files +# REPLACE: bucket-xxxxx with your BUCKET_HOST (clever env | grep BUCKET_HOST) +CC_FS_BUCKET="backend/server/media:REPLACE_BUCKET_HOST" + +# Nginx serves media files directly (bypasses Django) +STATIC_FILES_PATH="backend/server/media" +STATIC_URL_PREFIX="/media" + +# ============================================ +# POSTGRESQL DATABASE +# ============================================ + +# REPLACE: with your PostgreSQL add-on values +# (clever env | grep POSTGRESQL) +PGHOST="REPLACE_HOST" +PGDATABASE="REPLACE_DATABASE" +PGUSER="REPLACE_USER" +PGPASSWORD="REPLACE_PASSWORD" +PGPORT="REPLACE_PORT" + +# ============================================ +# DJANGO CONFIGURATION +# ============================================ + +# Django secret key (generate with: openssl rand -base64 32) +SECRET_KEY="REPLACE_GENERATE_SECRET_KEY" + +# Debug mode disabled in production +DEBUG="False" + +# Allow registrations +DISABLE_REGISTRATION="False" + +# ============================================ +# URLs (REPLACE WITH YOUR DOMAIN) +# ============================================ + +# Backend public URL +PUBLIC_URL="https://api.REPLACE_DOMAIN.com" + +# Frontend URL +FRONTEND_URL="https://REPLACE_DOMAIN.com" + +# Allowed CSRF origins +CSRF_TRUSTED_ORIGINS="https://REPLACE_DOMAIN.com,https://api.REPLACE_DOMAIN.com" + +# ============================================ +# EMAIL (OPTIONAL - Mailpace) +# ============================================ + +# Uncomment and configure if using Mailpace +# EMAIL_BACKEND="email" +# EMAIL_HOST="smtp.mailpace.com" +# EMAIL_PORT="587" +# EMAIL_USE_TLS="True" +# EMAIL_USE_SSL="False" +# EMAIL_HOST_USER="REPLACE_MAILPACE_API_TOKEN" +# EMAIL_HOST_PASSWORD="REPLACE_MAILPACE_API_TOKEN" +# DEFAULT_FROM_EMAIL="noreply@REPLACE_VERIFIED_DOMAIN.com" +``` + +### Frontend (Node.js) + +```bash +# ============================================ +# CLEVER CLOUD CONFIGURATION +# ============================================ + +# Frontend application folder +APP_FOLDER="frontend" + +# Use pnpm as package manager +CC_NODE_BUILD_TOOL="pnpm" + +# Install dev dependencies +CC_NODE_DEV_DEPENDENCIES="install" + +# Build command (runs on M instance) +CC_POST_BUILD_HOOK="cd frontend && pnpm run build" + +# Start command (runs on XS instance) +CC_RUN_COMMAND="cd frontend && node build" + +# ============================================ +# SVELTEKIT CONFIGURATION +# ============================================ + +# Frontend origin URL (REPLACE WITH YOUR DOMAIN) +ORIGIN="https://REPLACE_DOMAIN.com" + +# Backend API URL (REPLACE WITH YOUR DOMAIN) +PUBLIC_SERVER_URL="https://api.REPLACE_DOMAIN.com" + +# No size limit for uploads +BODY_SIZE_LIMIT="Infinity" +``` + +--- + +## Troubleshooting + +### Login Not Working + +**Cause:** You are using the default `*.cleverapps.io` domains. + +**Solution:** Use a custom domain (required). + +### Images/Media Not Loading + +Verify these variables are set: +```bash +clever env --alias adventurelog-backend | grep -E "(STATIC_FILES_PATH|STATIC_URL_PREFIX|CC_FS_BUCKET)" +``` + +### Memcached Error + +Verify `CC_PRE_RUN_HOOK` is configured: +```bash +clever env --alias adventurelog-backend | grep CC_PRE_RUN_HOOK +# Should display: CC_PRE_RUN_HOOK="memcached -u nobody -m 64 -p 11211 -d" +``` + +### Missing Country Data + +Redeploy to re-run `download-countries`: +```bash +clever restart --alias adventurelog-backend --without-cache +``` + +## Cost Estimation + +| Component | Size | Estimated Cost/Month | +|-----------|------|----------------------| +| Frontend (runtime) | XS | ~5€ | +| Frontend (build) | M (temporary) | ~0.50€ | +| Backend | XS | ~5€ | +| PostgreSQL | S | ~10€ | +| FS Bucket | - | ~2€ | +| Mailpace | - | Free tier | +| **Total** | | **~22.50€/month** | + +*Prices are estimates. Check [Clever Cloud pricing](https://www.clever-cloud.com/pricing/) for current rates.* diff --git a/frontend/src/lib/components/TemplateModal.svelte b/frontend/src/lib/components/TemplateModal.svelte new file mode 100644 index 000000000..a5b056438 --- /dev/null +++ b/frontend/src/lib/components/TemplateModal.svelte @@ -0,0 +1,176 @@ + + + + + + + + diff --git a/frontend/src/lib/components/cards/CollectionCard.svelte b/frontend/src/lib/components/cards/CollectionCard.svelte index d47f59af6..e7e8f0351 100644 --- a/frontend/src/lib/components/cards/CollectionCard.svelte +++ b/frontend/src/lib/components/cards/CollectionCard.svelte @@ -7,6 +7,8 @@ import ArchiveArrowDown from '~icons/mdi/archive-arrow-down'; import ArchiveArrowUp from '~icons/mdi/archive-arrow-up'; import ShareVariant from '~icons/mdi/share-variant'; + import ContentCopy from '~icons/mdi/content-copy'; + import FileDocumentPlus from '~icons/mdi/file-document-plus'; import { goto } from '$app/navigation'; import type { Location, Collection, User, SlimCollection, ContentImage } from '$lib/types'; @@ -20,6 +22,7 @@ import DeleteWarning from '../DeleteWarning.svelte'; import ShareModal from '../ShareModal.svelte'; import CardCarousel from '../CardCarousel.svelte'; + import TemplateModal from '../TemplateModal.svelte'; import ExitRun from '~icons/mdi/exit-run'; import Eye from '~icons/mdi/eye'; import EyeOff from '~icons/mdi/eye-off'; @@ -34,7 +37,9 @@ export let linkedCollectionList: string[] | null = null; export let user: User | null; let isShareModalOpen: boolean = false; + let isTemplateModalOpen: boolean = false; let copied: boolean = false; + let isDuplicating: boolean = false; async function copyLink() { try { @@ -71,6 +76,30 @@ } } + async function duplicateCollection() { + if (isDuplicating) return; + isDuplicating = true; + try { + const res = await fetch(`/api/collections/${collection.id}/duplicate/`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + } + }); + if (res.ok) { + const newCollection = await res.json(); + addToast('success', $t('collection.duplicated_success') || 'Collection duplicated successfully'); + dispatch('duplicate', newCollection); + } else { + addToast('error', $t('collection.duplicate_error') || 'Error duplicating collection'); + } + } catch (e) { + addToast('error', $t('collection.duplicate_error') || 'Error duplicating collection'); + } finally { + isDuplicating = false; + } + } + async function archiveCollection(is_archived: boolean) { console.log(JSON.stringify({ is_archived: is_archived })); let res = await fetch(`/api/collections/${collection.id}/`, { @@ -150,6 +179,10 @@ (isShareModalOpen = false)} /> {/if} +{#if isTemplateModalOpen} + (isTemplateModalOpen = false)} /> +{/if} +
@@ -368,6 +401,27 @@ {$t('adventures.export_zip')} +
  • + +
  • +
  • + +
  • + + + {$t('templates.create_from_template')} + +
    + +
  • + + + + + +
    + {#if currentTemplates.length === 0} + +
    +
    + +
    +

    + {$t('templates.no_templates') || 'No templates found'} +

    +

    + {activeView === 'my' + ? $t('templates.no_my_templates_desc') || 'Create templates from your collections to reuse their structure.' + : $t('templates.no_public_templates_desc') || 'No public templates available from other users yet.'} +

    + {#if activeView === 'my'} + + + {$t('templates.go_to_collections') || 'Go to Collections'} + + {/if} +
    + {:else} + +
    + {#each currentTemplates as template (template.id)} + {@const counts = getItemCount(template)} +
    +
    + +
    +
    +
    + +
    +
    +

    {template.name}

    +

    + {formatDate(template.created_at)} +

    +
    +
    +
    +
    + {#if template.is_public} + + {:else} + + {/if} +
    +
    +
    + + + {#if template.description} +

    + {template.description} +

    + {/if} + + +
    + {#if counts.locations > 0} +
    + + {counts.locations} +
    + {/if} + {#if counts.notes > 0} +
    + + {counts.notes} +
    + {/if} + {#if counts.checklists > 0} +
    + + {counts.checklists} +
    + {/if} + {#if counts.transportations > 0} +
    + + {counts.transportations} +
    + {/if} + {#if counts.lodgings > 0} +
    + + {counts.lodgings} +
    + {/if} + {#if counts.locations === 0 && counts.notes === 0 && counts.checklists === 0 && counts.transportations === 0 && counts.lodgings === 0} + + {$t('templates.empty_template') || 'Empty template'} + + {/if} +
    + + +
    + + {#if template.user === data.user?.uuid} + + + {/if} +
    +
    +
    + {/each} +
    + {/if} +
    +