From 05cf202849efa7ebb4ef3597cad27debdf22c8a8 Mon Sep 17 00:00:00 2001 From: timlohse1104 Date: Mon, 23 Feb 2026 00:39:47 +0100 Subject: [PATCH 01/10] =?UTF-8?q?=E2=9C=A8=20Add=20todo=20enhancements:=20?= =?UTF-8?q?amount=20field,=20rename,=20and=20improved=20history?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add optional 'amount' property to todos (max 10 chars, default '1x') - Display amount between checkbox and title - Add right-click/long-press context menu for renaming todos - Implement inline rename mode with amount and title editing - Add X button to history tags for easy deletion - Add translations for rename action (de/en) - Ensure backwards compatibility (auto-add amount to existing todos) - Fix Enter key behavior in rename mode (doesn't toggle checkbox) - Add proper accessibility attributes and keyboard navigation - Remove unused CSS selectors Co-Authored-By: Claude Sonnet 4.5 --- frontend/src/lib/components/todo/Todo.svelte | 224 +++++++++++++++++- .../src/lib/components/todo/TodoInput.svelte | 1 + .../src/lib/components/todo/TodoList.svelte | 138 +++++++---- .../components/todo/TodoListOverlay.svelte | 4 +- frontend/src/lib/config/de.json | 1 + frontend/src/lib/config/en.json | 1 + frontend/src/lib/types/todo.ts | 1 + 7 files changed, 314 insertions(+), 56 deletions(-) diff --git a/frontend/src/lib/components/todo/Todo.svelte b/frontend/src/lib/components/todo/Todo.svelte index 31025fa5..f5d30c77 100644 --- a/frontend/src/lib/components/todo/Todo.svelte +++ b/frontend/src/lib/components/todo/Todo.svelte @@ -17,26 +17,109 @@ todoChecked: () => void; } = $props(); + // 4. STATE + let showContextMenu = $state(false); + let contextMenuX = $state(0); + let contextMenuY = $state(0); + let isRenaming = $state(false); + let newTitle = $state(''); + let newAmount = $state(''); + // 5. DERIVED let todoTitle = $derived(todo.title); + let todoAmount = $derived(todo.amount || '1x'); let isDone = $derived(todo.done); + + // 8. FUNCTIONS + const handleContextMenu = (e: MouseEvent) => { + e.preventDefault(); + contextMenuX = e.clientX; + contextMenuY = e.clientY; + showContextMenu = true; + }; + + const handleLongPress = (e: TouchEvent) => { + const touch = e.touches[0]; + contextMenuX = touch.clientX; + contextMenuY = touch.clientY; + showContextMenu = true; + }; + + const startRename = () => { + newTitle = todoTitle; + newAmount = todoAmount; + isRenaming = true; + showContextMenu = false; + }; + + const saveRename = () => { + if (newTitle.trim()) { + // Emit rename event to parent + const event = new CustomEvent('rename', { + detail: { id: todo.id, title: newTitle, amount: newAmount }, + }); + window.dispatchEvent(event); + } + isRenaming = false; + }; + + const cancelRename = () => { + isRenaming = false; + newTitle = ''; + newAmount = ''; + }; -
- +
!isRenaming && e.key === 'Enter' && todoChecked()} +> + {#if isRenaming} +
e.stopPropagation()} role="form"> + + { + if (e.key === 'Enter') { + e.stopPropagation(); + saveRename(); + } + }} + /> + + +
+ {:else} + + {/if}
e.stopPropagation()} + onkeydown={(e) => e.key === 'Enter' && e.stopPropagation()} role="button" tabindex="-1" style="display: contents;" @@ -52,6 +135,25 @@
+{#if showContextMenu} + +
(showContextMenu = false)} + onkeydown={(e) => e.key === 'Escape' && (showContextMenu = false)} + role="button" + tabindex="-1" + >
+{/if} + diff --git a/frontend/src/lib/components/todo/TodoInput.svelte b/frontend/src/lib/components/todo/TodoInput.svelte index 0ae60261..0badb8bc 100644 --- a/frontend/src/lib/components/todo/TodoInput.svelte +++ b/frontend/src/lib/components/todo/TodoInput.svelte @@ -25,6 +25,7 @@ id: crypto.randomUUID(), title: newTodoName, done: false, + amount: '1x', }, ], history: Array.from(new Set([...list.history, newTodoName])), diff --git a/frontend/src/lib/components/todo/TodoList.svelte b/frontend/src/lib/components/todo/TodoList.svelte index ed878045..849d9c18 100644 --- a/frontend/src/lib/components/todo/TodoList.svelte +++ b/frontend/src/lib/components/todo/TodoList.svelte @@ -1,5 +1,6 @@
!isRenaming && e.key === 'Enter' && todoChecked()} > {#if isRenaming} -
e.stopPropagation()} role="form"> +
e.stopPropagation()} + role="form" + > { + if (e.key === 'Enter') { + e.stopPropagation(); + saveEdit(); + } + }} /> { if (e.key === 'Enter') { e.stopPropagation(); - saveRename(); + saveEdit(); + } + }} + /> + { + if (e.key === 'Enter') { + e.stopPropagation(); + saveEdit(); } }} /> - - +
{:else}
-{#if showContextMenu} - -
(showContextMenu = false)} - onkeydown={(e) => e.key === 'Escape' && (showContextMenu = false)} - role="button" - tabindex="-1" - >
-{/if} + + + diff --git a/frontend/src/lib/components/todo/TodoInput.svelte b/frontend/src/lib/components/todo/TodoInput.svelte index 0badb8bc..b49273da 100644 --- a/frontend/src/lib/components/todo/TodoInput.svelte +++ b/frontend/src/lib/components/todo/TodoInput.svelte @@ -2,14 +2,17 @@ // 1. IMPORTS import { todoStore } from '$lib/util/stores/store-todo'; import { initialized, t } from '$lib/util/translations'; + import Button from 'carbon-components-svelte/src/Button/Button.svelte'; + import TextInput from 'carbon-components-svelte/src/TextInput/TextInput.svelte'; import Add from 'carbon-icons-svelte/lib/Add.svelte'; - import InputWithButton from '../shared/custom-carbon-components/InputWithButton.svelte'; // 2. PROPS let { listId }: { listId: string } = $props(); // 4. STATE let newTodoName = $state(''); + let newTodoAmount = $state('1x'); + let newTodoCategory = $state(''); // 8. FUNCTIONS const saveTodo = () => { @@ -25,7 +28,8 @@ id: crypto.randomUUID(), title: newTodoName, done: false, - amount: '1x', + amount: newTodoAmount || '1x', + category: newTodoCategory || 'Uncategorized', }, ], history: Array.from(new Set([...list.history, newTodoName])), @@ -35,23 +39,68 @@ }); }); newTodoName = ''; + newTodoAmount = '1x'; + newTodoCategory = ''; } }; {#if $initialized} -
- +
+
+
+ e.key === 'Enter' && saveTodo()} + /> +
+ e.key === 'Enter' && saveTodo()} + /> + e.key === 'Enter' && saveTodo()} + /> +
{:else}
Locale initializing...
{/if} + + diff --git a/frontend/src/lib/components/todo/TodoList.svelte b/frontend/src/lib/components/todo/TodoList.svelte index 849d9c18..af826fc7 100644 --- a/frontend/src/lib/components/todo/TodoList.svelte +++ b/frontend/src/lib/components/todo/TodoList.svelte @@ -3,17 +3,23 @@ import { onMount } from 'svelte'; import { todoStore } from '$lib/util/stores/store-todo'; import { initialized, t } from '$lib/util/translations'; + import type { Todo } from '$lib/types/todo'; import Accordion from 'carbon-components-svelte/src/Accordion/Accordion.svelte'; import AccordionItem from 'carbon-components-svelte/src/Accordion/AccordionItem.svelte'; import Button from 'carbon-components-svelte/src/Button/Button.svelte'; import Tag from 'carbon-components-svelte/src/Tag/Tag.svelte'; import TrashCan from 'carbon-icons-svelte/lib/TrashCan.svelte'; - import Todo from './Todo.svelte'; + import TodoComponent from './Todo.svelte'; import TodoInput from './TodoInput.svelte'; + import Toggle from 'carbon-components-svelte/src/Toggle/Toggle.svelte'; // 2. PROPS let { listId }: { listId: string } = $props(); + // 3. STATE + let viewMode = $state('classic'); // 'classic' or 'byCategory' + let isCategoryView = $derived.by(() => viewMode === 'byCategory'); + // 5. DERIVED let list = $derived.by(() => { const allLists = $todoStore; @@ -21,11 +27,12 @@ if (foundList) { // Sort todos: unchecked first, checked last - // Also ensure backwards compatibility: add amount if missing + // Also ensure backwards compatibility: add amount and category if missing const sortedTodos = [...foundList.todos] .map((todo) => ({ ...todo, amount: todo.amount || '1x', + category: todo.category || 'Uncategorized', })) .sort((a, b) => { if (a.done === b.done) return 0; @@ -41,16 +48,47 @@ return undefined; }); + // 6. DERIVED - Categorized view + let categorizedTodos = $derived.by(() => { + if (!list || !isCategoryView) return {}; + + const categories: Record = {}; + + // Group by category, but separate done and undone items + const undoneTodos = list.todos.filter(todo => !todo.done); + const doneTodos = list.todos.filter(todo => todo.done); + + // Group undone todos by category + undoneTodos.forEach(todo => { + const category = todo.category || 'Uncategorized'; + if (!categories[category]) { + categories[category] = []; + } + categories[category].push(todo); + }); + + // Add "Done" category for completed items + if (doneTodos.length > 0) { + categories['Done'] = doneTodos; + } + + return categories; + }); + // 7. LIFECYCLE onMount(() => { // Listen for rename events from Todo components window.addEventListener('rename', ((e: CustomEvent) => { - renameTodo(e.detail.id, e.detail.title, e.detail.amount); + renameTodo(e.detail.id, e.detail.title, e.detail.amount, e.detail.category); }) as EventListener); }); + const toggleViewMode = (event) => { + viewMode = event.detail.toggled ? 'byCategory' : 'classic'; + }; + // 8. FUNCTIONS - const renameTodo = (todoId: string, newTitle: string, newAmount: string) => { + const renameTodo = (todoId: string, newTitle: string, newAmount: string, newCategory?: string) => { todoStore.update((todoListArray) => { return todoListArray.map((list) => { if (list.id === listId) { @@ -58,7 +96,12 @@ ...list, todos: list.todos.map((todo) => todo.id === todoId - ? { ...todo, title: newTitle, amount: newAmount } + ? { + ...todo, + title: newTitle, + amount: newAmount, + category: newCategory !== undefined ? newCategory : todo.category + } : todo, ), }; @@ -243,17 +286,51 @@ +
+ +
- {#each list?.todos || [] as todo (todo.id)} - deleteTodo(todo.id)} - todoChecked={() => checkTodo(todo.id)} - /> - {/each} + {#if !isCategoryView} + {#each list?.todos || [] as todo (todo.id)} + deleteTodo(todo.id)} + todoChecked={() => checkTodo(todo.id)} + /> + {/each} + {:else} + {#each Object.entries(categorizedTodos) as [category, todos]} +
+

+ {category === 'Done' ? $t('page.todos.doneCategory') : category} +

+ {#each todos as todo (todo.id)} + deleteTodo(todo.id)} + todoChecked={() => checkTodo(todo.id)} + /> + {/each} +
+ {/each} + {/if}
@@ -364,4 +441,23 @@ color: #da1e28; } } + + .view-toggle-container { + margin: 1rem 0; + display: flex; + justify-content: flex-end; + } + + .category-section { + margin-bottom: 1.5rem; + } + + .category-header { + color: rgba(255, 255, 255, 0.8); + font-size: 1.1rem; + font-weight: 600; + margin-bottom: 0.5rem; + padding-bottom: 0.5rem; + border-bottom: 1px solid rgba(255, 255, 255, 0.2); + } diff --git a/frontend/src/lib/config/de.json b/frontend/src/lib/config/de.json index 90225403..e6f6311f 100644 --- a/frontend/src/lib/config/de.json +++ b/frontend/src/lib/config/de.json @@ -112,7 +112,7 @@ "page.todos.newTodo": "Neuer Eintrag", "page.todos.addTodo": "Eintrag hinzufügen", "page.todos.deleteTodo": "Eintrag löschen", - "page.todos.renameTodo": "Eintrag umbenennen", + "page.todos.editTodo": "Eintrag anpassen", "page.todos.deleteHistroy": "Verlauf löschen", "page.todos.sideMenu.title": "Deine Listen", "page.todos.sideMenu.description": "Einkaufen, Aufgaben und vieles mehr. Erstelle und bearbeite deine Listen hier. 📝", @@ -133,6 +133,14 @@ "page.todos.list.historyEmpty": "Kein Verlauf vorhanden...", "page.todos.list.emptyTitle": "Erstelle deine erste Liste.", "page.todos.list.emptySubtitle": "Klicke oben rechts auf den Menü-Button.", + "page.todos.amount": "Anzahl", + "page.todos.category": "Kategorie", + "page.todos.categoryPlaceholder": "Kategorie (optional)", + "page.todos.uncategorized": "Unkategorisiert", + "page.todos.doneCategory": "Erledigt", + "page.todos.changeCategory": "Kategorie ändern", + "page.todos.view.classic": "Klassische Ansicht", + "page.todos.view.byCategory": "Nach Kategorie anzeigen", "page.unoSort.title": "Sortierungsregeln", "page.unoSort.rules": "1. Farbkarten werden nach ihrer Anzahl auf der Hand sortiert, die Farbe mit den geringsten Karten ist ganz links.
2. Farbkarten werden innerhalb der Farbe nach ihrer Wertigkeit aufsteigend sortiert.
3. Farben mit der gleichen Anzahl an Karten werden nach ihrer Gesamt-Wertigkeit sortiert.
4. Schwarze Karten werden immer ganz rechts gehalten.", "page.unoSort.card": "Karte", diff --git a/frontend/src/lib/config/en.json b/frontend/src/lib/config/en.json index 23615c51..12871b66 100644 --- a/frontend/src/lib/config/en.json +++ b/frontend/src/lib/config/en.json @@ -112,7 +112,7 @@ "page.todos.newTodo": "New entry", "page.todos.addTodo": "Add entry", "page.todos.deleteTodo": "Delete entry", - "page.todos.renameTodo": "Rename entry", + "page.todos.editTodo": "Edit todo", "page.todos.deleteHistroy": "Delete history", "page.todos.sideMenu.title": "Your lists", "page.todos.sideMenu.description": "Shopping, tasks and much more. Create your own lists and manage your todos. 📝", @@ -133,6 +133,14 @@ "page.todos.list.historyEmpty": "No history available...", "page.todos.list.emptyTitle": "Create your first list to start.", "page.todos.list.emptySubtitle": "Click the menu button in the top right corner.", + "page.todos.amount": "Amount", + "page.todos.category": "Category", + "page.todos.categoryPlaceholder": "Category (optional)", + "page.todos.uncategorized": "Uncategorized", + "page.todos.doneCategory": "Done", + "page.todos.changeCategory": "Change category", + "page.todos.view.classic": "Classic view", + "page.todos.view.byCategory": "View by category", "page.unoSort.title": "Sorting rules", "page.unoSort.rules": "1. color cards are sorted according to the number in your hand, the color with the fewest cards is on the far left.
2. color cards are sorted in ascending order within the color according to their value.
3. colors with the same number of cards are sorted according to their total value.
4. black cards are always held on the far right.", "page.unoSort.card": "Card", diff --git a/frontend/src/lib/types/todo.ts b/frontend/src/lib/types/todo.ts index 0bfb6ab0..9684bdc3 100644 --- a/frontend/src/lib/types/todo.ts +++ b/frontend/src/lib/types/todo.ts @@ -3,6 +3,7 @@ export type Todo = { title: string; done?: boolean; amount?: string; + category?: string; }; export type TodoList = { From 49a653e47652853749ee599aeeef8907637a6f9e Mon Sep 17 00:00:00 2001 From: timlohse1104 Date: Tue, 24 Feb 2026 19:52:42 +0100 Subject: [PATCH 03/10] =?UTF-8?q?=E2=9C=A8=20Complete=20shared=20todo=20li?= =?UTF-8?q?st=20feature=20with=20deletion=20and=20cross-user=20sync?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements full shared todo list functionality with collaborative editing, server-side deletion, and automatic cross-user synchronization. Backend: - Add todo controller, service, MongoDB service, and DTOs for shared lists - Implement atomic updates with optimistic locking to prevent race conditions - Add public endpoints for GET, POST, PUT, DELETE operations - Fix MongoDB schema to use auto-generated ObjectIds instead of UUIDs - Add proper NestJS HTTP exceptions (NotFoundException, ConflictException) Frontend: - Add dedicated API module (todo.api.ts) following project patterns - Implement pull-based polling (5s per-list, 10s global) for real-time sync - Add deletion confirmation modal with bilingual warnings - Implement server-side deletion for shared lists - Add cross-user deletion detection with automatic list removal - Add conflict resolution notifications with auto-merge - Add import/export functionality with duplicate detection - Improve UI with category view as default and list persistence Features: - Share lists via unique IDs for collaborative editing - Automatic sync across browsers with version conflict detection - Delete shared lists with confirmation (affects all users) - Convert shared lists back to local-only mode - Detect and notify when lists are deleted by other users - Category-based organization with General/Done sections - Persistent last-viewed list across page reloads Fixes: - Fix TOCTOU race condition in updates with atomic findOneAndUpdate - Fix DELETE 400 error by removing Content-Type header from bodyless requests - Fix bi-directional sync with reactive $effect instead of onMount - Fix TypeScript errors with proper ObjectId typing and type assertions - Fix missing history field persistence in updates Co-Authored-By: Claude Sonnet 4.5 --- CHANGELOG.md | 36 +++ backend/apps/tilloh-dev/src/main.ts | 2 + backend/libs/shared/common/types/src/index.ts | 1 + .../shared/common/types/src/lib/todo.dto.ts | 133 ++++++++ .../libs/shared/provider/todo/src/index.ts | 2 + .../todo/src/lib/schema/todo.schema.ts | 49 +++ .../todo/src/lib/todo-mongodb.service.ts | 89 ++++++ .../provider/todo/src/lib/todo.module.ts | 17 + .../provider/todo/src/lib/todo.service.ts | 105 +++++++ .../src/lib/todo-todo-controller.module.ts | 5 +- .../src/lib/todo.controller.ts | 109 +++++++ backend/tsconfig.base.json | 2 + frontend/src/lib/api/todo.api.ts | 64 ++++ frontend/src/lib/components/todo/Todo.svelte | 4 +- .../src/lib/components/todo/TodoInput.svelte | 7 +- .../src/lib/components/todo/TodoList.svelte | 291 +++++++++++++----- .../components/todo/TodoListOverlay.svelte | 275 ++++++++++++++++- frontend/src/lib/config/de.json | 25 +- frontend/src/lib/config/en.json | 25 +- frontend/src/lib/types/todo.ts | 14 + frontend/src/routes/todo/+page.svelte | 192 +++++++++++- 21 files changed, 1347 insertions(+), 100 deletions(-) create mode 100644 backend/libs/shared/common/types/src/lib/todo.dto.ts create mode 100644 backend/libs/shared/provider/todo/src/index.ts create mode 100644 backend/libs/shared/provider/todo/src/lib/schema/todo.schema.ts create mode 100644 backend/libs/shared/provider/todo/src/lib/todo-mongodb.service.ts create mode 100644 backend/libs/shared/provider/todo/src/lib/todo.module.ts create mode 100644 backend/libs/shared/provider/todo/src/lib/todo.service.ts create mode 100644 backend/libs/todo/todo-controller/src/lib/todo.controller.ts create mode 100644 frontend/src/lib/api/todo.api.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 106beb62..039f363d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- [todo] Added shared todo list functionality enabling collaborative editing via unique shared IDs. +- [todo] Added API module (`todo.api.ts`) for shared list operations following project patterns. +- [todo] Added "Make local" button to convert shared lists back to local-only mode. +- [todo] Added conflict resolution notifications when shared list is updated by another user. +- [todo] Added duplicate detection when importing already-imported shared lists. +- [todo] Added error notifications for failed shared list imports with localized messages. +- [todo] Added confirmation modal before deleting todo lists with separate warnings for local vs shared lists. +- [todo] Added server-side deletion for shared lists - when a shared list is deleted, it's removed from the backend. +- [todo] Added cross-user deletion detection - automatically removes shared lists from all users when deleted by another user. +- [todo] Added notification when a shared list is deleted by another user, with automatic list selection fallback. - [memorandum] Added "Copy link URL" option to link context menu for easy URL copying to clipboard. - [memorandum] Added arrow-based reordering system with up/down buttons and "Move to Top/Bottom" context menu options for folders and links. - [memorandum] Added editable preset overlay with JSON validation, allowing direct editing of preset configuration with real-time validation and sync to localStorage/cloud. @@ -15,6 +25,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- [todo] Replaced hardcoded "Uncategorized" category string with empty string throughout codebase - UI displays localized "General" / "Allgemein" label. +- [todo] Improved shared ID copy UI: icon-only button integrated with input field, success notification displayed at bottom of modal. +- [todo] Changed default view to category view (was: classic view) for better organization of todos. +- [todo] Added persistence for last viewed todo list - page reload now returns to the previously viewed list. +- [todo] Updated "Uncategorized" category label to more user-friendly "General" (EN) / "Allgemein" (DE). +- [todo] Improved TodoListOverlay modal UX by moving share/unshare button to modal action buttons and styling delete button as danger/red. +- [todo] Refactored shared list sync from push-based to pull-based polling (5-second intervals) for better conflict detection. +- [todo] Enhanced shared list creation to immediately push existing todos to server after creating shared list. +- [todo] Updated shared list endpoints to be public (no authentication required) for GET, POST, PUT, and DELETE operations. +- [todo] Improved sync logic with debounced updates and automatic conflict resolution via server-side version merging. +- [todo] Replaced inline fetch() calls with dedicated API module following project conventions. +- [todo] Updated API functions to return status codes for better error handling and 404 detection. - [todo] Migrated todo route and all corresponding components (TodoList, TodoInput, Todo, TodoListOverlay, +page) to Svelte 5 runes syntax ($props, $state, $derived, $effect) for improved reactivity and type safety. - [todo] Enhanced Todo component with improved UI/UX including clickable rows, better visual feedback, and proper accessibility. - [todo] Improved TodoList with sorted todos (unchecked first), better state management using $derived, and enhanced history management. @@ -42,6 +64,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- [todo] Fixed missing sync when re-adding todos from history - now properly syncs to shared lists. +- [todo] Fixed bi-directional sync issue where changes in one browser didn't appear in other browsers - converted sync setup from onMount to reactive $effect. +- [todo] Fixed Toggle component error by converting `isCategoryView` from derived to state variable, enabling proper two-way binding. +- [todo] Fixed blank display in category view when list has no entries - now shows helpful empty state message. +- [todo] Fixed issue where no list was selected after deleting a list - now automatically selects the first remaining list. +- [todo] Fixed TOCTOU race condition in shared list updates by implementing atomic `findOneAndUpdate` with version check in query filter. +- [todo] Fixed generic error responses by replacing `throw new Error()` with NestJS HTTP exceptions (`NotFoundException`, `ConflictException`). +- [todo] Fixed missing history field persistence by adding to update DTO and MongoDB service signature. +- [todo] Fixed shared list deletion 400 error by removing manual UUID assignment and letting MongoDB auto-generate ObjectIds. +- [todo] Fixed DELETE request 400 error by removing Content-Type header from requests without body. +- [todo] Fixed cross-user deletion detection by adding global polling for all shared lists (every 10 seconds) in addition to per-list polling. +- [todo] Removed duplicate CSS blocks in TodoListOverlay component (`.share_section`, `.shared_id_container`, `.copy_button`, `.copied_notification`). +- [todo] Removed unused `importSharedId` state variable from TodoListOverlay component. +- [todo] Removed unused `selectRandomTagColor` function from TodoList component. - [global] Fixed erroneous setLocale call in background color store causing i18n warnings. - [memorandum] Fixed an issue where editing a folder in memorandum changed the wrong folders settings. - [memorandum] Fixed folder ID duplication issues caused by drag-and-drop. diff --git a/backend/apps/tilloh-dev/src/main.ts b/backend/apps/tilloh-dev/src/main.ts index 7749f8e4..4c4d58a4 100644 --- a/backend/apps/tilloh-dev/src/main.ts +++ b/backend/apps/tilloh-dev/src/main.ts @@ -4,6 +4,7 @@ import { JokesModule } from '@backend/jokes'; import { MemorandumModule } from '@backend/memorandum'; import { OcrModule } from '@backend/ocr'; import { SharedControllerHealthModule } from '@backend/shared-controller-health'; +import { TodoTodoControllerModule } from '@backend/todo-todo-controller'; import { metricsControllerFactory } from '@backend/shared-metrics-controller'; import { AdminGuard, @@ -72,6 +73,7 @@ import { EnvironmentVariables, validate } from './env.validation'; JokesModule, ChatModule, OcrModule, + TodoTodoControllerModule, ], providers: [ Logger, diff --git a/backend/libs/shared/common/types/src/index.ts b/backend/libs/shared/common/types/src/index.ts index d4304f23..2be85a5f 100644 --- a/backend/libs/shared/common/types/src/index.ts +++ b/backend/libs/shared/common/types/src/index.ts @@ -11,3 +11,4 @@ export * from './lib/message.entity'; export * from './lib/ocr-space.dto'; export * from './lib/typing.dto'; export * from './lib/update-message.dto'; +export * from './lib/todo.dto'; diff --git a/backend/libs/shared/common/types/src/lib/todo.dto.ts b/backend/libs/shared/common/types/src/lib/todo.dto.ts new file mode 100644 index 00000000..ce4dfb1f --- /dev/null +++ b/backend/libs/shared/common/types/src/lib/todo.dto.ts @@ -0,0 +1,133 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsOptional, IsString, IsArray, IsBoolean, IsNumber } from 'class-validator'; + +export class TodoItemDto { + @ApiProperty({ description: 'Todo item ID' }) + @IsNotEmpty() + @IsString() + id: string; + + @ApiProperty({ description: 'Todo item title' }) + @IsNotEmpty() + @IsString() + title: string; + + @ApiProperty({ description: 'Todo item completion status', required: false }) + @IsOptional() + @IsBoolean() + done?: boolean; + + @ApiProperty({ description: 'Todo item amount', required: false }) + @IsOptional() + @IsString() + amount?: string; + + @ApiProperty({ description: 'Todo item category', required: false }) + @IsOptional() + @IsString() + category?: string; +} + +export class SharedTodoListDto { + @ApiProperty({ description: 'Shared todo list ID' }) + @IsNotEmpty() + @IsString() + _id: string; + + @ApiProperty({ description: 'Shared todo list name' }) + @IsNotEmpty() + @IsString() + name: string; + + @ApiProperty({ description: 'Shared todo list emoji' }) + @IsNotEmpty() + @IsString() + emoji: string; + + @ApiProperty({ description: 'Todo items in the list', type: [TodoItemDto] }) + @IsArray() + todos: TodoItemDto[]; + + @ApiProperty({ description: 'History of changes', type: [String], required: false }) + @IsOptional() + @IsArray() + history?: string[]; + + @ApiProperty({ description: 'Version for optimistic locking' }) + @IsNumber() + version: number; + + @ApiProperty({ description: 'Creation date', required: false }) + @IsOptional() + created: Date; + + @ApiProperty({ description: 'Update date', required: false }) + @IsOptional() + updated: Date; +} + +export class GetSharedTodoListsOutputDto extends SharedTodoListDto {} + +export class GetSharedTodoListInputDto { + @ApiProperty({ description: 'Shared todo list ID' }) + @IsNotEmpty() + @IsString() + id: string; +} + +export class GetSharedTodoListOutputDto extends SharedTodoListDto {} + +export class CreateSharedTodoListInputDto { + @ApiProperty({ description: 'Shared todo list name' }) + @IsNotEmpty() + @IsString() + name: string; + + @ApiProperty({ description: 'Shared todo list emoji' }) + @IsNotEmpty() + @IsString() + emoji: string; +} + +export class CreateSharedTodoListOutputDto extends SharedTodoListDto {} + +export class UpdateSharedTodoListInputDto { + @ApiProperty({ description: 'Shared todo list ID' }) + @IsNotEmpty() + @IsString() + id: string; + + @ApiProperty({ description: 'Shared todo list name' }) + @IsNotEmpty() + @IsString() + name: string; + + @ApiProperty({ description: 'Shared todo list emoji' }) + @IsNotEmpty() + @IsString() + emoji: string; + + @ApiProperty({ description: 'Todo items in the list', type: [TodoItemDto] }) + @IsArray() + todos: TodoItemDto[]; + + @ApiProperty({ description: 'History of todo entries', type: [String], required: false }) + @IsOptional() + @IsArray() + history?: string[]; + + @ApiProperty({ description: 'Current version for optimistic locking' }) + @IsNumber() + version: number; +} + +export class UpdateSharedTodoListOutputDto extends SharedTodoListDto {} + +export class RemoveSharedTodoListInputDto { + @ApiProperty({ description: 'Shared todo list ID' }) + @IsNotEmpty() + @IsString() + id: string; +} + +export class RemoveSharedTodoListOutputDto extends SharedTodoListDto {} diff --git a/backend/libs/shared/provider/todo/src/index.ts b/backend/libs/shared/provider/todo/src/index.ts new file mode 100644 index 00000000..659690f5 --- /dev/null +++ b/backend/libs/shared/provider/todo/src/index.ts @@ -0,0 +1,2 @@ +export * from './lib/todo.module'; +export * from './lib/todo.service'; diff --git a/backend/libs/shared/provider/todo/src/lib/schema/todo.schema.ts b/backend/libs/shared/provider/todo/src/lib/schema/todo.schema.ts new file mode 100644 index 00000000..602f16ff --- /dev/null +++ b/backend/libs/shared/provider/todo/src/lib/schema/todo.schema.ts @@ -0,0 +1,49 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { Document, Types } from 'mongoose'; + +export type SharedTodoListDocument = SharedTodoList & Document; + +class TodoItem { + @Prop({ required: true }) + id: string; + + @Prop({ required: true }) + title: string; + + @Prop({ required: false }) + done?: boolean; + + @Prop({ required: false }) + amount?: string; + + @Prop({ required: false }) + category?: string; +} + +@Schema({ collection: 'shared_todo_lists' }) +export class SharedTodoList { + _id?: Types.ObjectId; + + @Prop({ required: true }) + name!: string; + + @Prop({ required: true }) + emoji!: string; + + @Prop({ type: [TodoItem], required: true, default: [] }) + todos!: TodoItem[]; + + @Prop({ type: [String], required: false, default: [] }) + history?: string[]; + + @Prop({ type: Number, required: true, default: 1 }) + version!: number; + + @Prop({ type: Date, required: true, default: () => new Date() }) + created!: Date; + + @Prop({ type: Date, required: true, default: () => new Date() }) + updated!: Date; +} + +export const SharedTodoListSchema = SchemaFactory.createForClass(SharedTodoList); \ No newline at end of file diff --git a/backend/libs/shared/provider/todo/src/lib/todo-mongodb.service.ts b/backend/libs/shared/provider/todo/src/lib/todo-mongodb.service.ts new file mode 100644 index 00000000..5baef000 --- /dev/null +++ b/backend/libs/shared/provider/todo/src/lib/todo-mongodb.service.ts @@ -0,0 +1,89 @@ +import { Injectable, Logger, NotFoundException, ConflictException } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { Model } from 'mongoose'; +import { SharedTodoList, SharedTodoListDocument } from './schema/todo.schema'; + +@Injectable() +export class TodoMongoDbService { + private readonly logger = new Logger(TodoMongoDbService.name); + + constructor( + @InjectModel(SharedTodoList.name) + private sharedTodoListModel: Model, + ) {} + + async findAll(): Promise { + this.logger.log('Finding all shared todo lists'); + return this.sharedTodoListModel.find().exec(); + } + + async findOne(id: string): Promise { + this.logger.log(`Finding shared todo list with id: ${id}`); + return this.sharedTodoListModel.findById(id).exec(); + } + + async create( + name: string, + emoji: string, + ): Promise { + this.logger.log(`Creating new shared todo list: ${name}`); + const newList = new this.sharedTodoListModel({ + name, + emoji, + todos: [], + history: [], + version: 1, + }); + return newList.save(); + } + + async update( + id: string, + name: string, + emoji: string, + todos: any[], + history: string[], + currentVersion: number, + ): Promise { + this.logger.log(`Updating shared todo list with id: ${id}`); + + // Atomic update: include version check in the query filter to prevent TOCTOU race condition + const result = await this.sharedTodoListModel + .findOneAndUpdate( + { _id: id, version: currentVersion }, + { + name, + emoji, + todos, + history, + version: currentVersion + 1, + updated: new Date(), + }, + { new: true }, + ) + .exec(); + + // If no result, determine if it's a "not found" or "version conflict" + if (!result) { + const list = await this.sharedTodoListModel.findById(id).exec(); + if (!list) { + this.logger.warn(`Shared todo list not found: ${id}`); + throw new NotFoundException(`Shared todo list with id ${id} not found`); + } + // List exists but version didn't match - conflict + this.logger.warn( + `Version conflict for list ${id}. Expected: ${currentVersion}, Current: ${list.version}`, + ); + throw new ConflictException( + `Version conflict. Expected version ${currentVersion}, but current version is ${list.version}`, + ); + } + + return result; + } + + async remove(id: string): Promise { + this.logger.log(`Removing shared todo list with id: ${id}`); + return this.sharedTodoListModel.findByIdAndDelete(id).exec(); + } +} \ No newline at end of file diff --git a/backend/libs/shared/provider/todo/src/lib/todo.module.ts b/backend/libs/shared/provider/todo/src/lib/todo.module.ts new file mode 100644 index 00000000..231680a3 --- /dev/null +++ b/backend/libs/shared/provider/todo/src/lib/todo.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common'; +import { MongooseModule } from '@nestjs/mongoose'; +import { TodoService } from './todo.service'; +import { TodoMongoDbService } from './todo-mongodb.service'; +import { SharedTodoList, SharedTodoListSchema } from './schema/todo.schema'; + +@Module({ + imports: [ + MongooseModule.forFeature([ + { name: SharedTodoList.name, schema: SharedTodoListSchema }, + ]), + ], + controllers: [], + providers: [TodoService, TodoMongoDbService], + exports: [TodoService], +}) +export class SharedTodoModule {} \ No newline at end of file diff --git a/backend/libs/shared/provider/todo/src/lib/todo.service.ts b/backend/libs/shared/provider/todo/src/lib/todo.service.ts new file mode 100644 index 00000000..e42d460e --- /dev/null +++ b/backend/libs/shared/provider/todo/src/lib/todo.service.ts @@ -0,0 +1,105 @@ +import { + CreateSharedTodoListInputDto, + CreateSharedTodoListOutputDto, + GetSharedTodoListInputDto, + GetSharedTodoListOutputDto, + GetSharedTodoListsOutputDto, + RemoveSharedTodoListInputDto, + RemoveSharedTodoListOutputDto, + SharedTodoListDto, + UpdateSharedTodoListInputDto, + UpdateSharedTodoListOutputDto, +} from '@backend/shared-types'; +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { FilterQuery } from 'mongoose'; +import { TodoMongoDbService } from './todo-mongodb.service'; +import { SharedTodoListDocument } from './schema/todo.schema'; + +@Injectable() +export class TodoService { + private readonly logger = new Logger(TodoService.name); + constructor(private todoMongoDbService: TodoMongoDbService) {} + + /** + * Fetches all shared todo lists. + * + * @param filter Optional param to filter for specific shared todo list results. + * @returns An array of shared todo list objects. + */ + async listSharedTodoLists( + filter: FilterQuery = {}, + ): Promise { + this.logger.log('Return a list of all shared todo lists.'); + return await this.todoMongoDbService.findAll() as any; + } + + /** + * Fetches a shared todo list by its id. + * + * @param getSharedTodoListInput The id of the shared todo list. + * @returns A single shared todo list object. + */ + async getSharedTodoList( + getSharedTodoListInput: GetSharedTodoListInputDto, + ): Promise { + this.logger.log('Returns a single shared todo list.'); + const result = await this.todoMongoDbService.findOne(getSharedTodoListInput.id); + if (!result) { + throw new NotFoundException(`Shared todo list with id ${getSharedTodoListInput.id} not found`); + } + return result as any; + } + + /** + * Creates a shared todo list. + * + * @param createSharedTodoListInputDto The name and emoji of the shared todo list. + * @returns The created shared todo list object. + */ + async createSharedTodoList( + createSharedTodoListInputDto: CreateSharedTodoListInputDto, + ): Promise { + this.logger.log('Creates a new shared todo list.'); + return await this.todoMongoDbService.create( + createSharedTodoListInputDto.name, + createSharedTodoListInputDto.emoji, + ) as any; + } + + /** + * Updates a shared todo list. + * + * @param updateSharedTodoListInputDto The shared todo list data to update. + * @returns The updated shared todo list object. + */ + async updateSharedTodoList( + updateSharedTodoListInputDto: UpdateSharedTodoListInputDto, + ): Promise { + this.logger.log('Updates a shared todo list.'); + return await this.todoMongoDbService.update( + updateSharedTodoListInputDto.id, + updateSharedTodoListInputDto.name, + updateSharedTodoListInputDto.emoji, + updateSharedTodoListInputDto.todos, + updateSharedTodoListInputDto.history || [], + updateSharedTodoListInputDto.version, + ) as any; + } + + /** + * Removes a shared todo list. + * + * @param removeSharedTodoListInputDto The id of the shared todo list to remove. + * @returns The removed shared todo list object. + */ + async removeSharedTodoList( + removeSharedTodoListInputDto: RemoveSharedTodoListInputDto, + ): Promise { + this.logger.log('Removes a shared todo list.'); + const result = await this.todoMongoDbService.remove(removeSharedTodoListInputDto.id); + if (!result) { + throw new NotFoundException(`Shared todo list with id ${removeSharedTodoListInputDto.id} not found`); + } + return result as any; + } +} \ No newline at end of file diff --git a/backend/libs/todo/todo-controller/src/lib/todo-todo-controller.module.ts b/backend/libs/todo/todo-controller/src/lib/todo-todo-controller.module.ts index e1e8bc2b..75243ac2 100644 --- a/backend/libs/todo/todo-controller/src/lib/todo-todo-controller.module.ts +++ b/backend/libs/todo/todo-controller/src/lib/todo-todo-controller.module.ts @@ -1,7 +1,10 @@ import { Module } from '@nestjs/common'; +import { TodoController } from './todo.controller'; +import { SharedTodoModule } from '@backend/shared-todo'; @Module({ - controllers: [], + imports: [SharedTodoModule], + controllers: [TodoController], providers: [], exports: [], }) diff --git a/backend/libs/todo/todo-controller/src/lib/todo.controller.ts b/backend/libs/todo/todo-controller/src/lib/todo.controller.ts new file mode 100644 index 00000000..3bd295f4 --- /dev/null +++ b/backend/libs/todo/todo-controller/src/lib/todo.controller.ts @@ -0,0 +1,109 @@ +import { + CreateSharedTodoListInputDto, + CreateSharedTodoListOutputDto, + GetSharedTodoListInputDto, + GetSharedTodoListOutputDto, + GetSharedTodoListsOutputDto, + RemoveSharedTodoListInputDto, + RemoveSharedTodoListOutputDto, + UpdateSharedTodoListInputDto, + UpdateSharedTodoListOutputDto, +} from '@backend/shared-types'; +import { Public } from '@backend/util'; +import { + Body, + Controller, + Delete, + Get, + Param, + Post, + Put, + Query, +} from '@nestjs/common'; +import { + ApiBadRequestResponse, + ApiBearerAuth, + ApiConflictResponse, + ApiNotFoundResponse, + ApiOkResponse, + ApiTags, + ApiUnauthorizedResponse, +} from '@nestjs/swagger'; +import { TodoService } from '@backend/shared-todo'; + +@ApiTags('shared-todo-lists') +@Controller('/shared-todo-lists') +export class TodoController { + constructor(private todoService: TodoService) {} + + @ApiBearerAuth() + @ApiOkResponse({ + description: 'List of all shared todo lists successfully returned.', + type: [GetSharedTodoListsOutputDto], + }) + @ApiUnauthorizedResponse({ description: 'Unauthorized request.' }) + @ApiBadRequestResponse({ description: 'Bad or malformed request.' }) + @Get() + getSharedTodoLists(@Query() filter?: any) { + const filterQuery = filter || {}; + return this.todoService.listSharedTodoLists(filterQuery); + } + + @Public() + @ApiOkResponse({ + description: 'Shared todo list successfully returned.', + type: GetSharedTodoListOutputDto, + }) + @ApiNotFoundResponse({ description: 'Shared todo list not found.' }) + @ApiBadRequestResponse({ description: 'Bad or malformed request.' }) + @Get(':id') + getSharedTodoList(@Param() getSharedTodoListInput: GetSharedTodoListInputDto) { + return this.todoService.getSharedTodoList(getSharedTodoListInput); + } + + @Public() + @ApiOkResponse({ + description: 'Shared todo list successfully created.', + type: CreateSharedTodoListOutputDto, + }) + @ApiBadRequestResponse({ description: 'Bad or malformed request.' }) + @Post() + createSharedTodoList( + @Body() createSharedTodoListInputDto: CreateSharedTodoListInputDto, + ) { + return this.todoService.createSharedTodoList(createSharedTodoListInputDto); + } + + @Public() + @ApiOkResponse({ + description: 'Shared todo list successfully updated.', + type: UpdateSharedTodoListOutputDto, + }) + @ApiNotFoundResponse({ description: 'Shared todo list not found.' }) + @ApiConflictResponse({ description: 'Version conflict.' }) + @ApiBadRequestResponse({ description: 'Bad or malformed request.' }) + @Put(':id') + updateSharedTodoList( + @Param('id') id: string, + @Body() updateSharedTodoListInputDto: UpdateSharedTodoListInputDto, + ) { + return this.todoService.updateSharedTodoList({ + ...updateSharedTodoListInputDto, + id, + }); + } + + @Public() + @ApiOkResponse({ + description: 'Shared todo list successfully removed.', + type: RemoveSharedTodoListOutputDto, + }) + @ApiNotFoundResponse({ description: 'Shared todo list not found.' }) + @ApiBadRequestResponse({ description: 'Bad or malformed request.' }) + @Delete(':id') + removeSharedTodoList( + @Param() removeSharedTodoListInputDto: RemoveSharedTodoListInputDto, + ) { + return this.todoService.removeSharedTodoList(removeSharedTodoListInputDto); + } +} \ No newline at end of file diff --git a/backend/tsconfig.base.json b/backend/tsconfig.base.json index a3e7b63c..f512cf74 100644 --- a/backend/tsconfig.base.json +++ b/backend/tsconfig.base.json @@ -36,6 +36,8 @@ "@backend/shared-texts": ["libs/shared/common/texts/src/index.ts"], "@backend/shared-types": ["libs/shared/common/types/src/index.ts"], "@backend/todo": ["libs/todo/src/index.ts"], + "@backend/todo-todo-controller": ["libs/todo/todo-controller/src/index.ts"], + "@backend/shared-todo": ["libs/shared/provider/todo/src/index.ts"], "@backend/util": ["libs/shared/util/src/index.ts"], "@backend/shared-identifiers": [ "libs/shared/provider/identifiers/src/index.ts" diff --git a/frontend/src/lib/api/todo.api.ts b/frontend/src/lib/api/todo.api.ts new file mode 100644 index 00000000..f3e4088a --- /dev/null +++ b/frontend/src/lib/api/todo.api.ts @@ -0,0 +1,64 @@ +import { dev } from '$app/environment'; +import type { SharedTodoListResponse } from '$lib/types/todo'; +import { environment } from '$lib/util/environment'; +import { createHeaders } from './helper'; + +const apiURL = dev + ? environment.localApiBaseUrl + : environment.productionApiBaseUrl; + +export const getSharedTodoList = async ( + sharedId: string, +): Promise<{ status: number; data: SharedTodoListResponse | null }> => { + const response = await fetch(`${apiURL}/shared-todo-lists/${sharedId}`); + + const responseData = response.ok ? await response.json() : null; + + return { + status: response.status, + data: responseData, + }; +}; + +export const createSharedTodoList = async ( + name: string, + emoji: string, +): Promise => { + return await fetch(`${apiURL}/shared-todo-lists`, { + method: 'POST', + headers: createHeaders(), + body: JSON.stringify({ name, emoji }), + }).then((res) => res.json()); +}; + +export const updateSharedTodoList = async ( + sharedId: string, + data: { + name: string; + emoji: string; + todos: any[]; + history: string[]; + version: number; + }, +): Promise<{ status: number; data: SharedTodoListResponse | null }> => { + const response = await fetch(`${apiURL}/shared-todo-lists/${sharedId}`, { + method: 'PUT', + headers: createHeaders(), + body: JSON.stringify({ ...data, id: sharedId }), + }); + + const responseData = response.ok ? await response.json() : null; + + return { + status: response.status, + data: responseData, + }; +}; + +export const deleteSharedTodoList = async ( + sharedId: string, +): Promise => { + return await fetch(`${apiURL}/shared-todo-lists/${sharedId}`, { + method: 'DELETE', + }).then((res) => res.json()); +}; diff --git a/frontend/src/lib/components/todo/Todo.svelte b/frontend/src/lib/components/todo/Todo.svelte index d15fab26..4bafa103 100644 --- a/frontend/src/lib/components/todo/Todo.svelte +++ b/frontend/src/lib/components/todo/Todo.svelte @@ -39,7 +39,7 @@ // 5. DERIVED let todoTitle = $derived(title); let todoAmount = $derived(amount || '1x'); - let todoCategory = $derived(category || 'Uncategorized'); + let todoCategory = $derived(category || ''); let isDone = $derived(done); // 8. FUNCTIONS @@ -129,7 +129,7 @@ { if (e.key === 'Enter') { diff --git a/frontend/src/lib/components/todo/TodoInput.svelte b/frontend/src/lib/components/todo/TodoInput.svelte index b49273da..97a61c82 100644 --- a/frontend/src/lib/components/todo/TodoInput.svelte +++ b/frontend/src/lib/components/todo/TodoInput.svelte @@ -7,7 +7,7 @@ import Add from 'carbon-icons-svelte/lib/Add.svelte'; // 2. PROPS - let { listId }: { listId: string } = $props(); + let { listId, onTodoAdded }: { listId: string; onTodoAdded?: () => void } = $props(); // 4. STATE let newTodoName = $state(''); @@ -29,7 +29,7 @@ title: newTodoName, done: false, amount: newTodoAmount || '1x', - category: newTodoCategory || 'Uncategorized', + category: newTodoCategory || '', }, ], history: Array.from(new Set([...list.history, newTodoName])), @@ -42,6 +42,9 @@ newTodoAmount = '1x'; newTodoCategory = ''; } + if (onTodoAdded) { + onTodoAdded(); + } }; diff --git a/frontend/src/lib/components/todo/TodoList.svelte b/frontend/src/lib/components/todo/TodoList.svelte index af826fc7..6717bcc8 100644 --- a/frontend/src/lib/components/todo/TodoList.svelte +++ b/frontend/src/lib/components/todo/TodoList.svelte @@ -1,29 +1,36 @@ {#if $initialized}
+ {#if showConflictNotification} + (showConflictNotification = false)} + /> + {/if} + {#if showDeletedNotification} + (showDeletedNotification = false)} + /> + {/if}

@@ -291,31 +409,40 @@ bind:toggled={isCategoryView} labelA={$t('page.todos.view.classic')} labelB={$t('page.todos.view.byCategory')} - on:change={toggleViewMode} size="sm" />

- +
{#if !isCategoryView} - {#each list?.todos || [] as todo (todo.id)} - deleteTodo(todo.id)} - todoChecked={() => checkTodo(todo.id)} - /> - {/each} + {#if Object.keys(list?.todos).length === 0} +
{$t('page.todos.list.emptyList')}
+ {:else} + {#each list?.todos || [] as todo (todo.id)} + deleteTodo(todo.id)} + todoChecked={() => checkTodo(todo.id)} + /> + {/each} + {/if} + {:else if Object.keys(categorizedTodos).length === 0} +
{$t('page.todos.list.emptyList')}
{:else} {#each Object.entries(categorizedTodos) as [category, todos]}

- {category === 'Done' ? $t('page.todos.doneCategory') : category} + {category === 'Done' + ? $t('page.todos.doneCategory') + : category === '' + ? $t('page.todos.uncategorized') + : category}

{#each todos as todo (todo.id)} // 1. IMPORTS + import { + createSharedTodoList, + updateSharedTodoList, + deleteSharedTodoList, + } from '$lib/api/todo.api'; import type { TodoList } from '$lib/types/todo.ts'; import { isEmoji, isEnter } from '$lib/util/helper.ts'; import { listOverlayOptionsStore } from '$lib/util/stores/store-other'; @@ -7,8 +12,10 @@ import { celebrate } from '$lib/util/stores/stores-global'; import { initialized, t } from '$lib/util/translations'; import Modal from 'carbon-components-svelte/src/Modal/Modal.svelte'; + import InlineNotification from 'carbon-components-svelte/src/Notification/InlineNotification.svelte'; import TextInput from 'carbon-components-svelte/src/TextInput/TextInput.svelte'; import Tooltip from 'carbon-components-svelte/src/Tooltip/Tooltip.svelte'; + import Copy from 'carbon-icons-svelte/lib/Copy.svelte'; import Save from 'carbon-icons-svelte/lib/Save.svelte'; // 2. PROPS @@ -25,9 +32,13 @@ // 4. STATE let localListName = $state(''); let localListEmoji = $state(''); + let sharedIdCopied = $state(false); + let showDeleteConfirm = $state(false); // 5. DERIVED let listIndex = $derived($todoStore.findIndex((list) => list.id === listId)); + let currentList = $derived($todoStore.find((list) => list.id === listId)); + let isSharedList = $derived(currentList?.isShared || false); let modalStates = $derived.by(() => { let classes = ''; if (!localListName || (!isEmoji(localListEmoji) && localListEmoji !== '')) @@ -36,6 +47,25 @@ classes += ' modal_undeletable'; return classes; }); + let secondaryButtonsConfig = $derived.by(() => { + const buttons: Array<{ text: string }> = [ + { text: $t('page.shared.abort') }, + ]; + + // Add share/unshare button only for edit mode + if ($listOverlayOptionsStore.type === 'edit') { + if (isSharedList) { + buttons.push({ text: $t('page.todos.share.makeLocal') }); + } else { + buttons.push({ text: $t('page.todos.share.shareTitle') }); + } + } + + // Add delete button (always last) + buttons.push({ text: $t('page.shared.delete') }); + + return buttons as [{ text: string }, { text: string }]; + }); // 6. EFFECTS $effect(() => { @@ -51,6 +81,7 @@ emoji: localListEmoji || '📝', history: [], todos: [], + isShared: false, }; todoStore.update((n) => { return [...n, newList]; @@ -60,7 +91,9 @@ celebrate(); // Dispatch event to parent to set this as active list - window.dispatchEvent(new CustomEvent('list-created', { detail: { id: newList.id } })); + window.dispatchEvent( + new CustomEvent('list-created', { detail: { id: newList.id } }), + ); }; const updateList = () => { todoStore.update((n) => { @@ -78,11 +111,93 @@ closeOverlay(); }; - const deleteList = () => { + + const copySharedId = () => { + if (currentList?.sharedId) { + navigator.clipboard.writeText(currentList.sharedId); + sharedIdCopied = true; + setTimeout(() => (sharedIdCopied = false), 2000); + } + }; + + const toggleShareList = async () => { + if (isSharedList) { + // Make local only + todoStore.update((n) => { + return n.map((list) => { + if (list.id === listId) { + const { sharedId, version, ...rest } = list; + return { + ...rest, + isShared: false, + }; + } + return list; + }); + }); + } else { + // Make shared - call backend to create shared list + try { + const sharedList = await createSharedTodoList( + localListName, + localListEmoji || '📝', + ); + + // Update local store with shared list info + todoStore.update((n) => { + return n.map((list) => { + if (list.id === listId) { + return { + ...list, + isShared: true, + sharedId: sharedList._id, + version: sharedList.version, + }; + } + return list; + }); + }); + + // Push existing todos to the server + if (currentList?.todos && currentList.todos.length > 0) { + await updateSharedTodoList(sharedList._id, { + name: localListName, + emoji: localListEmoji || '📝', + todos: currentList.todos, + history: currentList.history || [], + version: sharedList.version, + }); + } + } catch (error) { + console.error('Error creating shared list:', error); + } + } + }; + const confirmDelete = () => { + showDeleteConfirm = true; + }; + + const deleteList = async () => { + showDeleteConfirm = false; + + // If it's a shared list, delete from server first + if (currentList?.isShared && currentList?.sharedId) { + try { + await deleteSharedTodoList(currentList.sharedId); + } catch (error) { + console.error('Error deleting shared list from server:', error); + // Continue with local deletion even if server deletion fails + } + } + + // Delete from local store todoStore.update((n) => { return n.filter((list) => list.id !== listId); }); closeOverlay(); + + // Dispatch event to parent to select a new list after deletion + window.dispatchEvent(new CustomEvent('list-deleted')); }; const proceedOnEnter = (event: KeyboardEvent) => { if (isEnter(event)) { @@ -109,16 +224,19 @@ ? $t('page.shared.append') : $t('page.shared.save')} primaryButtonIcon={Save} - secondaryButtons={[ - { text: $t('page.shared.abort') }, - { text: $t('page.shared.delete') }, - ]} + secondaryButtons={secondaryButtonsConfig} on:click:button--primary={$listOverlayOptionsStore.type === 'new' ? createList : updateList} on:click:button--secondary={({ detail }) => { if (detail.text === $t('page.shared.abort')) closeOverlay(); - if (detail.text === $t('page.shared.delete')) deleteList(); + if (detail.text === $t('page.shared.delete')) confirmDelete(); + if ( + detail.text === $t('page.todos.share.shareTitle') || + detail.text === $t('page.todos.share.makeLocal') + ) { + toggleShareList(); + } }} class={modalStates} > @@ -130,10 +248,10 @@ {:else}

{$t('page.todos.overlay.createSubtitle', { + >{$t('page.todos.overlay.editSubtitle', { listName: localListName, })}. + >

{/if} @@ -160,11 +278,65 @@
+ + {#if isSharedList && currentList?.sharedId} + + {/if} + + {#if sharedIdCopied} +
+ (sharedIdCopied = false)} + /> +
+ {/if} {:else}

Locale initializing...

{/if} + + (showDeleteConfirm = false)} + on:close={() => (showDeleteConfirm = false)} +> + {#if $initialized} +

+ {isSharedList + ? $t('page.todos.overlay.deleteSharedWarning', { listName: localListName }) + : $t('page.todos.overlay.deleteLocalWarning', { listName: localListName })} +

+ {/if} +
+ (event.code === 'Escape' ? closeOverlay() : 'foo')} /> @@ -180,6 +352,59 @@ margin-top: var(--default_padding); } + .share_section { + margin-top: var(--default_padding); + padding: calc(var(--default_padding) / 2); + background-color: var(--lightgrey20); + border-radius: 0.5rem; + } + + .share_section_divider { + border-color: var(--darkgrey80); + margin-bottom: calc(var(--default_padding) * 2); + } + + .shared_id_container { + display: flex; + gap: 0.5rem; + align-items: flex-end; + margin-top: 0.5rem; + } + + .shared_id_input { + flex: 1; + } + + .copy_button { + display: flex; + align-items: center; + justify-content: center; + background: var(--cds-field); + color: var(--cds-text-primary); + border: 1px solid var(--cds-border-strong); + border-radius: 0.25rem; + padding: 0.875rem 1rem; + cursor: pointer; + transition: background 0.2s; + height: 2.5rem; + min-width: 2.5rem; + margin-bottom: 1px; + } + + .copy_button:hover { + background: var(--cds-field-hover); + } + + .copy_button:active { + background: var(--cds-field-active); + } + + .notification_container { + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid var(--cds-border-subtle); + } + :global( .modal_unsavable > .bx--modal-container @@ -195,10 +420,40 @@ .modal_undeletable > .bx--modal-container > .bx--modal-footer - > .bx--btn--secondary:nth-child(2) + > .bx--btn--secondary:last-child ) { pointer-events: none; opacity: 0.6; cursor: not-allowed; } + + /* Make delete button red (last secondary button) */ + :global( + .bx--modal-container > .bx--modal-footer > .bx--btn--secondary:last-child + ) { + background-color: #da1e28; + border-color: #da1e28; + color: white; + } + + :global( + .bx--modal-container + > .bx--modal-footer + > .bx--btn--secondary:last-child:hover + ) { + background-color: #ba1b23; + border-color: #ba1b23; + } + + /* Don't apply red styling when button is disabled */ + :global( + .modal_undeletable + > .bx--modal-container + > .bx--modal-footer + > .bx--btn--secondary:last-child + ) { + background-color: transparent; + border-color: transparent; + color: inherit; + } diff --git a/frontend/src/lib/config/de.json b/frontend/src/lib/config/de.json index e6f6311f..e9c6e635 100644 --- a/frontend/src/lib/config/de.json +++ b/frontend/src/lib/config/de.json @@ -127,20 +127,43 @@ "page.todos.overlay.listEmoji": "Emoji für die Liste", "page.todos.overlay.listEmojiDescription": "Vergibst du kein Emoji erhältst du 📝", "page.todos.overlay.emojiTooltip": "Emojis kann man z.B. bei Emojipedia und bei EmojiFinder finden.", + "page.todos.overlay.deleteConfirmTitle": "Liste löschen?", + "page.todos.overlay.deleteLocalWarning": "Bist du sicher, dass du die Liste \"{{listName}}\" löschen möchtest? Diese Aktion kann nicht rückgängig gemacht werden.", + "page.todos.overlay.deleteSharedWarning": "Bist du sicher, dass du die geteilte Liste \"{{listName}}\" löschen möchtest? Dies wird sie für ALLE Benutzer löschen, die Zugriff darauf haben. Diese Aktion kann nicht rückgängig gemacht werden.", "page.todos.list.noEmoji": "Kein Emoji vorhanden...", "page.todos.list.noListTitle": "Kein Listentitel vorhanden...", "page.todos.list.history": "Verlauf", "page.todos.list.historyEmpty": "Kein Verlauf vorhanden...", + "page.todos.list.emptyList": "Noch keine Einträge. Füge deinen ersten Eintrag hinzu!", "page.todos.list.emptyTitle": "Erstelle deine erste Liste.", "page.todos.list.emptySubtitle": "Klicke oben rechts auf den Menü-Button.", "page.todos.amount": "Anzahl", "page.todos.category": "Kategorie", "page.todos.categoryPlaceholder": "Kategorie (optional)", - "page.todos.uncategorized": "Unkategorisiert", + "page.todos.uncategorized": "Allgemein", "page.todos.doneCategory": "Erledigt", "page.todos.changeCategory": "Kategorie ändern", "page.todos.view.classic": "Klassische Ansicht", "page.todos.view.byCategory": "Nach Kategorie anzeigen", + "page.todos.share.shareList": "Liste teilen", + "page.todos.share.importList": "Geteilte Liste importieren", + "page.todos.share.shareTitle": "Liste teilen", + "page.todos.share.shareDescription": "Teile diese Liste mit anderen, indem du ihnen diese ID sendest:", + "page.todos.share.copyId": "ID kopieren", + "page.todos.share.idCopied": "ID in die Zwischenablage kopiert!", + "page.todos.share.importTitle": "Geteilte Liste importieren", + "page.todos.share.importDescription": "Gib die ID der geteilten Liste ein, um sie zu importieren:", + "page.todos.share.importPlaceholder": "ID der geteilten Liste", + "page.todos.share.importButton": "Importieren", + "page.todos.share.sharedList": "Geteilte Liste", + "page.todos.share.localList": "Lokale Liste", + "page.todos.share.makeLocal": "Liste zurück zu lokal", + "page.todos.share.conflictResolved": "Liste aktualisiert", + "page.todos.share.conflictDescription": "Diese Liste wurde von jemand anderem aktualisiert. Deine Ansicht wurde aktualisiert.", + "page.todos.share.listDeleted": "Liste gelöscht", + "page.todos.share.listDeletedDescription": "Diese Liste wurde von einem anderen Benutzer gelöscht.", + "page.todos.share.importError": "Liste konnte nicht importiert werden. ID pruefen und erneut versuchen.", + "page.todos.share.importDuplicate": "Diese Liste wurde bereits importiert.", "page.unoSort.title": "Sortierungsregeln", "page.unoSort.rules": "1. Farbkarten werden nach ihrer Anzahl auf der Hand sortiert, die Farbe mit den geringsten Karten ist ganz links.
2. Farbkarten werden innerhalb der Farbe nach ihrer Wertigkeit aufsteigend sortiert.
3. Farben mit der gleichen Anzahl an Karten werden nach ihrer Gesamt-Wertigkeit sortiert.
4. Schwarze Karten werden immer ganz rechts gehalten.", "page.unoSort.card": "Karte", diff --git a/frontend/src/lib/config/en.json b/frontend/src/lib/config/en.json index 12871b66..ff20f95e 100644 --- a/frontend/src/lib/config/en.json +++ b/frontend/src/lib/config/en.json @@ -127,20 +127,43 @@ "page.todos.overlay.listEmoji": "Emoji for the list", "page.todos.overlay.listEmojiDescription": "If you don't assign an emoji, you will receive 📝", "page.todos.overlay.emojiTooltip": "Emojis can be found here Emojipedia and here EmojiFinder.", + "page.todos.overlay.deleteConfirmTitle": "Delete list?", + "page.todos.overlay.deleteLocalWarning": "Are you sure you want to delete the list \"{{listName}}\"? This action cannot be undone.", + "page.todos.overlay.deleteSharedWarning": "Are you sure you want to delete the shared list \"{{listName}}\"? This will delete it for ALL users who have access to it. This action cannot be undone.", "page.todos.list.noEmoji": "No emoji set...", "page.todos.list.noListTitle": "No list title found...", "page.todos.list.history": "History", "page.todos.list.historyEmpty": "No history available...", + "page.todos.list.emptyList": "No todos yet. Add your first entry!", "page.todos.list.emptyTitle": "Create your first list to start.", "page.todos.list.emptySubtitle": "Click the menu button in the top right corner.", "page.todos.amount": "Amount", "page.todos.category": "Category", "page.todos.categoryPlaceholder": "Category (optional)", - "page.todos.uncategorized": "Uncategorized", + "page.todos.uncategorized": "General", "page.todos.doneCategory": "Done", "page.todos.changeCategory": "Change category", "page.todos.view.classic": "Classic view", "page.todos.view.byCategory": "View by category", + "page.todos.share.shareList": "Share list", + "page.todos.share.importList": "Import shared list", + "page.todos.share.shareTitle": "Share this list", + "page.todos.share.shareDescription": "Share this list with others by sending them this ID:", + "page.todos.share.copyId": "Copy ID", + "page.todos.share.idCopied": "ID copied to clipboard!", + "page.todos.share.importTitle": "Import shared list", + "page.todos.share.importDescription": "Enter the shared list ID to import:", + "page.todos.share.importPlaceholder": "Shared list ID", + "page.todos.share.importButton": "Import", + "page.todos.share.sharedList": "Shared list", + "page.todos.share.localList": "Local list", + "page.todos.share.makeLocal": "Make this list local again", + "page.todos.share.conflictResolved": "List updated", + "page.todos.share.conflictDescription": "This list was updated by someone else. Your view has been refreshed.", + "page.todos.share.listDeleted": "List deleted", + "page.todos.share.listDeletedDescription": "This list was deleted by another user.", + "page.todos.share.importError": "Could not import list. Check the ID and try again.", + "page.todos.share.importDuplicate": "This list has already been imported.", "page.unoSort.title": "Sorting rules", "page.unoSort.rules": "1. color cards are sorted according to the number in your hand, the color with the fewest cards is on the far left.
2. color cards are sorted in ascending order within the color according to their value.
3. colors with the same number of cards are sorted according to their total value.
4. black cards are always held on the far right.", "page.unoSort.card": "Card", diff --git a/frontend/src/lib/types/todo.ts b/frontend/src/lib/types/todo.ts index 9684bdc3..4fcb55b1 100644 --- a/frontend/src/lib/types/todo.ts +++ b/frontend/src/lib/types/todo.ts @@ -12,4 +12,18 @@ export type TodoList = { emoji: string; todos: Todo[]; history: string[]; + isShared?: boolean; + sharedId?: string; + version?: number; +}; + +export type SharedTodoListResponse = { + _id: string; + name: string; + emoji: string; + todos: Todo[]; + history: string[]; + version: number; + created: string; + updated: string; }; diff --git a/frontend/src/routes/todo/+page.svelte b/frontend/src/routes/todo/+page.svelte index 2f86042f..6848be29 100644 --- a/frontend/src/routes/todo/+page.svelte +++ b/frontend/src/routes/todo/+page.svelte @@ -3,41 +3,117 @@ import ToggledApplicationInfo from '$lib/components/shared/ToggledApplicationInfo.svelte'; import TodoListComponent from '$lib/components/todo/TodoList.svelte'; import TodoListOverlay from '$lib/components/todo/TodoListOverlay.svelte'; + import { getSharedTodoList } from '$lib/api/todo.api'; import { applicationRoutes } from '$lib/config/applications'; + import type { TodoList } from '$lib/types/todo'; import { languageStore } from '$lib/util/stores/store-language'; import { listOverlayOptionsStore } from '$lib/util/stores/store-other'; import { todoStore } from '$lib/util/stores/store-todo'; import { initialized, setLocale, t } from '$lib/util/translations'; import Button from 'carbon-components-svelte/src/Button/Button.svelte'; + import InlineNotification from 'carbon-components-svelte/src/Notification/InlineNotification.svelte'; import Modal from 'carbon-components-svelte/src/Modal/Modal.svelte'; + import TextInput from 'carbon-components-svelte/src/TextInput/TextInput.svelte'; import ClickableTile from 'carbon-components-svelte/src/Tile/ClickableTile.svelte'; import Catalog from 'carbon-icons-svelte/lib/Catalog.svelte'; + import DocumentDownload from 'carbon-icons-svelte/lib/DocumentDownload.svelte'; import Edit from 'carbon-icons-svelte/lib/Edit.svelte'; import TaskAdd from 'carbon-icons-svelte/lib/TaskAdd.svelte'; import { onMount } from 'svelte'; // 2. CONST (non-reactive constants) const { todo: todoRoute } = applicationRoutes; + const LAST_VIEWED_LIST_KEY = 'tilloh-dev:todo:lastViewedListId'; // 4. STATE let currentListId = $state(''); let newListId = $state(''); let openMenu = $state(false); let locale = $state($languageStore); + let showImportModal = $state(false); + let importSharedId = $state(''); + let importErrorMessage = $state(''); + let showImportError = $state(false); // 6. EFFECTS $effect(() => { locale = $languageStore; }); + // Persist last viewed list ID to localStorage + $effect(() => { + if (currentListId) { + localStorage.setItem(LAST_VIEWED_LIST_KEY, currentListId); + } + }); + + // Global polling for all shared lists to detect deletions + $effect(() => { + const sharedLists = $todoStore.filter((list) => list.isShared && list.sharedId); + if (sharedLists.length === 0) return; + + const checkAllSharedLists = async () => { + const listsToRemove: string[] = []; + + for (const list of sharedLists) { + try { + const result = await getSharedTodoList(list.sharedId!); + if (result.status === 404) { + listsToRemove.push(list.id); + } + } catch (error) { + console.error('Error checking shared list:', list.name, error); + } + } + + if (listsToRemove.length > 0) { + console.log('Removing deleted shared lists:', listsToRemove.length); + todoStore.update((lists) => lists.filter((list) => !listsToRemove.includes(list.id))); + + // If current list was deleted, select another + if (listsToRemove.includes(currentListId)) { + window.dispatchEvent(new CustomEvent('list-deleted')); + } + } + }; + + // Check immediately + checkAllSharedLists(); + + // Then check every 10 seconds + const interval = setInterval(checkAllSharedLists, 10000); + + return () => clearInterval(interval); + }); + // 7. LIFECYCLE onMount(async () => { await setLocale($languageStore); + // Restore last viewed list from localStorage + const savedListId = localStorage.getItem(LAST_VIEWED_LIST_KEY); + if (savedListId && $todoStore.some((list) => list.id === savedListId)) { + currentListId = savedListId; + } else if ($todoStore.length > 0) { + // Fallback to first list if saved ID doesn't exist + currentListId = $todoStore[0].id; + } + // Listen for list creation events window.addEventListener('list-created', ((e: CustomEvent) => { currentListId = e.detail.id; }) as EventListener); + + // Listen for list deletion events + window.addEventListener('list-deleted', () => { + // Set currentListId to first remaining list, or empty if none exist + if ($todoStore.length > 0) { + currentListId = $todoStore[0].id; + } else { + currentListId = ''; + localStorage.removeItem(LAST_VIEWED_LIST_KEY); + } + }); }); // 8. FUNCTIONS @@ -53,6 +129,57 @@ $listOverlayOptionsStore.type = type; } }; + + const importSharedList = async () => { + if (!importSharedId.trim()) return; + + try { + // Check for duplicates + const existingList = $todoStore.find((list) => list.sharedId === importSharedId); + if (existingList) { + importErrorMessage = $t('page.todos.share.importDuplicate'); + showImportError = true; + setTimeout(() => { + showImportError = false; + }, 5000); + return; + } + + const result = await getSharedTodoList(importSharedId); + + if (result.status !== 200 || !result.data) { + importErrorMessage = $t('page.todos.share.importError'); + showImportError = true; + setTimeout(() => { + showImportError = false; + }, 5000); + return; + } + + const sharedList = result.data; + const newList: TodoList = { + id: crypto.randomUUID(), + name: sharedList.name, + emoji: sharedList.emoji, + todos: sharedList.todos, + history: sharedList.history || [], + isShared: true, + sharedId: sharedList._id, + version: sharedList.version, + }; + + todoStore.update((n) => [...n, newList]); + importSharedId = ''; + showImportModal = false; + } catch (error) { + console.error('Error importing shared list:', error); + importErrorMessage = $t('page.todos.share.importError'); + showImportError = true; + setTimeout(() => { + showImportError = false; + }, 5000); + } + }; const setActiveList = (id: string) => { currentListId = id; openMenu = false; @@ -98,6 +225,12 @@

{list.emoji} {list.name} + {#if list.isShared} + 🔗 + {/if}

+ + (showImportModal = false)} + > + {#if $initialized} +

{$t('page.todos.share.importDescription')}

+ e.key === 'Enter' && importSharedList()} + /> + {:else} +

Locale initializing...

+ {/if} +
+
+ {#if showImportError} + (showImportError = false)} + /> + {/if}
+ + {#if isOpen} +
+ + +
+ {#if filteredEmojis.length > 0} + {#each filteredEmojis as category} +
+

{category.name}

+
+ {#each category.emojis as emoji} + + {/each} +
+
+ {/each} + {:else} +
+

{$t('page.shared.noEmojisFound')}

+
+ {/if} +
+
+ {/if} +
+{:else} +
Loading...
+{/if} + + diff --git a/frontend/src/lib/components/todo/TodoListOverlay.svelte b/frontend/src/lib/components/todo/TodoListOverlay.svelte index 136199ee..57313154 100644 --- a/frontend/src/lib/components/todo/TodoListOverlay.svelte +++ b/frontend/src/lib/components/todo/TodoListOverlay.svelte @@ -17,6 +17,7 @@ import Tooltip from 'carbon-components-svelte/src/Tooltip/Tooltip.svelte'; import Copy from 'carbon-icons-svelte/lib/Copy.svelte'; import Save from 'carbon-icons-svelte/lib/Save.svelte'; + import EmojiPicker from '$lib/components/shared/EmojiPicker.svelte'; // 2. PROPS let { @@ -264,18 +265,11 @@ class="mb1" on:keyup={(event) => proceedOnEnter(event)} /> -
- proceedOnEnter(event)} - /> - - -

{@html $t('page.todos.overlay.emojiTooltip')}

-
+
+ + {$t('page.todos.overlay.listEmojiDescription')} + +
@@ -347,11 +341,24 @@ } .create_list_section { display: flex; - flex-direction: row; - gap: var(--default_padding); + flex-direction: column; + gap: 1rem; margin-top: var(--default_padding); } + .emoji_picker_section { + display: flex; + flex-direction: column; + gap: 0.5rem; + } + + .emoji_picker_label { + font-size: 0.75rem; + font-weight: 400; + color: var(--cds-text-secondary); + margin-bottom: 0.25rem; + } + .share_section { margin-top: var(--default_padding); padding: calc(var(--default_padding) / 2); diff --git a/frontend/src/lib/config/de.json b/frontend/src/lib/config/de.json index e9c6e635..283bc427 100644 --- a/frontend/src/lib/config/de.json +++ b/frontend/src/lib/config/de.json @@ -291,5 +291,8 @@ "page.shared.button.stadtwerk": "stadt.werk Seite öffnen", "page.shared.verificationError": "ID Verifikation ist fehlgeschlagen.", "page.shared.admin.verificationError": "Admin ID konnte nicht verifiziert werden.", - "page.shared.user.verificationError": "User ID konnte nicht verifiziert werden." + "page.shared.user.verificationError": "User ID konnte nicht verifiziert werden.", + "page.shared.selectEmoji": "Emoji auswählen", + "page.shared.searchEmoji": "Emoji suchen...", + "page.shared.noEmojisFound": "Keine Emojis gefunden" } \ No newline at end of file diff --git a/frontend/src/lib/config/en.json b/frontend/src/lib/config/en.json index ff20f95e..f672bf03 100644 --- a/frontend/src/lib/config/en.json +++ b/frontend/src/lib/config/en.json @@ -291,5 +291,8 @@ "page.shared.button.settings": "Open settings", "page.shared.verificationError": "Verification of ID failed.", "page.shared.admin.verificationError": "Admin ID could not be verified.", - "page.shared.user.verificationError": "User ID could not be verified." + "page.shared.user.verificationError": "User ID could not be verified.", + "page.shared.selectEmoji": "Select emoji", + "page.shared.searchEmoji": "Search emoji...", + "page.shared.noEmojisFound": "No emojis found" } \ No newline at end of file diff --git a/frontend/src/lib/util/emoji-data.ts b/frontend/src/lib/util/emoji-data.ts new file mode 100644 index 00000000..9e62ea58 --- /dev/null +++ b/frontend/src/lib/util/emoji-data.ts @@ -0,0 +1,1250 @@ +// Emoji keyword mappings for search (EN/DE) +export const emojiKeywords: Record = { + // Smileys & People + '😀': ['smile', 'happy', 'lächeln', 'glücklich', 'freude'], + '😃': ['smile', 'happy', 'big', 'lächeln', 'groß', 'fröhlich'], + '😄': ['smile', 'laugh', 'lächeln', 'lachen'], + '😁': ['grin', 'smile', 'grinsen', 'lächeln'], + '😆': ['laugh', 'lol', 'lachen', 'lustig'], + '😅': ['sweat', 'laugh', 'schweiß', 'lachen', 'nervös'], + '🤣': ['laugh', 'rolling', 'lachen', 'lustig', 'witzig'], + '😂': ['tears', 'laugh', 'cry', 'tränen', 'lachen', 'weinen'], + '🙂': ['smile', 'slight', 'lächeln', 'leicht'], + '😉': ['wink', 'zwinkern'], + '😊': ['blush', 'smile', 'erröten', 'lächeln'], + '😍': ['love', 'heart', 'eyes', 'liebe', 'herz', 'augen', 'verliebt'], + '🥰': ['love', 'hearts', 'liebe', 'herzen', 'verliebt'], + '😘': ['kiss', 'kuss', 'liebe'], + '😎': ['cool', 'sunglasses', 'sonnenbrille'], + '🤔': ['think', 'thinking', 'denken', 'nachdenken', 'überlegen'], + '😴': ['sleep', 'tired', 'schlafen', 'müde'], + '😢': ['cry', 'sad', 'weinen', 'traurig'], + '😭': ['cry', 'sob', 'weinen', 'schluchzen', 'traurig'], + '😡': ['angry', 'mad', 'wütend', 'sauer', 'ärger'], + '😱': ['scream', 'shock', 'schrei', 'schock', 'angst'], + '🤗': ['hug', 'umarmung'], + '🤫': ['shh', 'quiet', 'psst', 'leise', 'still'], + '🤐': ['zipper', 'quiet', 'schweigen', 'still'], + '🤢': ['sick', 'nausea', 'krank', 'übel'], + '🤮': ['vomit', 'sick', 'erbrechen', 'kotzen'], + '👋': ['wave', 'hello', 'bye', 'winken', 'hallo', 'tschüss'], + '👍': ['thumbs', 'up', 'good', 'like', 'daumen', 'hoch', 'gut'], + '👎': ['thumbs', 'down', 'bad', 'daumen', 'runter', 'schlecht'], + '👏': ['clap', 'applause', 'klatschen', 'applaus'], + '🙏': ['pray', 'thanks', 'beten', 'danke', 'gebet'], + '🤝': ['handshake', 'deal', 'händedruck', 'abmachung'], + '✋': ['hand', 'stop', 'high five', 'hand', 'stopp'], + '👌': ['ok', 'okay', 'perfect', 'gut', 'perfekt'], + '✌️': ['peace', 'victory', 'frieden', 'sieg'], + + // Animals & Nature + '🐶': ['dog', 'puppy', 'hund', 'welpe'], + '🐱': ['cat', 'kitty', 'katze', 'kätzchen'], + '🐭': ['mouse', 'maus'], + '🐹': ['hamster', 'hamster'], + '🐰': ['rabbit', 'bunny', 'hase', 'kaninchen'], + '🦊': ['fox', 'fuchs'], + '🐻': ['bear', 'bär'], + '🐼': ['panda', 'panda'], + '🐨': ['koala', 'koala'], + '🦁': ['lion', 'löwe'], + '🐯': ['tiger', 'tiger'], + '🐸': ['frog', 'frosch'], + '🐵': ['monkey', 'affe'], + '🐔': ['chicken', 'huhn'], + '🐧': ['penguin', 'pinguin'], + '🐦': ['bird', 'vogel'], + '🦆': ['duck', 'ente'], + '🦅': ['eagle', 'adler'], + '🦉': ['owl', 'eule'], + '🐝': ['bee', 'biene'], + '🦋': ['butterfly', 'schmetterling'], + '🐌': ['snail', 'schnecke'], + '🐞': ['ladybug', 'marienkäfer'], + '🐢': ['turtle', 'schildkröte'], + '🐍': ['snake', 'schlange'], + '🐙': ['octopus', 'tintenfisch', 'krake'], + '🐠': ['fish', 'tropical', 'fisch', 'tropisch'], + '🐬': ['dolphin', 'delfin'], + '🦈': ['shark', 'hai'], + '🐘': ['elephant', 'elefant'], + '🦒': ['giraffe', 'giraffe'], + '🦘': ['kangaroo', 'känguru'], + '🐎': ['horse', 'pferd'], + '🦄': ['unicorn', 'einhorn'], + '🌵': ['cactus', 'kaktus'], + '🌲': ['tree', 'evergreen', 'baum', 'tanne'], + '🌳': ['tree', 'baum'], + '🌴': ['palm', 'tree', 'palme', 'baum'], + '🌱': ['plant', 'seedling', 'pflanze', 'setzling'], + '🌹': ['rose', 'flower', 'rose', 'blume'], + '🌺': ['hibiscus', 'flower', 'hibiskus', 'blume'], + '🌻': ['sunflower', 'sonnenblume'], + '🌼': ['flower', 'blossom', 'blume', 'blüte'], + '🌷': ['tulip', 'tulpe'], + '🍄': ['mushroom', 'pilz'], + '🌍': ['earth', 'world', 'globe', 'erde', 'welt', 'globus'], + '🌎': ['earth', 'world', 'globe', 'americas', 'erde', 'welt', 'amerika'], + '🌏': ['earth', 'world', 'globe', 'asia', 'erde', 'welt', 'asien'], + '⭐': ['star', 'stern'], + '🌟': ['star', 'glowing', 'stern', 'leuchtend'], + '✨': ['sparkles', 'shine', 'funkeln', 'glitzer'], + '🔥': ['fire', 'hot', 'feuer', 'heiß', 'flamme'], + '💧': ['water', 'drop', 'wasser', 'tropfen'], + '🌊': ['wave', 'ocean', 'sea', 'welle', 'ozean', 'meer'], + '☀️': ['sun', 'sunny', 'sonne', 'sonnig'], + '🌙': ['moon', 'crescent', 'mond', 'sichel'], + '🌈': ['rainbow', 'regenbogen'], + '☁️': ['cloud', 'wolke'], + '⚡': ['lightning', 'bolt', 'blitz'], + '❄️': ['snow', 'snowflake', 'schnee', 'schneeflocke'], + + // Food & Drink + '🍏': ['apple', 'green', 'apfel', 'grün'], + '🍎': ['apple', 'red', 'apfel', 'rot'], + '🍊': ['orange', 'orange', 'apfelsine'], + '🍋': ['lemon', 'zitrone'], + '🍌': ['banana', 'banane'], + '🍉': ['watermelon', 'wassermelone'], + '🍇': ['grapes', 'trauben', 'weintrauben'], + '🍓': ['strawberry', 'erdbeere'], + '🍒': ['cherry', 'cherries', 'kirsche', 'kirschen'], + '🍑': ['peach', 'pfirsich'], + '🥝': ['kiwi', 'kiwi'], + '🍅': ['tomato', 'tomate'], + '🥑': ['avocado', 'avocado'], + '🥦': ['broccoli', 'brokkoli'], + '🥕': ['carrot', 'karotte', 'möhre'], + '🌽': ['corn', 'mais'], + '🍞': ['bread', 'brot'], + '🥖': ['baguette', 'bread', 'baguette', 'brot'], + '🧀': ['cheese', 'käse'], + '🥚': ['egg', 'ei'], + '🍳': ['cooking', 'egg', 'fried', 'kochen', 'ei', 'gebraten'], + '🥓': ['bacon', 'speck'], + '🍔': ['burger', 'hamburger', 'burger', 'hamburger'], + '🍟': ['fries', 'pommes'], + '🍕': ['pizza', 'pizza'], + '🌭': ['hot dog', 'hotdog'], + '🥪': ['sandwich', 'sandwich'], + '🌮': ['taco', 'taco'], + '🌯': ['burrito', 'wrap', 'burrito'], + '🍝': ['pasta', 'spaghetti', 'pasta', 'nudeln'], + '🍜': ['ramen', 'noodles', 'ramen', 'nudeln'], + '🍣': ['sushi', 'sushi'], + '🍱': ['bento', 'box', 'bento'], + '🍛': ['curry', 'rice', 'curry', 'reis'], + '🍦': ['ice cream', 'eis', 'eiscreme'], + '🍰': ['cake', 'kuchen', 'torte'], + '🎂': ['birthday', 'cake', 'geburtstag', 'kuchen', 'torte'], + '🍪': ['cookie', 'keks'], + '🍩': ['donut', 'doughnut', 'donut', 'krapfen'], + '🍫': ['chocolate', 'schokolade'], + '🍬': ['candy', 'sweet', 'süßigkeit', 'bonbon'], + '🍭': ['lollipop', 'lutscher'], + '☕': ['coffee', 'tea', 'kaffee', 'tee'], + '🍵': ['tea', 'green', 'tee', 'grün'], + '🍺': ['beer', 'bier'], + '🍻': ['beers', 'cheers', 'bier', 'prost', 'anstoßen'], + '🍷': ['wine', 'wein'], + '🥂': ['champagne', 'cheers', 'sekt', 'champagner', 'prost'], + '🍹': ['cocktail', 'tropical', 'cocktail', 'drink'], + + // Activities & Objects + '⚽': ['soccer', 'football', 'fußball'], + '🏀': ['basketball', 'basketball'], + '🏈': ['football', 'american', 'football', 'amerikanisch'], + '⚾': ['baseball', 'baseball'], + '🎾': ['tennis', 'tennis'], + '🏐': ['volleyball', 'volleyball'], + '🎱': ['billiard', 'pool', 'billard'], + '🎮': ['game', 'controller', 'spiel', 'controller', 'gaming'], + '🎯': ['target', 'dart', 'ziel', 'dart', 'darts'], + '🎲': ['dice', 'würfel'], + '🎭': ['theater', 'drama', 'theater', 'drama', 'maske'], + '🎨': ['art', 'palette', 'kunst', 'palette', 'malen'], + '🎬': ['movie', 'film', 'kino'], + '🎤': ['microphone', 'sing', 'mikrofon', 'singen'], + '🎧': ['headphones', 'music', 'kopfhörer', 'musik'], + '🎸': ['guitar', 'gitarre'], + '🎹': ['piano', 'keyboard', 'klavier'], + '🥁': ['drum', 'trommel', 'schlagzeug'], + '📱': ['phone', 'mobile', 'smartphone', 'handy', 'telefon'], + '💻': ['computer', 'laptop', 'pc', 'computer', 'laptop'], + '⌨️': ['keyboard', 'tastatur'], + '🖥': ['computer', 'desktop', 'monitor', 'computer', 'bildschirm'], + '🖨': ['printer', 'drucker'], + '🖱': ['mouse', 'computer', 'maus', 'computer'], + '📷': ['camera', 'photo', 'kamera', 'foto'], + '📸': ['camera', 'flash', 'kamera', 'blitz', 'foto'], + '📺': ['tv', 'television', 'fernseher'], + '⏰': ['alarm', 'clock', 'wecker', 'uhr'], + '⏱': ['stopwatch', 'stoppuhr'], + '⌛': ['hourglass', 'time', 'sanduhr', 'zeit'], + '💡': ['light', 'bulb', 'idea', 'licht', 'birne', 'idee'], + '🔦': ['flashlight', 'torch', 'taschenlampe'], + '🔋': ['battery', 'batterie', 'akku'], + '💰': ['money', 'bag', 'geld', 'sack', 'reich'], + '💵': ['dollar', 'money', 'dollar', 'geld'], + '💶': ['euro', 'money', 'euro', 'geld'], + '💳': ['card', 'credit', 'karte', 'kredit'], + '💎': ['diamond', 'gem', 'diamant', 'edelstein'], + '🔧': ['wrench', 'tool', 'schraubenschlüssel', 'werkzeug'], + '🔨': ['hammer', 'tool', 'hammer', 'werkzeug'], + '🔩': ['nut', 'bolt', 'mutter', 'schraube'], + '⚙️': ['gear', 'settings', 'zahnrad', 'einstellungen'], + '🔒': ['lock', 'secure', 'schloss', 'sicher'], + '🔑': ['key', 'schlüssel'], + '🎁': ['gift', 'present', 'geschenk', 'präsent'], + '🎈': ['balloon', 'luftballon'], + '🎉': ['party', 'celebration', 'party', 'feier', 'konfetti'], + '🎊': ['confetti', 'celebration', 'konfetti', 'feier'], + '🏆': ['trophy', 'winner', 'pokal', 'gewinner', 'sieg'], + '🥇': ['gold', 'medal', 'first', 'gold', 'medaille', 'erster'], + '🥈': ['silver', 'medal', 'second', 'silber', 'medaille', 'zweiter'], + '🥉': ['bronze', 'medal', 'third', 'bronze', 'medaille', 'dritter'], + + // Travel & Places + '🚗': ['car', 'auto', 'fahren'], + '🚕': ['taxi', 'taxi'], + '🚙': ['suv', 'car', 'auto'], + '🚌': ['bus', 'bus'], + '🚎': ['trolleybus', 'bus', 'bus'], + '🚓': ['police', 'car', 'polizei', 'auto'], + '🚑': ['ambulance', 'krankenwagen', 'rettung'], + '🚒': ['fire', 'truck', 'feuerwehr'], + '🚚': ['truck', 'delivery', 'lkw', 'lastwagen', 'lieferung'], + '🚲': ['bike', 'bicycle', 'fahrrad', 'rad'], + '🛵': ['scooter', 'moped', 'roller'], + '🏍': ['motorcycle', 'motorrad'], + '✈️': ['airplane', 'plane', 'flight', 'flugzeug', 'fliegen', 'flug'], + '🚀': ['rocket', 'space', 'rakete', 'weltraum'], + '🚁': ['helicopter', 'hubschrauber'], + '⛵': ['sailboat', 'boat', 'segelboot', 'boot'], + '🚢': ['ship', 'cruise', 'schiff', 'kreuzfahrt'], + '🏠': ['house', 'home', 'haus', 'zuhause'], + '🏡': ['house', 'garden', 'haus', 'garten'], + '🏢': ['office', 'building', 'büro', 'gebäude'], + '🏥': ['hospital', 'krankenhaus'], + '🏦': ['bank', 'bank'], + '🏪': ['store', 'shop', 'laden', 'geschäft'], + '🏫': ['school', 'schule'], + '🏨': ['hotel', 'hotel'], + '⛪': ['church', 'kirche'], + '🗼': ['tower', 'tokyo', 'turm'], + '🗽': ['statue', 'liberty', 'freiheitsstatue'], + '🏖': ['beach', 'strand'], + '🏝': ['island', 'insel'], + '⛰': ['mountain', 'berg'], + '🏔': ['mountain', 'snow', 'berg', 'schnee'], + '🌋': ['volcano', 'vulkan'], + '🏕': ['camping', 'camp', 'zelten', 'camping'], + '⛺': ['tent', 'camping', 'zelt', 'zelten'], + + // Symbols + '❤️': ['heart', 'love', 'herz', 'liebe'], + '🧡': ['heart', 'orange', 'herz', 'orange'], + '💛': ['heart', 'yellow', 'herz', 'gelb'], + '💚': ['heart', 'green', 'herz', 'grün'], + '💙': ['heart', 'blue', 'herz', 'blau'], + '💜': ['heart', 'purple', 'herz', 'lila', 'violett'], + '🖤': ['heart', 'black', 'herz', 'schwarz'], + '🤍': ['heart', 'white', 'herz', 'weiß'], + '💔': ['broken', 'heart', 'gebrochenes', 'herz'], + '💕': ['hearts', 'love', 'herzen', 'liebe'], + '💯': ['hundred', 'perfect', 'hundert', 'perfekt'], + '💢': ['anger', 'angry', 'wut', 'wütend', 'ärger'], + '✅': ['check', 'done', 'yes', 'haken', 'fertig', 'ja'], + '❌': ['x', 'cross', 'no', 'kreuz', 'nein'], + '⭕': ['circle', 'o', 'kreis'], + '❗': ['exclamation', 'important', 'ausrufezeichen', 'wichtig'], + '❓': ['question', 'frage'], + '⚠️': ['warning', 'warnung'], + '🚫': ['prohibited', 'no', 'verboten', 'nein'], + '✔️': ['check', 'yes', 'haken', 'ja'], + '🔴': ['red', 'circle', 'rot', 'kreis'], + '🟢': ['green', 'circle', 'grün', 'kreis'], + '🔵': ['blue', 'circle', 'blau', 'kreis'], + '🟡': ['yellow', 'circle', 'gelb', 'kreis'], + '🟠': ['orange', 'circle', 'orange', 'kreis'], + '🟣': ['purple', 'circle', 'lila', 'kreis', 'violett'], + '⚪': ['white', 'circle', 'weiß', 'kreis'], + '⚫': ['black', 'circle', 'schwarz', 'kreis'], + '➕': ['plus', 'add', 'plus', 'hinzufügen'], + '➖': ['minus', 'subtract', 'minus', 'abziehen'], + '✖️': ['multiply', 'x', 'multiplizieren', 'mal'], + '➗': ['divide', 'dividieren', 'teilen'], + '🔢': ['numbers', 'zahlen'], + '🔤': ['letters', 'abc', 'buchstaben'], + '🆕': ['new', 'neu'], + '🆒': ['cool', 'cool'], + '🆓': ['free', 'kostenlos', 'frei'], + '🆙': ['up', 'level up', 'hoch', 'aufsteigen'], + '🆗': ['ok', 'okay', 'ok'], + + // Flags + '🏁': ['checkered', 'flag', 'racing', 'zielflagge', 'rennen'], + '🚩': ['flag', 'red', 'flagge', 'rot'], + '🏳️': ['white', 'flag', 'weiße', 'flagge'], + '🏳️‍🌈': ['rainbow', 'flag', 'pride', 'regenbogen', 'flagge'], +}; + +export type EmojiCategory = { + name: string; + emojis: string[]; +}; + +export const emojiCategories: EmojiCategory[] = [ + { + name: 'Smileys & People', + emojis: [ + '😀', + '😃', + '😄', + '😁', + '😆', + '😅', + '🤣', + '😂', + '🙂', + '🙃', + '😉', + '😊', + '😇', + '🥰', + '😍', + '🤩', + '😘', + '😗', + '😚', + '😙', + '😋', + '😛', + '😜', + '🤪', + '😝', + '🤑', + '🤗', + '🤭', + '🤫', + '🤔', + '🤐', + '🤨', + '😐', + '😑', + '😶', + '😏', + '😒', + '🙄', + '😬', + '🤥', + '😌', + '😔', + '😪', + '🤤', + '😴', + '😷', + '🤒', + '🤕', + '🤢', + '🤮', + '🤧', + '🥵', + '🥶', + '😎', + '🤓', + '🧐', + '😕', + '😟', + '🙁', + '☹️', + '😮', + '😯', + '😲', + '😳', + '🥺', + '😦', + '😧', + '😨', + '😰', + '😥', + '😢', + '😭', + '😱', + '😖', + '😣', + '😞', + '😓', + '😩', + '😫', + '🥱', + '😤', + '😡', + '😠', + '🤬', + '👋', + '🤚', + '✋', + '🖐', + '👌', + '🤏', + '✌️', + '🤞', + '🤟', + '🤘', + '🤙', + '👈', + '👉', + '👆', + '👇', + '☝️', + '👍', + '👎', + '✊', + '👊', + '🤛', + '🤜', + '👏', + '🙌', + '👐', + '🤲', + '🤝', + '🙏', + ], + }, + { + name: 'Animals & Nature', + emojis: [ + '🐶', + '🐱', + '🐭', + '🐹', + '🐰', + '🦊', + '🐻', + '🐼', + '🐨', + '🐯', + '🦁', + '🐮', + '🐷', + '🐸', + '🐵', + '🐔', + '🐧', + '🐦', + '🐤', + '🦆', + '🦅', + '🦉', + '🦇', + '🐺', + '🐗', + '🐴', + '🦄', + '🐝', + '🐛', + '🦋', + '🐌', + '🐞', + '🐜', + '🦟', + '🦗', + '🕷', + '🦂', + '🐢', + '🐍', + '🦎', + '🦖', + '🦕', + '🐙', + '🦑', + '🦐', + '🦞', + '🦀', + '🐡', + '🐠', + '🐟', + '🐬', + '🐳', + '🐋', + '🦈', + '🐊', + '🐅', + '🐆', + '🦓', + '🦍', + '🦧', + '🐘', + '🦛', + '🦏', + '🐪', + '🐫', + '🦒', + '🦘', + '🐃', + '🐂', + '🐄', + '🐎', + '🐖', + '🐏', + '🐑', + '🦙', + '🐐', + '🦌', + '🐕', + '🐩', + '🦮', + '🐈', + '🐓', + '🦃', + '🦚', + '🦜', + '🦢', + '🦩', + '🕊', + '🐇', + '🦝', + '🦨', + '🦡', + '🦦', + '🦥', + '🐁', + '🐀', + '🐿', + '🦔', + '🌵', + '🎄', + '🌲', + '🌳', + '🌴', + '🌱', + '🌿', + '☘️', + '🍀', + '🎍', + '🎋', + '🍃', + '🍂', + '🍁', + '🌾', + '🌺', + '🌻', + '🌹', + '🥀', + '🌷', + '🌼', + '🌸', + '💐', + '🍄', + '🌰', + '🦀', + '🐚', + '🌍', + '🌎', + '🌏', + '🌕', + '🌖', + '🌗', + '🌘', + '🌑', + '🌒', + '🌓', + '🌔', + '🌙', + '🌚', + '🌛', + '🌜', + '☀️', + '🌝', + '🌞', + '⭐', + '🌟', + '💫', + '✨', + '☄️', + '🌠', + '🌌', + '☁️', + '⛅', + '⛈', + '🌤', + '🌥', + '🌦', + '🌧', + '🌨', + '🌩', + '🌪', + '🌫', + '🌬', + '🌀', + '🌈', + '🌂', + '☂️', + '☔', + '⛱', + '⚡', + '❄️', + '☃️', + '⛄', + '☄️', + '🔥', + '💧', + '🌊', + ], + }, + { + name: 'Food & Drink', + emojis: [ + '🍏', + '🍎', + '🍐', + '🍊', + '🍋', + '🍌', + '🍉', + '🍇', + '🍓', + '🍈', + '🍒', + '🍑', + '🥭', + '🍍', + '🥥', + '🥝', + '🍅', + '🍆', + '🥑', + '🥦', + '🥬', + '🥒', + '🌶', + '🌽', + '🥕', + '🥔', + '🍠', + '🥐', + '🥯', + '🍞', + '🥖', + '🥨', + '🧀', + '🥚', + '🍳', + '🥞', + '🥓', + '🥩', + '🍗', + '🍖', + '🌭', + '🍔', + '🍟', + '🍕', + '🥪', + '🥙', + '🌮', + '🌯', + '🥗', + '🥘', + '🥫', + '🍝', + '🍜', + '🍲', + '🍛', + '🍣', + '🍱', + '🥟', + '🍤', + '🍙', + '🍚', + '🍘', + '🍥', + '🥠', + '🥮', + '🍢', + '🍡', + '🍧', + '🍨', + '🍦', + '🥧', + '🧁', + '🍰', + '🎂', + '🍮', + '🍭', + '🍬', + '🍫', + '🍿', + '🍩', + '🍪', + '🌰', + '🥜', + '🍯', + '🥛', + '🍼', + '☕', + '🍵', + '🥤', + '🍶', + '🍺', + '🍻', + '🥂', + '🍷', + '🥃', + '🍸', + '🍹', + '🍾', + '🥄', + '🍴', + '🍽', + ], + }, + { + name: 'Activities & Objects', + emojis: [ + '⚽', + '🏀', + '🏈', + '⚾', + '🥎', + '🎾', + '🏐', + '🏉', + '🥏', + '🎱', + '🏓', + '🏸', + '🏒', + '🏑', + '🥍', + '🏏', + '🥅', + '⛳', + '🏹', + '🎣', + '🥊', + '🥋', + '🎽', + '⛸', + '🥌', + '🛷', + '🎿', + '⛷', + '🏂', + '🏋️', + '🤼', + '🤸', + '🤺', + '🤾', + '🏌️', + '🏇', + '🧘', + '🏊', + '🤽', + '🚣', + '🧗', + '🚴', + '🚵', + '🎪', + '🎭', + '🎨', + '🎬', + '🎤', + '🎧', + '🎼', + '🎹', + '🥁', + '🎷', + '🎺', + '🎸', + '🎻', + '🎲', + '♟', + '🎯', + '🎳', + '🎮', + '🎰', + '🧩', + '📱', + '💻', + '⌨️', + '🖥', + '🖨', + '🖱', + '🖲', + '🕹', + '💾', + '💿', + '📀', + '📼', + '📷', + '📸', + '📹', + '🎥', + '📽', + '🎞', + '📞', + '☎️', + '📟', + '📠', + '📺', + '📻', + '🎙', + '🎚', + '🎛', + '⏱', + '⏲', + '⏰', + '🕰', + '⌛', + '⏳', + '📡', + '🔋', + '🔌', + '💡', + '🔦', + '🕯', + '🧯', + '🛢', + '💸', + '💵', + '💴', + '💶', + '💷', + '💰', + '💳', + '💎', + '⚖️', + '🧰', + '🔧', + '🔨', + '⚒', + '🛠', + '⛏', + '🔩', + '⚙️', + '🧱', + '⛓', + '🧲', + '🔫', + '💣', + '🧨', + '🔪', + '🗡', + '⚔️', + '🛡', + '🚬', + '⚰️', + '⚱️', + '🏺', + '🔮', + '📿', + '💈', + '⚗️', + '🔭', + '🔬', + '🕳', + '💊', + '💉', + '🩸', + '🩹', + '🩺', + '🌡', + '🏷', + '🔖', + '🚽', + '🚿', + '🛁', + '🛀', + '🧴', + '🧷', + '🧹', + '🧺', + '🧻', + '🧼', + '🧽', + '🧯', + '🛒', + ], + }, + { + name: 'Travel & Places', + emojis: [ + '🚗', + '🚕', + '🚙', + '🚌', + '🚎', + '🏎', + '🚓', + '🚑', + '🚒', + '🚐', + '🚚', + '🚛', + '🚜', + '🛴', + '🚲', + '🛵', + '🏍', + '🛺', + '🚨', + '🚔', + '🚍', + '🚘', + '🚖', + '🚡', + '🚠', + '🚟', + '🚃', + '🚋', + '🚞', + '🚝', + '🚄', + '🚅', + '🚈', + '🚂', + '🚆', + '🚇', + '🚊', + '🚉', + '✈️', + '🛫', + '🛬', + '🛩', + '💺', + '🛰', + '🚀', + '🛸', + '🚁', + '🛶', + '⛵', + '🚤', + '🛥', + '🛳', + '⛴', + '🚢', + '⚓', + '⛽', + '🚧', + '🚦', + '🚥', + '🗺', + '🗿', + '🗽', + '🗼', + '🏰', + '🏯', + '🏟', + '🎡', + '🎢', + '🎠', + '⛲', + '⛱', + '🏖', + '🏝', + '🏜', + '🌋', + '⛰', + '🏔', + '🗻', + '🏕', + '⛺', + '🏠', + '🏡', + '🏘', + '🏚', + '🏗', + '🏭', + '🏢', + '🏬', + '🏣', + '🏤', + '🏥', + '🏦', + '🏨', + '🏪', + '🏫', + '🏩', + '💒', + '🏛', + '⛪', + '🕌', + '🕍', + '🛕', + '🕋', + '⛩', + '🛤', + '🛣', + '🗾', + '🎑', + '🏞', + '🌅', + '🌄', + '🌠', + '🎇', + '🎆', + '🌇', + '🌆', + '🏙', + '🌃', + '🌌', + '🌉', + '🌁', + ], + }, + { + name: 'Symbols', + emojis: [ + '❤️', + '🧡', + '💛', + '💚', + '💙', + '💜', + '🖤', + '🤍', + '🤎', + '💔', + '❣️', + '💕', + '💞', + '💓', + '💗', + '💖', + '💘', + '💝', + '💟', + '☮️', + '✝️', + '☪️', + '🕉', + '☸️', + '✡️', + '🔯', + '🕎', + '☯️', + '☦️', + '🛐', + '⛎', + '♈', + '♉', + '♊', + '♋', + '♌', + '♍', + '♎', + '♏', + '♐', + '♑', + '♒', + '♓', + '🆔', + '⚛️', + '🉑', + '☢️', + '☣️', + '📴', + '📳', + '🈶', + '🈚', + '🈸', + '🈺', + '🈷️', + '✴️', + '🆚', + '💮', + '🉐', + '㊙️', + '㊗️', + '🈴', + '🈵', + '🈹', + '🈲', + '🅰️', + '🅱️', + '🆎', + '🆑', + '🅾️', + '🆘', + '❌', + '⭕', + '🛑', + '⛔', + '📛', + '🚫', + '💯', + '💢', + '♨️', + '🚷', + '🚯', + '🚳', + '🚱', + '🔞', + '📵', + '🚭', + '❗', + '❕', + '❓', + '❔', + '‼️', + '⁉️', + '🔅', + '🔆', + '〽️', + '⚠️', + '🚸', + '🔱', + '⚜️', + '🔰', + '♻️', + '✅', + '🈯', + '💹', + '❇️', + '✳️', + '❎', + '🌐', + '💠', + 'Ⓜ️', + '🌀', + '💤', + '🏧', + '🚾', + '♿', + '🅿️', + '🈳', + '🈂️', + '🛂', + '🛃', + '🛄', + '🛅', + '🚹', + '🚺', + '🚼', + '🚻', + '🚮', + '🎦', + '📶', + '🈁', + '🔣', + 'ℹ️', + '🔤', + '🔡', + '🔠', + '🆖', + '🆗', + '🆙', + '🆒', + '🆕', + '🆓', + '0️⃣', + '1️⃣', + '2️⃣', + '3️⃣', + '4️⃣', + '5️⃣', + '6️⃣', + '7️⃣', + '8️⃣', + '9️⃣', + '🔟', + '🔢', + '#️⃣', + '*️⃣', + '⏏️', + '▶️', + '⏸', + '⏯', + '⏹', + '⏺', + '⏭', + '⏮', + '⏩', + '⏪', + '⏫', + '⏬', + '◀️', + '🔼', + '🔽', + '➡️', + '⬅️', + '⬆️', + '⬇️', + '↗️', + '↘️', + '↙️', + '↖️', + '↕️', + '↔️', + '↪️', + '↩️', + '⤴️', + '⤵️', + '🔀', + '🔁', + '🔂', + '🔄', + '🔃', + '🎵', + '🎶', + '➕', + '➖', + '➗', + '✖️', + '♾', + '💲', + '💱', + '™️', + '©️', + '®️', + '〰️', + '➰', + '➿', + '🔚', + '🔙', + '🔛', + '🔝', + '🔜', + '✔️', + '☑️', + '🔘', + '⚪', + '⚫', + '🔴', + '🟠', + '🟡', + '🟢', + '🔵', + '🟣', + '🟤', + '⭐', + '🌟', + '💫', + '✨', + '🔥', + '💥', + '💯', + '💢', + '💬', + '👁️‍🗨️', + '🗨', + '🗯', + '💭', + '💤', + ], + }, + { + name: 'Flags', + emojis: [ + '🏁', + '🚩', + '🎌', + '🏴', + '🏳️', + '🏳️‍🌈', + '🏳️‍⚧️', + '🏴‍☠️', + ], + }, +]; From 573e3499ae99cb50b85b30187bd5268a36bb924d Mon Sep 17 00:00:00 2001 From: timlohse1104 Date: Wed, 25 Feb 2026 00:26:48 +0100 Subject: [PATCH 10/10] Removed deprecated drag and drop function --- frontend/src/lib/util/drag-and-drop.ts | 79 -------------------------- 1 file changed, 79 deletions(-) delete mode 100644 frontend/src/lib/util/drag-and-drop.ts diff --git a/frontend/src/lib/util/drag-and-drop.ts b/frontend/src/lib/util/drag-and-drop.ts deleted file mode 100644 index f9ed21f7..00000000 --- a/frontend/src/lib/util/drag-and-drop.ts +++ /dev/null @@ -1,79 +0,0 @@ -export function draggable(node, data) { - let state = data; - - node.draggable = true; - node.style.cursor = 'grab'; - - function handleDragStart(e) { - if (!e.dataTransfer) return; - e.stopPropagation(); - e.dataTransfer.setData('text/plain', state); - } - - node.addEventListener('dragstart', handleDragStart); - - return { - update(data) { - state = data; - }, - - destroy() { - node.removeEventListener('dragstart', handleDragStart); - }, - }; -} - -export function dropzone(node, options) { - let state = { - dropEffect: 'move', - dragover_class: 'droppable', - ...options, - }; - - function handleDragEnter(e) { - if (!(e.target instanceof HTMLElement)) return; - e.target.classList.add(state.dragover_class); - } - - function handleDragLeave(e) { - if (!(e.target instanceof HTMLElement)) return; - e.target.classList.remove(state.dragover_class); - } - - function handleDragOver(e) { - e.preventDefault(); - if (!e.dataTransfer) return; - e.dataTransfer.dropEffect = state.dropEffect; - } - - function handleDrop(e) { - e.preventDefault(); - if (!e.dataTransfer) return; - const data = e.dataTransfer.getData('text/plain'); - if (!(e.target instanceof HTMLElement)) return; - e.target.classList.remove(state.dragover_class); - state.onDrop(data, e); - } - - node.addEventListener('dragenter', handleDragEnter); - node.addEventListener('dragleave', handleDragLeave); - node.addEventListener('dragover', handleDragOver); - node.addEventListener('drop', handleDrop); - - return { - update(options) { - state = { - dropEffect: 'move', - dragover_class: 'droppable', - ...options, - }; - }, - - destroy() { - node.removeEventListener('dragenter', handleDragEnter); - node.removeEventListener('dragleave', handleDragLeave); - node.removeEventListener('dragover', handleDragOver); - node.removeEventListener('drop', handleDrop); - }, - }; -}