mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-22 06:26:41 +02:00
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:
parent
e84371aa94
commit
ee42b6cc76
381 changed files with 39284 additions and 6275 deletions
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
69
packages/shared-ui/src/molecules/loaders/SkeletonCard.svelte
Normal file
69
packages/shared-ui/src/molecules/loaders/SkeletonCard.svelte
Normal 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>
|
||||
61
packages/shared-ui/src/molecules/loaders/SkeletonGrid.svelte
Normal file
61
packages/shared-ui/src/molecules/loaders/SkeletonGrid.svelte
Normal 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>
|
||||
52
packages/shared-ui/src/molecules/loaders/SkeletonList.svelte
Normal file
52
packages/shared-ui/src/molecules/loaders/SkeletonList.svelte
Normal 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>
|
||||
60
packages/shared-ui/src/molecules/loaders/SkeletonRow.svelte
Normal file
60
packages/shared-ui/src/molecules/loaders/SkeletonRow.svelte
Normal 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>
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
70
packages/shared-ui/src/molecules/tags/TagColorPicker.svelte
Normal file
70
packages/shared-ui/src/molecules/tags/TagColorPicker.svelte
Normal 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>
|
||||
143
packages/shared-ui/src/molecules/tags/TagEditModal.svelte
Normal file
143
packages/shared-ui/src/molecules/tags/TagEditModal.svelte
Normal 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>
|
||||
166
packages/shared-ui/src/molecules/tags/TagList.svelte
Normal file
166
packages/shared-ui/src/molecules/tags/TagList.svelte
Normal 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}
|
||||
257
packages/shared-ui/src/molecules/tags/TagSelector.svelte
Normal file
257
packages/shared-ui/src/molecules/tags/TagSelector.svelte
Normal 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>
|
||||
56
packages/shared-ui/src/molecules/tags/constants.ts
Normal file
56
packages/shared-ui/src/molecules/tags/constants.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue