feat(todo): add inline tag creation with color palette

Replace modal-based tag creation with inline form and preview.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Till-JS 2025-12-10 15:59:34 +01:00
parent a4c986f887
commit adb6d06232

View file

@ -2,7 +2,7 @@
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { TagList, TagEditModal, ConfirmationModal, type Tag } from '@manacore/shared-ui';
import { MagnifyingGlass, Plus, CaretLeft } from '@manacore/shared-icons';
import { MagnifyingGlass, Plus, CaretLeft, Check } from '@manacore/shared-icons';
import { labelsStore } from '$lib/stores/labels.svelte';
import type { Label } from '@todo/shared';
@ -12,6 +12,25 @@
let showDeleteConfirm = $state(false);
let labelToDelete = $state<Tag | null>(null);
// Inline create state
let newTagName = $state('');
let newTagColor = $state('#8b5cf6');
let isCreating = $state(false);
let newTagInputRef = $state<HTMLInputElement | null>(null);
// Predefined color palette
const colorPalette = [
'#ef4444', // red
'#f97316', // orange
'#eab308', // yellow
'#22c55e', // green
'#14b8a6', // teal
'#3b82f6', // blue
'#8b5cf6', // violet
'#ec4899', // pink
'#6b7280', // gray
];
const filteredLabels = $derived.by(() => {
if (!searchQuery.trim()) return labelsStore.labels;
const query = searchQuery.toLowerCase();
@ -27,9 +46,27 @@
};
}
function openCreateModal() {
editingLabel = null;
showModal = true;
// Inline create handlers
async function handleInlineCreate() {
if (!newTagName.trim() || isCreating) return;
isCreating = true;
try {
await labelsStore.createLabel({ name: newTagName.trim(), color: newTagColor });
newTagName = '';
newTagColor = '#8b5cf6';
} catch (e) {
console.error('Failed to create tag:', e);
} finally {
isCreating = false;
}
}
function handleInlineKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && newTagName.trim()) {
e.preventDefault();
handleInlineCreate();
}
}
function openEditModal(tag: Tag) {
@ -49,8 +86,6 @@
try {
if (editingLabel) {
await labelsStore.updateLabel(editingLabel.id, { name, color });
} else {
await labelsStore.createLabel({ name, color });
}
closeModal();
} catch (e) {
@ -109,11 +144,64 @@
<CaretLeft size={20} weight="bold" />
</a>
<h1 class="title">Tags</h1>
<button onclick={openCreateModal} class="add-button" aria-label="Neuer Tag">
<Plus size={20} weight="bold" />
</button>
</header>
<!-- Inline Create Form -->
<div class="inline-create-form">
<div class="create-input-row">
<!-- Name input -->
<input
bind:this={newTagInputRef}
type="text"
placeholder="Neuer Tag..."
bind:value={newTagName}
onkeydown={handleInlineKeydown}
class="create-input"
disabled={isCreating}
/>
<!-- Submit button -->
<button
type="button"
class="create-button"
onclick={handleInlineCreate}
disabled={!newTagName.trim() || isCreating}
aria-label="Tag erstellen"
>
{#if isCreating}
<div class="spinner"></div>
{:else}
<Plus size={18} weight="bold" />
{/if}
</button>
</div>
<!-- Color palette (always visible) -->
<div class="color-row">
<div class="color-palette">
{#each colorPalette as color}
<button
type="button"
class="color-option"
class:active={newTagColor === color}
style="background-color: {color}"
onclick={() => (newTagColor = color)}
aria-label="Farbe {color}"
></button>
{/each}
</div>
<!-- Preview -->
{#if newTagName.trim()}
<div class="create-preview">
<span class="preview-tag" style="--tag-color: {newTagColor}">
{newTagName}
</span>
</div>
{/if}
</div>
</div>
<!-- Search -->
<div class="search-wrapper">
<MagnifyingGlass size={20} class="search-icon" />
@ -150,15 +238,6 @@
{labelsStore.labels.length === 1 ? 'Tag' : 'Tags'}
</p>
{/if}
{#if !labelsStore.loading && labelsStore.labels.length === 0 && !searchQuery}
<div class="empty-cta">
<button onclick={openCreateModal} class="btn btn-primary">
<Plus size={16} weight="bold" />
Neuer Tag
</button>
</div>
{/if}
</div>
<!-- Create/Edit Modal using shared component -->
@ -233,13 +312,89 @@
color: hsl(var(--foreground));
}
.add-button {
/* Inline Create Form */
.inline-create-form {
margin-bottom: 1.5rem;
padding: 1rem;
background: hsl(var(--muted) / 0.3);
border: 1.5px solid hsl(var(--border));
border-radius: 0.75rem;
}
.create-input-row {
display: flex;
align-items: center;
gap: 0.75rem;
}
/* Color Row */
.color-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-top: 0.75rem;
}
.color-palette {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.color-option {
width: 1.75rem;
height: 1.75rem;
border-radius: 50%;
border: 2px solid transparent;
cursor: pointer;
transition: all 0.15s ease;
}
.color-option:hover {
transform: scale(1.15);
}
.color-option.active {
border-color: hsl(var(--foreground));
box-shadow: 0 0 0 2px hsl(var(--background));
}
/* Create Input */
.create-input {
flex: 1;
padding: 0.625rem 1rem;
border: 1.5px solid hsl(var(--border));
border-radius: 0.5rem;
background: hsl(var(--background));
color: hsl(var(--foreground));
font-size: 0.9375rem;
transition: all 0.2s ease;
}
.create-input:focus {
outline: none;
border-color: hsl(var(--primary));
box-shadow: 0 0 0 3px hsl(var(--primary) / 0.1);
}
.create-input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.create-input::placeholder {
color: hsl(var(--muted-foreground));
}
/* Create Button */
.create-button {
display: flex;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
border-radius: 50%;
border-radius: 0.5rem;
background: hsl(var(--primary));
color: hsl(var(--primary-foreground));
border: none;
@ -247,11 +402,51 @@
transition: all 0.2s ease;
}
.add-button:hover {
.create-button:hover:not(:disabled) {
transform: scale(1.05);
box-shadow: 0 4px 12px hsl(var(--primary) / 0.3);
}
.create-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Spinner */
.spinner {
width: 1rem;
height: 1rem;
border: 2px solid hsl(var(--primary-foreground) / 0.3);
border-top-color: hsl(var(--primary-foreground));
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Preview */
.create-preview {
display: flex;
align-items: center;
flex-shrink: 0;
}
.preview-tag {
display: inline-flex;
align-items: center;
padding: 0.25rem 0.75rem;
font-size: 0.8125rem;
font-weight: 500;
border-radius: 9999px;
background: var(--tag-color);
color: white;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
}
/* Search */
.search-wrapper {
position: relative;
@ -304,36 +499,4 @@
color: hsl(var(--muted-foreground));
margin-top: 1.5rem;
}
/* Empty CTA */
.empty-cta {
display: flex;
justify-content: center;
margin-top: 1rem;
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.625rem 1.25rem;
border-radius: 0.625rem;
font-weight: 600;
font-size: 0.875rem;
cursor: pointer;
transition: all 0.2s ease;
border: none;
text-decoration: none;
}
.btn-primary {
background: hsl(var(--primary));
color: hsl(var(--primary-foreground));
}
.btn-primary:hover {
box-shadow: 0 4px 12px hsl(var(--primary) / 0.3);
}
</style>