1
0
Fork 0
mirror of https://github.com/seanmorley15/AdventureLog.git synced 2025-07-19 04:49:37 +02:00

feat: enhance CategoryModal with add/edit functionality and improve localization support

This commit is contained in:
Sean Morley 2025-06-06 14:20:37 -04:00
parent c0f2d060db
commit 06a5bb06b3
13 changed files with 359 additions and 159 deletions

View file

@ -1,149 +1,353 @@
<script lang="ts">
import type { Category } from '$lib/types';
import { createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher();
import { onMount } from 'svelte';
let modal: HTMLDialogElement;
import { createEventDispatcher, onMount } from 'svelte';
import { t } from 'svelte-i18n';
const dispatch = createEventDispatcher();
let modal: HTMLDialogElement;
export let categories: Category[] = [];
let category_to_edit: Category | null = null;
let is_changed: boolean = false;
let has_loaded: boolean = false;
let categoryToEdit: Category | null = null;
let newCategory = { display_name: '', icon: '' };
let showAddForm = false;
let isChanged = false;
let hasLoaded = false;
let warningMessage: string | null = null;
let showEmojiPickerAdd = false;
let showEmojiPickerEdit = false;
onMount(async () => {
modal = document.getElementById('my_modal_1') as HTMLDialogElement;
if (modal) {
await import('emoji-picker-element');
modal = document.querySelector('#category-modal') as HTMLDialogElement;
modal.showModal();
}
let category_fetch = await fetch('/api/categories/categories');
categories = await category_fetch.json();
has_loaded = true;
// remove the general category if it exists
// categories = categories.filter((c) => c.name !== 'general');
await loadCategories();
});
async function saveCategory() {
if (category_to_edit) {
let edit_fetch = await fetch(`/api/categories/${category_to_edit.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(category_to_edit)
});
if (edit_fetch.ok) {
category_to_edit = null;
let the_category = (await edit_fetch.json()) as Category;
categories = categories.map((c) => {
if (c.id === the_category.id) {
return the_category;
}
return c;
});
is_changed = true;
async function loadCategories() {
try {
const res = await fetch('/api/categories/categories');
if (res.ok) {
categories = await res.json();
}
} catch (err) {
console.error('Failed to load categories:', err);
} finally {
hasLoaded = true;
}
}
function close() {
function closeModal() {
dispatch('close');
modal.close();
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
dispatch('close');
closeModal();
}
}
function removeCategory(category: Category) {
return async () => {
let response = await fetch(`/api/categories/${category.id}`, {
function handleEmojiSelectAdd(event: CustomEvent) {
newCategory.icon = event.detail.unicode;
showEmojiPickerAdd = false;
}
function handleEmojiSelectEdit(event: CustomEvent) {
if (categoryToEdit) {
categoryToEdit.icon = event.detail.unicode;
}
showEmojiPickerEdit = false;
}
async function createCategory(event: Event) {
event.preventDefault();
const nameTrimmed = newCategory.display_name.trim();
if (!nameTrimmed) {
warningMessage = $t('categories.name_required');
return;
}
warningMessage = null;
const payload = {
display_name: nameTrimmed,
name: nameTrimmed
.toLowerCase()
.replace(/\s+/g, '_')
.replace(/[^a-z0-9_]/g, ''),
icon: newCategory.icon.trim() || '🌍'
};
try {
const res = await fetch('/api/categories', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (res.ok) {
const created = await res.json();
categories = [...categories, created];
isChanged = true;
newCategory = { display_name: '', icon: '' };
showAddForm = false;
showEmojiPickerAdd = false;
}
} catch (err) {
console.error('Failed to create category:', err);
}
}
async function saveCategory(event: Event) {
event.preventDefault();
if (!categoryToEdit) return;
const nameTrimmed = categoryToEdit.display_name.trim();
if (!nameTrimmed) {
warningMessage = $t('categories.name_required');
return;
}
warningMessage = null;
try {
const res = await fetch(`/api/categories/${categoryToEdit.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...categoryToEdit, display_name: nameTrimmed })
});
if (res.ok) {
const updated = await res.json();
categories = categories.map((c) => (c.id === updated.id ? updated : c));
categoryToEdit = null;
isChanged = true;
showEmojiPickerEdit = false;
}
} catch (err) {
console.error('Failed to save category:', err);
}
}
function startEdit(category: Category) {
categoryToEdit = { ...category };
showAddForm = false;
showEmojiPickerAdd = false;
showEmojiPickerEdit = false;
}
function cancelEdit() {
categoryToEdit = null;
showEmojiPickerEdit = false;
}
async function removeCategory(category: Category) {
if (category.name === 'general') return;
try {
const res = await fetch(`/api/categories/${category.id}`, {
method: 'DELETE'
});
if (response.ok) {
if (res.ok) {
categories = categories.filter((c) => c.id !== category.id);
is_changed = true;
isChanged = true;
}
} catch (err) {
console.error('Failed to delete category:', err);
}
};
}
</script>
<dialog id="my_modal_1" class="modal">
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
<div class="modal-box" role="dialog" on:keydown={handleKeydown} tabindex="0">
<h3 class="font-bold text-lg">{$t('categories.manage_categories')}</h3>
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<dialog id="category-modal" class="modal" on:keydown={handleKeydown}>
<div class="modal-box max-w-2xl">
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-bold">{$t('categories.manage_categories')}</h2>
<button
type="button"
on:click={closeModal}
class="btn btn-sm btn-circle btn-ghost"
aria-label="Close"
>
</button>
</div>
{#if has_loaded}
{#each categories as category}
<div class="flex justify-between items-center mt-2">
<span>{category.display_name} {category.icon}</span>
<div class="flex space-x-2">
<button on:click={() => (category_to_edit = category)} class="btn btn-primary btn-sm"
>Edit</button
<!-- Category List -->
{#if hasLoaded}
{#if categories.length > 0}
<div class="space-y-2 mb-6">
{#each categories as category (category.id)}
<div class="flex items-center justify-between p-3 bg-base-200 rounded-lg">
<div class="flex items-center gap-3">
<span class="text-lg">{category.icon || '🌍'}</span>
<span class="font-medium">{category.display_name}</span>
</div>
<div class="flex gap-2">
<button
type="button"
on:click={() => startEdit(category)}
class="btn btn-xs btn-outline"
>
{#if category.name != 'general'}
<button on:click={removeCategory(category)} class="btn btn-warning btn-sm"
>{$t('adventures.remove')}</button
{$t('lodging.edit')}
</button>
{#if category.name !== 'general'}
<button
type="button"
on:click={() => removeCategory(category)}
class="btn btn-xs btn-error btn-outline"
>
{:else}
<button class="btn btn-warning btn-sm btn-disabled">{$t('adventures.remove')}</button>
{$t('adventures.remove')}
</button>
{/if}
</div>
</div>
{/each}
{#if categories.length === 0}
<p>{$t('categories.no_categories_found')}</p>
</div>
{:else}
<div class="text-center py-8 text-base-content/60">
{$t('categories.no_categories_found')}
</div>
{/if}
{:else}
<div class="flex items-center justify-center">
<span class="loading loading-spinner loading-lg m-4"></span>
<div class="text-center py-8">
<span class="loading loading-spinner loading-md"></span>
</div>
{/if}
{#if category_to_edit}
<h2 class="text-center text-xl font-semibold mt-2 mb-2">{$t('categories.edit_category')}</h2>
<div class="flex flex-row space-x-2 form-control">
<!-- Edit Category Form -->
{#if categoryToEdit}
<div class="bg-base-100 border border-base-300 rounded-lg p-4 mb-4">
<h3 class="font-medium mb-4">{$t('categories.edit_category')}</h3>
<form on:submit={saveCategory} class="space-y-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<!-- svelte-ignore a11y-label-has-associated-control -->
<label class="label">
<span class="label-text">{$t('categories.category_name')}</span>
</label>
<input
type="text"
placeholder={$t('adventures.name')}
bind:value={category_to_edit.display_name}
class="input input-bordered w-full max-w-xs"
/>
<input
type="text"
placeholder={$t('categories.icon')}
bind:value={category_to_edit.icon}
class="input input-bordered w-full max-w-xs"
class="input input-bordered w-full"
bind:value={categoryToEdit.display_name}
required
/>
</div>
<button class="btn btn-primary" on:click={saveCategory}>{$t('notes.save')}</button>
{/if}
<button class="btn btn-primary mt-4" on:click={close}>{$t('about.close')}</button>
{#if is_changed}
<div role="alert" class="alert alert-info mt-6">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
class="h-6 w-6 shrink-0 stroke-current"
<div>
<!-- svelte-ignore a11y-label-has-associated-control -->
<label class="label">
<span class="label-text">{$t('categories.icon')}</span>
</label>
<div class="flex gap-2">
<input
type="text"
class="input input-bordered flex-1"
bind:value={categoryToEdit.icon}
/>
<button
type="button"
on:click={() => (showEmojiPickerEdit = !showEmojiPickerEdit)}
class="btn btn-square btn-outline"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
></path>
</svg>
😀
</button>
</div>
</div>
</div>
{#if showEmojiPickerEdit}
<div class="p-2 border rounded-lg bg-base-100">
<emoji-picker on:emoji-click={handleEmojiSelectEdit}></emoji-picker>
</div>
{/if}
<div class="flex justify-end gap-2">
<button type="button" class="btn btn-ghost" on:click={cancelEdit}>
{$t('adventures.cancel')}
</button>
<button type="submit" class="btn btn-primary"> {$t('notes.save')} </button>
</div>
</form>
</div>
{/if}
<!-- Add Category Section -->
<div class="collapse collapse-plus bg-base-200 mb-4">
<input type="checkbox" bind:checked={showAddForm} />
<div class="collapse-title font-medium">{$t('categories.add_new_category')}</div>
{#if showAddForm}
<div class="collapse-content">
<form on:submit={createCategory} class="space-y-4">
<div>
<!-- svelte-ignore a11y-label-has-associated-control -->
<label class="label">
<span class="label-text">{$t('categories.category_name')}</span>
</label>
<input
type="text"
class="input input-bordered w-full"
bind:value={newCategory.display_name}
required
/>
</div>
<div>
<!-- svelte-ignore a11y-label-has-associated-control -->
<label class="label">
<span class="label-text">{$t('categories.icon')}</span>
</label>
<div class="flex gap-2">
<input
type="text"
class="input input-bordered flex-1"
bind:value={newCategory.icon}
placeholder="🌍"
/>
<button
type="button"
on:click={() => (showEmojiPickerAdd = !showEmojiPickerAdd)}
class="btn btn-square btn-outline"
>
😀
</button>
</div>
{#if showEmojiPickerAdd}
<div class="mt-2 p-2 border rounded-lg bg-base-100">
<emoji-picker on:emoji-click={handleEmojiSelectAdd}></emoji-picker>
</div>
{/if}
</div>
<button type="submit" class="btn btn-primary w-full">
{$t('collection.create')}
</button>
</form>
</div>
{/if}
</div>
<!-- Messages -->
{#if warningMessage}
<div class="alert alert-warning mb-4">
<span>{warningMessage}</span>
</div>
{/if}
{#if isChanged}
<div class="alert alert-success mb-4">
<span>{$t('categories.update_after_refresh')}</span>
</div>
{/if}
<!-- Footer -->
<div class="flex justify-end">
<button type="button" class="btn" on:click={closeModal}> {$t('about.close')} </button>
</div>
</div>
</dialog>
<style>
.modal-box {
max-height: 90vh;
overflow-y: auto;
}
</style>

View file

@ -20,13 +20,6 @@
import { onMount } from 'svelte';
let inputElement: HTMLInputElement | null = null;
let isScrolled = false;
// Handle scroll effect
function handleScroll() {
isScrolled = window.scrollY > 10;
}
let theme = '';
// Event listener for focusing input
@ -44,13 +37,15 @@
}
onMount(() => {
// Attach event listener on component mount
document.addEventListener('keydown', handleKeydown);
window.addEventListener('scroll', handleScroll);
theme = document.documentElement.getAttribute('data-theme') || '';
// @ts-ignore
theme = document.documentElement.getAttribute('data-theme');
// Cleanup event listener on component destruction
return () => {
document.removeEventListener('keydown', handleKeydown);
window.removeEventListener('scroll', handleScroll);
};
});
@ -113,11 +108,7 @@
<AboutModal on:close={() => (isAboutModalOpen = false)} />
{/if}
<nav
class="navbar sticky top-0 z-50 transition-all duration-300 {isScrolled
? 'bg-base-100/80 backdrop-blur-lg shadow-lg border-b border-base-300/50'
: 'bg-base-100'}"
>
<div class="navbar bg-base-100">
<div class="navbar-start">
<div class="dropdown z-50">
<div tabindex="0" role="button" class="btn btn-ghost lg:hidden">
@ -199,19 +190,9 @@
{/if}
</ul>
</div>
<a href="/" class="btn btn-ghost hover:bg-transparent group">
<div class="flex items-center space-x-3">
<div class="relative">
<img
src="/favicon.png"
alt="AdventureLog"
class="w-10 h-10 transition-transform duration-300 group-hover:scale-110"
/>
<div
class="absolute inset-0 bg-gradient-to-br from-blue-500/20 to-purple-500/20 rounded-full opacity-0 group-hover:opacity-100 transition-opacity duration-300"
></div>
</div>
</div>
<a class="btn btn-ghost p-0 text-2xl font-bold tracking-normal" href="/">
<span class="sm:inline hidden">AdventureLog</span>
<img src="/favicon.png" alt="Map Logo" class="w-10" />
</a>
</div>
<div class="navbar-center hidden lg:flex">
@ -274,26 +255,19 @@
{/if}
{#if data.user}
<form class="hidden md:flex relative group" on:submit={searchGo}>
<div class="relative">
<form class="flex gap-2">
<label class="input input-bordered flex items-center gap-2">
<input
type="text"
bind:value={query}
bind:this={inputElement}
class="grow"
placeholder={$t('navbar.search')}
class="input input-bordered bg-neutral border-base-300 focus:border-primary focus:bg-base-100 transition-all duration-300 pr-20 w-64 group-focus-within:w-72"
/>
<div class="absolute right-3 top-1/2 -translate-y-1/2 flex items-center space-x-2">
<kbd class="kbd kbd-sm opacity-60">/ </kbd>
<button
type="submit"
class="btn btn-sm btn-circle btn-ghost hover:btn-primary transition-all duration-200"
aria-label="Search"
>
<Magnify class="w-4 h-4 text-neutral-300" />
bind:this={inputElement}
/><kbd class="kbd">/</kbd>
</label>
<button on:click={searchGo} type="submit" class="btn btn-neutral flex items-center gap-1">
<Magnify class="w-5 h-5" />
</button>
</div>
</div>
</form>
{/if}
</ul>
@ -356,4 +330,4 @@
</ul>
</div>
</div>
</nav>
</div>

View file

@ -630,7 +630,9 @@
"manage_categories": "Kategorien verwalten",
"no_categories_found": "Keine Kategorien gefunden.",
"select_category": "Kategorie wählen",
"update_after_refresh": "Die Abenteuerkarten werden aktualisiert, sobald Sie die Seite aktualisieren."
"update_after_refresh": "Die Abenteuerkarten werden aktualisiert, sobald Sie die Seite aktualisieren.",
"add_category": "Kategorie hinzufügen",
"add_new_category": "Neue Kategorie hinzufügen"
},
"dashboard": {
"add_some": "Warum nicht gleich Ihr nächstes Abenteuer planen? Sie können ein neues Abenteuer hinzufügen, indem Sie auf den Button unten klicken.",

View file

@ -660,7 +660,9 @@
"icon": "Icon",
"update_after_refresh": "The adventure cards will be updated once you refresh the page.",
"select_category": "Select Category",
"category_name": "Category Name"
"category_name": "Category Name",
"add_category": "Add Category",
"add_new_category": "Add New Category"
},
"dashboard": {
"welcome_back": "Welcome back",

View file

@ -630,7 +630,9 @@
"manage_categories": "Administrar categorías",
"no_categories_found": "No se encontraron categorías.",
"select_category": "Seleccionar categoría",
"update_after_refresh": "Las tarjetas de aventuras se actualizarán una vez que actualices la página."
"update_after_refresh": "Las tarjetas de aventuras se actualizarán una vez que actualices la página.",
"add_category": "Agregar categoría",
"add_new_category": "Agregar nueva categoría"
},
"dashboard": {
"add_some": "¿Por qué no empezar a planificar tu próxima aventura? \nPuedes agregar una nueva aventura haciendo clic en el botón de abajo.",

View file

@ -630,7 +630,9 @@
"manage_categories": "Gérer les catégories",
"no_categories_found": "Aucune catégorie trouvée.",
"select_category": "Sélectionnez une catégorie",
"update_after_refresh": "Les cartes d'aventure seront mises à jour une fois que vous aurez actualisé la page."
"update_after_refresh": "Les cartes d'aventure seront mises à jour une fois que vous aurez actualisé la page.",
"add_category": "Ajouter une catégorie",
"add_new_category": "Ajouter une nouvelle catégorie"
},
"dashboard": {
"add_some": "Pourquoi ne pas commencer à planifier votre prochaine aventure ? \nVous pouvez ajouter une nouvelle aventure en cliquant sur le bouton ci-dessous.",

View file

@ -630,7 +630,9 @@
"manage_categories": "Gestisci categorie",
"no_categories_found": "Nessuna categoria trovata.",
"select_category": "Seleziona Categoria",
"update_after_refresh": "Le carte avventura verranno aggiornate una volta aggiornata la pagina."
"update_after_refresh": "Le carte avventura verranno aggiornate una volta aggiornata la pagina.",
"add_category": "Aggiungi categoria",
"add_new_category": "Aggiungi nuova categoria"
},
"dashboard": {
"add_some": "Perché non iniziare a pianificare la tua prossima avventura? \nPuoi aggiungere una nuova avventura facendo clic sul pulsante in basso.",

View file

@ -307,7 +307,9 @@
"manage_categories": "카테고리 관리",
"no_categories_found": "카테고리가 없습니다.",
"select_category": "카테고리 선택",
"update_after_refresh": "페이지를 새로고침해야 모험 카드가 업데이트됩니다."
"update_after_refresh": "페이지를 새로고침해야 모험 카드가 업데이트됩니다.",
"add_category": "카테고리 추가",
"add_new_category": "새 카테고리를 추가하십시오"
},
"checklist": {
"add_item": "항목 추가",

View file

@ -630,7 +630,9 @@
"manage_categories": "Beheer categorieën",
"no_categories_found": "Geen categorieën gevonden.",
"select_category": "Selecteer een categorie",
"update_after_refresh": "De avonturenkaarten worden bijgewerkt zodra u de pagina vernieuwt."
"update_after_refresh": "De avonturenkaarten worden bijgewerkt zodra u de pagina vernieuwt.",
"add_category": "Categorie toevoegen",
"add_new_category": "Voeg een nieuwe categorie toe"
},
"dashboard": {
"add_some": "Waarom begint u niet met het plannen van uw volgende avontuur? \nJe kunt een nieuw avontuur toevoegen door op de onderstaande knop te klikken.",

View file

@ -660,7 +660,9 @@
"icon": "Ikon",
"update_after_refresh": "Eventyrkortene vil oppdateres når du oppdaterer siden.",
"select_category": "Velg kategori",
"category_name": "Kategorinavn"
"category_name": "Kategorinavn",
"add_category": "Legg til kategori",
"add_new_category": "Legg til ny kategori"
},
"dashboard": {
"welcome_back": "Velkommen tilbake",

View file

@ -630,7 +630,9 @@
"icon": "Ikona",
"update_after_refresh": "Karty podróży zostaną zaktualizowane po odświeżeniu strony.",
"select_category": "Wybierz kategorię",
"category_name": "Nazwa kategorii"
"category_name": "Nazwa kategorii",
"add_category": "Dodaj kategorię",
"add_new_category": "Dodaj nową kategorię"
},
"dashboard": {
"add_some": "Dlaczego nie zacząć planować kolejnej przygody? \nMożesz dodać nową przygodę, klikając przycisk poniżej.",

View file

@ -630,7 +630,9 @@
"manage_categories": "Hantera kategorier",
"no_categories_found": "Inga kategorier hittades.",
"select_category": "Välj Kategori",
"update_after_refresh": "Äventyrskorten kommer att uppdateras när du uppdaterar sidan."
"update_after_refresh": "Äventyrskorten kommer att uppdateras när du uppdaterar sidan.",
"add_category": "Lägg till kategori",
"add_new_category": "Lägg till en ny kategori"
},
"dashboard": {
"add_some": "Varför inte börja planera ditt nästa äventyr? \nDu kan lägga till ett nytt äventyr genom att klicka på knappen nedan.",

View file

@ -630,7 +630,9 @@
"manage_categories": "管理类别",
"no_categories_found": "未找到类别。",
"select_category": "选择类别",
"update_after_refresh": "刷新页面后,冒险卡将更新。"
"update_after_refresh": "刷新页面后,冒险卡将更新。",
"add_category": "添加类别",
"add_new_category": "添加新类别"
},
"dashboard": {
"add_some": "为什么不开始计划你的下一次冒险呢?\n您可以通过单击下面的按钮添加新的冒险。",