feat: major update with network graphs, themes, todo extensions, and more

## New Features

### Network Graph Visualization (Contacts, Calendar, Todo)
- D3.js force simulation for physics-based layout
- Zoom & pan with mouse/touchpad
- Keyboard shortcuts: +/- zoom, 0 reset, Esc deselect, / search, F focus
- Filtering by tags, company/location/project, connection strength
- Shared components in @manacore/shared-ui

### Central Tags API (mana-core-auth)
- CRUD endpoints for tags
- Schema: tags table with userId, name, color, app
- Shared tag components in @manacore/shared-ui

### Custom Themes System
- Theme editor with live preview and color picker
- Community theme gallery
- Theme sharing (public, unlisted, private)
- Backend API in mana-core-auth

### Todo App Extensions
- Glass-pill design for task input and items
- Settings page with 20+ preferences
- Task edit modal with inline editing
- Statistics page with visualizations
- PWA support with offline capabilities
- Multiple kanban boards

### Contacts App Features
- Duplicate detection
- Photo upload
- Batch operations
- Enhanced favorites page with multiple view modes
- Alphabet view improvements
- Search modal

### Help System
- @manacore/shared-help-content
- @manacore/shared-help-ui
- @manacore/shared-help-types

### Other Features
- Themes page for all apps
- Referral system frontend
- CommandBar (global search)
- Skeleton loaders
- Settings page improvements

## Bug Fixes
- Network graph simulation initialization
- Database schema TEXT for user_id columns (Better Auth compatibility)
- Various styling fixes

## Documentation
- Daily report for 2025-12-10
- CI/CD deployment guide

🤖 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 02:37:46 +01:00
parent e84371aa94
commit ee42b6cc76
381 changed files with 39284 additions and 6275 deletions

View file

@ -9,13 +9,32 @@ export type { SelectOption } from './Select.types';
export { GlassCard, StatRow } from './stats';
// Tag components
export { TagBadge } from './tags';
export {
TagBadge,
TagColorPicker,
TagEditModal,
TagSelector,
TagList,
TAG_COLORS,
DEFAULT_TAG_COLOR,
getRandomTagColor,
getTagColorByName,
} from './tags';
export type { Tag, TagData, TagColorName, TagColorHex } from './tags';
// Media components
export { AudioPlayer } from './media';
// Loading components
export { SkeletonBox, SkeletonText } from './loaders';
export {
SkeletonBox,
SkeletonText,
SkeletonAvatar,
SkeletonRow,
SkeletonList,
SkeletonCard,
SkeletonGrid,
} from './loaders';
// Feedback components
export { EmptyState } from './feedback';

View file

@ -0,0 +1,24 @@
<script lang="ts">
/**
* SkeletonAvatar - Circular skeleton for profile pictures/avatars
*
* @example
* ```svelte
* <SkeletonAvatar size="40px" />
* <SkeletonAvatar size="64px" />
* ```
*/
import SkeletonBox from './SkeletonBox.svelte';
interface Props {
/** Size of the avatar (width & height) */
size?: string;
/** Additional CSS classes */
class?: string;
}
let { size = '40px', class: className = '' }: Props = $props();
</script>
<SkeletonBox width={size} height={size} circle class={className} />

View file

