feat: enhance backup export functionality with itinerary items and export IDs

This commit is contained in:
Sean Morley
2026-01-02 11:05:28 -05:00
parent 121d55c3d7
commit 736f95213e
4 changed files with 383 additions and 180 deletions

View File

@@ -19,7 +19,8 @@ from django.contrib.contenttypes.models import ContentType
from adventures.models import (
Location, Collection, Transportation, Note, Checklist, ChecklistItem,
ContentImage, ContentAttachment, Category, Lodging, Visit, Trail, Activity
ContentImage, ContentAttachment, Category, Lodging, Visit, Trail, Activity,
CollectionItineraryItem
)
from worldtravel.models import VisitedCity, VisitedRegion, City, Region, Country
@@ -52,7 +53,8 @@ class BackupViewSet(viewsets.ViewSet):
'checklists': [],
'lodging': [],
'visited_cities': [],
'visited_regions': []
'visited_regions': [],
'itinerary_items': []
}
# Export Visited Cities
@@ -198,12 +200,13 @@ class BackupViewSet(viewsets.ViewSet):
export_data['locations'].append(location_data)
# Export Transportation
for transport in user.transportation_set.all():
for idx, transport in enumerate(user.transportation_set.all()):
collection_export_id = None
if transport.collection:
collection_export_id = collection_name_to_id.get(transport.collection.name)
export_data['transportation'].append({
'export_id': idx,
'type': transport.type,
'name': transport.name,
'description': transport.description,
@@ -225,12 +228,13 @@ class BackupViewSet(viewsets.ViewSet):
})
# Export Notes
for note in user.note_set.all():
for idx, note in enumerate(user.note_set.all()):
collection_export_id = None
if note.collection:
collection_export_id = collection_name_to_id.get(note.collection.name)
export_data['notes'].append({
'export_id': idx,
'name': note.name,
'content': note.content,
'links': note.links,
@@ -240,12 +244,13 @@ class BackupViewSet(viewsets.ViewSet):
})
# Export Checklists
for checklist in user.checklist_set.all():
for idx, checklist in enumerate(user.checklist_set.all()):
collection_export_id = None
if checklist.collection:
collection_export_id = collection_name_to_id.get(checklist.collection.name)
checklist_data = {
'export_id': idx,
'name': checklist.name,
'date': checklist.date.isoformat() if checklist.date else None,
'is_public': checklist.is_public,
@@ -263,12 +268,13 @@ class BackupViewSet(viewsets.ViewSet):
export_data['checklists'].append(checklist_data)
# Export Lodging
for lodging in user.lodging_set.all():
for idx, lodging in enumerate(user.lodging_set.all()):
collection_export_id = None
if lodging.collection:
collection_export_id = collection_name_to_id.get(lodging.collection.name)
export_data['lodging'].append({
'export_id': idx,
'name': lodging.name,
'type': lodging.type,
'description': lodging.description,
@@ -286,6 +292,40 @@ class BackupViewSet(viewsets.ViewSet):
'collection_export_id': collection_export_id
})
# Export Itinerary Items
# Create export_id mappings for all content types
location_id_to_export_id = {loc.id: idx for idx, loc in enumerate(user.location_set.all())}
transportation_id_to_export_id = {t.id: idx for idx, t in enumerate(user.transportation_set.all())}
note_id_to_export_id = {n.id: idx for idx, n in enumerate(user.note_set.all())}
lodging_id_to_export_id = {l.id: idx for idx, l in enumerate(user.lodging_set.all())}
checklist_id_to_export_id = {c.id: idx for idx, c in enumerate(user.checklist_set.all())}
for collection_idx, collection in enumerate(user.collection_set.all()):
for itinerary_item in collection.itinerary_items.all():
content_type_str = itinerary_item.content_type.model
item_reference = None
# Determine how to reference the item based on content type using export_ids
if content_type_str == 'location':
item_reference = location_id_to_export_id.get(itinerary_item.object_id)
elif content_type_str == 'transportation':
item_reference = transportation_id_to_export_id.get(itinerary_item.object_id)
elif content_type_str == 'note':
item_reference = note_id_to_export_id.get(itinerary_item.object_id)
elif content_type_str == 'lodging':
item_reference = lodging_id_to_export_id.get(itinerary_item.object_id)
elif content_type_str == 'checklist':
item_reference = checklist_id_to_export_id.get(itinerary_item.object_id)
if item_reference is not None:
export_data['itinerary_items'].append({
'collection_export_id': collection_idx,
'content_type': content_type_str,
'item_reference': item_reference,
'date': itinerary_item.date.isoformat() if itinerary_item.date else None,
'order': itinerary_item.order
})
# Create ZIP file
with tempfile.NamedTemporaryFile(delete=False, suffix='.zip') as tmp_file:
with zipfile.ZipFile(tmp_file.name, 'w', zipfile.ZIP_DEFLATED) as zip_file:
@@ -402,6 +442,9 @@ class BackupViewSet(viewsets.ViewSet):
def _clear_user_data(self, user):
"""Clear all existing user data before import"""
# Delete itinerary items first (they reference collections and content)
CollectionItineraryItem.objects.filter(collection__user=user).delete()
# Delete in reverse order of dependencies
user.activity_set.all().delete() # Delete activities first
user.trail_set.all().delete() # Delete trails
@@ -439,7 +482,7 @@ class BackupViewSet(viewsets.ViewSet):
'transportation': 0, 'notes': 0, 'checklists': 0,
'checklist_items': 0, 'lodging': 0, 'images': 0,
'attachments': 0, 'visited_cities': 0, 'visited_regions': 0,
'trails': 0, 'activities': 0, 'gpx_files': 0
'trails': 0, 'activities': 0, 'gpx_files': 0, 'itinerary_items': 0
}
# Import Visited Cities
@@ -684,12 +727,13 @@ class BackupViewSet(viewsets.ViewSet):
summary['locations'] += 1
# Import Transportation
transportation_map = {} # Map export_id to actual transportation object
for trans_data in backup_data.get('transportation', []):
collection = None
if trans_data.get('collection_export_id') is not None:
collection = collection_map.get(trans_data['collection_export_id'])
Transportation.objects.create(
transportation = Transportation.objects.create(
user=user,
type=trans_data['type'],
name=trans_data['name'],
@@ -710,15 +754,19 @@ class BackupViewSet(viewsets.ViewSet):
is_public=trans_data.get('is_public', False),
collection=collection
)
# Only add to map if export_id exists (for backward compatibility with old backups)
if 'export_id' in trans_data:
transportation_map[trans_data['export_id']] = transportation
summary['transportation'] += 1
# Import Notes
note_map = {} # Map export_id to actual note object
for note_data in backup_data.get('notes', []):
collection = None
if note_data.get('collection_export_id') is not None:
collection = collection_map.get(note_data['collection_export_id'])
Note.objects.create(
note = Note.objects.create(
user=user,
name=note_data['name'],
content=note_data.get('content'),
@@ -727,9 +775,13 @@ class BackupViewSet(viewsets.ViewSet):
is_public=note_data.get('is_public', False),
collection=collection
)
# Only add to map if export_id exists (for backward compatibility with old backups)
if 'export_id' in note_data:
note_map[note_data['export_id']] = note
summary['notes'] += 1
# Import Checklists
checklist_map = {} # Map export_id to actual checklist object
for check_data in backup_data.get('checklists', []):
collection = None
if check_data.get('collection_export_id') is not None:
@@ -753,15 +805,19 @@ class BackupViewSet(viewsets.ViewSet):
)
summary['checklist_items'] += 1
# Only add to map if export_id exists (for backward compatibility with old backups)
if 'export_id' in check_data:
checklist_map[check_data['export_id']] = checklist
summary['checklists'] += 1
# Import Lodging
lodging_map = {} # Map export_id to actual lodging object
for lodg_data in backup_data.get('lodging', []):
collection = None
if lodg_data.get('collection_export_id') is not None:
collection = collection_map.get(lodg_data['collection_export_id'])
Lodging.objects.create(
lodging = Lodging.objects.create(
user=user,
name=lodg_data['name'],
type=lodg_data.get('type', 'other'),
@@ -779,6 +835,50 @@ class BackupViewSet(viewsets.ViewSet):
is_public=lodg_data.get('is_public', False),
collection=collection
)
# Only add to map if export_id exists (for backward compatibility with old backups)
if 'export_id' in lodg_data:
lodging_map[lodg_data['export_id']] = lodging
summary['lodging'] += 1
# Import Itinerary Items
# Maps already created during import of each content type
for itinerary_data in backup_data.get('itinerary_items', []):
collection = collection_map.get(itinerary_data['collection_export_id'])
if not collection:
continue
content_type_str = itinerary_data['content_type']
item_reference = itinerary_data['item_reference']
# Get the actual object based on content type
content_object = None
content_type = None
if content_type_str == 'location':
content_object = location_map.get(item_reference) # item_reference is export_id
content_type = ContentType.objects.get(model='location')
elif content_type_str == 'transportation':
content_object = transportation_map.get(item_reference) # item_reference is export_id
content_type = ContentType.objects.get(model='transportation')
elif content_type_str == 'note':
content_object = note_map.get(item_reference) # item_reference is export_id
content_type = ContentType.objects.get(model='note')
elif content_type_str == 'lodging':
content_object = lodging_map.get(item_reference) # item_reference is export_id
content_type = ContentType.objects.get(model='lodging')
elif content_type_str == 'checklist':
content_object = checklist_map.get(item_reference) # item_reference is export_id
content_type = ContentType.objects.get(model='checklist')
if content_object and content_type:
CollectionItineraryItem.objects.create(
collection=collection,
content_type=content_type,
object_id=content_object.id,
date=itinerary_data.get('date'),
order=itinerary_data['order']
)
summary['itinerary_items'] += 1
return summary

View File

@@ -521,7 +521,9 @@ class RecommendationsViewSet(viewsets.ViewSet):
# Validate geocode results
if isinstance(geocode_results, dict) and geocode_results.get('error'):
return Response({"error": f"Geocoding failed: {geocode_results.get('error')}"}, status=400)
# Log internal geocoding error details but do not expose them to the client
logger.warning("Geocoding helper returned an error: %s", geocode_results.get('error'))
return Response({"error": "Geocoding failed. Please try a different location or contact support."}, status=400)
if not geocode_results:
return Response({"error": "Could not geocode provided location."}, status=400)
@@ -667,8 +669,10 @@ class RecommendationsViewSet(viewsets.ViewSet):
# If no results at all and user requested only OSM, return error status
if len(final_results) == 0 and sources == 'osm' and osm_error:
# Log internal error details for investigation but do not expose them to clients
logger.debug("OSM query error (internal): %s", osm_error)
return Response({
"error": osm_error,
"error": "OpenStreetMap service temporarily unavailable. Please try again later.",
"count": 0,
"results": [],
"sources_used": response_data["sources_used"]

View File

@@ -1,11 +1,12 @@
<script lang="ts">
import { onMount } from 'svelte';
import type { Category } from '$lib/types';
import { onMount, tick } from 'svelte';
import { t } from 'svelte-i18n';
import type { Category } from '$lib/types';
export let selected_category: Category | null = null;
export let searchTerm: string = '';
let new_category: Category = {
export let searchTerm = '';
const emptyCategory: Category = {
name: '',
display_name: '',
icon: '',
@@ -14,72 +15,121 @@
num_locations: 0
};
$: {
console.log('Selected category changed:', selected_category);
let newCategory: Category = { ...emptyCategory };
let categories: Category[] = [];
let isOpen = false;
let isEmojiPickerVisible = false;
let dropdownRef: HTMLDivElement;
let mobileSearchInputRef: HTMLInputElement;
let desktopSearchInputRef: HTMLInputElement;
$: sortedCategories = [...categories].sort(
(a, b) => (b.num_locations || 0) - (a.num_locations || 0)
);
$: filteredCategories = sortedCategories.filter((category) => {
if (!searchTerm) return true;
return category.display_name.toLowerCase().includes(searchTerm.toLowerCase());
});
function closeDropdown() {
isOpen = false;
isEmojiPickerVisible = false;
}
let categories: Category[] = [];
async function openDropdown() {
isOpen = true;
await tick();
(mobileSearchInputRef ?? desktopSearchInputRef)?.focus();
}
let isOpen: boolean = false;
let isEmojiPickerVisible: boolean = false;
function toggleDropdown() {
isOpen ? closeDropdown() : openDropdown();
}
function toggleEmojiPicker() {
isEmojiPickerVisible = !isEmojiPickerVisible;
}
function toggleDropdown() {
isOpen = !isOpen;
}
function selectCategory(category: Category) {
console.log('category', category);
selected_category = category;
isOpen = false;
closeDropdown();
}
function custom_category() {
new_category.name = new_category.display_name.toLowerCase().replace(/ /g, '_');
if (!new_category.icon) {
new_category.icon = '🌎'; // Default icon if none selected
}
selectCategory(new_category);
function createCustomCategory() {
const displayName = newCategory.display_name.trim();
if (!displayName) return;
const generatedId =
newCategory.id ||
(typeof crypto !== 'undefined' && 'randomUUID' in crypto
? crypto.randomUUID()
: `custom-${Date.now()}`);
const category: Category = {
...newCategory,
id: generatedId,
name: displayName.toLowerCase().replace(/\s+/g, '_'),
icon: newCategory.icon || '🌎'
};
categories = [category, ...categories];
selectCategory(category);
newCategory = { ...emptyCategory };
}
function handleEmojiSelect(event: CustomEvent) {
new_category.icon = event.detail.unicode;
newCategory.icon = event.detail.unicode;
}
// Close dropdown when clicking outside
let dropdownRef: HTMLDivElement;
onMount(() => {
const loadData = async () => {
await import('emoji-picker-element');
let res = await fetch('/api/categories');
categories = await res.json();
categories = categories.sort((a, b) => (b.num_locations || 0) - (a.num_locations || 0));
try {
await import('emoji-picker-element');
} catch (error) {
console.error('Emoji picker failed to load', error);
}
try {
const res = await fetch('/api/categories');
const data = await res.json();
categories = Array.isArray(data) ? data : [];
} catch (error) {
console.error('Unable to load categories', error);
}
};
loadData();
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef && !dropdownRef.contains(event.target as Node)) {
isOpen = false;
const handlePointerDown = (event: PointerEvent) => {
if (!dropdownRef) return;
if (!dropdownRef.contains(event.target as Node)) {
closeDropdown();
}
};
document.addEventListener('click', handleClickOutside);
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
closeDropdown();
}
};
document.addEventListener('pointerdown', handlePointerDown);
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('click', handleClickOutside);
document.removeEventListener('pointerdown', handlePointerDown);
document.removeEventListener('keydown', handleKeyDown);
};
});
</script>
<div class="dropdown w-full" bind:this={dropdownRef}>
<!-- Main dropdown trigger -->
<div
tabindex="0"
role="button"
<button
type="button"
class="btn btn-outline w-full justify-between sm:h-auto h-12"
aria-haspopup="listbox"
aria-expanded={isOpen}
on:click={toggleDropdown}
>
<span class="flex items-center gap-2">
@@ -91,29 +141,39 @@
{/if}
</span>
<svg
class="w-4 h-4 transition-transform duration-200 {isOpen ? 'rotate-180' : ''}"
class={`w-4 h-4 transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</div>
</button>
{#if isOpen}
<!-- Mobile Modal Overlay (only on small screens) -->
<div class="fixed inset-0 bg-black/50 z-40 sm:hidden" on:click={() => (isOpen = false)}></div>
<button
type="button"
class="fixed inset-0 bg-black/50 z-40 sm:hidden focus:outline-none"
aria-label={$t('adventures.back')}
on:click={closeDropdown}
on:keydown={(event) => event.key === 'Enter' && closeDropdown()}
></button>
<!-- Mobile Bottom Sheet -->
<div
class="fixed bottom-0 left-0 right-0 z-50 bg-base-100 rounded-t-2xl shadow-2xl border-t border-base-300 max-h-[90vh] flex flex-col sm:hidden"
>
<!-- Mobile Header -->
<div class="flex-shrink-0 bg-base-100 border-b border-base-300 p-4">
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold">{$t('categories.select_category')}</h2>
<button class="btn btn-ghost btn-sm btn-circle" on:click={() => (isOpen = false)}>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<button type="button" class="btn btn-ghost btn-sm btn-circle" on:click={closeDropdown}>
<svg
class="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
@@ -126,10 +186,15 @@
</div>
<div class="flex-1 overflow-y-auto min-h-0">
<!-- Mobile Category Creator Section -->
<div class="p-4 border-b border-base-300">
<h3 class="font-semibold text-sm text-base-content/80 mb-3 flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<div class="p-4 border-b border-base-300 space-y-4">
<div class="flex items-center gap-2 text-sm font-semibold text-base-content/80">
<svg
class="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
@@ -138,41 +203,45 @@
/>
</svg>
{$t('categories.add_new_category')}
</h3>
</div>
<div class="space-y-3">
<div class="space-y-2">
<input
type="text"
placeholder={$t('categories.category_name')}
class="input input-bordered w-full h-12 text-base"
bind:value={newCategory.display_name}
/>
<div class="join w-full">
<input
type="text"
placeholder={$t('categories.category_name')}
class="input input-bordered w-full h-12 text-base"
bind:value={new_category.display_name}
placeholder={$t('categories.icon')}
class="input input-bordered join-item flex-1 h-12 text-base"
bind:value={newCategory.icon}
/>
<div class="join w-full">
<input
type="text"
placeholder={$t('categories.icon')}
class="input input-bordered join-item flex-1 h-12 text-base"
bind:value={new_category.icon}
/>
<button
on:click={toggleEmojiPicker}
type="button"
class="btn join-item h-12 w-12 text-lg"
class:btn-active={isEmojiPickerVisible}
>
😊
</button>
</div>
<button
type="button"
class="btn join-item h-12 w-12 text-lg"
on:click={toggleEmojiPicker}
class:btn-active={isEmojiPickerVisible}
>
😊
</button>
</div>
<button
on:click={custom_category}
type="button"
class="btn btn-primary h-12 w-full"
disabled={!new_category.display_name.trim()}
on:click={createCustomCategory}
disabled={!newCategory.display_name.trim()}
>
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg
class="w-4 h-4 mr-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
@@ -191,29 +260,38 @@
</div>
</div>
<!-- Mobile Categories List -->
<div class="p-4">
<h3 class="font-semibold text-sm text-base-content/80 mb-3">
<div class="p-4 space-y-4">
<div class="flex items-center gap-2 text-sm font-semibold text-base-content/80">
<svg
class="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
/>
</svg>
{$t('categories.select_category')}
</h3>
</div>
{#if categories.length > 0}
<div class="form-control mb-4">
<div class="form-control">
<input
type="text"
placeholder={$t('navbar.search')}
class="input input-bordered w-full h-12 text-base"
bind:value={searchTerm}
bind:this={mobileSearchInputRef}
/>
</div>
<div class="space-y-2">
{#each categories
.slice()
.sort((a, b) => (b.num_locations || 0) - (a.num_locations || 0))
.filter((category) => !searchTerm || category.display_name
.toLowerCase()
.includes(searchTerm.toLowerCase())) as category}
{#each filteredCategories as category}
<button
type="button"
class="w-full text-left p-4 rounded-lg border border-base-300 hover:border-primary hover:bg-primary/5 transition-colors"
@@ -236,22 +314,43 @@
</button>
{/each}
</div>
{:else}
<div class="text-center py-8 text-base-content/60">
<svg
class="w-12 h-12 mx-auto mb-2 opacity-50"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.99 1.99 0 013 12V7a4 4 0 014-4z"
/>
</svg>
<p class="text-sm">{$t('categories.no_categories_yet')}</p>
</div>
{/if}
</div>
</div>
<!-- Bottom safe area -->
<div class="flex-shrink-0 h-4"></div>
<div class="flex-shrink-0 h-4"></div>
</div>
</div>
<!-- Desktop Dropdown -->
<div
class="dropdown-content z-[1] w-full mt-1 bg-base-300 rounded-box shadow-xl border border-base-300 max-h-96 overflow-y-auto hidden sm:block"
class="dropdown-content z-[1] w-full mt-1 bg-base-100 rounded-box shadow-xl border border-base-300 max-h-[28rem] overflow-y-auto hidden sm:block"
>
<!-- Desktop Category Creator Section -->
<div class="p-4 border-b border-base-300">
<h3 class="font-semibold text-sm text-base-content/80 mb-3 flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<div class="p-4 border-b border-base-300 space-y-3">
<div class="flex items-center gap-2 text-sm font-semibold text-base-content/80">
<svg
class="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
@@ -260,69 +359,74 @@
/>
</svg>
{$t('categories.add_new_category')}
</h3>
</div>
<div class="space-y-3">
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
<div class="form-control">
<input
type="text"
placeholder={$t('categories.category_name')}
class="input input-bordered input-sm w-full"
bind:value={new_category.display_name}
/>
</div>
<div class="form-control">
<div class="input-group">
<input
type="text"
placeholder={$t('categories.icon')}
class="input input-bordered input-sm flex-1"
bind:value={new_category.icon}
/>
<button
on:click={toggleEmojiPicker}
type="button"
class="btn btn-square btn-sm btn-secondary"
class:btn-active={isEmojiPickerVisible}
>
😊
</button>
</div>
</div>
</div>
<div class="flex justify-end">
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
<input
type="text"
placeholder={$t('categories.category_name')}
class="input input-bordered input-sm w-full"
bind:value={newCategory.display_name}
/>
<div class="input-group">
<input
type="text"
placeholder={$t('categories.icon')}
class="input input-bordered input-sm flex-1"
bind:value={newCategory.icon}
/>
<button
on:click={custom_category}
type="button"
class="btn btn-primary btn-sm"
disabled={!new_category.display_name.trim()}
class="btn btn-square btn-sm btn-secondary"
on:click={toggleEmojiPicker}
class:btn-active={isEmojiPickerVisible}
>
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 6v6m0 0v6m0-6h6m-6 0H6"
/>
</svg>
{$t('adventures.add')}
😊
</button>
</div>
{#if isEmojiPickerVisible}
<div class="p-3 rounded-lg border border-base-300">
<emoji-picker on:emoji-click={handleEmojiSelect}></emoji-picker>
</div>
{/if}
</div>
<div class="flex justify-end">
<button
type="button"
class="btn btn-primary btn-sm"
on:click={createCustomCategory}
disabled={!newCategory.display_name.trim()}
>
<svg
class="w-4 h-4 mr-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 6v6m0 0v6m0-6h6m-6 0H6"
/>
</svg>
{$t('adventures.add')}
</button>
</div>
{#if isEmojiPickerVisible}
<div class="p-3 rounded-lg border border-base-300">
<emoji-picker on:emoji-click={handleEmojiSelect}></emoji-picker>
</div>
{/if}
</div>
<!-- Desktop Categories List Section -->
<div class="p-4">
<h3 class="font-semibold text-sm text-base-content/80 mb-3 flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<div class="p-4 space-y-3">
<div class="flex items-center gap-2 text-sm font-semibold text-base-content/80">
<svg
class="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
@@ -331,27 +435,22 @@
/>
</svg>
{$t('categories.select_category')}
</h3>
</div>
{#if categories.length > 0}
<div class="form-control mb-3">
<input
type="text"
placeholder={$t('navbar.search')}
class="input input-bordered input-sm w-full"
bind:value={searchTerm}
/>
</div>
<input
type="text"
placeholder={$t('navbar.search')}
class="input input-bordered input-sm w-full"
bind:value={searchTerm}
bind:this={desktopSearchInputRef}
/>
<div
class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2 max-h-60 overflow-y-auto"
role="listbox"
>
{#each categories
.slice()
.sort((a, b) => (b.num_locations || 0) - (a.num_locations || 0))
.filter((category) => !searchTerm || category.display_name
.toLowerCase()
.includes(searchTerm.toLowerCase())) as category}
{#each filteredCategories as category (category.id)}
<button
type="button"
class="btn btn-ghost btn-sm justify-start h-auto py-2 px-3"
@@ -374,15 +473,14 @@
{/each}
</div>
{#if categories.filter((category) => !searchTerm || category.display_name
.toLowerCase()
.includes(searchTerm.toLowerCase())).length === 0}
{#if filteredCategories.length === 0}
<div class="text-center py-8 text-base-content/60">
<svg
class="w-12 h-12 mx-auto mb-2 opacity-50"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
stroke-linecap="round"
@@ -401,6 +499,7 @@
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
stroke-linecap="round"

View File

@@ -1,4 +1,4 @@
export let appVersion = 'v0.12.0-pre-dev-010126';
export let appVersion = 'v0.12.0-pre-dev-010226';
export let versionChangelog = 'https://github.com/seanmorley15/AdventureLog/releases/tag/v0.11.0';
export let appTitle = 'AdventureLog';
export let copyrightYear = '2023-2026';