mirror of
https://github.com/seanmorley15/AdventureLog.git
synced 2026-03-06 15:36:47 -05:00
feat: enhance backup export functionality with itinerary items and export IDs
This commit is contained in:
@@ -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
|
||||
@@ -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"]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user