@ -0,0 +1,69 @@
<script lang="ts">
/**
* SkeletonCard - Configurable card skeleton with avatar, title, body, footer
*
* @example
* ```svelte
* <SkeletonCard showAvatar titleLines={1} bodyLines={2} />
* <SkeletonCard showFooter />
* ```
*/
import SkeletonBox from './SkeletonBox.svelte';
import SkeletonText from './SkeletonText.svelte';
import SkeletonAvatar from './SkeletonAvatar.svelte';
interface Props {
/** Show avatar/image placeholder */
showAvatar?: boolean;
/** Avatar size */
avatarSize?: string;
/** Number of title lines */
titleLines?: number;
/** Number of body text lines */
bodyLines?: number;
/** Show footer section */
showFooter?: boolean;
/** Opacity for fade effect in lists */
opacity?: number;
/** Additional CSS classes */
class?: string;
}
let {
showAvatar = false,
avatarSize = '48px',
titleLines = 1,
bodyLines = 2,
showFooter = false,
opacity = 1,
class: className = '',
}: Props = $props();
</script>
<div
class="skeleton-card rounded-lg border border-border bg-card p-4 {className}"
style="opacity: {opacity};"
>
<div class="flex gap-3">
{#if showAvatar}
<SkeletonAvatar size={avatarSize} />
{/if}
<div class="flex-1 min-w-0">
{#if titleLines > 0}
<SkeletonText lines={titleLines} lineHeight="18px" gap="6px" lastLineWidth="60%" />
{/if}
{#if bodyLines > 0}
<div class="mt-2">
<SkeletonText lines={bodyLines} lineHeight="14px" gap="6px" lastLineWidth="80%" />
</div>
{/if}
</div>
</div>
{#if showFooter}
<div class="mt-4 flex items-center justify-between border-t border-border pt-4">
<SkeletonBox width="80px" height="14px" />
<SkeletonBox width="60px" height="14px" />
</div>
{/if}
</div>

View file

@ -0,0 +1,61 @@
<script lang="ts">
/**
* SkeletonGrid - Grid of skeleton cards with fade effect
*
* @example
* ```svelte
* <SkeletonGrid count={6} columns={3} />
* <SkeletonGrid count={8} columns={4} showAvatar />
* ```
*/
import SkeletonCard from './SkeletonCard.svelte';
interface Props {
/** Number of cards to show */
count?: number;
/** Number of columns (CSS grid) */
columns?: number;
/** Show avatar in cards */
showAvatar?: boolean;
/** Avatar size */
avatarSize?: string;
/** Number of body lines per card */
bodyLines?: number;
/** Apply cascading fade effect */
fadeEffect?: boolean;
/** Minimum opacity for fade effect */
minOpacity?: number;
/** Gap between cards */
gap?: string;
/** Additional CSS classes */
class?: string;
}
let {
count = 6,
columns = 3,
showAvatar = true,
avatarSize = '48px',
bodyLines = 2,
fadeEffect = true,
minOpacity = 0.4,
gap = '1rem',
class: className = '',
}: Props = $props();
function calculateOpacity(index: number): number {
if (!fadeEffect) return 1;
const fadeStep = (1 - minOpacity) / Math.max(count - 1, 1);
return Math.max(minOpacity, 1 - index * fadeStep);
}
</script>
<div
class="skeleton-grid grid {className}"
style="grid-template-columns: repeat({columns}, minmax(0, 1fr)); gap: {gap};"
>
{#each Array(count) as _, i}
<SkeletonCard {showAvatar} {avatarSize} {bodyLines} opacity={calculateOpacity(i)} />
{/each}
</div>

View file

@ -0,0 +1,52 @@
<script lang="ts">
/**
* SkeletonList - List of skeleton rows with cascading fade effect
*
* @example
* ```svelte
* <SkeletonList count={5} />
* <SkeletonList count={10} showAvatar fadeEffect />
* ```
*/
import SkeletonRow from './SkeletonRow.svelte';
interface Props {
/** Number of rows to show */
count?: number;
/** Show avatar in each row */
showAvatar?: boolean;
/** Avatar size */
avatarSize?: string;
/** Apply cascading fade effect (rows fade out towards bottom) */
fadeEffect?: boolean;
/** Minimum opacity for fade effect */
minOpacity?: number;
/** Gap between rows */
gap?: string;
/** Additional CSS classes */
class?: string;
}
let {
count = 5,
showAvatar = true,
avatarSize = '40px',
fadeEffect = true,
minOpacity = 0.3,
gap = '0',
class: className = '',
}: Props = $props();
function calculateOpacity(index: number): number {
if (!fadeEffect) return 1;
const fadeStep = (1 - minOpacity) / Math.max(count - 1, 1);
return Math.max(minOpacity, 1 - index * fadeStep);
}
</script>
<div class="skeleton-list flex flex-col {className}" style="gap: {gap};">
{#each Array(count) as _, i}
<SkeletonRow {showAvatar} {avatarSize} opacity={calculateOpacity(i)} />
{/each}
</div>

View file

@ -0,0 +1,60 @@
<script lang="ts">
/**
* SkeletonRow - Single row skeleton with avatar and text
*
* @example
* ```svelte
* <SkeletonRow showAvatar />
* <SkeletonRow opacity={0.5} />
* ```
*/
import SkeletonBox from './SkeletonBox.svelte';
import SkeletonAvatar from './SkeletonAvatar.svelte';
interface Props {
/** Show avatar placeholder */
showAvatar?: boolean;
/** Avatar size */
avatarSize?: string;
/** Opacity for fade effect */
opacity?: number;
/** Show secondary line */
showSecondaryLine?: boolean;
/** Show right-side content */
showRightContent?: boolean;
/** Additional CSS classes */
class?: string;
}
let {
showAvatar = true,
avatarSize = '40px',
opacity = 1,
showSecondaryLine = true,
showRightContent = true,
class: className = '',
}: Props = $props();
</script>
<div
class="skeleton-row flex items-center gap-3 border-b border-border px-4 py-3 {className}"
style="opacity: {opacity};"
role="status"
aria-label="Loading"
>
{#if showAvatar}
<SkeletonAvatar size={avatarSize} />
{/if}
<div class="flex-1 min-w-0">
<SkeletonBox width="45%" height="16px" />
{#if showSecondaryLine}
<div class="mt-1.5">
<SkeletonBox width="65%" height="13px" />
</div>
{/if}
</div>
{#if showRightContent}
<SkeletonBox width="70px" height="13px" />
{/if}
</div>

View file

@ -1,5 +1,25 @@
/**
* Loading state components
*
* Primitives:
* - SkeletonBox: Base rectangular skeleton with shimmer
* - SkeletonText: Multi-line text skeleton
* - SkeletonAvatar: Circular avatar skeleton
*
* Composites:
* - SkeletonRow: Single list row with avatar + text
* - SkeletonList: Multiple rows with fade effect
* - SkeletonCard: Card with avatar, title, body, footer
* - SkeletonGrid: Grid of cards with fade effect
*/
// Primitives
export { default as SkeletonBox } from './SkeletonBox.svelte';
export { default as SkeletonText } from './SkeletonText.svelte';
export { default as SkeletonAvatar } from './SkeletonAvatar.svelte';
// Composites
export { default as SkeletonRow } from './SkeletonRow.svelte';
export { default as SkeletonList } from './SkeletonList.svelte';
export { default as SkeletonCard } from './SkeletonCard.svelte';
export { default as SkeletonGrid } from './SkeletonGrid.svelte';

View file

@ -0,0 +1,70 @@
<script lang="ts">
import { Check } from '@manacore/shared-icons';
import { TAG_COLORS, DEFAULT_TAG_COLOR } from './constants';
interface Props {
selectedColor?: string;
onColorChange: (color: string) => void;
size?: 'sm' | 'md' | 'lg';
}
let { selectedColor = DEFAULT_TAG_COLOR, onColorChange, size = 'md' }: Props = $props();
const sizeClasses = {
sm: 'w-6 h-6',
md: 'w-8 h-8',
lg: 'w-10 h-10',
};
const iconSizes = {
sm: 12,
md: 14,
lg: 18,
};
const gapClasses = {
sm: 'gap-1.5',
md: 'gap-2',
lg: 'gap-2.5',
};
function handleColorSelect(hex: string) {
onColorChange(hex);
}
function handleKeyDown(e: KeyboardEvent, hex: string) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleColorSelect(hex);
}
}
</script>
<div class="flex flex-wrap {gapClasses[size]}" role="radiogroup" aria-label="Tag color">
{#each TAG_COLORS as color}
{@const isSelected = selectedColor?.toLowerCase() === color.hex.toLowerCase()}
<button
type="button"
class="
{sizeClasses[size]}
rounded-full
flex items-center justify-center
transition-all duration-150
ring-offset-2 ring-offset-white dark:ring-offset-gray-900
focus:outline-none focus:ring-2 focus:ring-primary
{isSelected ? 'ring-2 ring-black/30 dark:ring-white/50 scale-110' : 'hover:scale-110'}
"
style="background-color: {color.hex}"
onclick={() => handleColorSelect(color.hex)}
onkeydown={(e) => handleKeyDown(e, color.hex)}
role="radio"
aria-checked={isSelected}
aria-label={color.name}
title={color.name}
>
{#if isSelected}
<Check size={iconSizes[size]} weight="bold" class="text-white drop-shadow-sm" />
{/if}
</button>
{/each}
</div>

View file

@ -0,0 +1,143 @@
<script lang="ts">
import { Modal } from '../../organisms';
import { Input } from '../../molecules';
import TagBadge from './TagBadge.svelte';
import TagColorPicker from './TagColorPicker.svelte';
import { DEFAULT_TAG_COLOR } from './constants';
import type { Tag } from './constants';
interface Props {
tag?: Tag | null;
isOpen: boolean;
onClose: () => void;
onSave: (name: string, color: string) => void;
onDelete?: () => void;
usageCount?: number;
title?: string;
saveLabel?: string;
deleteLabel?: string;
cancelLabel?: string;
namePlaceholder?: string;
colorLabel?: string;
previewLabel?: string;
usageLabel?: string;
deleteConfirmMessage?: string;
}
let {
tag = null,
isOpen,
onClose,
onSave,
onDelete,
usageCount,
title = 'Tag bearbeiten',
saveLabel = 'Speichern',
deleteLabel = 'Löschen',
cancelLabel = 'Abbrechen',
namePlaceholder = 'Tag Name',
colorLabel = 'Farbe',
previewLabel = 'Vorschau',
usageLabel = 'Verwendung',
deleteConfirmMessage = 'Möchtest du diesen Tag wirklich löschen?',
}: Props = $props();
let name = $state(tag?.name ?? '');
let color = $state(tag?.color ?? tag?.style?.color ?? DEFAULT_TAG_COLOR);
// Reset form when tag changes
$effect(() => {
if (isOpen) {
name = tag?.name ?? '';
color = tag?.color ?? tag?.style?.color ?? DEFAULT_TAG_COLOR;
}
});
function handleSave() {
if (name.trim()) {
onSave(name.trim(), color);
}
}
function handleDelete() {
if (onDelete && confirm(deleteConfirmMessage)) {
onDelete();
}
}
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Enter' && name.trim()) {
e.preventDefault();
handleSave();
}
}
const previewTag = $derived({ name: name || namePlaceholder, color });
</script>
<Modal visible={isOpen} {onClose} {title} maxWidth="sm">
<div class="space-y-6">
<!-- Name Input -->
<div>
<Input bind:value={name} placeholder={namePlaceholder} onkeydown={handleKeyDown} autofocus />
</div>
<!-- Color Picker -->
<div>
<label class="block text-sm font-medium text-muted-foreground mb-3">
{colorLabel}
</label>
<TagColorPicker selectedColor={color} onColorChange={(c) => (color = c)} />
</div>
<!-- Preview -->
<div>
<label class="block text-sm font-medium text-muted-foreground mb-3">
{previewLabel}
</label>
<div class="flex items-center gap-2">
<TagBadge tag={previewTag} />
</div>
</div>
<!-- Usage Count (optional) -->
{#if usageCount !== undefined && usageCount > 0}
<div class="text-sm text-muted-foreground">
{usageLabel}: {usageCount}
</div>
{/if}
</div>
{#snippet footer()}
<div class="flex items-center justify-between">
<div>
{#if onDelete && tag}
<button
type="button"
onclick={handleDelete}
class="px-4 py-2 text-sm font-medium text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors"
>
{deleteLabel}
</button>
{/if}
</div>
<div class="flex items-center gap-3">
<button
type="button"
onclick={onClose}
class="px-4 py-2 text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/10 rounded-lg transition-colors"
>
{cancelLabel}
</button>
<button
type="button"
onclick={handleSave}
disabled={!name.trim()}
class="px-4 py-2 text-sm font-medium bg-primary text-primary-foreground rounded-lg hover:opacity-90 transition-opacity disabled:opacity-50 disabled:cursor-not-allowed"
>
{saveLabel}
</button>
</div>
</div>
{/snippet}
</Modal>

View file

@ -0,0 +1,166 @@
<script lang="ts">
import { PencilSimple, Trash } from '@manacore/shared-icons';
import { DEFAULT_TAG_COLOR } from './constants';
import type { Tag } from './constants';
interface Props {
tags: Tag[];
onEdit?: (tag: Tag) => void;
onDelete?: (tag: Tag) => void;
onClick?: (tag: Tag) => void;
layout?: 'grid' | 'list';
emptyMessage?: string;
emptyDescription?: string;
loading?: boolean;
}
let {
tags,
onEdit,
onDelete,
onClick,
layout = 'grid',
emptyMessage = 'Keine Tags vorhanden',
emptyDescription = 'Erstelle deinen ersten Tag',
loading = false,
}: Props = $props();
function getTagColor(tag: Tag): string {
return tag.color ?? tag.style?.color ?? DEFAULT_TAG_COLOR;
}
function handleKeyDown(e: KeyboardEvent, tag: Tag, action: 'click' | 'edit' | 'delete') {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
if (action === 'click' && onClick) onClick(tag);
if (action === 'edit' && onEdit) onEdit(tag);
if (action === 'delete' && onDelete) onDelete(tag);
}
}
const gridClasses = 'grid grid-cols-1 sm:grid-cols-2 gap-4';
const listClasses = 'flex flex-col gap-3';
</script>
{#if loading}
<!-- Loading Skeleton -->
<div class={layout === 'grid' ? gridClasses : listClasses}>
{#each Array(6) as _, i}
<div
class="
flex items-center gap-3 p-4
bg-gray-100 dark:bg-gray-800
rounded-xl animate-pulse
"
>
<div class="w-10 h-10 bg-gray-200 dark:bg-gray-700 rounded-lg"></div>
<div class="flex-1">
<div class="h-4 w-24 bg-gray-200 dark:bg-gray-700 rounded"></div>
</div>
</div>
{/each}
</div>
{:else if tags.length === 0}
<!-- Empty State -->
<div class="flex flex-col items-center justify-center py-12 text-center">
<div
class="w-16 h-16 mb-4 rounded-full bg-gray-100 dark:bg-gray-800 flex items-center justify-center"
>
<svg class="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"
></path>
</svg>
</div>
<h3 class="text-lg font-medium text-foreground mb-1">{emptyMessage}</h3>
<p class="text-sm text-muted-foreground">{emptyDescription}</p>
</div>
{:else}
<!-- Tag Grid/List -->
<div class={layout === 'grid' ? gridClasses : listClasses}>
{#each tags as tag (tag.id)}
{@const color = getTagColor(tag)}
<div
class="
group relative flex items-center gap-3 p-4
bg-white dark:bg-white/5
border border-gray-200 dark:border-white/10
rounded-xl
transition-all duration-200
hover:shadow-md hover:border-gray-300 dark:hover:border-white/20
{onClick ? 'cursor-pointer' : ''}
"
onclick={() => onClick?.(tag)}
onkeydown={(e) => handleKeyDown(e, tag, 'click')}
role={onClick ? 'button' : undefined}
tabindex={onClick ? 0 : undefined}
>
<!-- Color Icon -->
<div
class="w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0"
style="background-color: {color}20"
>
<div class="w-4 h-4 rounded-full" style="background-color: {color}"></div>
</div>
<!-- Tag Name -->
<span class="flex-1 font-medium text-foreground min-w-0">
{tag.name}
</span>
<!-- Actions -->
{#if onEdit || onDelete}
<div class="flex items-center gap-1 flex-shrink-0">
{#if onEdit}
<button
type="button"
onclick={(e) => {
e.stopPropagation();
onEdit(tag);
}}
onkeydown={(e) => {
e.stopPropagation();
handleKeyDown(e, tag, 'edit');
}}
class="
p-2 rounded-lg
text-muted-foreground hover:text-foreground
hover:bg-gray-100 dark:hover:bg-white/10
transition-colors
"
aria-label="Tag bearbeiten"
>
<PencilSimple size={16} />
</button>
{/if}
{#if onDelete}
<button
type="button"
onclick={(e) => {
e.stopPropagation();
onDelete(tag);
}}
onkeydown={(e) => {
e.stopPropagation();
handleKeyDown(e, tag, 'delete');
}}
class="
p-2 rounded-lg
text-muted-foreground hover:text-red-500
hover:bg-red-50 dark:hover:bg-red-900/20
transition-colors
"
aria-label="Tag löschen"
>
<Trash size={16} />
</button>
{/if}
</div>
{/if}
</div>
{/each}
</div>
{/if}

View file

@ -0,0 +1,257 @@
<script lang="ts">
import { Plus, MagnifyingGlass, X } from '@manacore/shared-icons';
import TagBadge from './TagBadge.svelte';
import TagColorPicker from './TagColorPicker.svelte';
import { DEFAULT_TAG_COLOR } from './constants';
import type { Tag } from './constants';
interface Props {
tags: Tag[];
selectedTags: Tag[];
onTagsChange: (tags: Tag[]) => void;
onCreateTag?: (name: string, color: string) => Promise<Tag>;
placeholder?: string;
addTagLabel?: string;
searchPlaceholder?: string;
createLabel?: string;
maxTags?: number;
}
let {
tags,
selectedTags,
onTagsChange,
onCreateTag,
placeholder = 'Tags hinzufügen...',
addTagLabel = 'Tag hinzufügen',
searchPlaceholder = 'Tag suchen...',
createLabel = 'Erstellen',
maxTags,
}: Props = $props();
let isOpen = $state(false);
let searchQuery = $state('');
let isCreating = $state(false);
let newTagName = $state('');
let newTagColor = $state(DEFAULT_TAG_COLOR);
const filteredTags = $derived.by(() => {
const selectedIds = new Set(selectedTags.map((t) => t.id));
return tags
.filter((tag) => !selectedIds.has(tag.id))
.filter((tag) => tag.name.toLowerCase().includes(searchQuery.toLowerCase()));
});
const canAddMore = $derived(!maxTags || selectedTags.length < maxTags);
function handleSelectTag(tag: Tag) {
if (canAddMore) {
onTagsChange([...selectedTags, tag]);
searchQuery = '';
}
}
function handleRemoveTag(tag: Tag) {
onTagsChange(selectedTags.filter((t) => t.id !== tag.id));
}
async function handleCreateTag() {
if (!onCreateTag || !newTagName.trim()) return;
try {
const createdTag = await onCreateTag(newTagName.trim(), newTagColor);
onTagsChange([...selectedTags, createdTag]);
newTagName = '';
newTagColor = DEFAULT_TAG_COLOR;
isCreating = false;
searchQuery = '';
} catch (error) {
console.error('Failed to create tag:', error);
}
}
function handleClickOutside(e: MouseEvent) {
const target = e.target as HTMLElement;
if (!target.closest('.tag-selector-container')) {
isOpen = false;
isCreating = false;
}
}
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') {
isOpen = false;
isCreating = false;
}
}
</script>
<svelte:window onclick={handleClickOutside} onkeydown={handleKeyDown} />
<div class="tag-selector-container relative">
<!-- Selected Tags Display -->
<div class="flex flex-wrap items-center gap-2 mb-2">
{#each selectedTags as tag (tag.id)}
<TagBadge {tag} removable onRemove={() => handleRemoveTag(tag)} />
{/each}
{#if canAddMore}
<button
type="button"
onclick={() => (isOpen = !isOpen)}
class="
inline-flex items-center gap-1.5 px-3 py-1.5
text-sm text-muted-foreground
border border-dashed border-gray-300 dark:border-gray-600
rounded-full
hover:border-gray-400 dark:hover:border-gray-500
hover:text-foreground
transition-colors
"
>
<Plus size={14} weight="bold" />
<span>{addTagLabel}</span>
</button>
{/if}
</div>
<!-- Dropdown -->
{#if isOpen}
<div
class="
absolute z-50 mt-1 w-full min-w-[280px]
bg-white dark:bg-gray-800
border border-gray-200 dark:border-gray-700
rounded-xl shadow-lg
overflow-hidden
"
>
<!-- Search Input -->
<div class="p-3 border-b border-gray-100 dark:border-gray-700">
<div class="relative">
<MagnifyingGlass
size={16}
class="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"
/>
<input
type="text"
bind:value={searchQuery}
placeholder={searchPlaceholder}
class="
w-full pl-9 pr-3 py-2
text-sm
bg-gray-50 dark:bg-gray-900
border border-gray-200 dark:border-gray-700
rounded-lg
focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent
"
/>
</div>
</div>
<!-- Tag List -->
<div class="max-h-48 overflow-y-auto p-2">
{#if filteredTags.length > 0}
{#each filteredTags as tag (tag.id)}
<button
type="button"
onclick={() => handleSelectTag(tag)}
class="
w-full flex items-center gap-2 px-3 py-2
text-sm text-left
rounded-lg
hover:bg-gray-100 dark:hover:bg-gray-700
transition-colors
"
>
<span
class="w-3 h-3 rounded-full flex-shrink-0"
style="background-color: {tag.color ?? tag.style?.color ?? DEFAULT_TAG_COLOR}"
></span>
<span class="truncate">{tag.name}</span>
</button>
{/each}
{:else if searchQuery && !isCreating}
<div class="px-3 py-2 text-sm text-muted-foreground text-center">Kein Tag gefunden</div>
{/if}
</div>
<!-- Create New Tag -->
{#if onCreateTag}
<div class="p-3 border-t border-gray-100 dark:border-gray-700">
{#if isCreating}
<div class="space-y-3">
<input
type="text"
bind:value={newTagName}
{placeholder}
class="
w-full px-3 py-2
text-sm
bg-gray-50 dark:bg-gray-900
border border-gray-200 dark:border-gray-700
rounded-lg
focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent
"
onkeydown={(e) => e.key === 'Enter' && handleCreateTag()}
/>
<TagColorPicker
selectedColor={newTagColor}
onColorChange={(c) => (newTagColor = c)}
size="sm"
/>
<div class="flex items-center gap-2">
<button
type="button"
onclick={() => (isCreating = false)}
class="
flex-1 px-3 py-1.5
text-sm text-muted-foreground
hover:bg-gray-100 dark:hover:bg-gray-700
rounded-lg transition-colors
"
>
<X size={14} class="inline mr-1" />
Abbrechen
</button>
<button
type="button"
onclick={handleCreateTag}
disabled={!newTagName.trim()}
class="
flex-1 px-3 py-1.5
text-sm font-medium
bg-primary text-primary-foreground
rounded-lg
hover:opacity-90 transition-opacity
disabled:opacity-50 disabled:cursor-not-allowed
"
>
{createLabel}
</button>
</div>
</div>
{:else}
<button
type="button"
onclick={() => {
isCreating = true;
newTagName = searchQuery;
}}
class="
w-full flex items-center justify-center gap-2 px-3 py-2
text-sm font-medium
text-primary
hover:bg-primary/10
rounded-lg transition-colors
"
>
<Plus size={16} weight="bold" />
<span>Neuen Tag erstellen</span>
</button>
{/if}
</div>
{/if}
</div>
{/if}
</div>

View file

@ -0,0 +1,56 @@
/**
* Centralized tag constants and types for @manacore/shared-ui
*/
export const TAG_COLORS = [
{ name: 'red', hex: '#ef4444' },
{ name: 'orange', hex: '#f97316' },
{ name: 'amber', hex: '#f59e0b' },
{ name: 'lime', hex: '#84cc16' },
{ name: 'green', hex: '#22c55e' },
{ name: 'teal', hex: '#14b8a6' },
{ name: 'cyan', hex: '#06b6d4' },
{ name: 'blue', hex: '#3b82f6' },
{ name: 'indigo', hex: '#6366f1' },
{ name: 'violet', hex: '#8b5cf6' },
{ name: 'pink', hex: '#ec4899' },
{ name: 'slate', hex: '#64748b' },
] as const;
export const DEFAULT_TAG_COLOR = '#3b82f6'; // blue
export type TagColorName = (typeof TAG_COLORS)[number]['name'];
export type TagColorHex = (typeof TAG_COLORS)[number]['hex'];
export interface Tag {
id: string;
name: string;
color?: string | null;
style?: { color?: string };
}
export interface TagData {
name?: string;
text?: string;
color?: string;
style?: { color?: string };
}
/**
* Get a random color from the palette
*/
export function getRandomTagColor(): string {
return TAG_COLORS[Math.floor(Math.random() * TAG_COLORS.length)].hex;
}
/**
* Get color by name
*/
export function getTagColorByName(name: TagColorName): string {
for (const color of TAG_COLORS) {
if (color.name === name) {
return color.hex;
}
}
return DEFAULT_TAG_COLOR;
}

View file

@ -1 +1,10 @@
// Components
export { default as TagBadge } from './TagBadge.svelte';
export { default as TagColorPicker } from './TagColorPicker.svelte';
export { default as TagEditModal } from './TagEditModal.svelte';
export { default as TagSelector } from './TagSelector.svelte';
export { default as TagList } from './TagList.svelte';
// Constants and Types
export { TAG_COLORS, DEFAULT_TAG_COLOR, getRandomTagColor, getTagColorByName } from './constants';
export type { Tag, TagData, TagColorName, TagColorHex } from './constants';