feat(todo,contacts): add TagStrip above PillNav, unified with Calendar design

Add horizontal glass-morphism tag filter strip to Todo and Contacts apps,
matching the existing Calendar TagStrip pattern. Includes TagStripModal for
inline tag search/create/edit/delete. Contacts app gets a centralized
tagsStore replacing multiple independent tag-loading calls.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-23 20:47:41 +01:00
parent 1075e811f2
commit 92adabc8e2
9 changed files with 2423 additions and 93 deletions

View file

@ -1,10 +1,9 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
import { onMount } from 'svelte';
import { PillViewSwitcher, FilterDropdown, type FilterDropdownOption } from '@manacore/shared-ui';
import { X } from '@manacore/shared-icons';
import { contactsFilterStore } from '$lib/stores/filter.svelte';
import { tagsApi, type ContactTag, type Contact } from '$lib/api/contacts';
import type { Contact } from '$lib/api/contacts';
import type { ContactFilter, BirthdayFilter } from '$lib/components/FilterBar.svelte';
interface Props {
@ -13,14 +12,6 @@
let { contacts }: Props = $props();
// Tags for filter
let tags = $state<ContactTag[]>([]);
// Tag options for FilterDropdown
let tagOptions = $derived<FilterDropdownOption[]>(
tags.map((tag) => ({ value: tag.id, label: tag.name }))
);
// Contact filter options
let contactFilterOptions = $derived<FilterDropdownOption[]>([
{ value: 'all', label: $_('filters.contact.all') },
@ -54,27 +45,16 @@
companies.map((company) => ({ value: company, label: company }))
);
// Count active filters
// Count active filters (tags now handled by TagStrip)
let activeFilterCount = $derived.by(() => {
let count = 0;
if (contactsFilterStore.selectedTagId) count++;
if (contactsFilterStore.contactFilter !== 'all') count++;
if (contactsFilterStore.birthdayFilter !== 'all') count++;
if (contactsFilterStore.selectedCompany) count++;
return count;
});
async function loadTags() {
try {
const response = await tagsApi.list();
tags = response.tags || [];
} catch (e) {
console.error('Failed to load tags:', e);
}
}
function clearAllFilters() {
contactsFilterStore.setSelectedTagId(null);
contactsFilterStore.setContactFilter('all');
contactsFilterStore.setBirthdayFilter('all');
contactsFilterStore.setSelectedCompany(null);
@ -89,25 +69,11 @@
function handleSortChange(value: string) {
contactsFilterStore.setSortField(value as 'firstName' | 'lastName');
}
onMount(() => {
loadTags();
});
</script>
<div class="toolbar-content-inner">
<!-- Filter Dropdowns -->
<!-- Filter Dropdowns (tags moved to TagStrip) -->
<div class="filter-group">
<!-- Tags Filter -->
<FilterDropdown
options={tagOptions}
value={contactsFilterStore.selectedTagId}
onChange={(v) => contactsFilterStore.setSelectedTagId(typeof v === 'string' ? v : null)}
placeholder={$_('filters.allTags')}
embedded={true}
direction="up"
/>
<!-- Contact Info Filter -->
<FilterDropdown
options={contactFilterOptions}

View file

@ -0,0 +1,323 @@
<script lang="ts">
import { tagsStore } from '$lib/stores/tags.svelte';
import { contactsFilterStore } from '$lib/stores/filter.svelte';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { DotsThree, Plus, X } from '@manacore/shared-icons';
import TagStripModal from './TagStripModal.svelte';
let showModal = $state(false);
function handleTagClick(tagId: string) {
if (contactsFilterStore.selectedTagId === tagId) {
contactsFilterStore.setSelectedTagId(null);
} else {
contactsFilterStore.setSelectedTagId(tagId);
}
}
function isTagSelected(tagId: string): boolean {
return contactsFilterStore.selectedTagId === tagId;
}
const hasSelectedTags = $derived(contactsFilterStore.selectedTagId !== null);
function handleOpenModal() {
showModal = true;
}
function handleCloseModal() {
showModal = false;
}
const sortedTags = $derived.by(() => {
return [...tagsStore.tags].sort((a, b) => a.name.localeCompare(b.name, 'de'));
});
const hasTags = $derived(tagsStore.tags.length > 0);
onMount(async () => {
if (tagsStore.tags.length === 0) {
await tagsStore.fetchTags();
}
});
</script>
<div class="tag-strip-wrapper">
<div class="tag-strip-container">
<!-- Clear Filter Button (always rendered to prevent layout shift) -->
<button
class="clear-filter-pill glass-tag"
class:hidden={!hasSelectedTags}
onclick={() => contactsFilterStore.setSelectedTagId(null)}
title="Filter löschen"
disabled={!hasSelectedTags}
>
<X size={16} weight="bold" />
<span class="tag-name">Filter</span>
</button>
<!-- More Pill (opens modal) -->
<button class="more-pill glass-tag" onclick={handleOpenModal} title="Alle Tags anzeigen">
<DotsThree size={18} weight="bold" />
<span class="tag-name">Alle Tags</span>
</button>
{#if tagsStore.loading}
<div class="loading-state">Lädt...</div>
{:else if !hasTags}
<button class="empty-state glass-tag" onclick={() => goto('/tags')}>
<span>Keine Tags vorhanden</span>
<span class="add-hint">+ Erstellen</span>
</button>
{:else}
{#each sortedTags as tag (tag.id)}
<button
class="tag-pill glass-tag"
class:selected={isTagSelected(tag.id)}
onclick={() => handleTagClick(tag.id)}
title={tag.name}
style="--tag-color: {tag.color || '#3b82f6'}"
>
<span class="tag-dot"></span>
<span class="tag-name">{tag.name}</span>
</button>
{/each}
<!-- Create Tag Button -->
<button
class="create-pill glass-tag"
onclick={() => goto('/tags?new=true')}
title="Neuer Tag"
>
<Plus size={16} weight="bold" />
<span class="tag-name">Neuer Tag</span>
</button>
{/if}
</div>
</div>
<!-- Tags Modal -->
<TagStripModal visible={showModal} onClose={handleCloseModal} />
<style>
.tag-strip-wrapper {
position: fixed;
bottom: calc(70px + env(safe-area-inset-bottom, 0px));
left: 0;
right: 0;
z-index: 49;
display: flex;
flex-direction: column;
align-items: stretch;
pointer-events: none;
transition: bottom 0.2s ease;
}
.tag-strip-container {
display: flex;
align-items: center;
gap: 0.75rem;
background: transparent;
pointer-events: auto;
width: fit-content;
max-width: 100%;
margin-left: auto;
margin-right: auto;
padding: 0.5rem 2rem;
overflow-x: auto;
scrollbar-width: none;
-ms-overflow-style: none;
}
.tag-strip-container::-webkit-scrollbar {
display: none;
}
.tag-pill,
.more-pill,
.create-pill,
.clear-filter-pill {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
flex-shrink: 0;
transition: all 0.15s ease;
}
/* Selected tag state */
.tag-pill.selected {
background: var(--tag-color) !important;
border-color: var(--tag-color) !important;
}
.tag-pill.selected .tag-dot {
background-color: white;
}
.tag-pill.selected .tag-name {
color: white;
}
/* Clear filter pill */
.clear-filter-pill {
color: #ef4444;
background: rgba(239, 68, 68, 0.1) !important;
border-color: rgba(239, 68, 68, 0.3) !important;
}
.clear-filter-pill .tag-name {
color: #ef4444;
font-weight: 600;
}
:global(.dark) .clear-filter-pill {
color: #f87171;
background: rgba(239, 68, 68, 0.15) !important;
border-color: rgba(239, 68, 68, 0.3) !important;
}
:global(.dark) .clear-filter-pill .tag-name {
color: #f87171;
}
.clear-filter-pill:hover:not(.hidden) {
background: rgba(239, 68, 68, 0.2) !important;
border-color: rgba(239, 68, 68, 0.5) !important;
}
.clear-filter-pill.hidden {
visibility: hidden;
pointer-events: none;
}
/* More pill with neutral style */
.more-pill {
color: #374151;
}
.more-pill .tag-name {
color: #374151;
font-weight: 500;
}
:global(.dark) .more-pill {
color: #f3f4f6;
}
:global(.dark) .more-pill .tag-name {
color: #f3f4f6;
}
/* Create pill with neutral style */
.create-pill {
color: #374151;
}
.create-pill .tag-name {
color: #374151;
font-weight: 500;
}
:global(.dark) .create-pill {
color: #f3f4f6;
}
:global(.dark) .create-pill .tag-name {
color: #f3f4f6;
}
/* Glass tag styling */
.glass-tag {
padding: 0.5rem 1rem;
border-radius: 9999px;
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(0, 0, 0, 0.1);
box-shadow:
0 4px 6px -1px rgba(0, 0, 0, 0.1),
0 2px 4px -1px rgba(0, 0, 0, 0.06);
}
:global(.dark) .glass-tag {
background: rgba(255, 255, 255, 0.12);
border: 1px solid rgba(255, 255, 255, 0.15);
}
.glass-tag:hover {
transform: scale(1.05);
background: rgba(255, 255, 255, 0.95);
border-color: rgba(0, 0, 0, 0.15);
box-shadow:
0 10px 15px -3px rgba(0, 0, 0, 0.1),
0 4px 6px -2px rgba(0, 0, 0, 0.05);
}
:global(.dark) .glass-tag:hover {
background: rgba(255, 255, 255, 0.2);
border-color: rgba(255, 255, 255, 0.25);
}
.glass-tag:active {
transform: scale(0.98);
}
.tag-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background-color: var(--tag-color);
flex-shrink: 0;
}
.tag-name {
font-size: 0.9375rem;
font-weight: 500;
color: #374151;
white-space: nowrap;
}
:global(.dark) .tag-name {
color: #f3f4f6;
}
.loading-state {
font-size: 0.875rem;
color: hsl(var(--color-muted-foreground));
padding: 0.5rem;
}
.empty-state {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
color: #6b7280;
font-size: 0.875rem;
flex-shrink: 0;
}
:global(.dark) .empty-state {
color: #9ca3af;
}
.add-hint {
font-size: 0.875rem;
color: #3b82f6;
font-weight: 500;
}
/* Responsive */
@media (max-width: 640px) {
.tag-strip-wrapper {
left: 0;
right: 0;
}
.tag-strip-container {
padding: 0.5rem 1rem;
}
}
</style>

View file

@ -0,0 +1,802 @@
<script lang="ts">
import { tagsStore } from '$lib/stores/tags.svelte';
import { Plus, X, Check, Pencil, Trash, MagnifyingGlass } from '@manacore/shared-icons';
import { TagColorPicker } from '@manacore/shared-ui';
import type { ContactTag } from '$lib/api/contacts';
interface Props {
visible: boolean;
onClose: () => void;
}
let { visible, onClose }: Props = $props();
// Search state
let searchQuery = $state('');
// New tag form state
let showNewTagForm = $state(false);
let newTagName = $state('');
let newTagColor = $state('#3b82f6');
let isCreatingTag = $state(false);
// Edit tag state
let editingTag = $state<ContactTag | null>(null);
let editTagName = $state('');
let editTagColor = $state('#3b82f6');
let isSavingTag = $state(false);
// Filtered and sorted tags
const sortedTags = $derived.by(() => {
const tags = [...tagsStore.tags].sort((a, b) => a.name.localeCompare(b.name, 'de'));
if (!searchQuery.trim()) return tags;
const query = searchQuery.toLowerCase();
return tags.filter((t) => t.name.toLowerCase().includes(query));
});
// ==================== NEW TAG ====================
function openNewTagForm() {
showNewTagForm = true;
newTagName = '';
newTagColor = '#3b82f6';
}
function closeNewTagForm() {
showNewTagForm = false;
newTagName = '';
newTagColor = '#3b82f6';
}
async function handleCreateTag() {
if (!newTagName.trim() || isCreatingTag) return;
isCreatingTag = true;
try {
await tagsStore.createTag({
name: newTagName.trim(),
color: newTagColor,
});
closeNewTagForm();
} catch (e) {
console.error('Failed to create tag:', e);
}
isCreatingTag = false;
}
function handleNewTagKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && newTagName.trim()) {
e.preventDefault();
handleCreateTag();
} else if (e.key === 'Escape') {
e.stopPropagation();
closeNewTagForm();
}
}
// ==================== EDIT TAG ====================
function openEditTag(tag: ContactTag) {
editingTag = tag;
editTagName = tag.name;
editTagColor = tag.color;
}
function closeEditTag() {
editingTag = null;
editTagName = '';
editTagColor = '#3b82f6';
}
async function handleSaveTag() {
if (!editingTag || !editTagName.trim() || isSavingTag) return;
isSavingTag = true;
try {
await tagsStore.updateTag(editingTag.id, {
name: editTagName.trim(),
color: editTagColor,
});
closeEditTag();
} catch (e) {
console.error('Failed to update tag:', e);
}
isSavingTag = false;
}
async function handleDeleteTag() {
if (!editingTag) return;
try {
await tagsStore.deleteTag(editingTag.id);
closeEditTag();
} catch (e) {
console.error('Failed to delete tag:', e);
}
}
function handleEditTagKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && editTagName.trim()) {
e.preventDefault();
handleSaveTag();
} else if (e.key === 'Escape') {
e.stopPropagation();
closeEditTag();
}
}
// ==================== KEYBOARD ====================
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
if (editingTag) {
closeEditTag();
} else if (showNewTagForm) {
closeNewTagForm();
} else {
onClose();
}
}
}
const hasOpenForm = $derived(showNewTagForm || editingTag !== null);
</script>
<svelte:window onkeydown={handleKeydown} />
{#if visible}
<!-- Backdrop -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="modal-backdrop" onclick={onClose}></div>
<!-- Modal -->
<div class="tag-modal" role="dialog" aria-modal="true" aria-label="Tags">
<!-- Header -->
<div class="modal-header">
<h2 class="modal-title">Tags</h2>
<div class="header-actions">
<button class="header-btn" onclick={openNewTagForm} title="Neuer Tag">
<Plus size={18} weight="bold" />
</button>
<button class="header-btn close-btn" onclick={onClose} title="Schließen">
<X size={18} weight="bold" />
</button>
</div>
</div>
<!-- Content -->
<div class="modal-content">
{#if tagsStore.loading}
<div class="loading-state">Lädt...</div>
{:else if tagsStore.tags.length === 0 && !showNewTagForm}
<div class="empty-state">
<p>Keine Tags vorhanden</p>
<button class="create-btn" onclick={openNewTagForm}>
<Plus size={16} weight="bold" />
Tag erstellen
</button>
</div>
{:else}
<!-- New Tag Form -->
{#if showNewTagForm}
<div class="edit-form-section">
<div class="edit-form-header">
<span class="edit-form-title">Neuer Tag</span>
<button class="icon-btn" onclick={closeNewTagForm} title="Abbrechen">
<X size={14} weight="bold" />
</button>
</div>
<div class="edit-form">
<div class="form-row">
<div class="color-preview" style="background-color: {newTagColor}"></div>
<input
type="text"
bind:value={newTagName}
onkeydown={handleNewTagKeydown}
placeholder="Tag Name"
class="name-input"
autofocus
/>
</div>
<div class="color-picker-row">
<TagColorPicker
selectedColor={newTagColor}
onColorChange={(c) => (newTagColor = c)}
/>
</div>
<div class="form-actions">
<button
class="btn btn-primary"
onclick={handleCreateTag}
disabled={!newTagName.trim() || isCreatingTag}
>
<Check size={14} weight="bold" />
Erstellen
</button>
</div>
</div>
</div>
{/if}
<!-- Edit Tag Form -->
{#if editingTag}
<div class="edit-form-section">
<div class="edit-form-header">
<span class="edit-form-title">Tag bearbeiten</span>
<button class="icon-btn" onclick={closeEditTag} title="Abbrechen">
<X size={14} weight="bold" />
</button>
</div>
<div class="edit-form">
<div class="form-row">
<div class="color-preview" style="background-color: {editTagColor}"></div>
<input
type="text"
bind:value={editTagName}
onkeydown={handleEditTagKeydown}
placeholder="Tag Name"
class="name-input"
autofocus
/>
</div>
<div class="color-picker-row">
<TagColorPicker
selectedColor={editTagColor}
onColorChange={(c) => (editTagColor = c)}
/>
</div>
<div class="form-actions">
<button class="btn btn-danger" onclick={handleDeleteTag} title="Tag löschen">
<Trash size={14} weight="bold" />
</button>
<button
class="btn btn-primary"
onclick={handleSaveTag}
disabled={!editTagName.trim() || isSavingTag}
>
<Check size={14} weight="bold" />
Speichern
</button>
</div>
</div>
</div>
{/if}
<!-- Tags list -->
{#if !hasOpenForm || sortedTags.length > 0}
<div class="tags-grid">
{#each sortedTags as tag (tag.id)}
<div
class="tag-pill glass-tag"
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}
</div>
{#if searchQuery && sortedTags.length === 0}
<div class="search-empty">
<p>Keine Tags gefunden für "{searchQuery}"</p>
</div>
{/if}
{/if}
{/if}
</div>
<!-- Search (sticky bottom) -->
<div class="search-wrapper">
<MagnifyingGlass size={16} class="search-icon" />
<input
type="text"
placeholder="Tags suchen..."
bind:value={searchQuery}
class="search-input"
/>
{#if searchQuery}
<button class="search-clear" onclick={() => (searchQuery = '')} title="Suche leeren">
<X size={14} weight="bold" />
</button>
{/if}
</div>
</div>
{/if}
<style>
.modal-backdrop {
position: fixed;
inset: 0;
background: transparent;
z-index: 99;
}
.tag-modal {
position: fixed;
bottom: calc(140px + env(safe-area-inset-bottom, 0px));
left: 50%;
transform: translateX(-50%);
width: calc(100% - 2rem);
max-width: 500px;
max-height: 70vh;
z-index: 100;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 1rem;
box-shadow:
0 25px 50px -12px rgba(0, 0, 0, 0.25),
0 0 0 1px rgba(255, 255, 255, 0.1);
display: flex;
flex-direction: column;
overflow: hidden;
}
:global(.dark) .tag-modal {
background: rgba(30, 30, 30, 0.95);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1rem 0.5rem;
}
.modal-title {
font-size: 1.125rem;
font-weight: 600;
color: #1f2937;
}
:global(.dark) .modal-title {
color: #f3f4f6;
}
.header-actions {
display: flex;
align-items: center;
gap: 0.25rem;
}
.header-btn {
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border-radius: 0.5rem;
background: transparent;
border: none;
cursor: pointer;
color: #6b7280;
transition: all 0.15s ease;
}
.header-btn:hover {
background: rgba(0, 0, 0, 0.05);
color: #1f2937;
}
:global(.dark) .header-btn {
color: #9ca3af;
}
:global(.dark) .header-btn:hover {
background: rgba(255, 255, 255, 0.1);
color: #f3f4f6;
}
/* Search (sticky bottom) */
.search-wrapper {
position: relative;
padding: 0.75rem 1rem;
border-top: 1px solid rgba(0, 0, 0, 0.08);
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
flex-shrink: 0;
}
:global(.dark) .search-wrapper {
border-top-color: rgba(255, 255, 255, 0.08);
background: rgba(30, 30, 30, 0.9);
}
.search-wrapper :global(.search-icon) {
position: absolute;
left: 1.75rem;
top: 50%;
transform: translateY(-50%);
color: #9ca3af;
pointer-events: none;
}
.search-input {
width: 100%;
padding: 0.5rem 2rem 0.5rem 2.25rem;
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 0.5rem;
background: rgba(0, 0, 0, 0.03);
color: #374151;
font-size: 0.8125rem;
outline: none;
transition: all 0.15s ease;
}
.search-input:focus {
border-color: #3b82f6;
background: white;
}
:global(.dark) .search-input {
background: rgba(255, 255, 255, 0.05);
border-color: rgba(255, 255, 255, 0.1);
color: #f3f4f6;
}
:global(.dark) .search-input:focus {
border-color: #60a5fa;
background: rgba(255, 255, 255, 0.1);
}
.search-clear {
position: absolute;
right: 1.5rem;
top: 50%;
transform: translateY(-50%);
display: flex;
align-items: center;
justify-content: center;
width: 1.25rem;
height: 1.25rem;
border-radius: 50%;
background: rgba(0, 0, 0, 0.1);
border: none;
cursor: pointer;
color: #6b7280;
}
.search-clear:hover {
background: rgba(0, 0, 0, 0.15);
}
:global(.dark) .search-clear {
background: rgba(255, 255, 255, 0.1);
color: #9ca3af;
}
.search-empty {
text-align: center;
padding: 1.5rem;
color: #9ca3af;
font-size: 0.875rem;
}
.modal-content {
flex: 1;
overflow-y: auto;
padding: 0.5rem 0.75rem 0.75rem;
}
.loading-state,
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem;
color: #6b7280;
font-size: 0.875rem;
gap: 1rem;
}
:global(.dark) .loading-state,
:global(.dark) .empty-state {
color: #9ca3af;
}
.create-btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: #3b82f6;
color: white;
border: none;
border-radius: 9999px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
}
.create-btn:hover {
background: #2563eb;
}
/* Edit Form Section */
.edit-form-section {
background: rgba(59, 130, 246, 0.05);
border: 1px solid rgba(59, 130, 246, 0.2);
border-radius: 0.75rem;
margin-bottom: 0.75rem;
overflow: hidden;
}
:global(.dark) .edit-form-section {
background: rgba(96, 165, 250, 0.1);
border-color: rgba(96, 165, 250, 0.2);
}
.edit-form-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 0.75rem;
background: rgba(59, 130, 246, 0.1);
}
:global(.dark) .edit-form-header {
background: rgba(96, 165, 250, 0.15);
}
.edit-form-title {
font-size: 0.75rem;
font-weight: 600;
color: #3b82f6;
text-transform: uppercase;
letter-spacing: 0.025em;
}
:global(.dark) .edit-form-title {
color: #60a5fa;
}
.edit-form {
padding: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.form-row {
display: flex;
align-items: center;
gap: 0.5rem;
}
.color-preview {
width: 24px;
height: 24px;
border-radius: 50%;
flex-shrink: 0;
border: 2px solid rgba(0, 0, 0, 0.1);
}
:global(.dark) .color-preview {
border-color: rgba(255, 255, 255, 0.2);
}
.name-input {
flex: 1;
padding: 0.5rem 0.75rem;
border: 1px solid rgba(0, 0, 0, 0.15);
border-radius: 0.5rem;
background: white;
color: #374151;
font-size: 0.875rem;
outline: none;
transition: all 0.15s ease;
}
.name-input:focus {
border-color: #3b82f6;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);
}
:global(.dark) .name-input {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.2);
color: #f3f4f6;
}
:global(.dark) .name-input:focus {
border-color: #60a5fa;
box-shadow: 0 0 0 2px rgba(96, 165, 250, 0.2);
}
.color-picker-row {
padding-left: 32px;
}
.color-picker-row :global(.color-picker) {
gap: 0.375rem;
}
.color-picker-row :global(.color-swatch) {
width: 24px;
height: 24px;
}
.form-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.5rem;
padding-top: 0.25rem;
}
.btn {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
border-radius: 0.5rem;
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
border: none;
transition: all 0.15s ease;
}
.btn-primary {
background: #3b82f6;
color: white;
}
.btn-primary:hover:not(:disabled) {
background: #2563eb;
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-danger {
background: #ef4444;
color: white;
}
.btn-danger:hover {
background: #dc2626;
}
.icon-btn {
display: flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
border-radius: 0.375rem;
background: transparent;
border: none;
cursor: pointer;
color: #6b7280;
transition: all 0.15s ease;
}
.icon-btn:hover {
background: rgba(0, 0, 0, 0.1);
color: #374151;
}
:global(.dark) .icon-btn {
color: #9ca3af;
}
:global(.dark) .icon-btn:hover {
background: rgba(255, 255, 255, 0.1);
color: #f3f4f6;
}
/* Tags grid */
.tags-grid {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
padding: 0.25rem 0;
}
/* Tag Pill */
.tag-pill {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.625rem;
border-radius: 9999px;
cursor: pointer;
flex-shrink: 0;
transition: all 0.15s ease;
position: relative;
}
.glass-tag {
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(0, 0, 0, 0.1);
box-shadow:
0 2px 4px -1px rgba(0, 0, 0, 0.06),
0 1px 2px -1px rgba(0, 0, 0, 0.04);
}
:global(.dark) .glass-tag {
background: rgba(255, 255, 255, 0.12);
border: 1px solid rgba(255, 255, 255, 0.15);
}
.glass-tag:hover {
background: rgba(255, 255, 255, 0.95);
border-color: rgba(0, 0, 0, 0.15);
}
:global(.dark) .glass-tag:hover {
background: rgba(255, 255, 255, 0.2);
border-color: rgba(255, 255, 255, 0.25);
}
.tag-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background-color: var(--tag-color);
flex-shrink: 0;
}
.tag-name {
font-size: 0.8125rem;
font-weight: 500;
color: #374151;
white-space: nowrap;
}
:global(.dark) .tag-name {
color: #f3f4f6;
}
.tag-edit-btn {
display: flex;
align-items: center;
justify-content: center;
width: 1rem;
height: 1rem;
border-radius: 50%;
background: rgba(0, 0, 0, 0.1);
border: none;
cursor: pointer;
color: #6b7280;
opacity: 0;
transition: all 0.15s ease;
margin-left: 0.125rem;
}
.tag-pill:hover .tag-edit-btn {
opacity: 1;
}
.tag-edit-btn:hover {
background: rgba(0, 0, 0, 0.2);
color: #374151;
}
:global(.dark) .tag-edit-btn {
background: rgba(255, 255, 255, 0.15);
color: #9ca3af;
}
:global(.dark) .tag-edit-btn:hover {
background: rgba(255, 255, 255, 0.25);
color: #f3f4f6;
}
</style>

View file

@ -0,0 +1,117 @@
/**
* Tags Store - Manages tag state using Svelte 5 runes
*
* Centralized store for tags, used by TagStrip, TagStripModal, and tags page.
* Uses the central Tags API from mana-core-auth.
*/
import { tagsApi, type ContactTag } from '$lib/api/contacts';
// State
let tags = $state<ContactTag[]>([]);
let loading = $state(false);
let error = $state<string | null>(null);
export const tagsStore = {
// Getters
get tags() {
return tags;
},
get loading() {
return loading;
},
get error() {
return error;
},
/**
* Fetch all tags from API
*/
async fetchTags() {
loading = true;
error = null;
try {
const response = await tagsApi.list();
tags = response.tags || [];
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to fetch tags';
console.error('Failed to fetch tags:', e);
} finally {
loading = false;
}
},
/**
* Get tag by ID
*/
getById(id: string): ContactTag | undefined {
return tags.find((t) => t.id === id);
},
/**
* Get tag color by ID
*/
getColor(tagId: string): string {
const tag = tags.find((t) => t.id === tagId);
return tag?.color || '#6b7280';
},
/**
* Create a new tag
*/
async createTag(data: { name: string; color?: string }) {
loading = true;
error = null;
try {
const response = await tagsApi.create(data);
tags = [...tags, response.tag];
return response.tag;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to create tag';
console.error('Failed to create tag:', e);
throw e;
} finally {
loading = false;
}
},
/**
* Update an existing tag
*/
async updateTag(id: string, data: { name?: string; color?: string }) {
error = null;
try {
const response = await tagsApi.update(id, data);
tags = tags.map((t) => (t.id === id ? response.tag : t));
return response.tag;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to update tag';
console.error('Failed to update tag:', e);
throw e;
}
},
/**
* Delete a tag
*/
async deleteTag(id: string) {
error = null;
try {
await tagsApi.delete(id);
tags = tags.filter((t) => t.id !== id);
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to delete tag';
console.error('Failed to delete tag:', e);
throw e;
}
},
/**
* Clear all state (for logout)
*/
clear() {
tags = [];
loading = false;
error = null;
},
};

View file

@ -42,6 +42,8 @@
formatParsedContactPreview,
} from '$lib/utils/contact-parser';
import ContactsToolbar from '$lib/components/ContactsToolbar.svelte';
import TagStrip from '$lib/components/TagStrip.svelte';
import { tagsStore } from '$lib/stores/tags.svelte';
import { contactsOnboarding } from '$lib/stores/app-onboarding.svelte';
import { MiniOnboardingModal } from '@manacore/shared-app-onboarding';
@ -74,8 +76,8 @@
showContactsToolbar && !contactsFilterStore.isToolbarCollapsed
);
// Dynamic bottom offset based on toolbar state
const inputBarBottomOffset = $derived(isToolbarExpanded ? '140px' : '70px');
// Dynamic bottom offset based on toolbar state (TagStrip adds ~40px)
const inputBarBottomOffset = $derived(isToolbarExpanded ? '180px' : '110px');
// Use theme store's isDark directly
let isDark = $derived(theme.isDark);
@ -281,13 +283,9 @@
// Load user settings and tags
await userSettings.load();
// Load tags for Quick-Create
try {
const tagsResult = await tagsApi.list();
availableTags = (tagsResult.tags || []).map((t) => ({ id: t.id, name: t.name }));
} catch (e) {
console.error('Failed to load tags:', e);
}
// Load tags (used by TagStrip and Quick-Create)
await tagsStore.fetchTags();
availableTags = tagsStore.tags.map((t) => ({ id: t.id, name: t.name }));
});
</script>
@ -338,6 +336,9 @@
ariaLabel="Hauptnavigation"
/>
<!-- TagStrip (above PillNav) -->
<TagStrip />
<!-- Global Quick Input Bar -->
<QuickInputBar
onSearch={handleSearch}

