mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-22 18:06:42 +02:00
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:
parent
1075e811f2
commit
92adabc8e2
9 changed files with 2423 additions and 93 deletions
|
|
@ -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}
|
||||
|
|
|
|||
323
apps/contacts/apps/web/src/lib/components/TagStrip.svelte
Normal file
323
apps/contacts/apps/web/src/lib/components/TagStrip.svelte
Normal 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>
|
||||
802
apps/contacts/apps/web/src/lib/components/TagStripModal.svelte
Normal file
802
apps/contacts/apps/web/src/lib/components/TagStripModal.svelte
Normal 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>
|
||||
117
apps/contacts/apps/web/src/lib/stores/tags.svelte.ts
Normal file
117
apps/contacts/apps/web/src/lib/stores/tags.svelte.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
336
apps/todo/apps/web/src/lib/components/TagStrip.svelte
Normal file
336
apps/todo/apps/web/src/lib/components/TagStrip.svelte
Normal 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>
|
||||
802
apps/todo/apps/web/src/lib/components/TagStripModal.svelte
Normal file
802
apps/todo/apps/web/src/lib/components/TagStripModal.svelte
Normal 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>
|
||||
|
|
@ -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}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue