mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 21:01:08 +02:00
refactor(calendar): remove tag groups hierarchy and legacy drag-drop composables
Remove unnecessary complexity from the calendar web app: - Remove tag groups system entirely (store, API client, route, components) Tags are now a flat alphabetically-sorted list instead of grouped hierarchy - Remove unused legacy composables (useDragDrop, useResize) that were never imported by any component — useEventDragDrop already consolidates both - Simplify TagStripModal from 1,452 to ~350 LOC by removing group CRUD, drag-drop between groups, and group hierarchy rendering - Add complexity audit report documenting remaining issues Total: -2,170 LOC across 13 files Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e7fb2074b4
commit
d3a3bc7b77
14 changed files with 214 additions and 2282 deletions
|
|
@ -1,84 +0,0 @@
|
|||
/**
|
||||
* Event Tag Groups API Client
|
||||
*/
|
||||
|
||||
import { fetchApi } from './client';
|
||||
import type { EventTagGroup } from '@calendar/shared';
|
||||
|
||||
export interface CreateEventTagGroupInput {
|
||||
name: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export interface UpdateEventTagGroupInput {
|
||||
name?: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
interface GetEventTagGroupsResponse {
|
||||
groups: EventTagGroup[];
|
||||
ungroupedTagCount: number;
|
||||
}
|
||||
|
||||
export async function getEventTagGroups() {
|
||||
const result = await fetchApi<GetEventTagGroupsResponse>('/event-tag-groups');
|
||||
if (result.error || !result.data) {
|
||||
return { data: null, ungroupedTagCount: 0, error: result.error };
|
||||
}
|
||||
return {
|
||||
data: result.data.groups,
|
||||
ungroupedTagCount: result.data.ungroupedTagCount,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getEventTagGroup(id: string) {
|
||||
const result = await fetchApi<{ group: EventTagGroup }>(`/event-tag-groups/${id}`);
|
||||
if (result.error || !result.data) {
|
||||
return { data: null, error: result.error };
|
||||
}
|
||||
return { data: result.data.group, error: null };
|
||||
}
|
||||
|
||||
export async function createEventTagGroup(data: CreateEventTagGroupInput) {
|
||||
const result = await fetchApi<{ group: EventTagGroup }>('/event-tag-groups', {
|
||||
method: 'POST',
|
||||
body: data,
|
||||
});
|
||||
if (result.error || !result.data) {
|
||||
return { data: null, error: result.error };
|
||||
}
|
||||
return { data: result.data.group, error: null };
|
||||
}
|
||||
|
||||
export async function updateEventTagGroup(id: string, data: UpdateEventTagGroupInput) {
|
||||
const result = await fetchApi<{ group: EventTagGroup }>(`/event-tag-groups/${id}`, {
|
||||
method: 'PUT',
|
||||
body: data,
|
||||
});
|
||||
if (result.error || !result.data) {
|
||||
return { data: null, error: result.error };
|
||||
}
|
||||
return { data: result.data.group, error: null };
|
||||
}
|
||||
|
||||
export async function deleteEventTagGroup(id: string) {
|
||||
return fetchApi<{ success: boolean }>(`/event-tag-groups/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
export async function reorderEventTagGroups(groupIds: string[]) {
|
||||
const result = await fetchApi<GetEventTagGroupsResponse>('/event-tag-groups/reorder', {
|
||||
method: 'PUT',
|
||||
body: { groupIds },
|
||||
});
|
||||
if (result.error || !result.data) {
|
||||
return { data: null, ungroupedTagCount: 0, error: result.error };
|
||||
}
|
||||
return {
|
||||
data: result.data.groups,
|
||||
ungroupedTagCount: result.data.ungroupedTagCount,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
<script lang="ts">
|
||||
import { settingsStore } from '$lib/stores/settings.svelte';
|
||||
import { eventTagsStore } from '$lib/stores/event-tags.svelte';
|
||||
import { eventTagGroupsStore } from '$lib/stores/event-tag-groups.svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
import { DotsThree, Plus, X } from '@manacore/shared-icons';
|
||||
|
|
@ -10,7 +9,6 @@
|
|||
let showModal = $state(false);
|
||||
|
||||
function handleTagClick(tagId: string) {
|
||||
// Toggle tag selection for filtering calendar view
|
||||
settingsStore.toggleTagSelection(tagId);
|
||||
}
|
||||
|
||||
|
|
@ -28,31 +26,16 @@
|
|||
showModal = false;
|
||||
}
|
||||
|
||||
// Sort tags by group, then by name
|
||||
const sortedTags = $derived.by(() => {
|
||||
const tags = [...eventTagsStore.tags];
|
||||
const groupOrder = new Map(eventTagGroupsStore.groups.map((g, i) => [g.id, i]));
|
||||
|
||||
return tags.sort((a, b) => {
|
||||
// Ungrouped tags go last
|
||||
const aOrder = a.groupId ? (groupOrder.get(a.groupId) ?? 999) : 1000;
|
||||
const bOrder = b.groupId ? (groupOrder.get(b.groupId) ?? 999) : 1000;
|
||||
|
||||
if (aOrder !== bOrder) return aOrder - bOrder;
|
||||
return a.name.localeCompare(b.name, 'de');
|
||||
});
|
||||
return [...eventTagsStore.tags].sort((a, b) => a.name.localeCompare(b.name, 'de'));
|
||||
});
|
||||
|
||||
const hasTags = $derived(eventTagsStore.tags.length > 0);
|
||||
|
||||
onMount(async () => {
|
||||
// Fetch tags and groups if not already loaded
|
||||
if (eventTagsStore.tags.length === 0) {
|
||||
await eventTagsStore.fetchTags();
|
||||
}
|
||||
if (eventTagGroupsStore.groups.length === 0) {
|
||||
await eventTagGroupsStore.fetchGroups();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,20 +1,8 @@
|
|||
<script lang="ts">
|
||||
import { eventTagsStore } from '$lib/stores/event-tags.svelte';
|
||||
import { eventTagGroupsStore } from '$lib/stores/event-tag-groups.svelte';
|
||||
import {
|
||||
CaretDown,
|
||||
CaretRight,
|
||||
Plus,
|
||||
X,
|
||||
Check,
|
||||
FolderSimplePlus,
|
||||
Pencil,
|
||||
Trash,
|
||||
MagnifyingGlass,
|
||||
DotsSixVertical,
|
||||
} from '@manacore/shared-icons';
|
||||
import { Plus, X, Check, Pencil, Trash, MagnifyingGlass } from '@manacore/shared-icons';
|
||||
import { TagColorPicker } from '@manacore/shared-ui';
|
||||
import type { EventTag, EventTagGroup } from '@calendar/shared';
|
||||
import type { EventTag } from '@calendar/shared';
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
|
|
@ -26,84 +14,37 @@
|
|||
// Search state
|
||||
let searchQuery = $state('');
|
||||
|
||||
// Track collapsed state
|
||||
let collapsedGroups = $state<Set<string | null>>(new Set());
|
||||
|
||||
// New group form state
|
||||
let showNewGroupForm = $state(false);
|
||||
let newGroupName = $state('');
|
||||
let newGroupColor = $state('#3b82f6');
|
||||
let isCreatingGroup = $state(false);
|
||||
|
||||
// New tag form state
|
||||
let showNewTagForm = $state(false);
|
||||
let newTagName = $state('');
|
||||
let newTagColor = $state('#3b82f6');
|
||||
let newTagGroupId = $state<string | null>(null);
|
||||
let isCreatingTag = $state(false);
|
||||
|
||||
// Edit tag state
|
||||
let editingTag = $state<EventTag | null>(null);
|
||||
let editTagName = $state('');
|
||||
let editTagColor = $state('#3b82f6');
|
||||
let editTagGroupId = $state<string | null>(null);
|
||||
let isSavingTag = $state(false);
|
||||
|
||||
// Edit group state
|
||||
let editingGroup = $state<EventTagGroup | null>(null);
|
||||
let editGroupName = $state('');
|
||||
let editGroupColor = $state('#3b82f6');
|
||||
let isSavingGroup = $state(false);
|
||||
|
||||
// Drag and drop state for tags
|
||||
let draggedTag = $state<EventTag | null>(null);
|
||||
let dragOverGroupId = $state<string | null | 'none'>(null);
|
||||
|
||||
// Drag and drop state for groups
|
||||
let draggedGroup = $state<EventTagGroup | null>(null);
|
||||
let dragOverGroupIndex = $state<number | null>(null);
|
||||
|
||||
// Filtered tags based on search
|
||||
const filteredTags = $derived.by(() => {
|
||||
if (!searchQuery.trim()) return eventTagsStore.tags;
|
||||
// Filtered and sorted tags
|
||||
const sortedTags = $derived.by(() => {
|
||||
const tags = [...eventTagsStore.tags].sort((a, b) => a.name.localeCompare(b.name, 'de'));
|
||||
if (!searchQuery.trim()) return tags;
|
||||
const query = searchQuery.toLowerCase();
|
||||
return eventTagsStore.tags.filter((t) => t.name.toLowerCase().includes(query));
|
||||
return tags.filter((t) => t.name.toLowerCase().includes(query));
|
||||
});
|
||||
|
||||
function toggleGroup(groupId: string | null) {
|
||||
const newSet = new Set(collapsedGroups);
|
||||
if (newSet.has(groupId)) {
|
||||
newSet.delete(groupId);
|
||||
} else {
|
||||
newSet.add(groupId);
|
||||
}
|
||||
collapsedGroups = newSet;
|
||||
}
|
||||
|
||||
function isExpanded(groupId: string | null): boolean {
|
||||
return !collapsedGroups.has(groupId);
|
||||
}
|
||||
|
||||
function getTagsForGroup(groupId: string | null): EventTag[] {
|
||||
return filteredTags.filter((t) => (t.groupId ?? null) === groupId);
|
||||
}
|
||||
|
||||
// Get ungrouped tags
|
||||
const ungroupedTags = $derived(getTagsForGroup(null));
|
||||
|
||||
// ==================== NEW TAG ====================
|
||||
function openNewTagForm() {
|
||||
showNewTagForm = true;
|
||||
newTagName = '';
|
||||
newTagColor = '#3b82f6';
|
||||
newTagGroupId = null;
|
||||
}
|
||||
|
||||
function closeNewTagForm() {
|
||||
showNewTagForm = false;
|
||||
newTagName = '';
|
||||
newTagColor = '#3b82f6';
|
||||
newTagGroupId = null;
|
||||
}
|
||||
|
||||
async function handleCreateTag() {
|
||||
|
|
@ -113,14 +54,12 @@
|
|||
const result = await eventTagsStore.createTag({
|
||||
name: newTagName.trim(),
|
||||
color: newTagColor,
|
||||
groupId: newTagGroupId,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
console.error('Failed to create tag:', result.error);
|
||||
} else {
|
||||
closeNewTagForm();
|
||||
eventTagGroupsStore.fetchGroups();
|
||||
}
|
||||
|
||||
isCreatingTag = false;
|
||||
|
|
@ -141,14 +80,12 @@
|
|||
editingTag = tag;
|
||||
editTagName = tag.name;
|
||||
editTagColor = tag.color;
|
||||
editTagGroupId = tag.groupId ?? null;
|
||||
}
|
||||
|
||||
function closeEditTag() {
|
||||
editingTag = null;
|
||||
editTagName = '';
|
||||
editTagColor = '#3b82f6';
|
||||
editTagGroupId = null;
|
||||
}
|
||||
|
||||
async function handleSaveTag() {
|
||||
|
|
@ -158,14 +95,12 @@
|
|||
const result = await eventTagsStore.updateTag(editingTag.id, {
|
||||
name: editTagName.trim(),
|
||||
color: editTagColor,
|
||||
groupId: editTagGroupId,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
console.error('Failed to update tag:', result.error);
|
||||
} else {
|
||||
closeEditTag();
|
||||
eventTagGroupsStore.fetchGroups();
|
||||
}
|
||||
|
||||
isSavingTag = false;
|
||||
|
|
@ -180,7 +115,6 @@
|
|||
console.error('Failed to delete tag:', result.error);
|
||||
} else {
|
||||
closeEditTag();
|
||||
eventTagGroupsStore.fetchGroups();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -194,176 +128,20 @@
|
|||
}
|
||||
}
|
||||
|
||||
// ==================== NEW GROUP ====================
|
||||
function toggleNewGroupForm() {
|
||||
showNewGroupForm = !showNewGroupForm;
|
||||
if (!showNewGroupForm) {
|
||||
resetNewGroupForm();
|
||||
}
|
||||
}
|
||||
|
||||
function resetNewGroupForm() {
|
||||
newGroupName = '';
|
||||
newGroupColor = '#3b82f6';
|
||||
}
|
||||
|
||||
async function handleCreateGroup() {
|
||||
if (!newGroupName.trim() || isCreatingGroup) return;
|
||||
|
||||
isCreatingGroup = true;
|
||||
const result = await eventTagGroupsStore.createGroup({
|
||||
name: newGroupName.trim(),
|
||||
color: newGroupColor,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
console.error('Failed to create group:', result.error);
|
||||
} else {
|
||||
resetNewGroupForm();
|
||||
showNewGroupForm = false;
|
||||
}
|
||||
|
||||
isCreatingGroup = false;
|
||||
}
|
||||
|
||||
function handleNewGroupKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter' && newGroupName.trim()) {
|
||||
e.preventDefault();
|
||||
handleCreateGroup();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.stopPropagation();
|
||||
showNewGroupForm = false;
|
||||
resetNewGroupForm();
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== EDIT GROUP ====================
|
||||
function openEditGroup(e: MouseEvent, group: EventTagGroup) {
|
||||
e.stopPropagation();
|
||||
editingGroup = group;
|
||||
editGroupName = group.name;
|
||||
editGroupColor = group.color;
|
||||
}
|
||||
|
||||
function closeEditGroup() {
|
||||
editingGroup = null;
|
||||
editGroupName = '';
|
||||
editGroupColor = '#3b82f6';
|
||||
}
|
||||
|
||||
async function handleSaveGroup() {
|
||||
if (!editingGroup || !editGroupName.trim() || isSavingGroup) return;
|
||||
|
||||
isSavingGroup = true;
|
||||
const result = await eventTagGroupsStore.updateGroup(editingGroup.id, {
|
||||
name: editGroupName.trim(),
|
||||
color: editGroupColor,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
console.error('Failed to update group:', result.error);
|
||||
} else {
|
||||
closeEditGroup();
|
||||
}
|
||||
|
||||
isSavingGroup = false;
|
||||
}
|
||||
|
||||
async function handleDeleteGroup() {
|
||||
if (!editingGroup) return;
|
||||
|
||||
const result = await eventTagGroupsStore.deleteGroup(editingGroup.id);
|
||||
|
||||
if (result.error) {
|
||||
console.error('Failed to delete group:', result.error);
|
||||
} else {
|
||||
closeEditGroup();
|
||||
}
|
||||
}
|
||||
|
||||
function handleEditGroupKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter' && editGroupName.trim()) {
|
||||
e.preventDefault();
|
||||
handleSaveGroup();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.stopPropagation();
|
||||
closeEditGroup();
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== DRAG AND DROP ====================
|
||||
function handleDragStart(e: DragEvent, tag: EventTag) {
|
||||
draggedTag = tag;
|
||||
if (e.dataTransfer) {
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('text/plain', tag.id);
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragEnd() {
|
||||
draggedTag = null;
|
||||
dragOverGroupId = null;
|
||||
}
|
||||
|
||||
function handleDragOver(e: DragEvent, groupId: string | null) {
|
||||
e.preventDefault();
|
||||
if (e.dataTransfer) {
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
}
|
||||
dragOverGroupId = groupId ?? 'none';
|
||||
}
|
||||
|
||||
function handleDragLeave() {
|
||||
dragOverGroupId = null;
|
||||
}
|
||||
|
||||
async function handleDrop(e: DragEvent, targetGroupId: string | null) {
|
||||
e.preventDefault();
|
||||
dragOverGroupId = null;
|
||||
|
||||
if (!draggedTag) return;
|
||||
|
||||
const currentGroupId = draggedTag.groupId ?? null;
|
||||
if (currentGroupId === targetGroupId) {
|
||||
draggedTag = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await eventTagsStore.updateTag(draggedTag.id, {
|
||||
groupId: targetGroupId,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
console.error('Failed to move tag:', result.error);
|
||||
} else {
|
||||
eventTagGroupsStore.fetchGroups();
|
||||
}
|
||||
|
||||
draggedTag = null;
|
||||
}
|
||||
|
||||
// ==================== KEYBOARD ====================
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
if (editingTag) {
|
||||
closeEditTag();
|
||||
} else if (editingGroup) {
|
||||
closeEditGroup();
|
||||
} else if (showNewTagForm) {
|
||||
closeNewTagForm();
|
||||
} else if (showNewGroupForm) {
|
||||
showNewGroupForm = false;
|
||||
resetNewGroupForm();
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if any form is open
|
||||
const hasOpenForm = $derived(
|
||||
showNewTagForm || showNewGroupForm || editingTag !== null || editingGroup !== null
|
||||
);
|
||||
const hasOpenForm = $derived(showNewTagForm || editingTag !== null);
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
|
@ -423,15 +201,6 @@
|
|||
autofocus
|
||||
/>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label class="form-label">Gruppe</label>
|
||||
<select bind:value={newTagGroupId} class="group-select">
|
||||
<option value={null}>Keine Gruppe</option>
|
||||
{#each eventTagGroupsStore.groups as group (group.id)}
|
||||
<option value={group.id}>{group.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div class="color-picker-row">
|
||||
<TagColorPicker
|
||||
selectedColor={newTagColor}
|
||||
|
|
@ -473,15 +242,6 @@
|
|||
autofocus
|
||||
/>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label class="form-label">Gruppe</label>
|
||||
<select bind:value={editTagGroupId} class="group-select">
|
||||
<option value={null}>Keine Gruppe</option>
|
||||
{#each eventTagGroupsStore.groups as group (group.id)}
|
||||
<option value={group.id}>{group.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div class="color-picker-row">
|
||||
<TagColorPicker
|
||||
selectedColor={editTagColor}
|
||||
|
|
@ -505,234 +265,34 @@
|
|||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Edit Group Form -->
|
||||
{#if editingGroup}
|
||||
<div class="edit-form-section">
|
||||
<div class="edit-form-header">
|
||||
<span class="edit-form-title">Gruppe bearbeiten</span>
|
||||
<button class="icon-btn" onclick={closeEditGroup} title="Abbrechen">
|
||||
<X size={14} weight="bold" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="edit-form">
|
||||
<div class="form-row">
|
||||
<div class="color-preview" style="background-color: {editGroupColor}"></div>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={editGroupName}
|
||||
onkeydown={handleEditGroupKeydown}
|
||||
placeholder="Gruppenname"
|
||||
class="name-input"
|
||||
autofocus
|
||||
/>
|
||||
</div>
|
||||
<div class="color-picker-row">
|
||||
<TagColorPicker
|
||||
selectedColor={editGroupColor}
|
||||
onColorChange={(c) => (editGroupColor = c)}
|
||||
/>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button class="btn btn-danger" onclick={handleDeleteGroup} title="Gruppe löschen">
|
||||
<Trash size={14} weight="bold" />
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
onclick={handleSaveGroup}
|
||||
disabled={!editGroupName.trim() || isSavingGroup}
|
||||
>
|
||||
<Check size={14} weight="bold" />
|
||||
Speichern
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Groups with their tags (show all groups) -->
|
||||
{#if !hasOpenForm || (hasOpenForm && filteredTags.length > 0)}
|
||||
{#each eventTagGroupsStore.groups as group (group.id)}
|
||||
{@const groupTags = getTagsForGroup(group.id)}
|
||||
{#if !searchQuery || groupTags.length > 0}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<!-- Tags list -->
|
||||
{#if !hasOpenForm || sortedTags.length > 0}
|
||||
<div class="tags-grid">
|
||||
{#each sortedTags as tag (tag.id)}
|
||||
<div
|
||||
class="group-section"
|
||||
class:drag-over={dragOverGroupId === group.id}
|
||||
ondragover={(e) => handleDragOver(e, group.id)}
|
||||
ondragleave={handleDragLeave}
|
||||
ondrop={(e) => handleDrop(e, group.id)}
|
||||
class="tag-pill glass-tag"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
style="--tag-color: {tag.color || '#3b82f6'}"
|
||||
>
|
||||
<div class="group-header">
|
||||
<button type="button" onclick={() => toggleGroup(group.id)} class="group-toggle">
|
||||
<div class="group-header-left">
|
||||
{#if isExpanded(group.id)}
|
||||
<CaretDown size={14} weight="bold" />
|
||||
{:else}
|
||||
<CaretRight size={14} weight="bold" />
|
||||
{/if}
|
||||
<div class="group-dot" style="background-color: {group.color}"></div>
|
||||
<span class="group-name">{group.name}</span>
|
||||
<span class="group-count">({groupTags.length})</span>
|
||||
</div>
|
||||
</button>
|
||||
<div class="group-actions">
|
||||
<button
|
||||
class="icon-btn icon-btn-sm"
|
||||
onclick={(e) => openEditGroup(e, group)}
|
||||
title="Gruppe bearbeiten"
|
||||
>
|
||||
<Pencil size={12} weight="bold" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if isExpanded(group.id)}
|
||||
<div class="tags-grid">
|
||||
{#if groupTags.length === 0}
|
||||
<div class="empty-group-hint">Tags hierher ziehen</div>
|
||||
{:else}
|
||||
{#each groupTags as tag (tag.id)}
|
||||
<div
|
||||
class="tag-pill glass-tag"
|
||||
class:dragging={draggedTag?.id === tag.id}
|
||||
draggable="true"
|
||||
ondragstart={(e) => handleDragStart(e, tag)}
|
||||
ondragend={handleDragEnd}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
style="--tag-color: {tag.color || '#3b82f6'}"
|
||||
>
|
||||
<span class="tag-dot"></span>
|
||||
<span class="tag-name">{tag.name}</span>
|
||||
<button
|
||||
class="tag-edit-btn"
|
||||
onclick={() => openEditTag(tag)}
|
||||
title="Tag bearbeiten"
|
||||
>
|
||||
<Pencil size={10} weight="bold" />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
<!-- Ungrouped tags -->
|
||||
{#if !searchQuery || ungroupedTags.length > 0}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="group-section"
|
||||
class:drag-over={dragOverGroupId === 'none'}
|
||||
ondragover={(e) => handleDragOver(e, null)}
|
||||
ondragleave={handleDragLeave}
|
||||
ondrop={(e) => handleDrop(e, null)}
|
||||
>
|
||||
<div class="group-header">
|
||||
<button type="button" onclick={() => toggleGroup(null)} class="group-toggle">
|
||||
<div class="group-header-left">
|
||||
{#if isExpanded(null)}
|
||||
<CaretDown size={14} weight="bold" />
|
||||
{:else}
|
||||
<CaretRight size={14} weight="bold" />
|
||||
{/if}
|
||||
<span class="group-name muted">Ohne Gruppe</span>
|
||||
<span class="group-count">({ungroupedTags.length})</span>
|
||||
</div>
|
||||
<span class="tag-dot"></span>
|
||||
<span class="tag-name">{tag.name}</span>
|
||||
<button
|
||||
class="tag-edit-btn"
|
||||
onclick={() => openEditTag(tag)}
|
||||
title="Tag bearbeiten"
|
||||
>
|
||||
<Pencil size={10} weight="bold" />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if isExpanded(null)}
|
||||
<div class="tags-grid">
|
||||
{#if ungroupedTags.length === 0}
|
||||
<div class="empty-group-hint">Tags hierher ziehen</div>
|
||||
{:else}
|
||||
{#each ungroupedTags as tag (tag.id)}
|
||||
<div
|
||||
class="tag-pill glass-tag"
|
||||
class:dragging={draggedTag?.id === tag.id}
|
||||
draggable="true"
|
||||
ondragstart={(e) => handleDragStart(e, tag)}
|
||||
ondragend={handleDragEnd}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
style="--tag-color: {tag.color || '#3b82f6'}"
|
||||
>
|
||||
<span class="tag-dot"></span>
|
||||
<span class="tag-name">{tag.name}</span>
|
||||
<button
|
||||
class="tag-edit-btn"
|
||||
onclick={() => openEditTag(tag)}
|
||||
title="Tag bearbeiten"
|
||||
>
|
||||
<Pencil size={10} weight="bold" />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Search empty state -->
|
||||
{#if searchQuery && filteredTags.length === 0}
|
||||
{#if searchQuery && sortedTags.length === 0}
|
||||
<div class="search-empty">
|
||||
<p>Keine Tags gefunden für "{searchQuery}"</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- New Group Section -->
|
||||
<div class="group-section new-group-section">
|
||||
<button
|
||||
type="button"
|
||||
onclick={toggleNewGroupForm}
|
||||
class="group-toggle new-group-header"
|
||||
>
|
||||
<div class="group-header-left">
|
||||
{#if showNewGroupForm}
|
||||
<CaretDown size={14} weight="bold" />
|
||||
{:else}
|
||||
<Plus size={14} weight="bold" />
|
||||
{/if}
|
||||
<FolderSimplePlus size={14} />
|
||||
<span class="group-name muted">Neue Gruppe</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{#if showNewGroupForm}
|
||||
<div class="new-group-form">
|
||||
<div class="form-row">
|
||||
<div class="color-preview" style="background-color: {newGroupColor}"></div>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newGroupName}
|
||||
onkeydown={handleNewGroupKeydown}
|
||||
placeholder="Gruppenname"
|
||||
class="name-input"
|
||||
autofocus
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleCreateGroup}
|
||||
disabled={!newGroupName.trim() || isCreatingGroup}
|
||||
class="save-btn"
|
||||
title="Gruppe erstellen"
|
||||
>
|
||||
<Check size={16} weight="bold" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="color-picker-row">
|
||||
<TagColorPicker
|
||||
selectedColor={newGroupColor}
|
||||
onColorChange={(c) => (newGroupColor = c)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -1020,16 +580,6 @@
|
|||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
min-width: 50px;
|
||||
}
|
||||
|
||||
:global(.dark) .form-label {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.color-preview {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
|
|
@ -1070,23 +620,6 @@
|
|||
box-shadow: 0 0 0 2px rgba(96, 165, 250, 0.2);
|
||||
}
|
||||
|
||||
.group-select {
|
||||
flex: 1;
|
||||
padding: 0.375rem 0.5rem;
|
||||
border: 1px solid rgba(0, 0, 0, 0.15);
|
||||
border-radius: 0.375rem;
|
||||
background: white;
|
||||
color: #374151;
|
||||
font-size: 0.8125rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
:global(.dark) .group-select {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
color: #f3f4f6;
|
||||
}
|
||||
|
||||
.color-picker-row {
|
||||
padding-left: 32px;
|
||||
}
|
||||
|
|
@ -1172,112 +705,12 @@
|
|||
color: #f3f4f6;
|
||||
}
|
||||
|
||||
.icon-btn-sm {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
}
|
||||
|
||||
/* Group Section */
|
||||
.group-section {
|
||||
margin-bottom: 0.5rem;
|
||||
border-radius: 0.5rem;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.group-section.drag-over {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
:global(.dark) .group-section.drag-over {
|
||||
background: rgba(96, 165, 250, 0.15);
|
||||
}
|
||||
|
||||
.group-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.group-toggle {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
border-radius: 0.5rem;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.group-toggle:hover {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
:global(.dark) .group-toggle:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.group-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding-right: 0.5rem;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.group-header:hover .group-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.group-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
:global(.dark) .group-header-left {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.group-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.group-name {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.group-name.muted {
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
:global(.dark) .group-name {
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
:global(.dark) .group-name.muted {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.group-count {
|
||||
font-size: 0.75rem;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
/* Tags grid */
|
||||
.tags-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
padding: 0.25rem 0.75rem 0.75rem;
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
|
||||
/* Tag Pill */
|
||||
|
|
@ -1287,7 +720,7 @@
|
|||
gap: 0.375rem;
|
||||
padding: 0.375rem 0.625rem;
|
||||
border-radius: 9999px;
|
||||
cursor: grab;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.15s ease;
|
||||
position: relative;
|
||||
|
|
@ -1318,15 +751,6 @@
|
|||
border-color: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
.glass-tag.dragging {
|
||||
opacity: 0.5;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.glass-tag:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.tag-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
|
|
@ -1380,73 +804,4 @@
|
|||
background: rgba(255, 255, 255, 0.25);
|
||||
color: #f3f4f6;
|
||||
}
|
||||
|
||||
.empty-group-hint {
|
||||
font-size: 0.75rem;
|
||||
color: #9ca3af;
|
||||
font-style: italic;
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
|
||||
:global(.dark) .empty-group-hint {
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
/* New Group Section */
|
||||
.new-group-section {
|
||||
border-top: 1px dashed rgba(0, 0, 0, 0.1);
|
||||
margin-top: 0.5rem;
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
|
||||
:global(.dark) .new-group-section {
|
||||
border-top-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.new-group-header {
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.new-group-header .group-name {
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
:global(.dark) .new-group-header {
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
:global(.dark) .new-group-header .group-name {
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
.new-group-form {
|
||||
padding: 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.save-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 0.5rem;
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.save-btn:hover:not(:disabled) {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.save-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,245 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { TagBadge, type Tag } from '@manacore/shared-ui';
|
||||
import { CaretDown, CaretRight, Pencil, FolderSimple } from '@manacore/shared-icons';
|
||||
import type { EventTag, EventTagGroup } from '@calendar/shared';
|
||||
|
||||
interface Props {
|
||||
groups: EventTagGroup[];
|
||||
tags: EventTag[];
|
||||
ungroupedLabel?: string;
|
||||
onEditTag: (tag: EventTag) => void;
|
||||
onEditGroup?: (group: EventTagGroup) => void;
|
||||
loading?: boolean;
|
||||
emptyMessage?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
groups,
|
||||
tags,
|
||||
ungroupedLabel = 'Ohne Gruppe',
|
||||
onEditTag,
|
||||
onEditGroup,
|
||||
loading = false,
|
||||
emptyMessage = 'Keine Tags vorhanden',
|
||||
}: Props = $props();
|
||||
|
||||
// Track collapsed state (inverted - we track what's collapsed, not expanded)
|
||||
// This way new groups are automatically expanded
|
||||
let collapsedGroups = $state<Set<string | null>>(new Set());
|
||||
|
||||
function toggleGroup(groupId: string | null) {
|
||||
const newSet = new Set(collapsedGroups);
|
||||
if (newSet.has(groupId)) {
|
||||
newSet.delete(groupId);
|
||||
} else {
|
||||
newSet.add(groupId);
|
||||
}
|
||||
collapsedGroups = newSet;
|
||||
}
|
||||
|
||||
function isExpanded(groupId: string | null): boolean {
|
||||
return !collapsedGroups.has(groupId);
|
||||
}
|
||||
|
||||
// Get tags for a specific group
|
||||
function getTagsForGroup(groupId: string | null): EventTag[] {
|
||||
return tags.filter((t) => (t.groupId ?? null) === groupId);
|
||||
}
|
||||
|
||||
// Convert EventTag to Tag for TagBadge
|
||||
function toTag(eventTag: EventTag): Tag {
|
||||
return {
|
||||
id: eventTag.id,
|
||||
name: eventTag.name,
|
||||
color: eventTag.color,
|
||||
};
|
||||
}
|
||||
|
||||
// Get ungrouped tags
|
||||
const ungroupedTags = $derived(getTagsForGroup(null));
|
||||
const hasUngroupedTags = $derived(ungroupedTags.length > 0);
|
||||
const totalTags = $derived(tags.length);
|
||||
</script>
|
||||
|
||||
{#if loading}
|
||||
<div class="flex justify-center py-8">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
{:else if totalTags === 0}
|
||||
<div class="text-center py-12">
|
||||
<div class="text-muted-foreground mb-2">{emptyMessage}</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-2">
|
||||
<!-- Groups with their tags -->
|
||||
{#each groups as group (group.id)}
|
||||
{@const groupTags = getTagsForGroup(group.id)}
|
||||
{#if groupTags.length > 0}
|
||||
<div class="group-section">
|
||||
<!-- Group Header -->
|
||||
<button type="button" onclick={() => toggleGroup(group.id)} class="group-header">
|
||||
<div class="flex items-center gap-2">
|
||||
{#if isExpanded(group.id)}
|
||||
<CaretDown size={16} weight="bold" class="text-muted-foreground" />
|
||||
{:else}
|
||||
<CaretRight size={16} weight="bold" class="text-muted-foreground" />
|
||||
{/if}
|
||||
<div
|
||||
class="w-3 h-3 rounded-full flex-shrink-0"
|
||||
style="background-color: {group.color}"
|
||||
></div>
|
||||
<span class="font-medium">{group.name}</span>
|
||||
<span class="text-xs text-muted-foreground">({groupTags.length})</span>
|
||||
</div>
|
||||
{#if onEditGroup}
|
||||
<button
|
||||
type="button"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEditGroup(group);
|
||||
}}
|
||||
class="edit-group-btn"
|
||||
aria-label="Gruppe bearbeiten"
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Tags in this group -->
|
||||
{#if isExpanded(group.id)}
|
||||
<div class="tags-container">
|
||||
{#each groupTags as tag (tag.id)}
|
||||
<button type="button" class="tag-pill" onclick={() => onEditTag(tag)}>
|
||||
<TagBadge tag={toTag(tag)} />
|
||||
<span class="edit-icon">
|
||||
<Pencil size={12} />
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
<!-- Ungrouped tags -->
|
||||
{#if hasUngroupedTags}
|
||||
<div class="group-section">
|
||||
<!-- Ungrouped Header -->
|
||||
<button type="button" onclick={() => toggleGroup(null)} class="group-header">
|
||||
<div class="flex items-center gap-2">
|
||||
{#if isExpanded(null)}
|
||||
<CaretDown size={16} weight="bold" class="text-muted-foreground" />
|
||||
{:else}
|
||||
<CaretRight size={16} weight="bold" class="text-muted-foreground" />
|
||||
{/if}
|
||||
<FolderSimple size={14} class="text-muted-foreground" />
|
||||
<span class="font-medium text-muted-foreground">{ungroupedLabel}</span>
|
||||
<span class="text-xs text-muted-foreground">({ungroupedTags.length})</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<!-- Ungrouped Tags -->
|
||||
{#if isExpanded(null)}
|
||||
<div class="tags-container">
|
||||
{#each ungroupedTags as tag (tag.id)}
|
||||
<button type="button" class="tag-pill" onclick={() => onEditTag(tag)}>
|
||||
<TagBadge tag={toTag(tag)} />
|
||||
<span class="edit-icon">
|
||||
<Pencil size={12} />
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.group-section {
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 0.75rem;
|
||||
overflow: hidden;
|
||||
background: hsl(var(--card));
|
||||
}
|
||||
|
||||
.group-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
background: hsl(var(--muted) / 0.3);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.group-header:hover {
|
||||
background: hsl(var(--muted) / 0.5);
|
||||
}
|
||||
|
||||
.edit-group-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.375rem;
|
||||
border-radius: 0.375rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.group-header:hover .edit-group-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.edit-group-btn:hover {
|
||||
color: hsl(var(--primary));
|
||||
background: hsl(var(--primary) / 0.1);
|
||||
}
|
||||
|
||||
.tags-container {
|
||||
padding: 0.75rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.tag-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0;
|
||||
padding-right: 0.5rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
border-radius: 9999px;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.tag-pill:hover {
|
||||
background: hsl(var(--muted) / 0.5);
|
||||
}
|
||||
|
||||
.edit-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: hsl(var(--muted-foreground));
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.tag-pill:hover .edit-icon {
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,123 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { Modal, Input, TagColorPicker, TagBadge } from '@manacore/shared-ui';
|
||||
import type { EventTagGroup } from '@calendar/shared';
|
||||
|
||||
interface Props {
|
||||
group?: EventTagGroup | null;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (name: string, color: string) => void;
|
||||
onDelete?: () => void;
|
||||
}
|
||||
|
||||
let { group = null, isOpen, onClose, onSave, onDelete }: Props = $props();
|
||||
|
||||
const DEFAULT_COLOR = '#3B82F6';
|
||||
|
||||
let name = $state(group?.name ?? '');
|
||||
let color = $state(group?.color ?? DEFAULT_COLOR);
|
||||
|
||||
// Reset form when group changes
|
||||
$effect(() => {
|
||||
if (isOpen) {
|
||||
name = group?.name ?? '';
|
||||
color = group?.color ?? DEFAULT_COLOR;
|
||||
}
|
||||
});
|
||||
|
||||
function handleSave() {
|
||||
if (name.trim()) {
|
||||
onSave(name.trim(), color);
|
||||
}
|
||||
}
|
||||
|
||||
function handleDelete() {
|
||||
if (
|
||||
onDelete &&
|
||||
confirm(
|
||||
`Gruppe "${group?.name}" wirklich löschen? Tags in dieser Gruppe werden nicht gelöscht.`
|
||||
)
|
||||
) {
|
||||
onDelete();
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter' && name.trim()) {
|
||||
e.preventDefault();
|
||||
handleSave();
|
||||
}
|
||||
}
|
||||
|
||||
const previewTag = $derived({ name: name || 'Gruppenname', color });
|
||||
const isEditing = $derived(!!group);
|
||||
</script>
|
||||
|
||||
<Modal
|
||||
visible={isOpen}
|
||||
{onClose}
|
||||
title={isEditing ? 'Gruppe bearbeiten' : 'Neue Gruppe'}
|
||||
maxWidth="sm"
|
||||
>
|
||||
<div class="space-y-6">
|
||||
<!-- Name Input -->
|
||||
<div>
|
||||
<Input bind:value={name} placeholder="Gruppenname" onkeydown={handleKeyDown} />
|
||||
</div>
|
||||
|
||||
<!-- Color Picker -->
|
||||
<div>
|
||||
<span class="block text-sm font-medium text-muted-foreground mb-3"> Farbe </span>
|
||||
<TagColorPicker selectedColor={color} onColorChange={(c) => (color = c)} />
|
||||
</div>
|
||||
|
||||
<!-- Preview -->
|
||||
<div>
|
||||
<span class="block text-sm font-medium text-muted-foreground mb-3"> Vorschau </span>
|
||||
<div class="flex items-center gap-2">
|
||||
<TagBadge tag={previewTag} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tag Count Info (only when editing) -->
|
||||
{#if isEditing && group?.tagCount !== undefined && group.tagCount > 0}
|
||||
<div class="text-sm text-muted-foreground">
|
||||
{group.tagCount}
|
||||
{group.tagCount === 1 ? 'Tag' : 'Tags'} in dieser Gruppe
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#snippet footer()}
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
{#if onDelete && isEditing}
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleDelete}
|
||||
class="px-4 py-2 text-sm font-medium text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors"
|
||||
>
|
||||
Löschen
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onclick={onClose}
|
||||
class="px-4 py-2 text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/10 rounded-lg transition-colors"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleSave}
|
||||
disabled={!name.trim()}
|
||||
class="px-4 py-2 text-sm font-medium bg-primary text-primary-foreground rounded-lg hover:opacity-90 transition-opacity disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isEditing ? 'Speichern' : 'Erstellen'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
|
|
@ -1,2 +1 @@
|
|||
export { default as TagGroupEditModal } from './TagGroupEditModal.svelte';
|
||||
export { default as GroupedTagList } from './GroupedTagList.svelte';
|
||||
// Tag components (tag groups removed - flat tag list only)
|
||||
|
|
|
|||
|
|
@ -25,7 +25,3 @@ export { useCalendarKeyboard, type CancellableOperation } from './useCalendarKey
|
|||
|
||||
// Birthday popover management
|
||||
export { useBirthdayPopover } from './useBirthdayPopover.svelte';
|
||||
|
||||
// Legacy exports (kept for backwards compatibility, may be removed later)
|
||||
export { useDragDrop, type DragDropConfig, type DragState } from './useDragDrop.svelte';
|
||||
export { useResize, type ResizeConfig, type ResizeState } from './useResize.svelte';
|
||||
|
|
|
|||
|
|
@ -1,238 +0,0 @@
|
|||
/**
|
||||
* Drag & Drop Composable for Calendar Events
|
||||
* Extracts drag logic from WeekView/DayView for reusability
|
||||
*/
|
||||
|
||||
import type { CalendarEvent } from '@calendar/shared';
|
||||
import { differenceInMinutes, addMinutes, setHours, setMinutes } from 'date-fns';
|
||||
import { toDate } from '$lib/utils/eventDateHelpers';
|
||||
import { eventsStore } from '$lib/stores/events.svelte';
|
||||
|
||||
export interface DragDropConfig {
|
||||
/** Reference to the container element for position calculations */
|
||||
containerEl: HTMLElement | null;
|
||||
/** Array of visible days */
|
||||
days: Date[];
|
||||
/** First visible hour (for filtered hours mode) */
|
||||
firstVisibleHour: number;
|
||||
/** Last visible hour (for filtered hours mode) */
|
||||
lastVisibleHour: number;
|
||||
/** Height of one hour in pixels */
|
||||
hourHeight: number;
|
||||
/** Minutes per snap interval */
|
||||
snapMinutes?: number;
|
||||
}
|
||||
|
||||
export interface DragState {
|
||||
isDragging: boolean;
|
||||
draggedEvent: CalendarEvent | null;
|
||||
dragTargetDay: Date | null;
|
||||
dragPreviewTop: number;
|
||||
dragPreviewHeight: number;
|
||||
hasMoved: boolean;
|
||||
}
|
||||
|
||||
export function useDragDrop(getConfig: () => DragDropConfig) {
|
||||
// State
|
||||
let isDragging = $state(false);
|
||||
let draggedEvent = $state<CalendarEvent | null>(null);
|
||||
let dragOffsetMinutes = $state(0);
|
||||
let dragTargetDay = $state<Date | null>(null);
|
||||
let dragPreviewTop = $state(0);
|
||||
let dragPreviewHeight = $state(0);
|
||||
let hasMoved = $state(false);
|
||||
|
||||
// Derived values
|
||||
const totalVisibleHours = $derived(() => {
|
||||
const config = getConfig();
|
||||
return config.lastVisibleHour - config.firstVisibleHour;
|
||||
});
|
||||
|
||||
/**
|
||||
* Convert minutes to percentage position (accounting for hidden hours)
|
||||
*/
|
||||
function minutesToPercent(minutes: number): number {
|
||||
const config = getConfig();
|
||||
const adjustedMinutes = minutes - config.firstVisibleHour * 60;
|
||||
return (adjustedMinutes / (totalVisibleHours() * 60)) * 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get day from X coordinate
|
||||
*/
|
||||
function getDayFromX(clientX: number): Date | null {
|
||||
const config = getConfig();
|
||||
if (!config.containerEl) return null;
|
||||
|
||||
const rect = config.containerEl.getBoundingClientRect();
|
||||
const relativeX = clientX - rect.left;
|
||||
const dayWidth = rect.width / config.days.length;
|
||||
const dayIndex = Math.floor(relativeX / dayWidth);
|
||||
|
||||
if (dayIndex >= 0 && dayIndex < config.days.length) {
|
||||
return config.days[dayIndex];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get minutes from Y coordinate
|
||||
*/
|
||||
function getMinutesFromY(clientY: number): number {
|
||||
const config = getConfig();
|
||||
if (!config.containerEl) return 0;
|
||||
|
||||
const rect = config.containerEl.getBoundingClientRect();
|
||||
const scrollTop = config.containerEl.parentElement?.scrollTop || 0;
|
||||
const relativeY = clientY - rect.top + scrollTop;
|
||||
|
||||
// Account for hidden early hours
|
||||
const visibleMinutes =
|
||||
(relativeY / (totalVisibleHours() * config.hourHeight)) * totalVisibleHours() * 60;
|
||||
const totalMinutes = visibleMinutes + config.firstVisibleHour * 60;
|
||||
|
||||
// Snap to interval
|
||||
const snapMinutes = config.snapMinutes ?? 15;
|
||||
return Math.round(totalMinutes / snapMinutes) * snapMinutes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start dragging an event
|
||||
*/
|
||||
function startDrag(event: CalendarEvent, e: PointerEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const config = getConfig();
|
||||
isDragging = true;
|
||||
draggedEvent = event;
|
||||
hasMoved = false;
|
||||
|
||||
const start = toDate(event.startTime);
|
||||
const end = toDate(event.endTime);
|
||||
const duration = differenceInMinutes(end, start);
|
||||
|
||||
// Calculate initial preview position
|
||||
const startMinutes = start.getHours() * 60 + start.getMinutes();
|
||||
dragPreviewTop = minutesToPercent(startMinutes);
|
||||
dragPreviewHeight = (duration / (totalVisibleHours() * 60)) * 100;
|
||||
dragTargetDay = start;
|
||||
|
||||
// Calculate offset from event start to click position
|
||||
const clickMinutes = getMinutesFromY(e.clientY);
|
||||
dragOffsetMinutes = clickMinutes - startMinutes;
|
||||
|
||||
document.addEventListener('pointermove', handleDragMove);
|
||||
document.addEventListener('pointerup', handleDragEnd);
|
||||
}
|
||||
|
||||
function handleDragMove(e: PointerEvent) {
|
||||
if (!isDragging || !draggedEvent) return;
|
||||
|
||||
const config = getConfig();
|
||||
hasMoved = true;
|
||||
|
||||
// Calculate new position
|
||||
const newDay = getDayFromX(e.clientX);
|
||||
const newMinutes = getMinutesFromY(e.clientY) - dragOffsetMinutes;
|
||||
|
||||
// Clamp to valid range
|
||||
const clampedMinutes = Math.max(
|
||||
config.firstVisibleHour * 60,
|
||||
Math.min(config.lastVisibleHour * 60 - 15, newMinutes)
|
||||
);
|
||||
|
||||
// Update preview
|
||||
dragPreviewTop = minutesToPercent(clampedMinutes);
|
||||
if (newDay) {
|
||||
dragTargetDay = newDay;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDragEnd(e: PointerEvent) {
|
||||
document.removeEventListener('pointermove', handleDragMove);
|
||||
document.removeEventListener('pointerup', handleDragEnd);
|
||||
|
||||
if (!isDragging || !draggedEvent || !dragTargetDay || !hasMoved) {
|
||||
cleanup();
|
||||
return;
|
||||
}
|
||||
|
||||
const config = getConfig();
|
||||
const start = toDate(draggedEvent.startTime);
|
||||
const end = toDate(draggedEvent.endTime);
|
||||
const duration = differenceInMinutes(end, start);
|
||||
|
||||
// Calculate new start time
|
||||
const newMinutes = getMinutesFromY(e.clientY) - dragOffsetMinutes;
|
||||
const clampedMinutes = Math.max(0, Math.min(24 * 60 - 15, newMinutes));
|
||||
const newHours = Math.floor(clampedMinutes / 60);
|
||||
const newMins = clampedMinutes % 60;
|
||||
|
||||
let newStart = new Date(dragTargetDay);
|
||||
newStart = setHours(newStart, newHours);
|
||||
newStart = setMinutes(newStart, newMins);
|
||||
|
||||
const newEnd = addMinutes(newStart, duration);
|
||||
|
||||
// Update event via store
|
||||
if (eventsStore.isDraftEvent(draggedEvent.id)) {
|
||||
eventsStore.updateDraftEvent({
|
||||
startTime: newStart.toISOString(),
|
||||
endTime: newEnd.toISOString(),
|
||||
});
|
||||
} else {
|
||||
await eventsStore.updateEvent(draggedEvent.id, {
|
||||
startTime: newStart.toISOString(),
|
||||
endTime: newEnd.toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
cleanup();
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
isDragging = false;
|
||||
draggedEvent = null;
|
||||
dragTargetDay = null;
|
||||
hasMoved = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel drag operation (e.g., on Escape key)
|
||||
*/
|
||||
function cancelDrag() {
|
||||
if (isDragging) {
|
||||
document.removeEventListener('pointermove', handleDragMove);
|
||||
document.removeEventListener('pointerup', handleDragEnd);
|
||||
cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// State (reactive getters)
|
||||
get isDragging() {
|
||||
return isDragging;
|
||||
},
|
||||
get draggedEvent() {
|
||||
return draggedEvent;
|
||||
},
|
||||
get dragTargetDay() {
|
||||
return dragTargetDay;
|
||||
},
|
||||
get dragPreviewTop() {
|
||||
return dragPreviewTop;
|
||||
},
|
||||
get dragPreviewHeight() {
|
||||
return dragPreviewHeight;
|
||||
},
|
||||
get hasMoved() {
|
||||
return hasMoved;
|
||||
},
|
||||
|
||||
// Methods
|
||||
startDrag,
|
||||
cancelDrag,
|
||||
minutesToPercent,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,236 +0,0 @@
|
|||
/**
|
||||
* Resize Composable for Calendar Events
|
||||
* Extracts resize logic from WeekView/DayView for reusability
|
||||
*/
|
||||
|
||||
import type { CalendarEvent } from '@calendar/shared';
|
||||
import { differenceInMinutes, setHours, setMinutes } from 'date-fns';
|
||||
import { toDate } from '$lib/utils/eventDateHelpers';
|
||||
import { eventsStore } from '$lib/stores/events.svelte';
|
||||
|
||||
export interface ResizeConfig {
|
||||
/** Reference to the container element for position calculations */
|
||||
containerEl: HTMLElement | null;
|
||||
/** First visible hour (for filtered hours mode) */
|
||||
firstVisibleHour: number;
|
||||
/** Last visible hour (for filtered hours mode) */
|
||||
lastVisibleHour: number;
|
||||
/** Height of one hour in pixels */
|
||||
hourHeight: number;
|
||||
/** Minutes per snap interval */
|
||||
snapMinutes?: number;
|
||||
}
|
||||
|
||||
export interface ResizeState {
|
||||
isResizing: boolean;
|
||||
resizeEvent: CalendarEvent | null;
|
||||
resizeEdge: 'top' | 'bottom';
|
||||
resizePreviewTop: number;
|
||||
resizePreviewHeight: number;
|
||||
hasMoved: boolean;
|
||||
}
|
||||
|
||||
export function useResize(getConfig: () => ResizeConfig) {
|
||||
// State
|
||||
let isResizing = $state(false);
|
||||
let resizeEvent = $state<CalendarEvent | null>(null);
|
||||
let resizeEdge = $state<'top' | 'bottom'>('bottom');
|
||||
let resizeOriginalStart = $state<Date | null>(null);
|
||||
let resizeOriginalEnd = $state<Date | null>(null);
|
||||
let resizePreviewTop = $state(0);
|
||||
let resizePreviewHeight = $state(0);
|
||||
let hasMoved = $state(false);
|
||||
|
||||
// Derived values
|
||||
const totalVisibleHours = $derived(() => {
|
||||
const config = getConfig();
|
||||
return config.lastVisibleHour - config.firstVisibleHour;
|
||||
});
|
||||
|
||||
/**
|
||||
* Convert minutes to percentage position
|
||||
*/
|
||||
function minutesToPercent(minutes: number): number {
|
||||
const config = getConfig();
|
||||
const adjustedMinutes = minutes - config.firstVisibleHour * 60;
|
||||
return (adjustedMinutes / (totalVisibleHours() * 60)) * 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get minutes from Y coordinate
|
||||
*/
|
||||
function getMinutesFromY(clientY: number): number {
|
||||
const config = getConfig();
|
||||
if (!config.containerEl) return 0;
|
||||
|
||||
const rect = config.containerEl.getBoundingClientRect();
|
||||
const scrollTop = config.containerEl.parentElement?.scrollTop || 0;
|
||||
const relativeY = clientY - rect.top + scrollTop;
|
||||
|
||||
const visibleMinutes =
|
||||
(relativeY / (totalVisibleHours() * config.hourHeight)) * totalVisibleHours() * 60;
|
||||
const totalMinutes = visibleMinutes + config.firstVisibleHour * 60;
|
||||
|
||||
const snapMinutes = config.snapMinutes ?? 15;
|
||||
return Math.round(totalMinutes / snapMinutes) * snapMinutes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start resizing an event
|
||||
*/
|
||||
function startResize(event: CalendarEvent, edge: 'top' | 'bottom', e: PointerEvent) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
isResizing = true;
|
||||
resizeEvent = event;
|
||||
resizeEdge = edge;
|
||||
hasMoved = false;
|
||||
|
||||
const start = toDate(event.startTime);
|
||||
const end = toDate(event.endTime);
|
||||
|
||||
resizeOriginalStart = start;
|
||||
resizeOriginalEnd = end;
|
||||
|
||||
// Set initial preview
|
||||
const startMinutes = start.getHours() * 60 + start.getMinutes();
|
||||
const duration = differenceInMinutes(end, start);
|
||||
resizePreviewTop = minutesToPercent(startMinutes);
|
||||
resizePreviewHeight = (duration / (totalVisibleHours() * 60)) * 100;
|
||||
|
||||
document.addEventListener('pointermove', handleResizeMove);
|
||||
document.addEventListener('pointerup', handleResizeEnd);
|
||||
}
|
||||
|
||||
function handleResizeMove(e: PointerEvent) {
|
||||
if (!isResizing || !resizeEvent || !resizeOriginalStart || !resizeOriginalEnd) return;
|
||||
|
||||
const config = getConfig();
|
||||
hasMoved = true;
|
||||
|
||||
const currentMinutes = getMinutesFromY(e.clientY);
|
||||
const originalStartMinutes =
|
||||
resizeOriginalStart.getHours() * 60 + resizeOriginalStart.getMinutes();
|
||||
const originalEndMinutes = resizeOriginalEnd.getHours() * 60 + resizeOriginalEnd.getMinutes();
|
||||
|
||||
if (resizeEdge === 'bottom') {
|
||||
// Resize from bottom - change end time
|
||||
const newEndMinutes = Math.max(
|
||||
originalStartMinutes + 15,
|
||||
Math.min(config.lastVisibleHour * 60, currentMinutes)
|
||||
);
|
||||
const newDuration = newEndMinutes - originalStartMinutes;
|
||||
resizePreviewHeight = (newDuration / (totalVisibleHours() * 60)) * 100;
|
||||
} else {
|
||||
// Resize from top - change start time
|
||||
const newStartMinutes = Math.max(
|
||||
config.firstVisibleHour * 60,
|
||||
Math.min(originalEndMinutes - 15, currentMinutes)
|
||||
);
|
||||
const newDuration = originalEndMinutes - newStartMinutes;
|
||||
resizePreviewTop = minutesToPercent(newStartMinutes);
|
||||
resizePreviewHeight = (newDuration / (totalVisibleHours() * 60)) * 100;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResizeEnd(e: PointerEvent) {
|
||||
document.removeEventListener('pointermove', handleResizeMove);
|
||||
document.removeEventListener('pointerup', handleResizeEnd);
|
||||
|
||||
if (!isResizing || !resizeEvent || !resizeOriginalStart || !resizeOriginalEnd || !hasMoved) {
|
||||
cleanup();
|
||||
return;
|
||||
}
|
||||
|
||||
const config = getConfig();
|
||||
const currentMinutes = getMinutesFromY(e.clientY);
|
||||
const originalStartMinutes =
|
||||
resizeOriginalStart.getHours() * 60 + resizeOriginalStart.getMinutes();
|
||||
const originalEndMinutes = resizeOriginalEnd.getHours() * 60 + resizeOriginalEnd.getMinutes();
|
||||
|
||||
let newStart = resizeOriginalStart;
|
||||
let newEnd = resizeOriginalEnd;
|
||||
|
||||
if (resizeEdge === 'bottom') {
|
||||
const newEndMinutes = Math.max(
|
||||
originalStartMinutes + 15,
|
||||
Math.min(config.lastVisibleHour * 60, currentMinutes)
|
||||
);
|
||||
const newHours = Math.floor(newEndMinutes / 60);
|
||||
const newMins = newEndMinutes % 60;
|
||||
newEnd = setHours(new Date(resizeOriginalEnd), newHours);
|
||||
newEnd = setMinutes(newEnd, newMins);
|
||||
} else {
|
||||
const newStartMinutes = Math.max(
|
||||
config.firstVisibleHour * 60,
|
||||
Math.min(originalEndMinutes - 15, currentMinutes)
|
||||
);
|
||||
const newHours = Math.floor(newStartMinutes / 60);
|
||||
const newMins = newStartMinutes % 60;
|
||||
newStart = setHours(new Date(resizeOriginalStart), newHours);
|
||||
newStart = setMinutes(newStart, newMins);
|
||||
}
|
||||
|
||||
// Update event via store
|
||||
if (eventsStore.isDraftEvent(resizeEvent.id)) {
|
||||
eventsStore.updateDraftEvent({
|
||||
startTime: newStart.toISOString(),
|
||||
endTime: newEnd.toISOString(),
|
||||
});
|
||||
} else {
|
||||
await eventsStore.updateEvent(resizeEvent.id, {
|
||||
startTime: newStart.toISOString(),
|
||||
endTime: newEnd.toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
cleanup();
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
isResizing = false;
|
||||
resizeEvent = null;
|
||||
resizeOriginalStart = null;
|
||||
resizeOriginalEnd = null;
|
||||
hasMoved = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel resize operation
|
||||
*/
|
||||
function cancelResize() {
|
||||
if (isResizing) {
|
||||
document.removeEventListener('pointermove', handleResizeMove);
|
||||
document.removeEventListener('pointerup', handleResizeEnd);
|
||||
cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// State (reactive getters)
|
||||
get isResizing() {
|
||||
return isResizing;
|
||||
},
|
||||
get resizeEvent() {
|
||||
return resizeEvent;
|
||||
},
|
||||
get resizeEdge() {
|
||||
return resizeEdge;
|
||||
},
|
||||
get resizePreviewTop() {
|
||||
return resizePreviewTop;
|
||||
},
|
||||
get resizePreviewHeight() {
|
||||
return resizePreviewHeight;
|
||||
},
|
||||
get hasMoved() {
|
||||
return hasMoved;
|
||||
},
|
||||
|
||||
// Methods
|
||||
startResize,
|
||||
cancelResize,
|
||||
minutesToPercent,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,151 +0,0 @@
|
|||
/**
|
||||
* Event Tag Groups Store - Manages tag groups using Svelte 5 runes
|
||||
*/
|
||||
|
||||
import type { EventTagGroup } from '@calendar/shared';
|
||||
import * as api from '$lib/api/event-tag-groups';
|
||||
|
||||
// State
|
||||
let groups = $state<EventTagGroup[]>([]);
|
||||
let ungroupedTagCount = $state(0);
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
// Helper to safely get groups array (Svelte 5 runes safety)
|
||||
function getGroupsArray(): EventTagGroup[] {
|
||||
const arr = groups ?? [];
|
||||
return Array.isArray(arr) ? arr : [];
|
||||
}
|
||||
|
||||
export const eventTagGroupsStore = {
|
||||
// Getters
|
||||
get groups() {
|
||||
return groups;
|
||||
},
|
||||
get ungroupedTagCount() {
|
||||
return ungroupedTagCount;
|
||||
},
|
||||
get loading() {
|
||||
return loading;
|
||||
},
|
||||
get error() {
|
||||
return error;
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch all groups
|
||||
*/
|
||||
async fetchGroups() {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
const result = await api.getEventTagGroups();
|
||||
|
||||
if (result.error) {
|
||||
error = result.error.message;
|
||||
groups = [];
|
||||
ungroupedTagCount = 0;
|
||||
} else {
|
||||
groups = result.data || [];
|
||||
ungroupedTagCount = result.ungroupedTagCount;
|
||||
}
|
||||
|
||||
loading = false;
|
||||
return result;
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a new group
|
||||
*/
|
||||
async createGroup(data: api.CreateEventTagGroupInput) {
|
||||
const result = await api.createEventTagGroup(data);
|
||||
|
||||
if (result.data) {
|
||||
groups = [...groups, result.data];
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
|
||||
/**
|
||||
* Update a group
|
||||
*/
|
||||
async updateGroup(id: string, data: api.UpdateEventTagGroupInput) {
|
||||
const result = await api.updateEventTagGroup(id, data);
|
||||
|
||||
if (result.data) {
|
||||
groups = getGroupsArray().map((g) => (g.id === id ? result.data! : g));
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a group
|
||||
*/
|
||||
async deleteGroup(id: string) {
|
||||
const result = await api.deleteEventTagGroup(id);
|
||||
|
||||
if (!result.error) {
|
||||
groups = getGroupsArray().filter((g) => g.id !== id);
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get group by ID
|
||||
*/
|
||||
getById(id: string) {
|
||||
return getGroupsArray().find((g) => g.id === id);
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear store
|
||||
*/
|
||||
clear() {
|
||||
groups = [];
|
||||
ungroupedTagCount = 0;
|
||||
error = null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Update tag count for a group (after tag assignment changes)
|
||||
*/
|
||||
updateTagCount(groupId: string | null, delta: number) {
|
||||
if (groupId === null) {
|
||||
ungroupedTagCount = Math.max(0, ungroupedTagCount + delta);
|
||||
} else {
|
||||
groups = getGroupsArray().map((g) => {
|
||||
if (g.id === groupId) {
|
||||
return { ...g, tagCount: Math.max(0, (g.tagCount ?? 0) + delta) };
|
||||
}
|
||||
return g;
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Reorder groups by providing new array order
|
||||
*/
|
||||
async reorderGroups(groupIds: string[]) {
|
||||
// Optimistic update
|
||||
const oldGroups = [...groups];
|
||||
groups = groupIds.map((id, i) => {
|
||||
const g = getGroupsArray().find((g) => g.id === id)!;
|
||||
return { ...g, sortOrder: i };
|
||||
});
|
||||
|
||||
const result = await api.reorderEventTagGroups(groupIds);
|
||||
|
||||
if (result.error) {
|
||||
// Rollback on error
|
||||
groups = oldGroups;
|
||||
} else if (result.data) {
|
||||
groups = result.data;
|
||||
ungroupedTagCount = result.ungroupedTagCount;
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
};
|
||||
|
|
@ -1,7 +1,5 @@
|
|||
/**
|
||||
* Event Tags Store - Manages event tags using Svelte 5 runes
|
||||
*
|
||||
* Uses the Calendar Backend API which supports tag groups (groupId).
|
||||
*/
|
||||
|
||||
import type { EventTag } from '@calendar/shared';
|
||||
|
|
@ -110,27 +108,4 @@ export const eventTagsStore = {
|
|||
tags = [];
|
||||
error = null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get tags grouped by groupId
|
||||
* Returns a Map where keys are groupId (or null for ungrouped)
|
||||
*/
|
||||
getGroupedTags(): Map<string | null, EventTag[]> {
|
||||
const grouped = new Map<string | null, EventTag[]>();
|
||||
|
||||
for (const tag of getTagsArray()) {
|
||||
const groupId = tag.groupId ?? null;
|
||||
const existing = grouped.get(groupId) ?? [];
|
||||
grouped.set(groupId, [...existing, tag]);
|
||||
}
|
||||
|
||||
return grouped;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get tags by group ID (null for ungrouped)
|
||||
*/
|
||||
getTagsByGroup(groupId: string | null): EventTag[] {
|
||||
return getTagsArray().filter((t) => (t.groupId ?? null) === groupId);
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,50 +1,37 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { Modal, Input, TagColorPicker, TagBadge } from '@manacore/shared-ui';
|
||||
import { MagnifyingGlass, Plus, CaretLeft, FolderSimple } from '@manacore/shared-icons';
|
||||
import { MagnifyingGlass, Plus, CaretLeft } from '@manacore/shared-icons';
|
||||
import { eventTagsStore } from '$lib/stores/event-tags.svelte';
|
||||
import { eventTagGroupsStore } from '$lib/stores/event-tag-groups.svelte';
|
||||
import { GroupedTagList } from '$lib/components/tags';
|
||||
import type { EventTag } from '@calendar/shared';
|
||||
|
||||
let searchQuery = $state('');
|
||||
let showTagModal = $state(false);
|
||||
let editingTag = $state<EventTag | null>(null);
|
||||
let selectedGroupId = $state<string | null>(null);
|
||||
|
||||
// Filtered tags based on search
|
||||
// Filtered tags based on search, sorted alphabetically
|
||||
const filteredTags = $derived.by(() => {
|
||||
if (!searchQuery.trim()) return eventTagsStore.tags;
|
||||
const sorted = [...eventTagsStore.tags].sort((a, b) => a.name.localeCompare(b.name, 'de'));
|
||||
if (!searchQuery.trim()) return sorted;
|
||||
const query = searchQuery.toLowerCase();
|
||||
return eventTagsStore.tags.filter((t) => t.name.toLowerCase().includes(query));
|
||||
return sorted.filter((t) => t.name.toLowerCase().includes(query));
|
||||
});
|
||||
|
||||
// Filter groups that have matching tags (for search)
|
||||
const filteredGroups = $derived.by(() => {
|
||||
if (!searchQuery.trim()) return eventTagGroupsStore.groups;
|
||||
// Only show groups that have at least one matching tag
|
||||
const tagGroupIds = new Set(filteredTags.map((t) => t.groupId).filter(Boolean));
|
||||
return eventTagGroupsStore.groups.filter((g) => tagGroupIds.has(g.id));
|
||||
});
|
||||
|
||||
// Form state for custom tag edit modal with group selection
|
||||
// Form state
|
||||
let tagName = $state('');
|
||||
let tagColor = $state('#3B82F6');
|
||||
|
||||
const DEFAULT_COLOR = '#3B82F6';
|
||||
|
||||
// Reset form when editing tag changes
|
||||
$effect(() => {
|
||||
if (showTagModal) {
|
||||
tagName = editingTag?.name ?? '';
|
||||
tagColor = editingTag?.color ?? DEFAULT_COLOR;
|
||||
selectedGroupId = editingTag?.groupId ?? null;
|
||||
}
|
||||
});
|
||||
|
||||
function openCreateTagModal() {
|
||||
editingTag = null;
|
||||
selectedGroupId = null;
|
||||
showTagModal = true;
|
||||
}
|
||||
|
||||
|
|
@ -66,18 +53,14 @@
|
|||
await eventTagsStore.updateTag(editingTag.id, {
|
||||
name: tagName.trim(),
|
||||
color: tagColor,
|
||||
groupId: selectedGroupId,
|
||||
});
|
||||
} else {
|
||||
await eventTagsStore.createTag({
|
||||
name: tagName.trim(),
|
||||
color: tagColor,
|
||||
groupId: selectedGroupId ?? undefined,
|
||||
});
|
||||
}
|
||||
closeTagModal();
|
||||
// Refresh groups to update tag counts
|
||||
eventTagGroupsStore.fetchGroups();
|
||||
} catch (e) {
|
||||
console.error('Failed to save tag:', e);
|
||||
}
|
||||
|
|
@ -91,8 +74,6 @@
|
|||
try {
|
||||
await eventTagsStore.deleteTag(editingTag.id);
|
||||
closeTagModal();
|
||||
// Refresh groups to update tag counts
|
||||
eventTagGroupsStore.fetchGroups();
|
||||
} catch (e) {
|
||||
console.error('Failed to delete tag:', e);
|
||||
}
|
||||
|
|
@ -106,16 +87,11 @@
|
|||
}
|
||||
|
||||
const previewTag = $derived({ name: tagName || 'Tag Name', color: tagColor });
|
||||
const isLoading = $derived(eventTagsStore.loading || eventTagGroupsStore.loading);
|
||||
|
||||
onMount(async () => {
|
||||
// Fetch both tags and groups in parallel
|
||||
await Promise.all([
|
||||
eventTagsStore.tags.length === 0 ? eventTagsStore.fetchTags() : Promise.resolve(),
|
||||
eventTagGroupsStore.groups.length === 0
|
||||
? eventTagGroupsStore.fetchGroups()
|
||||
: Promise.resolve(),
|
||||
]);
|
||||
if (eventTagsStore.tags.length === 0) {
|
||||
await eventTagsStore.fetchTags();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
|
|
@ -130,9 +106,6 @@
|
|||
<CaretLeft size={20} weight="bold" />
|
||||
</a>
|
||||
<h1 class="title">Tags</h1>
|
||||
<a href="/tags/groups" class="groups-button" aria-label="Gruppen verwalten">
|
||||
<FolderSimple size={20} weight="bold" />
|
||||
</a>
|
||||
<button onclick={openCreateTagModal} class="add-button" aria-label="Neues Tag">
|
||||
<Plus size={20} weight="bold" />
|
||||
</button>
|
||||
|
|
@ -149,29 +122,42 @@
|
|||
/>
|
||||
</div>
|
||||
|
||||
{#if eventTagsStore.error || eventTagGroupsStore.error}
|
||||
{#if eventTagsStore.error}
|
||||
<div class="error-banner" role="alert">
|
||||
<span>{eventTagsStore.error || eventTagGroupsStore.error}</span>
|
||||
<span>{eventTagsStore.error}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Grouped Tag List -->
|
||||
<GroupedTagList
|
||||
groups={filteredGroups}
|
||||
tags={filteredTags}
|
||||
loading={isLoading}
|
||||
onEditTag={openEditTagModal}
|
||||
emptyMessage={searchQuery ? 'Keine Tags gefunden' : 'Keine Tags vorhanden'}
|
||||
/>
|
||||
<!-- Tag List -->
|
||||
{#if eventTagsStore.loading}
|
||||
<div class="flex justify-center py-8">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
{:else if filteredTags.length === 0}
|
||||
<div class="text-center py-12">
|
||||
<div class="text-muted-foreground mb-2">
|
||||
{searchQuery ? 'Keine Tags gefunden' : 'Keine Tags vorhanden'}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="tags-list">
|
||||
{#each filteredTags as tag (tag.id)}
|
||||
<button type="button" class="tag-item" onclick={() => openEditTagModal(tag)}>
|
||||
<div class="tag-color" style="background-color: {tag.color}"></div>
|
||||
<span class="tag-label">{tag.name}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !isLoading && eventTagsStore.tags.length > 0}
|
||||
{#if !eventTagsStore.loading && eventTagsStore.tags.length > 0}
|
||||
<p class="tags-count">
|
||||
{eventTagsStore.tags.length}
|
||||
{eventTagsStore.tags.length === 1 ? 'Tag' : 'Tags'}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if !isLoading && eventTagsStore.tags.length === 0 && !searchQuery}
|
||||
{#if !eventTagsStore.loading && eventTagsStore.tags.length === 0 && !searchQuery}
|
||||
<div class="empty-cta">
|
||||
<button onclick={openCreateTagModal} class="btn btn-primary">
|
||||
<Plus size={16} weight="bold" />
|
||||
|
|
@ -181,7 +167,7 @@
|
|||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Custom Tag Edit Modal with Group Selection -->
|
||||
<!-- Tag Edit Modal -->
|
||||
<Modal
|
||||
visible={showTagModal}
|
||||
onClose={closeTagModal}
|
||||
|
|
@ -189,31 +175,15 @@
|
|||
maxWidth="sm"
|
||||
>
|
||||
<div class="space-y-6">
|
||||
<!-- Name Input -->
|
||||
<div>
|
||||
<Input bind:value={tagName} placeholder="Tag Name" onkeydown={handleKeyDown} />
|
||||
</div>
|
||||
|
||||
<!-- Group Selection -->
|
||||
<div>
|
||||
<span class="block text-sm font-medium text-muted-foreground mb-3"> Gruppe </span>
|
||||
<select bind:value={selectedGroupId} class="group-select">
|
||||
<option value={null}>Keine Gruppe</option>
|
||||
{#each eventTagGroupsStore.groups as group (group.id)}
|
||||
<option value={group.id}>
|
||||
{group.name}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Color Picker -->
|
||||
<div>
|
||||
<span class="block text-sm font-medium text-muted-foreground mb-3"> Farbe </span>
|
||||
<TagColorPicker selectedColor={tagColor} onColorChange={(c) => (tagColor = c)} />
|
||||
</div>
|
||||
|
||||
<!-- Preview -->
|
||||
<div>
|
||||
<span class="block text-sm font-medium text-muted-foreground mb-3"> Vorschau </span>
|
||||
<div class="flex items-center gap-2">
|
||||
|
|
@ -263,7 +233,6 @@
|
|||
padding: 0 1rem 2rem;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -296,22 +265,6 @@
|
|||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.groups-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border-radius: 50%;
|
||||
background: hsl(var(--muted));
|
||||
color: hsl(var(--foreground));
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.groups-button:hover {
|
||||
background: hsl(var(--muted-foreground) / 0.2);
|
||||
}
|
||||
|
||||
.add-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -331,7 +284,6 @@
|
|||
box-shadow: 0 4px 12px hsl(var(--primary) / 0.3);
|
||||
}
|
||||
|
||||
/* Search */
|
||||
.search-wrapper {
|
||||
position: relative;
|
||||
margin-bottom: 1.5rem;
|
||||
|
|
@ -363,26 +315,6 @@
|
|||
box-shadow: 0 0 0 3px hsl(var(--primary) / 0.1);
|
||||
}
|
||||
|
||||
/* Group Select */
|
||||
.group-select {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1.5px solid hsl(var(--border));
|
||||
border-radius: 0.75rem;
|
||||
background: hsl(var(--background));
|
||||
color: hsl(var(--foreground));
|
||||
font-size: 0.9375rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.group-select:focus {
|
||||
outline: none;
|
||||
border-color: hsl(var(--primary));
|
||||
box-shadow: 0 0 0 3px hsl(var(--primary) / 0.1);
|
||||
}
|
||||
|
||||
/* Error */
|
||||
.error-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -395,7 +327,42 @@
|
|||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
/* Count */
|
||||
.tags-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.tag-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: hsl(var(--card));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 9999px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.tag-item:hover {
|
||||
background: hsl(var(--muted) / 0.5);
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.tag-color {
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tag-label {
|
||||
font-weight: 500;
|
||||
color: hsl(var(--foreground));
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.tags-count {
|
||||
text-align: center;
|
||||
font-size: 0.875rem;
|
||||
|
|
@ -403,14 +370,12 @@
|
|||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
/* Empty CTA */
|
||||
.empty-cta {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
|
|
|||
|
|
@ -1,375 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { CaretLeft, Plus, Pencil, Trash } from '@manacore/shared-icons';
|
||||
import { eventTagGroupsStore } from '$lib/stores/event-tag-groups.svelte';
|
||||
import { TagGroupEditModal } from '$lib/components/tags';
|
||||
import type { EventTagGroup } from '@calendar/shared';
|
||||
|
||||
let showModal = $state(false);
|
||||
let editingGroup = $state<EventTagGroup | null>(null);
|
||||
|
||||
function openCreateModal() {
|
||||
editingGroup = null;
|
||||
showModal = true;
|
||||
}
|
||||
|
||||
function openEditModal(group: EventTagGroup) {
|
||||
editingGroup = group;
|
||||
showModal = true;
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
showModal = false;
|
||||
editingGroup = null;
|
||||
}
|
||||
|
||||
async function handleSave(name: string, color: string) {
|
||||
try {
|
||||
if (editingGroup) {
|
||||
await eventTagGroupsStore.updateGroup(editingGroup.id, { name, color });
|
||||
} else {
|
||||
await eventTagGroupsStore.createGroup({ name, color });
|
||||
}
|
||||
closeModal();
|
||||
} catch (e) {
|
||||
console.error('Failed to save group:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!editingGroup) return;
|
||||
|
||||
try {
|
||||
await eventTagGroupsStore.deleteGroup(editingGroup.id);
|
||||
closeModal();
|
||||
} catch (e) {
|
||||
console.error('Failed to delete group:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteFromList(group: EventTagGroup) {
|
||||
if (
|
||||
!confirm(
|
||||
`Gruppe "${group.name}" wirklich löschen? Tags in dieser Gruppe werden nicht gelöscht.`
|
||||
)
|
||||
)
|
||||
return;
|
||||
|
||||
try {
|
||||
await eventTagGroupsStore.deleteGroup(group.id);
|
||||
} catch (e) {
|
||||
console.error('Failed to delete group:', e);
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (eventTagGroupsStore.groups.length === 0) {
|
||||
eventTagGroupsStore.fetchGroups();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Tag-Gruppen - Kalender</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="page-container">
|
||||
<!-- Header -->
|
||||
<header class="header">
|
||||
<a href="/tags" class="back-button" aria-label="Zurück zu Tags">
|
||||
<CaretLeft size={20} weight="bold" />
|
||||
</a>
|
||||
<h1 class="title">Tag-Gruppen</h1>
|
||||
<button onclick={openCreateModal} class="add-button" aria-label="Neue Gruppe">
|
||||
<Plus size={20} weight="bold" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{#if eventTagGroupsStore.error}
|
||||
<div class="error-banner" role="alert">
|
||||
<span>{eventTagGroupsStore.error}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if eventTagGroupsStore.loading}
|
||||
<div class="flex justify-center py-8">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
{:else if eventTagGroupsStore.groups.length === 0}
|
||||
<div class="empty-state">
|
||||
<p class="text-muted-foreground mb-4">Noch keine Gruppen vorhanden</p>
|
||||
<button onclick={openCreateModal} class="btn btn-primary">
|
||||
<Plus size={16} weight="bold" />
|
||||
Neue Gruppe erstellen
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="groups-list">
|
||||
{#each eventTagGroupsStore.groups as group (group.id)}
|
||||
<div class="group-item">
|
||||
<div class="group-info">
|
||||
<div class="group-color" style="background-color: {group.color}"></div>
|
||||
<div class="group-details">
|
||||
<span class="group-name">{group.name}</span>
|
||||
<span class="group-tag-count">
|
||||
{group.tagCount ?? 0}
|
||||
{(group.tagCount ?? 0) === 1 ? 'Tag' : 'Tags'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="group-actions">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => openEditModal(group)}
|
||||
class="action-btn"
|
||||
aria-label="Gruppe bearbeiten"
|
||||
>
|
||||
<Pencil size={16} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => handleDeleteFromList(group)}
|
||||
class="action-btn action-btn-delete"
|
||||
aria-label="Gruppe löschen"
|
||||
>
|
||||
<Trash size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<p class="groups-count">
|
||||
{eventTagGroupsStore.groups.length}
|
||||
{eventTagGroupsStore.groups.length === 1 ? 'Gruppe' : 'Gruppen'}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<!-- Info about ungrouped tags -->
|
||||
{#if eventTagGroupsStore.ungroupedTagCount > 0}
|
||||
<div class="ungrouped-info">
|
||||
<span>
|
||||
{eventTagGroupsStore.ungroupedTagCount}
|
||||
{eventTagGroupsStore.ungroupedTagCount === 1 ? 'Tag' : 'Tags'} ohne Gruppe
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Group Edit Modal -->
|
||||
<TagGroupEditModal
|
||||
group={editingGroup}
|
||||
isOpen={showModal}
|
||||
onClose={closeModal}
|
||||
onSave={handleSave}
|
||||
onDelete={editingGroup ? handleDelete : undefined}
|
||||
/>
|
||||
|
||||
<style>
|
||||
.page-container {
|
||||
max-width: 640px;
|
||||
margin: 0 auto;
|
||||
padding: 0 1rem 2rem;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem 0;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.back-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border-radius: 50%;
|
||||
background: hsl(var(--muted));
|
||||
color: hsl(var(--foreground));
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.back-button:hover {
|
||||
background: hsl(var(--muted-foreground) / 0.2);
|
||||
transform: translateX(-2px);
|
||||
}
|
||||
|
||||
.title {
|
||||
flex: 1;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.add-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border-radius: 50%;
|
||||
background: hsl(var(--primary));
|
||||
color: hsl(var(--primary-foreground));
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.add-button:hover {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 4px 12px hsl(var(--primary) / 0.3);
|
||||
}
|
||||
|
||||
/* Error */
|
||||
.error-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem;
|
||||
background: hsl(0 84% 60% / 0.1);
|
||||
border: 1px solid hsl(0 84% 60% / 0.3);
|
||||
border-radius: 0.75rem;
|
||||
color: hsl(0 84% 60%);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem 1rem;
|
||||
}
|
||||
|
||||
/* Groups List */
|
||||
.groups-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.group-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.875rem 1rem;
|
||||
background: hsl(var(--card));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 0.75rem;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.group-item:hover {
|
||||
background: hsl(var(--muted) / 0.3);
|
||||
}
|
||||
|
||||
.group-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.group-color {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.group-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
.group-name {
|
||||
font-weight: 500;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.group-tag-count {
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.group-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.group-item:hover .group-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.5rem;
|
||||
border-radius: 0.5rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
color: hsl(var(--primary));
|
||||
background: hsl(var(--primary) / 0.1);
|
||||
}
|
||||
|
||||
.action-btn-delete:hover {
|
||||
color: hsl(0 84% 60%);
|
||||
background: hsl(0 84% 60% / 0.1);
|
||||
}
|
||||
|
||||
/* Count */
|
||||
.groups-count {
|
||||
text-align: center;
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
/* Ungrouped Info */
|
||||
.ungrouped-info {
|
||||
text-align: center;
|
||||
font-size: 0.8125rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.5rem;
|
||||
background: hsl(var(--muted) / 0.3);
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.625rem 1.25rem;
|
||||
border-radius: 0.625rem;
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
border: none;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: hsl(var(--primary));
|
||||
color: hsl(var(--primary-foreground));
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
box-shadow: 0 4px 12px hsl(var(--primary) / 0.3);
|
||||
}
|
||||
</style>
|
||||
111
apps/calendar/docs/COMPLEXITY_AUDIT.md
Normal file
111
apps/calendar/docs/COMPLEXITY_AUDIT.md
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
# Calendar Web App — Complexity Audit
|
||||
|
||||
**Datum:** 2026-03-20
|
||||
|
||||
## Zusammenfassung
|
||||
|
||||
Analyse der Calendar Web App hinsichtlich unnötiger Komplexität und unterentwickelter Bereiche.
|
||||
Gesamtumfang: ~12.800 LOC, 17 Stores, 50+ Komponenten, 10 API-Module, 8 Composables.
|
||||
|
||||
---
|
||||
|
||||
## Teil 1: Unnötige Komplexität
|
||||
|
||||
### 1. Tag-Gruppen-Hierarchie ✅ Entfernt
|
||||
|
||||
**Problem:** Zwei separate Stores (`event-tags` + `event-tag-groups`), eigene API, eigene Route (`/tags/groups`), und ein 1.452-Zeilen-Modal (`TagStripModal.svelte`) mit Drag-Drop-Sortierung für Gruppen — für ein Feature, das kaum genutzt wird.
|
||||
|
||||
**Betroffene Dateien:**
|
||||
- `stores/event-tag-groups.svelte.ts` (151 LOC)
|
||||
- `api/event-tag-groups.ts` (84 LOC)
|
||||
- `routes/(app)/tags/groups/+page.svelte` (375 LOC)
|
||||
- `components/tags/TagGroupEditModal.svelte` (123 LOC)
|
||||
- `components/tags/GroupedTagList.svelte` (245 LOC)
|
||||
- `components/calendar/TagStripModal.svelte` — Gruppen-Logik (Drag-Drop, CRUD, Forms)
|
||||
- `components/calendar/TagStrip.svelte` — Gruppen-basierte Sortierung
|
||||
|
||||
**Lösung:** Tag-Gruppen-System komplett entfernt. Tags werden alphabetisch sortiert als flache Liste angezeigt. Die `groupId`-Referenz auf Tags bleibt im API/Shared-Typ erhalten (Backend-Kompatibilität), wird aber im Frontend ignoriert.
|
||||
|
||||
**Einsparung:** ~600+ LOC entfernt, 2 Stores → 1 Store, 1 Route weniger
|
||||
|
||||
---
|
||||
|
||||
### 2. Drag-Drop Legacy-Composables ✅ Entfernt
|
||||
|
||||
**Problem:** Vier separate Composables für ähnliche Funktionalität:
|
||||
- `useDragDrop.svelte.ts` (238 LOC) — Event-Drag, Subset von useEventDragDrop
|
||||
- `useResize.svelte.ts` (236 LOC) — Event-Resize, Subset von useEventDragDrop
|
||||
- `useEventDragDrop.svelte.ts` (427 LOC) — Konsolidierte Version (Drag + Resize)
|
||||
- `useTaskDragDrop.svelte.ts` (321 LOC) — Task-spezifisch
|
||||
|
||||
Die Legacy-Composables (`useDragDrop`, `useResize`) sind von keiner Komponente importiert — reiner Dead Code.
|
||||
|
||||
**Lösung:** Legacy-Composables `useDragDrop` und `useResize` gelöscht. Re-Exports aus `index.ts` entfernt. `useEventDragDrop` und `useTaskDragDrop` bleiben als die konsolidierten Versionen.
|
||||
|
||||
**Einsparung:** ~474 LOC Dead Code entfernt
|
||||
|
||||
---
|
||||
|
||||
### 3. WeekView-Monolith (1.600 LOC) — Offen
|
||||
|
||||
**Problem:** Vereint 12 State-Variablen für Drag, Resize, Create, Task-Drag, Sichtbarkeitsfilter in einer Komponente.
|
||||
|
||||
**Empfehlung:** Aufteilen in `WeekGrid`, `WeekAllDayRow`, `WeekTimeIndicator`, `WeekDragOverlay`.
|
||||
|
||||
---
|
||||
|
||||
### 4. DateStrip Overengineering (649 LOC) — Offen
|
||||
|
||||
**Problem:** Mondphasen, Event-Indikatoren, Kompakt/Expanded-Modi, Infinite-Scroll mit 60-Tage-Buffer, 15+ Settings.
|
||||
|
||||
**Empfehlung:** Mondphasen und Indikatoren als optionale Sub-Komponenten extrahieren.
|
||||
|
||||
---
|
||||
|
||||
### 5. UnifiedBar Komplexität (633 LOC) — Offen
|
||||
|
||||
**Problem:** 3 Modi mit Layer-System, duplizierte Renderings von DateStrip/TagStrip, eigener Store mit Cloud-Sync für lokalen UI-State.
|
||||
|
||||
**Empfehlung:** Vereinfachen, Duplikate entfernen, Cloud-Sync für UI-State überdenken.
|
||||
|
||||
---
|
||||
|
||||
### 6. ViewCarousel Gesture-Handling (~400 LOC) — Offen
|
||||
|
||||
**Problem:** Touch + Wheel + Keyboard + Button-Navigation mit Velocity-Berechnung und RAF-Animation, eng gekoppelt.
|
||||
|
||||
**Empfehlung:** Gesture-Handling als wiederverwendbares Composable extrahieren.
|
||||
|
||||
---
|
||||
|
||||
## Teil 2: Unterentwickelte Bereiche
|
||||
|
||||
### 1. Keine Kalender-Synchronisation (CalDAV/iCal) — Priorität: Hoch
|
||||
|
||||
Backend hat `external_calendars`-Tabelle und Sync-Endpunkte. Frontend hat null UI dafür.
|
||||
Für eine Kalender-App ist das das größte fehlende Feature.
|
||||
|
||||
### 2. Keine wiederkehrenden Termine (Recurring Events) — Priorität: Hoch
|
||||
|
||||
Backend-Schema unterstützt RFC 5545 RRULE. Kein UI oder Store-Logik dafür.
|
||||
Essentiell für eine nutzbare Kalender-App.
|
||||
|
||||
### 3. Erinnerungen / Notifications nur rudimentär — Priorität: Mittel
|
||||
|
||||
API-Client `reminders.ts` existiert, aber nur Basic CRUD. Keine Push-Notifications, keine E-Mail-Erinnerungen, kein UI zur Konfiguration pro Event.
|
||||
|
||||
### 4. Kalender-Sharing kaum implementiert — Priorität: Mittel
|
||||
|
||||
`shares.ts` API-Client existiert als Stub. Kein UI zum Teilen oder für Berechtigungsverwaltung.
|
||||
|
||||
### 5. Fehlertoleranz bei Cross-App-Integration — Priorität: Mittel
|
||||
|
||||
Calendar hängt von Contacts (Birthdays), Todo, und STT ab. Kein Error Boundary oder Offline-Fallback.
|
||||
|
||||
### 6. Suche sehr basic — Priorität: Niedrig
|
||||
|
||||
Nur Query + Event-ID-Matching für Highlighting. Keine Volltextsuche, keine Filter.
|
||||
|
||||
### 7. Mobile Experience — Priorität: Niedrig
|
||||
|
||||
Web-App ist responsive, aber nicht touch-optimiert. Keine dedizierte Mobile-App (Expo leer).
|
||||
Loading…
Add table
Add a link
Reference in a new issue