View file

@ -1,20 +1,11 @@
<script lang="ts">
import { onMount } from 'svelte';
import { _ } from 'svelte-i18n';
import { tagsApi } from '$lib/api/contacts';
import type { ContactTag } from '$lib/api/contacts';
import {
TagList,
TagEditModal,
TagColorPicker,
DEFAULT_TAG_COLOR,
type Tag,
} from '@manacore/shared-ui';
import { tagsStore } from '$lib/stores/tags.svelte';
import { TagList, TagEditModal, type Tag } from '@manacore/shared-ui';
import { MagnifyingGlass, Plus, CaretLeft } from '@manacore/shared-icons';
let loading = $state(true);
let tags = $state<ContactTag[]>([]);
let error = $state<string | null>(null);
let searchQuery = $state('');
// Modal state
@ -22,24 +13,11 @@
let editingTag = $state<ContactTag | null>(null);
const filteredTags = $derived.by(() => {
if (!searchQuery.trim()) return tags;
if (!searchQuery.trim()) return tagsStore.tags;
const query = searchQuery.toLowerCase();
return tags.filter((t) => t.name.toLowerCase().includes(query));
return tagsStore.tags.filter((t) => t.name.toLowerCase().includes(query));
});
async function loadTags() {
loading = true;
error = null;
try {
const response = await tagsApi.list();
tags = response.tags || [];
} catch (e) {
error = e instanceof Error ? e.message : $_('messages.error');
} finally {
loading = false;
}
}
function openCreateModal() {
editingTag = null;
showModal = true;
@ -56,18 +34,15 @@
}
async function handleSave(name: string, color: string) {
error = null;
try {
if (editingTag) {
const response = await tagsApi.update(editingTag.id, { name, color });
tags = tags.map((t) => (t.id === editingTag!.id ? response.tag : t));
await tagsStore.updateTag(editingTag.id, { name, color });
} else {
const response = await tagsApi.create({ name, color });
tags = [...tags, response.tag];
await tagsStore.createTag({ name, color });
}
closeModal();
} catch (e) {
error = e instanceof Error ? e.message : $_('messages.error');
console.error('Failed to save tag:', e);
}
}
@ -75,11 +50,10 @@
if (!editingTag) return;
try {
await tagsApi.delete(editingTag.id);
tags = tags.filter((t) => t.id !== editingTag!.id);
await tagsStore.deleteTag(editingTag.id);
closeModal();
} catch (e) {
error = e instanceof Error ? e.message : $_('messages.error');
console.error('Failed to delete tag:', e);
}
}
@ -87,14 +61,17 @@
if (!confirm($_('tags.confirmDelete', { values: { name: tag.name } }))) return;
try {
await tagsApi.delete(tag.id);
tags = tags.filter((t) => t.id !== tag.id);
await tagsStore.deleteTag(tag.id);
} catch (e) {
error = e instanceof Error ? e.message : $_('messages.error');
console.error('Failed to delete tag:', e);
}
}
onMount(loadTags);
onMount(() => {
if (tagsStore.tags.length === 0) {
tagsStore.fetchTags();
}
});
</script>
<svelte:head>
@ -124,16 +101,16 @@
/>
</div>
{#if error}
{#if tagsStore.error}
<div class="error-banner" role="alert">
<span>{error}</span>
<span>{tagsStore.error}</span>
</div>
{/if}
<!-- Tag List using shared component -->
<TagList
tags={filteredTags}
{loading}
loading={tagsStore.loading}
onEdit={(tag) => openEditModal(tag as ContactTag)}
onDelete={handleDeleteFromList}
emptyMessage={searchQuery ? $_('tags.noResults') : $_('tags.noTags')}
@ -142,14 +119,14 @@
: $_('tags.createFirst')}
/>
{#if !loading && tags.length > 0}
{#if !tagsStore.loading && tagsStore.tags.length > 0}
<p class="tags-count">
{tags.length}
{tags.length === 1 ? $_('tags.tagSingular') : $_('tags.tagPlural')}
{tagsStore.tags.length}
{tagsStore.tags.length === 1 ? $_('tags.tagSingular') : $_('tags.tagPlural')}
</p>
{/if}
{#if !loading && tags.length === 0 && !searchQuery}
{#if !tagsStore.loading && tagsStore.tags.length === 0 && !searchQuery}
<div class="empty-cta">
<button onclick={openCreateModal} class="btn btn-primary">
<Plus size={16} weight="bold" />

View file

@ -0,0 +1,336 @@
<script lang="ts">
import { labelsStore } from '$lib/stores/labels.svelte';
import { viewStore } from '$lib/stores/view.svelte';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { DotsThree, Plus, X } from '@manacore/shared-icons';
import TagStripModal from './TagStripModal.svelte';
interface Props {
/** Whether the filter strip below is visible (affects vertical position) */
filterStripVisible?: boolean;
}
let { filterStripVisible = false }: Props = $props();
let showModal = $state(false);
function handleTagClick(tagId: string) {
const current = viewStore.filterLabelIds;
if (current.includes(tagId)) {
viewStore.setFilterLabelIds(current.filter((id) => id !== tagId));
} else {
viewStore.setFilterLabelIds([...current, tagId]);
}
}
function isTagSelected(tagId: string): boolean {
return viewStore.filterLabelIds.includes(tagId);
}
const hasSelectedTags = $derived(viewStore.filterLabelIds.length > 0);
function handleOpenModal() {
showModal = true;
}
function handleCloseModal() {
showModal = false;
}
const sortedTags = $derived.by(() => {
return [...labelsStore.labels].sort((a, b) => a.name.localeCompare(b.name, 'de'));
});
const hasTags = $derived(labelsStore.labels.length > 0);
onMount(async () => {
if (labelsStore.labels.length === 0) {
await labelsStore.fetchLabels();
}
});
</script>
<div class="tag-strip-wrapper" class:above-filter-strip={filterStripVisible}>
<div class="tag-strip-container">
<!-- Clear Filter Button (always rendered to prevent layout shift) -->
<button
class="clear-filter-pill glass-tag"
class:hidden={!hasSelectedTags}
onclick={() => viewStore.setFilterLabelIds([])}
title="Filter löschen"
disabled={!hasSelectedTags}
>
<X size={16} weight="bold" />
<span class="tag-name">Filter</span>
</button>
<!-- More Pill (opens modal) -->
<button class="more-pill glass-tag" onclick={handleOpenModal} title="Alle Tags anzeigen">
<DotsThree size={18} weight="bold" />
<span class="tag-name">Alle Tags</span>
</button>
{#if labelsStore.loading}
<div class="loading-state">Lädt...</div>
{:else if !hasTags}
<button class="empty-state glass-tag" onclick={() => goto('/tags')}>
<span>Keine Tags vorhanden</span>
<span class="add-hint">+ Erstellen</span>
</button>
{:else}
{#each sortedTags as tag (tag.id)}
<button
class="tag-pill glass-tag"
class:selected={isTagSelected(tag.id)}
onclick={() => handleTagClick(tag.id)}
title={tag.name}
style="--tag-color: {tag.color || '#8b5cf6'}"
>
<span class="tag-dot"></span>
<span class="tag-name">{tag.name}</span>
</button>
{/each}
<!-- Create Tag Button -->
<button
class="create-pill glass-tag"
onclick={() => goto('/tags?new=true')}
title="Neuer Tag"
>
<Plus size={16} weight="bold" />
<span class="tag-name">Neuer Tag</span>
</button>
{/if}
</div>
</div>
<!-- Tags Modal -->
<TagStripModal visible={showModal} onClose={handleCloseModal} />
<style>
.tag-strip-wrapper {
position: fixed;
bottom: calc(70px + env(safe-area-inset-bottom, 0px));
left: 0;
right: 0;
z-index: 49;
display: flex;
flex-direction: column;
align-items: stretch;
pointer-events: none;
transition: bottom 0.2s ease;
}
/* When filter strip is also visible, stack above it */
.tag-strip-wrapper.above-filter-strip {
bottom: calc(110px + env(safe-area-inset-bottom, 0px));
}
.tag-strip-container {
display: flex;
align-items: center;
gap: 0.75rem;
background: transparent;
pointer-events: auto;
width: fit-content;
max-width: 100%;
margin-left: auto;
margin-right: auto;
padding: 0.5rem 2rem;
overflow-x: auto;
scrollbar-width: none;
-ms-overflow-style: none;
}
.tag-strip-container::-webkit-scrollbar {
display: none;
}
.tag-pill,
.more-pill,
.create-pill,
.clear-filter-pill {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
flex-shrink: 0;
transition: all 0.15s ease;
}
/* Selected tag state */
.tag-pill.selected {
background: var(--tag-color) !important;
border-color: var(--tag-color) !important;
}
.tag-pill.selected .tag-dot {
background-color: white;
}
.tag-pill.selected .tag-name {
color: white;
}
/* Clear filter pill */
.clear-filter-pill {
color: #ef4444;
background: rgba(239, 68, 68, 0.1) !important;
border-color: rgba(239, 68, 68, 0.3) !important;
}
.clear-filter-pill .tag-name {
color: #ef4444;
font-weight: 600;
}
:global(.dark) .clear-filter-pill {
color: #f87171;
background: rgba(239, 68, 68, 0.15) !important;
border-color: rgba(239, 68, 68, 0.3) !important;
}
:global(.dark) .clear-filter-pill .tag-name {
color: #f87171;
}
.clear-filter-pill:hover:not(.hidden) {
background: rgba(239, 68, 68, 0.2) !important;
border-color: rgba(239, 68, 68, 0.5) !important;
}
.clear-filter-pill.hidden {
visibility: hidden;
pointer-events: none;
}
/* More pill with neutral style */
.more-pill {
color: #374151;
}
.more-pill .tag-name {
color: #374151;
font-weight: 500;
}
:global(.dark) .more-pill {
color: #f3f4f6;
}
:global(.dark) .more-pill .tag-name {
color: #f3f4f6;
}
/* Create pill with neutral style */
.create-pill {
color: #374151;
}
.create-pill .tag-name {
color: #374151;
font-weight: 500;
}
:global(.dark) .create-pill {
color: #f3f4f6;
}
:global(.dark) .create-pill .tag-name {
color: #f3f4f6;
}
/* Glass tag styling */
.glass-tag {
padding: 0.5rem 1rem;
border-radius: 9999px;
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(0, 0, 0, 0.1);
box-shadow:
0 4px 6px -1px rgba(0, 0, 0, 0.1),
0 2px 4px -1px rgba(0, 0, 0, 0.06);
}
:global(.dark) .glass-tag {
background: rgba(255, 255, 255, 0.12);
border: 1px solid rgba(255, 255, 255, 0.15);
}
.glass-tag:hover {
transform: scale(1.05);
background: rgba(255, 255, 255, 0.95);
border-color: rgba(0, 0, 0, 0.15);
box-shadow:
0 10px 15px -3px rgba(0, 0, 0, 0.1),
0 4px 6px -2px rgba(0, 0, 0, 0.05);
}
:global(.dark) .glass-tag:hover {
background: rgba(255, 255, 255, 0.2);
border-color: rgba(255, 255, 255, 0.25);
}
.glass-tag:active {
transform: scale(0.98);
}
.tag-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background-color: var(--tag-color);
flex-shrink: 0;
}
.tag-name {
font-size: 0.9375rem;
font-weight: 500;
color: #374151;
white-space: nowrap;
}
:global(.dark) .tag-name {
color: #f3f4f6;
}
.loading-state {
font-size: 0.875rem;
color: hsl(var(--color-muted-foreground));
padding: 0.5rem;
}
.empty-state {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
color: #6b7280;
font-size: 0.875rem;
flex-shrink: 0;
}
:global(.dark) .empty-state {
color: #9ca3af;
}
.add-hint {
font-size: 0.875rem;
color: #8b5cf6;
font-weight: 500;
}
/* Responsive */
@media (max-width: 640px) {
.tag-strip-wrapper {
left: 0;
right: 0;
}
.tag-strip-container {
padding: 0.5rem 1rem;
}
}
</style>

View file

@ -0,0 +1,802 @@
<script lang="ts">
import { labelsStore } from '$lib/stores/labels.svelte';
import { Plus, X, Check, Pencil, Trash, MagnifyingGlass } from '@manacore/shared-icons';
import { TagColorPicker } from '@manacore/shared-ui';
import type { Label } from '$lib/api/labels';
interface Props {
visible: boolean;
onClose: () => void;
}
let { visible, onClose }: Props = $props();
// Search state
let searchQuery = $state('');
// New tag form state
let showNewTagForm = $state(false);
let newTagName = $state('');
let newTagColor = $state('#8b5cf6');
let isCreatingTag = $state(false);
// Edit tag state
let editingTag = $state<Label | null>(null);
let editTagName = $state('');
let editTagColor = $state('#8b5cf6');
let isSavingTag = $state(false);
// Filtered and sorted tags
const sortedTags = $derived.by(() => {
const tags = [...labelsStore.labels].sort((a, b) => a.name.localeCompare(b.name, 'de'));
if (!searchQuery.trim()) return tags;
const query = searchQuery.toLowerCase();
return tags.filter((t) => t.name.toLowerCase().includes(query));
});
// ==================== NEW TAG ====================
function openNewTagForm() {
showNewTagForm = true;
newTagName = '';
newTagColor = '#8b5cf6';
}
function closeNewTagForm() {
showNewTagForm = false;
newTagName = '';
newTagColor = '#8b5cf6';
}
async function handleCreateTag() {
if (!newTagName.trim() || isCreatingTag) return;
isCreatingTag = true;
try {
await labelsStore.createLabel({
name: newTagName.trim(),
color: newTagColor,
});
closeNewTagForm();
} catch (e) {
console.error('Failed to create tag:', e);
}
isCreatingTag = false;
}
function handleNewTagKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && newTagName.trim()) {
e.preventDefault();
handleCreateTag();
} else if (e.key === 'Escape') {
e.stopPropagation();
closeNewTagForm();
}
}
// ==================== EDIT TAG ====================
function openEditTag(tag: Label) {
editingTag = tag;
editTagName = tag.name;
editTagColor = tag.color;
}
function closeEditTag() {
editingTag = null;
editTagName = '';
editTagColor = '#8b5cf6';
}
async function handleSaveTag() {
if (!editingTag || !editTagName.trim() || isSavingTag) return;
isSavingTag = true;
try {
await labelsStore.updateLabel(editingTag.id, {
name: editTagName.trim(),
color: editTagColor,
});
closeEditTag();
} catch (e) {
console.error('Failed to update tag:', e);
}
isSavingTag = false;
}
async function handleDeleteTag() {
if (!editingTag) return;
try {
await labelsStore.deleteLabel(editingTag.id);
closeEditTag();
} catch (e) {
console.error('Failed to delete tag:', e);
}
}
function handleEditTagKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && editTagName.trim()) {
e.preventDefault();
handleSaveTag();
} else if (e.key === 'Escape') {
e.stopPropagation();
closeEditTag();
}
}
// ==================== KEYBOARD ====================
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
if (editingTag) {
closeEditTag();
} else if (showNewTagForm) {
closeNewTagForm();
} else {
onClose();
}
}
}
const hasOpenForm = $derived(showNewTagForm || editingTag !== null);
</script>
<svelte:window onkeydown={handleKeydown} />
{#if visible}
<!-- Backdrop -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="modal-backdrop" onclick={onClose}></div>
<!-- Modal -->
<div class="tag-modal" role="dialog" aria-modal="true" aria-label="Tags">
<!-- Header -->
<div class="modal-header">
<h2 class="modal-title">Tags</h2>
<div class="header-actions">
<button class="header-btn" onclick={openNewTagForm} title="Neuer Tag">
<Plus size={18} weight="bold" />
</button>
<button class="header-btn close-btn" onclick={onClose} title="Schließen">
<X size={18} weight="bold" />
</button>
</div>
</div>
<!-- Content -->
<div class="modal-content">
{#if labelsStore.loading}
<div class="loading-state">Lädt...</div>
{:else if labelsStore.labels.length === 0 && !showNewTagForm}
<div class="empty-state">
<p>Keine Tags vorhanden</p>
<button class="create-btn" onclick={openNewTagForm}>
<Plus size={16} weight="bold" />
Tag erstellen
</button>
</div>
{:else}
<!-- New Tag Form -->
{#if showNewTagForm}
<div class="edit-form-section">
<div class="edit-form-header">
<span class="edit-form-title">Neuer Tag</span>
<button class="icon-btn" onclick={closeNewTagForm} title="Abbrechen">
<X size={14} weight="bold" />
</button>
</div>
<div class="edit-form">
<div class="form-row">
<div class="color-preview" style="background-color: {newTagColor}"></div>
<input
type="text"
bind:value={newTagName}
onkeydown={handleNewTagKeydown}
placeholder="Tag Name"
class="name-input"
autofocus
/>
</div>
<div class="color-picker-row">
<TagColorPicker
selectedColor={newTagColor}
onColorChange={(c) => (newTagColor = c)}
/>
</div>
<div class="form-actions">
<button
class="btn btn-primary"
onclick={handleCreateTag}
disabled={!newTagName.trim() || isCreatingTag}
>
<Check size={14} weight="bold" />
Erstellen
</button>
</div>
</div>
</div>
{/if}
<!-- Edit Tag Form -->
{#if editingTag}
<div class="edit-form-section">
<div class="edit-form-header">
<span class="edit-form-title">Tag bearbeiten</span>
<button class="icon-btn" onclick={closeEditTag} title="Abbrechen">
<X size={14} weight="bold" />
</button>
</div>
<div class="edit-form">
<div class="form-row">
<div class="color-preview" style="background-color: {editTagColor}"></div>
<input
type="text"
bind:value={editTagName}
onkeydown={handleEditTagKeydown}
placeholder="Tag Name"
class="name-input"
autofocus
/>
</div>
<div class="color-picker-row">
<TagColorPicker
selectedColor={editTagColor}
onColorChange={(c) => (editTagColor = c)}
/>
</div>
<div class="form-actions">
<button class="btn btn-danger" onclick={handleDeleteTag} title="Tag löschen">
<Trash size={14} weight="bold" />
</button>
<button
class="btn btn-primary"
onclick={handleSaveTag}
disabled={!editTagName.trim() || isSavingTag}
>
<Check size={14} weight="bold" />
Speichern
</button>
</div>
</div>
</div>
{/if}
<!-- Tags list -->
{#if !hasOpenForm || sortedTags.length > 0}
<div class="tags-grid">
{#each sortedTags as tag (tag.id)}
<div
class="tag-pill glass-tag"
role="button"
tabindex="0"
style="--tag-color: {tag.color || '#8b5cf6'}"
>
<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 searchQuery && sortedTags.length === 0}
<div class="search-empty">
<p>Keine Tags gefunden für "{searchQuery}"</p>
</div>
{/if}
{/if}
{/if}
</div>
<!-- Search (sticky bottom) -->
<div class="search-wrapper">
<MagnifyingGlass size={16} class="search-icon" />
<input
type="text"
placeholder="Tags suchen..."
bind:value={searchQuery}
class="search-input"
/>
{#if searchQuery}
<button class="search-clear" onclick={() => (searchQuery = '')} title="Suche leeren">
<X size={14} weight="bold" />
</button>
{/if}
</div>
</div>
{/if}
<style>
.modal-backdrop {
position: fixed;
inset: 0;
background: transparent;
z-index: 99;
}
.tag-modal {
position: fixed;
bottom: calc(140px + env(safe-area-inset-bottom, 0px));
left: 50%;
transform: translateX(-50%);
width: calc(100% - 2rem);
max-width: 500px;
max-height: 70vh;
z-index: 100;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 1rem;
box-shadow:
0 25px 50px -12px rgba(0, 0, 0, 0.25),
0 0 0 1px rgba(255, 255, 255, 0.1);
display: flex;
flex-direction: column;
overflow: hidden;
}
:global(.dark) .tag-modal {
background: rgba(30, 30, 30, 0.95);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1rem 0.5rem;
}
.modal-title {
font-size: 1.125rem;
font-weight: 600;
color: #1f2937;
}
:global(.dark) .modal-title {
color: #f3f4f6;
}
.header-actions {
display: flex;
align-items: center;
gap: 0.25rem;
}
.header-btn {
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border-radius: 0.5rem;
background: transparent;
border: none;
cursor: pointer;
color: #6b7280;
transition: all 0.15s ease;
}
.header-btn:hover {
background: rgba(0, 0, 0, 0.05);
color: #1f2937;
}
:global(.dark) .header-btn {
color: #9ca3af;
}
:global(.dark) .header-btn:hover {
background: rgba(255, 255, 255, 0.1);
color: #f3f4f6;
}
/* Search (sticky bottom) */
.search-wrapper {
position: relative;
padding: 0.75rem 1rem;
border-top: 1px solid rgba(0, 0, 0, 0.08);
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
flex-shrink: 0;
}
:global(.dark) .search-wrapper {
border-top-color: rgba(255, 255, 255, 0.08);
background: rgba(30, 30, 30, 0.9);
}
.search-wrapper :global(.search-icon) {
position: absolute;
left: 1.75rem;
top: 50%;
transform: translateY(-50%);
color: #9ca3af;
pointer-events: none;
}
.search-input {
width: 100%;
padding: 0.5rem 2rem 0.5rem 2.25rem;
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 0.5rem;
background: rgba(0, 0, 0, 0.03);
color: #374151;
font-size: 0.8125rem;
outline: none;
transition: all 0.15s ease;
}
.search-input:focus {
border-color: #8b5cf6;
background: white;
}
:global(.dark) .search-input {
background: rgba(255, 255, 255, 0.05);
border-color: rgba(255, 255, 255, 0.1);
color: #f3f4f6;
}
:global(.dark) .search-input:focus {
border-color: #a78bfa;
background: rgba(255, 255, 255, 0.1);
}
.search-clear {
position: absolute;
right: 1.5rem;
top: 50%;
transform: translateY(-50%);
display: flex;
align-items: center;
justify-content: center;
width: 1.25rem;
height: 1.25rem;
border-radius: 50%;
background: rgba(0, 0, 0, 0.1);
border: none;
cursor: pointer;
color: #6b7280;
}
.search-clear:hover {
background: rgba(0, 0, 0, 0.15);
}
:global(.dark) .search-clear {
background: rgba(255, 255, 255, 0.1);
color: #9ca3af;
}
.search-empty {
text-align: center;
padding: 1.5rem;
color: #9ca3af;
font-size: 0.875rem;
}
.modal-content {
flex: 1;
overflow-y: auto;
padding: 0.5rem 0.75rem 0.75rem;
}
.loading-state,
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem;
color: #6b7280;
font-size: 0.875rem;
gap: 1rem;
}
:global(.dark) .loading-state,
:global(.dark) .empty-state {
color: #9ca3af;
}
.create-btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: #8b5cf6;
color: white;
border: none;
border-radius: 9999px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
}
.create-btn:hover {
background: #7c3aed;
}
/* Edit Form Section */
.edit-form-section {
background: rgba(139, 92, 246, 0.05);
border: 1px solid rgba(139, 92, 246, 0.2);
border-radius: 0.75rem;
margin-bottom: 0.75rem;
overflow: hidden;
}
:global(.dark) .edit-form-section {
background: rgba(167, 139, 250, 0.1);
border-color: rgba(167, 139, 250, 0.2);
}
.edit-form-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 0.75rem;
background: rgba(139, 92, 246, 0.1);
}
:global(.dark) .edit-form-header {
background: rgba(167, 139, 250, 0.15);
}
.edit-form-title {
font-size: 0.75rem;
font-weight: 600;
color: #8b5cf6;
text-transform: uppercase;
letter-spacing: 0.025em;
}
:global(.dark) .edit-form-title {
color: #a78bfa;
}
.edit-form {
padding: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.form-row {
display: flex;
align-items: center;
gap: 0.5rem;
}
.color-preview {
width: 24px;
height: 24px;
border-radius: 50%;
flex-shrink: 0;
border: 2px solid rgba(0, 0, 0, 0.1);
}
:global(.dark) .color-preview {
border-color: rgba(255, 255, 255, 0.2);
}
.name-input {
flex: 1;
padding: 0.5rem 0.75rem;
border: 1px solid rgba(0, 0, 0, 0.15);
border-radius: 0.5rem;
background: white;
color: #374151;
font-size: 0.875rem;
outline: none;
transition: all 0.15s ease;
}
.name-input:focus {
border-color: #8b5cf6;
box-shadow: 0 0 0 2px rgba(139, 92, 246, 0.2);
}
:global(.dark) .name-input {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.2);
color: #f3f4f6;
}
:global(.dark) .name-input:focus {
border-color: #a78bfa;
box-shadow: 0 0 0 2px rgba(167, 139, 250, 0.2);
}
.color-picker-row {
padding-left: 32px;
}
.color-picker-row :global(.color-picker) {
gap: 0.375rem;
}
.color-picker-row :global(.color-swatch) {
width: 24px;
height: 24px;
}
.form-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.5rem;
padding-top: 0.25rem;
}
.btn {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
border-radius: 0.5rem;
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
border: none;
transition: all 0.15s ease;
}
.btn-primary {
background: #8b5cf6;
color: white;
}
.btn-primary:hover:not(:disabled) {
background: #7c3aed;
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-danger {
background: #ef4444;
color: white;
}
.btn-danger:hover {
background: #dc2626;
}
.icon-btn {
display: flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
border-radius: 0.375rem;
background: transparent;
border: none;
cursor: pointer;
color: #6b7280;
transition: all 0.15s ease;
}
.icon-btn:hover {
background: rgba(0, 0, 0, 0.1);
color: #374151;
}
:global(.dark) .icon-btn {
color: #9ca3af;
}
:global(.dark) .icon-btn:hover {
background: rgba(255, 255, 255, 0.1);
color: #f3f4f6;
}
/* Tags grid */
.tags-grid {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
padding: 0.25rem 0;
}
/* Tag Pill */
.tag-pill {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.625rem;
border-radius: 9999px;
cursor: pointer;
flex-shrink: 0;
transition: all 0.15s ease;
position: relative;
}
.glass-tag {
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(0, 0, 0, 0.1);
box-shadow:
0 2px 4px -1px rgba(0, 0, 0, 0.06),
0 1px 2px -1px rgba(0, 0, 0, 0.04);
}
:global(.dark) .glass-tag {
background: rgba(255, 255, 255, 0.12);
border: 1px solid rgba(255, 255, 255, 0.15);
}
.glass-tag:hover {
background: rgba(255, 255, 255, 0.95);
border-color: rgba(0, 0, 0, 0.15);
}
:global(.dark) .glass-tag:hover {
background: rgba(255, 255, 255, 0.2);
border-color: rgba(255, 255, 255, 0.25);
}
.tag-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background-color: var(--tag-color);
flex-shrink: 0;
}
.tag-name {
font-size: 0.8125rem;
font-weight: 500;
color: #374151;
white-space: nowrap;
}
:global(.dark) .tag-name {
color: #f3f4f6;
}
.tag-edit-btn {
display: flex;
align-items: center;
justify-content: center;
width: 1rem;
height: 1rem;
border-radius: 50%;
background: rgba(0, 0, 0, 0.1);
border: none;
cursor: pointer;
color: #6b7280;
opacity: 0;
transition: all 0.15s ease;
margin-left: 0.125rem;
}
.tag-pill:hover .tag-edit-btn {
opacity: 1;
}
.tag-edit-btn:hover {
background: rgba(0, 0, 0, 0.2);
color: #374151;
}
:global(.dark) .tag-edit-btn {
background: rgba(255, 255, 255, 0.15);
color: #9ca3af;
}
:global(.dark) .tag-edit-btn:hover {
background: rgba(255, 255, 255, 0.25);
color: #f3f4f6;
}
</style>

View file

@ -24,6 +24,7 @@
import { tasksStore } from '$lib/stores/tasks.svelte';
import { theme } from '$lib/stores/theme';
import TaskFilters from '$lib/components/TaskFilters.svelte';
import TagStrip from '$lib/components/TagStrip.svelte';
import { viewStore, type SortBy } from '$lib/stores/view.svelte';
import type { TaskPriority } from '@todo/shared';
import {
@ -357,6 +358,9 @@
ariaLabel="Hauptnavigation"
/>
<!-- TagStrip (above PillNav, always visible when PillNav is open) -->
<TagStrip filterStripVisible={isFilterStripVisible} />
<!-- TaskFilters strip (shown when Filter pill is active in PillNav) -->
{#if isFilterStripVisible}
<TaskFilters
@ -389,12 +393,14 @@
placeholder="Neue Aufgabe oder suchen..."
emptyText="Keine Aufgaben gefunden"
searchingText="Suche..."
searchText="Suchen"
onCreate={handleCreate}
onParseCreate={handleParseCreate}
createText="Erstellen"
deferSearch={true}
appIcon="todo"
hasFabRight={true}
bottomOffset={isPillNavCollapsed ? '16px' : isFilterStripVisible ? '140px' : '70px'}
bottomOffset={isPillNavCollapsed ? '16px' : isFilterStripVisible ? '180px' : '110px'}
/>
{/if}