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

@ -0,0 +1,601 @@
<script lang="ts">
import { goto } from '$app/navigation';
export interface CommandBarItem {
id: string;
title: string;
subtitle?: string;
icon?: string;
imageUrl?: string;
isFavorite?: boolean;
}
export interface QuickAction {
id: string;
label: string;
href?: string;
icon: string;
shortcut?: string;
onclick?: () => void;
}
interface Props {
open: boolean;
onClose: () => void;
onSearch: (query: string) => Promise<CommandBarItem[]>;
onSelect: (item: CommandBarItem) => void;
quickActions?: QuickAction[];
placeholder?: string;
emptyText?: string;
searchingText?: string;
}
let {
open = $bindable(),
onClose,
onSearch,
onSelect,
quickActions = [],
placeholder = 'Suchen...',
emptyText = 'Keine Ergebnisse gefunden',
searchingText = 'Suche...',
}: Props = $props();
let searchQuery = $state('');
let results = $state<CommandBarItem[]>([]);
let loading = $state(false);
let selectedIndex = $state(0);
let searchTimeout: ReturnType<typeof setTimeout>;
let inputElement: HTMLInputElement;
// Reset state when modal opens
$effect(() => {
if (open) {
searchQuery = '';
results = [];
selectedIndex = 0;
setTimeout(() => inputElement?.focus(), 50);
}
});
async function handleSearch() {
clearTimeout(searchTimeout);
if (!searchQuery.trim()) {
results = [];
loading = false;
return;
}
loading = true;
searchTimeout = setTimeout(async () => {
try {
results = await onSearch(searchQuery);
selectedIndex = 0;
} catch (e) {
console.error('Search error:', e);
results = [];
} finally {
loading = false;
}
}, 150);
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
event.preventDefault();
onClose();
return;
}
if (event.key === 'ArrowDown') {
event.preventDefault();
const maxIndex = searchQuery.trim() ? results.length - 1 : quickActions.length - 1;
selectedIndex = Math.min(selectedIndex + 1, maxIndex);
return;
}
if (event.key === 'ArrowUp') {
event.preventDefault();
selectedIndex = Math.max(selectedIndex - 1, 0);
return;
}
if (event.key === 'Enter') {
event.preventDefault();
if (searchQuery.trim() && results.length > 0) {
selectItem(results[selectedIndex]);
} else if (!searchQuery.trim() && quickActions.length > 0) {
const action = quickActions[selectedIndex];
if (action.href) {
goto(action.href);
onClose();
} else if (action.onclick) {
action.onclick();
onClose();
}
}
return;
}
}
function selectItem(item: CommandBarItem) {
onSelect(item);
onClose();
}
function getInitials(item: CommandBarItem): string {
const parts = item.title.split(' ');
if (parts.length >= 2) {
return (parts[0][0] + parts[1][0]).toUpperCase();
}
return item.title.substring(0, 2).toUpperCase();
}
function handleBackdropClick(e: MouseEvent) {
if (e.target === e.currentTarget) {
onClose();
}
}
function handleQuickAction(action: QuickAction) {
if (action.href) {
goto(action.href);
} else if (action.onclick) {
action.onclick();
}
onClose();
}
</script>
{#if open}
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
class="command-backdrop"
role="dialog"
aria-modal="true"
aria-label="Suchen"
onclick={handleBackdropClick}
onkeydown={handleKeydown}
>
<div class="command-modal">
<!-- Search Input -->
<div class="command-input-wrapper">
<svg class="command-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
<input
bind:this={inputElement}
type="text"
{placeholder}
bind:value={searchQuery}
oninput={handleSearch}
class="command-input"
/>
<kbd class="command-shortcut">ESC</kbd>
</div>
<!-- Results -->
{#if searchQuery.trim()}
<div class="command-results">
{#if loading}
<div class="command-loading">
<div class="loading-spinner"></div>
<span>{searchingText}</span>
</div>
{:else if results.length === 0}
<div class="command-empty">
<span>{emptyText}</span>
</div>
{:else}
{#each results as item, index (item.id)}
<button
type="button"
class="command-result"
class:selected={index === selectedIndex}
onclick={() => selectItem(item)}
onmouseenter={() => (selectedIndex = index)}
>
<div class="result-avatar">
{#if item.imageUrl}
<img
src={item.imageUrl}
alt={item.title}
class="w-full h-full rounded-full object-cover"
/>
{:else}
{getInitials(item)}
{/if}
</div>
<div class="result-info">
<div class="result-name">{item.title}</div>
{#if item.subtitle}
<div class="result-details">
<span>{item.subtitle}</span>
</div>
{/if}
</div>
{#if item.isFavorite}
<svg class="result-favorite" fill="currentColor" viewBox="0 0 24 24">
<path
d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"
/>
</svg>
{/if}
</button>
{/each}
{/if}
</div>
{:else if quickActions.length > 0}
<!-- Quick Actions when no search -->
<div class="quick-actions-list">
{#each quickActions as action, index (action.id)}
<button
type="button"
class="quick-action"
class:selected={index === selectedIndex}
onclick={() => handleQuickAction(action)}
onmouseenter={() => (selectedIndex = index)}
>
<svg class="quick-action-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
{#if action.icon === 'plus'}
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4v16m8-8H4"
/>
{:else if action.icon === 'heart'}
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"
/>
{:else if action.icon === 'tag'}
<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"
/>
{:else if action.icon === 'upload'}
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"
/>
{:else if action.icon === 'calendar'}
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
{:else if action.icon === 'clock'}
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
{:else if action.icon === 'check'}
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
{:else if action.icon === 'settings'}
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
{:else if action.icon === 'list'}
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 6h16M4 10h16M4 14h16M4 18h16"
/>
{/if}
</svg>
<span>{action.label}</span>
{#if action.shortcut}
<kbd>{action.shortcut}</kbd>
{/if}
</button>
{/each}
</div>
{/if}
<!-- Footer -->
<div class="command-footer">
<div class="footer-hints">
<span><kbd>↑↓</kbd> Navigation</span>
<span><kbd></kbd> Öffnen</span>
<span><kbd>ESC</kbd> Schließen</span>
</div>
</div>
</div>
</div>
{/if}
<style>
.command-backdrop {
position: fixed;
inset: 0;
z-index: 100;
display: flex;
align-items: flex-start;
justify-content: center;
padding-top: 15vh;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
animation: fadeIn 0.15s ease;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.command-modal {
width: 100%;
max-width: 560px;
margin: 0 1rem;
background: #1a1a1a;
border: 1px solid #333;
border-radius: 12px;
box-shadow:
0 25px 50px -12px rgba(0, 0, 0, 0.5),
0 0 0 1px rgba(255, 255, 255, 0.1);
overflow: hidden;
animation: slideIn 0.2s ease;
color: #e5e5e5;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-10px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.command-input-wrapper {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem 1.25rem;
border-bottom: 1px solid #333;
}
.command-icon {
width: 1.25rem;
height: 1.25rem;
color: #888;
flex-shrink: 0;
}
.command-input {
flex: 1;
border: none;
background: transparent;
font-size: 1rem;
color: #fff;
outline: none;
}
.command-input::placeholder {
color: #666;
}
.command-shortcut {
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
font-family: inherit;
background: #2a2a2a;
border: 1px solid #444;
border-radius: 4px;
color: #888;
}
.command-results {
max-height: 320px;
overflow-y: auto;
}
.command-loading,
.command-empty {
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
padding: 2rem;
color: #888;
font-size: 0.875rem;
}
.loading-spinner {
width: 1.25rem;
height: 1.25rem;
border: 2px solid #333;
border-top-color: #3b82f6;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.command-result {
display: flex;
align-items: center;
gap: 0.75rem;
width: 100%;
padding: 0.75rem 1.25rem;
background: transparent;
border: none;
cursor: pointer;
text-align: left;
transition: background 0.1s ease;
color: #e5e5e5;
}
.command-result:hover,
.command-result.selected {
background: #2a2a2a;
}
.result-avatar {
width: 40px;
height: 40px;
min-width: 40px;
border-radius: 9999px;
background: #3b82f6;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 0.875rem;
}
.result-info {
flex: 1;
min-width: 0;
}
.result-name {
font-weight: 500;
color: #fff;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.result-details {
display: flex;
gap: 0.5rem;
font-size: 0.8125rem;
color: #888;
}
.result-details span {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.result-favorite {
width: 1rem;
height: 1rem;
color: #ef4444;
flex-shrink: 0;
}
.quick-actions-list {
padding: 0.5rem;
}
.quick-action {
display: flex;
align-items: center;
gap: 0.75rem;
width: 100%;
padding: 0.75rem 1rem;
border-radius: 8px;
color: #e5e5e5;
background: transparent;
border: none;
cursor: pointer;
text-align: left;
transition: background 0.1s ease;
}
.quick-action:hover,
.quick-action.selected {
background: #2a2a2a;
}
.quick-action-icon {
width: 1.25rem;
height: 1.25rem;
color: #888;
}
.quick-action span {
flex: 1;
font-size: 0.9375rem;
}
.quick-action kbd {
padding: 0.125rem 0.375rem;
font-size: 0.6875rem;
font-family: inherit;
background: #2a2a2a;
border: 1px solid #444;
border-radius: 4px;
color: #888;
}
.command-footer {
padding: 0.75rem 1.25rem;
border-top: 1px solid #333;
background: #141414;
}
.footer-hints {
display: flex;
gap: 1rem;
font-size: 0.75rem;
color: #666;
}
.footer-hints kbd {
padding: 0.125rem 0.25rem;
font-family: inherit;
background: #2a2a2a;
border: 1px solid #444;
border-radius: 3px;
margin-right: 0.25rem;
}
@media (max-width: 640px) {
.command-backdrop {
padding-top: 5vh;
}
.footer-hints {
flex-wrap: wrap;
gap: 0.5rem;
}
}
</style>

View file

@ -0,0 +1,2 @@
export { default as CommandBar } from './CommandBar.svelte';
export type { CommandBarItem, QuickAction } from './CommandBar.svelte';

View file

@ -9,13 +9,32 @@ export type { SelectOption } from './molecules';
export { GlassCard, StatRow } from './molecules';
// Tags
export { TagBadge } from './molecules';
export {
TagBadge,
TagColorPicker,
TagEditModal,
TagSelector,
TagList,
TAG_COLORS,
DEFAULT_TAG_COLOR,
getRandomTagColor,
getTagColorByName,
} from './molecules';
export type { Tag, TagData, TagColorName, TagColorHex } from './molecules';
// Media
export { AudioPlayer } from './molecules';
// Loading/Skeletons
export { SkeletonBox, SkeletonText } from './molecules';
export {
SkeletonBox,
SkeletonText,
SkeletonAvatar,
SkeletonRow,
SkeletonList,
SkeletonCard,
SkeletonGrid,
} from './molecules';
// Feedback
export { EmptyState } from './molecules';
@ -27,6 +46,28 @@ export { ModalFooter, DataCard, PageHeader, KeyboardShortcutsPanel } from './mol
export { Modal, ConfirmationModal, FormModal, AppSlider } from './organisms';
export type { AppItem } from './organisms';
// Network Graph
export {
NetworkGraph,
NetworkControls,
stringToColor,
getInitials,
SIMULATION_CONFIG,
NODE_CONFIG,
LABEL_CONFIG,
} from './organisms';
export type {
NetworkNode,
NetworkLink,
NetworkTag,
NetworkTransform,
NetworkGraphProps,
NetworkControlsProps,
NetworkGraphResponse,
SimulationNode,
SimulationLink,
} from './organisms';
// Navigation
export {
NavLink,
@ -63,5 +104,9 @@ export {
GlobalSettingsSection,
} from './settings';
// Command Bar
export { CommandBar } from './command-bar';
export type { CommandBarItem, QuickAction } from './command-bar';
// Pages
export { default as AppsPage } from './pages/AppsPage.svelte';

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';

View file

@ -9,6 +9,98 @@
} from './types';
import PillDropdown from './PillDropdown.svelte';
import PillTabGroup from './PillTabGroup.svelte';
// Phosphor Icons (via shared-icons)
import {
House,
Users,
Tag,
Heart,
Gear,
ChatCircle,
Question,
ShareNetwork,
Bell,
Clock,
Timer,
Target,
Globe,
Tray,
Check,
CheckCircle,
Plus,
Columns,
Microphone,
CalendarBlank,
Folder,
Archive,
Upload,
MusicNote,
File,
ChartBar,
MagnifyingGlass,
List,
Compass,
Moon,
Sun,
SignOut,
CaretDown,
CaretUp,
CaretLeft,
Fire,
GridFour,
Palette,
CreditCard,
Buildings,
User,
} from '@manacore/shared-icons';
// Map icon names to Phosphor components
const phosphorIcons: Record<string, any> = {
home: House,
users: Users,
user: User,
tag: Tag,
heart: Heart,
settings: Gear,
chat: ChatCircle,
'help-circle': Question,
'share-2': ShareNetwork,
bell: Bell,
clock: Clock,
timer: Timer,
target: Target,
globe: Globe,
inbox: Tray,
check: Check,
checkCircle: CheckCircle,
plus: Plus,
columns: Columns,
kanban: Columns,
mic: Microphone,
calendar: CalendarBlank,
folder: Folder,
archive: Archive,
upload: Upload,
music: MusicNote,
document: File,
chart: ChartBar,
search: MagnifyingGlass,
list: List,
compass: Compass,
moon: Moon,
sun: Sun,
logout: SignOut,
chevronDown: CaretDown,
chevronUp: CaretUp,
chevronLeft: CaretLeft,
menu: List,
fire: Fire,
grid: GridFour,
gridSmall: GridFour,
palette: Palette,
creditCard: CreditCard,
building: Buildings,
};
// Convert app items to dropdown items (will be computed as derived)
function createAppDropdownItems(
@ -325,6 +417,10 @@
palette:
'M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01',
chat: 'M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z',
'help-circle':
'M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z',
'share-2':
'M18 8a3 3 0 100-6 3 3 0 000 6zM6 15a3 3 0 100-6 3 3 0 000 6zM18 22a3 3 0 100-6 3 3 0 000 6zM8.59 13.51l6.83 3.98M15.41 6.51l-6.82 3.98',
};
function getIconPath(name: string): string {
@ -368,23 +464,10 @@
d="M12.3047 1C12.3392 1.04573 19.608 10.6706 19.6084 14.6953C19.6084 18.7293 16.3386 21.9998 12.3047 22C8.27061 22 5 18.7294 5 14.6953C5.00041 10.661 12.3047 1 12.3047 1ZM12.3047 7.3916C12.2811 7.42276 8.65234 12.2288 8.65234 14.2393C8.65241 16.2562 10.2877 17.8916 12.3047 17.8916C14.3217 17.8916 15.957 16.2562 15.957 14.2393C15.957 12.2301 12.3331 7.42917 12.3047 7.3916Z"
/>
</svg>
{:else if item.icon === 'settings'}
<svg class="pill-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d={getIconPath('settings')}
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d={getIconPath('settingsInner')}
/>
</svg>
{:else if item.iconSvg}
{@html item.iconSvg}
{:else if phosphorIcons[item.icon]}
<svelte:component this={phosphorIcons[item.icon]} size={18} class="pill-icon" />
{:else}
<svg class="pill-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
@ -416,14 +499,18 @@
{:else if isNavItem(element)}
<a href={element.href} class="pill glass-pill" class:active={isActive(element.href)}>
{#if element.icon}
<svg class="pill-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d={getIconPath(element.icon)}
/>
</svg>
{#if phosphorIcons[element.icon]}
<svelte:component this={phosphorIcons[element.icon]} size={18} class="pill-icon" />
{:else}
<svg class="pill-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d={getIconPath(element.icon)}
/>
</svg>
{/if}
{/if}
<span class="pill-label">{element.label}</span>
</a>

View file

@ -3,3 +3,25 @@ export { default as ConfirmationModal } from './ConfirmationModal.svelte';
export { default as FormModal } from './FormModal.svelte';
export { default as AppSlider } from './AppSlider.svelte';
export type { AppItem } from './AppSlider.types';
// Network Graph
export {
NetworkGraph,
NetworkControls,
stringToColor,
getInitials,
SIMULATION_CONFIG,
NODE_CONFIG,
LABEL_CONFIG,
} from './network';
export type {
NetworkNode,
NetworkLink,
NetworkTag,
NetworkTransform,
NetworkGraphProps,
NetworkControlsProps,
NetworkGraphResponse,
SimulationNode,
SimulationLink,
} from './network';

View file

@ -0,0 +1,604 @@
<script lang="ts">
import { Search, ZoomIn, ZoomOut, RotateCcw, Filter, X, Focus, Keyboard } from 'lucide-svelte';
import type { NetworkTag } from './network.types';
interface Props {
searchQuery?: string;
tags?: NetworkTag[];
selectedTagId?: string | null;
subtitles?: string[];
selectedSubtitle?: string | null;
subtitleLabel?: string;
nodeCount?: number;
linkCount?: number;
nodeLabel?: string;
linkLabel?: string;
searchPlaceholder?: string;
minStrength?: number;
onSearch?: (query: string) => void;
onTagFilter?: (tagId: string | null) => void;
onSubtitleFilter?: (subtitle: string | null) => void;
onStrengthFilter?: (minStrength: number) => void;
onZoomIn?: () => void;
onZoomOut?: () => void;
onResetZoom?: () => void;
onFocusSelected?: () => void;
onClearFilters?: () => void;
}
let {
searchQuery = '',
tags = [],
selectedTagId = null,
subtitles = [],
selectedSubtitle = null,
subtitleLabel = 'Filter',
nodeCount = 0,
linkCount = 0,
nodeLabel = 'Elemente',
linkLabel = 'Verbindungen',
searchPlaceholder = 'Suchen...',
minStrength = 0,
onSearch,
onTagFilter,
onSubtitleFilter,
onStrengthFilter,
onZoomIn,
onZoomOut,
onResetZoom,
onFocusSelected,
onClearFilters,
}: Props = $props();
let searchInput = $state(searchQuery);
let showFilters = $state(false);
let showKeyboardHelp = $state(false);
let strengthValue = $state(minStrength);
let searchInputElement: HTMLInputElement;
// Sync searchInput with external searchQuery
$effect(() => {
searchInput = searchQuery;
});
// Sync strength with external minStrength
$effect(() => {
strengthValue = minStrength;
});
function handleSearchInput(event: Event) {
const target = event.target as HTMLInputElement;
searchInput = target.value;
onSearch?.(target.value);
}
function clearSearch() {
searchInput = '';
onSearch?.('');
}
function handleTagChange(event: Event) {
const target = event.target as HTMLSelectElement;
onTagFilter?.(target.value || null);
}
function handleSubtitleChange(event: Event) {
const target = event.target as HTMLSelectElement;
onSubtitleFilter?.(target.value || null);
}
function clearAllFilters() {
searchInput = '';
strengthValue = 0;
onClearFilters?.();
}
function handleStrengthChange(event: Event) {
const target = event.target as HTMLInputElement;
strengthValue = parseInt(target.value, 10);
onStrengthFilter?.(strengthValue);
}
function focusSearch() {
searchInputElement?.focus();
}
const hasActiveFilters = $derived(
searchQuery || selectedTagId || selectedSubtitle || minStrength > 0
);
// Keyboard shortcuts info
const keyboardShortcuts = [
{ key: '+/-', description: 'Zoom in/out' },
{ key: '0', description: 'Reset zoom' },
{ key: 'F', description: 'Fokus auf Auswahl' },
{ key: '/', description: 'Suche fokussieren' },
{ key: 'Esc', description: 'Auswahl aufheben' },
];
// Export focus function for parent
export { focusSearch };
</script>
<div class="network-controls">
<!-- Search bar -->
<div class="search-container">
<Search size={18} class="search-icon" />
<input
bind:this={searchInputElement}
type="text"
placeholder={searchPlaceholder}
value={searchInput}
oninput={handleSearchInput}
class="search-input"
/>
{#if searchInput}
<button onclick={clearSearch} class="clear-btn" aria-label="Suche löschen">
<X size={16} />
</button>
{/if}
</div>
<!-- Filter toggle -->
{#if tags.length > 0 || subtitles.length > 0}
<button
onclick={() => (showFilters = !showFilters)}
class="control-btn"
class:active={showFilters || hasActiveFilters}
aria-label="Filter anzeigen"
title="Filter"
>
<Filter size={18} />
{#if hasActiveFilters}
<span class="filter-badge"></span>
{/if}
</button>
{/if}
<!-- Zoom controls -->
<div class="zoom-controls">
<button onclick={onZoomIn} class="control-btn" aria-label="Vergrößern" title="Vergrößern (+)">
<ZoomIn size={18} />
</button>
<button
onclick={onZoomOut}
class="control-btn"
aria-label="Verkleinern"
title="Verkleinern (-)"
>
<ZoomOut size={18} />
</button>
<button
onclick={onResetZoom}
class="control-btn"
aria-label="Ansicht zurücksetzen"
title="Zurücksetzen (0)"
>
<RotateCcw size={18} />
</button>
<button
onclick={onFocusSelected}
class="control-btn"
aria-label="Auf Auswahl fokussieren"
title="Fokus auf Auswahl (F)"
>
<Focus size={18} />
</button>
</div>
<!-- Keyboard help toggle -->
<button
onclick={() => (showKeyboardHelp = !showKeyboardHelp)}
class="control-btn"
class:active={showKeyboardHelp}
aria-label="Tastaturkürzel anzeigen"
title="Tastaturkürzel"
>
<Keyboard size={18} />
</button>
<!-- Stats -->
<div class="stats">
<span class="stat">
{nodeCount}
{nodeLabel}
</span>
<span class="stat-divider"></span>
<span class="stat">
{linkCount}
{linkLabel}
</span>
</div>
</div>
<!-- Keyboard shortcuts help -->
{#if showKeyboardHelp}
<div class="keyboard-help">
<div class="keyboard-help-title">Tastaturkürzel</div>
<div class="keyboard-shortcuts">
{#each keyboardShortcuts as shortcut}
<div class="shortcut">
<kbd class="shortcut-key">{shortcut.key}</kbd>
<span class="shortcut-desc">{shortcut.description}</span>
</div>
{/each}
</div>
</div>
{/if}
<!-- Filter panel -->
{#if showFilters}
<div class="filter-panel">
<div class="filter-row">
<!-- Tag filter -->
{#if tags.length > 0}
<div class="filter-group">
<label for="tag-filter" class="filter-label">Tag</label>
<select
id="tag-filter"
onchange={handleTagChange}
value={selectedTagId || ''}
class="filter-select"
>
<option value="">Alle Tags</option>
{#each tags as tag}
<option value={tag.id}>
{tag.name}
</option>
{/each}
</select>
</div>
{/if}
<!-- Subtitle filter (e.g., Company, Project) -->
{#if subtitles.length > 0}
<div class="filter-group">
<label for="subtitle-filter" class="filter-label">{subtitleLabel}</label>
<select
id="subtitle-filter"
onchange={handleSubtitleChange}
value={selectedSubtitle || ''}
class="filter-select"
>
<option value="">Alle</option>
{#each subtitles as subtitle}
<option value={subtitle}>
{subtitle}
</option>
{/each}
</select>
</div>
{/if}
<!-- Strength filter slider -->
<div class="filter-group strength-group">
<label for="strength-filter" class="filter-label">
Min. Stärke: {strengthValue}%
</label>
<input
id="strength-filter"
type="range"
min="0"
max="100"
step="10"
value={strengthValue}
oninput={handleStrengthChange}
class="strength-slider"
/>
</div>
<!-- Clear filters button -->
{#if hasActiveFilters}
<button onclick={clearAllFilters} class="clear-filters-btn">
<X size={14} />
Filter löschen
</button>
{/if}
</div>
</div>
{/if}
<style>
.network-controls {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
background: hsl(var(--card) / 0.8);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid hsl(var(--border) / 0.5);
border-radius: 9999px;
flex-wrap: wrap;
}
.search-container {
position: relative;
flex: 1;
min-width: 200px;
max-width: 300px;
}
.search-container :global(.search-icon) {
position: absolute;
left: 0.75rem;
top: 50%;
transform: translateY(-50%);
color: hsl(var(--muted-foreground));
pointer-events: none;
}
.search-input {
width: 100%;
padding: 0.5rem 2rem 0.5rem 2.5rem;
border: 1px solid hsl(var(--border));
border-radius: 0.5rem;
background: hsl(var(--background));
color: hsl(var(--foreground));
font-size: 0.875rem;
transition:
border-color 0.2s,
box-shadow 0.2s;
}
.search-input:focus {
outline: none;
border-color: hsl(var(--primary));
box-shadow: 0 0 0 2px hsl(var(--primary) / 0.1);
}
.search-input::placeholder {
color: hsl(var(--muted-foreground));
}
.clear-btn {
position: absolute;
right: 0.5rem;
top: 50%;
transform: translateY(-50%);
padding: 0.25rem;
background: none;
border: none;
color: hsl(var(--muted-foreground));
cursor: pointer;
border-radius: 0.25rem;
display: flex;
align-items: center;
justify-content: center;
}
.clear-btn:hover {
color: hsl(var(--foreground));
background: hsl(var(--muted));
}
.control-btn {
position: relative;
padding: 0.5rem;
background: hsl(var(--background));
border: 1px solid hsl(var(--border));
border-radius: 0.5rem;
color: hsl(var(--muted-foreground));
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.control-btn:hover {
background: hsl(var(--muted));
color: hsl(var(--foreground));
}
.control-btn.active {
background: hsl(var(--primary) / 0.1);
border-color: hsl(var(--primary));
color: hsl(var(--primary));
}
.filter-badge {
position: absolute;
top: -2px;
right: -2px;
width: 8px;
height: 8px;
background: hsl(var(--primary));
border-radius: 50%;
}
.zoom-controls {
display: flex;
gap: 0.25rem;
padding-left: 0.5rem;
border-left: 1px solid hsl(var(--border));
}
.stats {
display: flex;
align-items: center;
gap: 0.5rem;
margin-left: auto;
font-size: 0.75rem;
color: hsl(var(--muted-foreground));
}
.stat-divider {
opacity: 0.5;
}
/* Filter panel */
.filter-panel {
margin-top: 0.5rem;
padding: 0.75rem 1rem;
background: hsl(var(--card) / 0.8);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid hsl(var(--border) / 0.5);
border-radius: 1rem;
}
.filter-row {
display: flex;
align-items: flex-end;
gap: 1rem;
flex-wrap: wrap;
}
.filter-group {
display: flex;
flex-direction: column;
gap: 0.25rem;
min-width: 150px;
}
.filter-label {
font-size: 0.75rem;
font-weight: 500;
color: hsl(var(--muted-foreground));
}
.filter-select {
padding: 0.5rem 0.75rem;
border: 1px solid hsl(var(--border));
border-radius: 0.5rem;
background: hsl(var(--background));
color: hsl(var(--foreground));
font-size: 0.875rem;
cursor: pointer;
}
.filter-select:focus {
outline: none;
border-color: hsl(var(--primary));
}
.clear-filters-btn {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.5rem 0.75rem;
background: hsl(var(--destructive) / 0.1);
border: 1px solid hsl(var(--destructive) / 0.2);
border-radius: 0.5rem;
color: hsl(var(--destructive));
font-size: 0.875rem;
cursor: pointer;
transition: all 0.2s;
}
.clear-filters-btn:hover {
background: hsl(var(--destructive) / 0.15);
}
/* Strength slider */
.strength-group {
min-width: 180px;
}
.strength-slider {
width: 100%;
height: 6px;
border-radius: 3px;
background: hsl(var(--muted));
appearance: none;
cursor: pointer;
}
.strength-slider::-webkit-slider-thumb {
appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: hsl(var(--primary));
cursor: pointer;
transition: transform 0.1s;
}
.strength-slider::-webkit-slider-thumb:hover {
transform: scale(1.15);
}
.strength-slider::-moz-range-thumb {
width: 16px;
height: 16px;
border: none;
border-radius: 50%;
background: hsl(var(--primary));
cursor: pointer;
}
/* Keyboard help panel */
.keyboard-help {
margin-top: 0.5rem;
padding: 0.75rem 1rem;
background: hsl(var(--card) / 0.8);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid hsl(var(--border) / 0.5);
border-radius: 1rem;
}
.keyboard-help-title {
font-size: 0.75rem;
font-weight: 600;
color: hsl(var(--muted-foreground));
margin-bottom: 0.5rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.keyboard-shortcuts {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
}
.shortcut {
display: flex;
align-items: center;
gap: 0.5rem;
}
.shortcut-key {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.75rem;
padding: 0.25rem 0.5rem;
background: hsl(var(--muted));
border: 1px solid hsl(var(--border));
border-radius: 0.375rem;
font-family: monospace;
font-size: 0.75rem;
font-weight: 600;
color: hsl(var(--foreground));
}
.shortcut-desc {
font-size: 0.8125rem;
color: hsl(var(--muted-foreground));
}
@media (max-width: 640px) {
.network-controls {
flex-direction: column;
align-items: stretch;
}
.search-container {
max-width: none;
}
.zoom-controls {
padding-left: 0;
border-left: none;
justify-content: center;
}
.stats {
justify-content: center;
margin-left: 0;
}
}
</style>

View file

@ -0,0 +1,671 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { zoom, zoomIdentity, type ZoomBehavior } from 'd3-zoom';
import { select, type Selection } from 'd3-selection';
import 'd3-transition'; // Side-effect import for .transition() method
import type { SimulationNode, SimulationLink, NetworkTransform } from './network.types';
import { stringToColor, getInitials, NODE_CONFIG, LABEL_CONFIG } from './constants';
interface Props {
nodes: SimulationNode[];
links: SimulationLink[];
selectedNodeId?: string | null;
onNodeClick?: (node: SimulationNode) => void;
onNodeDoubleClick?: (node: SimulationNode) => void;
onBackgroundClick?: () => void;
onDragStart?: (node: SimulationNode) => void;
onDrag?: (node: SimulationNode, x: number, y: number) => void;
onDragEnd?: (node: SimulationNode) => void;
onFocusSearch?: () => void;
}
let {
nodes,
links,
selectedNodeId = null,
onNodeClick,
onNodeDoubleClick,
onBackgroundClick,
onDragStart,
onDrag,
onDragEnd,
onFocusSearch,
}: Props = $props();
let svgElement: SVGSVGElement;
let containerElement: HTMLDivElement;
let zoomBehavior: ZoomBehavior<SVGSVGElement, unknown> | null = null;
let transform = $state<NetworkTransform>({ x: 0, y: 0, k: 1 });
let draggedNode: SimulationNode | null = null;
// Tooltip state
let hoveredLink = $state<SimulationLink | null>(null);
let tooltipPosition = $state({ x: 0, y: 0 });
// Setup zoom behavior
$effect(() => {
if (svgElement) {
zoomBehavior = zoom<SVGSVGElement, unknown>()
.scaleExtent([0.1, 4])
.on('zoom', (event) => {
transform = {
x: event.transform.x,
y: event.transform.y,
k: event.transform.k,
};
});
select(svgElement).call(zoomBehavior);
}
});
function handleNodeClick(node: SimulationNode) {
onNodeClick?.(node);
}
function handleBackgroundClick(event: MouseEvent) {
if (event.target === svgElement) {
onBackgroundClick?.();
}
}
function handleNodeDoubleClick(node: SimulationNode) {
onNodeDoubleClick?.(node);
}
function handleDragStart(event: MouseEvent, node: SimulationNode) {
event.stopPropagation();
draggedNode = node;
onDragStart?.(node);
}
function handleDrag(event: MouseEvent) {
if (!draggedNode || !svgElement) return;
const rect = svgElement.getBoundingClientRect();
const x = (event.clientX - rect.left - transform.x) / transform.k;
const y = (event.clientY - rect.top - transform.y) / transform.k;
onDrag?.(draggedNode, x, y);
}
function handleDragEnd() {
if (draggedNode) {
onDragEnd?.(draggedNode);
draggedNode = null;
}
}
function resetZoom() {
if (svgElement && zoomBehavior) {
select(svgElement).transition().duration(300).call(zoomBehavior.transform, zoomIdentity);
}
}
function zoomIn() {
if (svgElement && zoomBehavior) {
select(svgElement).transition().duration(200).call(zoomBehavior.scaleBy, 1.3);
}
}
function zoomOut() {
if (svgElement && zoomBehavior) {
select(svgElement).transition().duration(200).call(zoomBehavior.scaleBy, 0.7);
}
}
function focusOnSelectedNode() {
if (!selectedNodeId || !svgElement || !zoomBehavior || !containerElement) return;
const node = nodes.find((n) => n.id === selectedNodeId);
if (!node || node.x === undefined || node.y === undefined) return;
const rect = containerElement.getBoundingClientRect();
const centerX = rect.width / 2;
const centerY = rect.height / 2;
// Calculate transform to center on node
const scale = 1.5;
const x = centerX - node.x * scale;
const y = centerY - node.y * scale;
select(svgElement)
.transition()
.duration(500)
.call(zoomBehavior.transform, zoomIdentity.translate(x, y).scale(scale));
}
// Keyboard shortcuts
function handleKeydown(event: KeyboardEvent) {
// Ignore if typing in an input
if (
event.target instanceof HTMLInputElement ||
event.target instanceof HTMLTextAreaElement ||
event.target instanceof HTMLSelectElement
) {
return;
}
switch (event.key) {
case '+':
case '=':
event.preventDefault();
zoomIn();
break;
case '-':
case '_':
event.preventDefault();
zoomOut();
break;
case '0':
event.preventDefault();
resetZoom();
break;
case 'Escape':
event.preventDefault();
onBackgroundClick?.();
break;
case 'f':
case 'F':
if (!event.ctrlKey && !event.metaKey) {
event.preventDefault();
focusOnSelectedNode();
}
break;
case '/':
event.preventDefault();
onFocusSearch?.();
break;
}
}
// Setup keyboard listener
$effect(() => {
if (typeof window !== 'undefined') {
window.addEventListener('keydown', handleKeydown);
return () => {
window.removeEventListener('keydown', handleKeydown);
};
}
});
// Get link coordinates
function getLinkCoords(link: SimulationLink) {
const source = link.source as SimulationNode;
const target = link.target as SimulationNode;
return {
x1: source.x ?? 0,
y1: source.y ?? 0,
x2: target.x ?? 0,
y2: target.y ?? 0,
};
}
// Link hover handlers
function handleLinkMouseEnter(event: MouseEvent, link: SimulationLink) {
hoveredLink = link;
updateTooltipPosition(event);
}
function handleLinkMouseMove(event: MouseEvent) {
if (hoveredLink) {
updateTooltipPosition(event);
}
}
function handleLinkMouseLeave() {
hoveredLink = null;
}
function updateTooltipPosition(event: MouseEvent) {
if (!containerElement) return;
const rect = containerElement.getBoundingClientRect();
tooltipPosition = {
x: event.clientX - rect.left,
y: event.clientY - rect.top,
};
}
// Get node names for tooltip
function getLinkNodeNames(link: SimulationLink): { source: string; target: string } {
const source =
typeof link.source === 'string' ? nodes.find((n) => n.id === link.source) : link.source;
const target =
typeof link.target === 'string' ? nodes.find((n) => n.id === link.target) : link.target;
return {
source: source?.name ?? 'Unknown',
target: target?.name ?? 'Unknown',
};
}
// Check if a node is connected to selected node
function isConnectedToSelected(nodeId: string): boolean {
if (!selectedNodeId) return false;
if (nodeId === selectedNodeId) return true;
return links.some((link) => {
const sourceId = typeof link.source === 'string' ? link.source : link.source.id;
const targetId = typeof link.target === 'string' ? link.target : link.target.id;
return (
(sourceId === selectedNodeId && targetId === nodeId) ||
(targetId === selectedNodeId && sourceId === nodeId)
);
});
}
// Export functions for parent component
export { resetZoom, zoomIn, zoomOut, focusOnSelectedNode };
</script>
<div
bind:this={containerElement}
class="network-graph-container"
onmousemove={handleDrag}
onmouseup={handleDragEnd}
onmouseleave={handleDragEnd}
role="application"
aria-label="Network Graph"
>
<svg
bind:this={svgElement}
class="network-graph-svg"
style="width: 100%; height: 100%;"
onclick={handleBackgroundClick}
>
<g transform="translate({transform.x}, {transform.y}) scale({transform.k})">
<!-- Links -->
<g class="links">
{#each links as link}
{@const coords = getLinkCoords(link)}
{@const sourceId = typeof link.source === 'string' ? link.source : link.source.id}
{@const targetId = typeof link.target === 'string' ? link.target : link.target.id}
{@const isHighlighted =
selectedNodeId && (sourceId === selectedNodeId || targetId === selectedNodeId)}
<!-- Invisible wider line for easier hover -->
<line
x1={coords.x1}
y1={coords.y1}
x2={coords.x2}
y2={coords.y2}
stroke="transparent"
stroke-width="20"
class="link-hitbox"
onmouseenter={(e) => handleLinkMouseEnter(e, link)}
onmousemove={handleLinkMouseMove}
onmouseleave={handleLinkMouseLeave}
/>
<!-- Visible link -->
<line
x1={coords.x1}
y1={coords.y1}
x2={coords.x2}
y2={coords.y2}
stroke-width={Math.max(1, link.strength / 25)}
class="link"
class:highlighted={isHighlighted}
class:dimmed={selectedNodeId && !isHighlighted}
class:hovered={hoveredLink === link}
pointer-events="none"
/>
{/each}
</g>
<!-- Nodes -->
<g class="nodes">
{#each nodes as node (node.id)}
{@const isSelected = node.id === selectedNodeId}
{@const isConnected = isConnectedToSelected(node.id)}
{@const isDimmed = selectedNodeId && !isConnected}
{@const nodeRadius = isSelected ? NODE_CONFIG.selectedRadius : NODE_CONFIG.radius}
{@const avatarRadius = isSelected
? NODE_CONFIG.selectedAvatarRadius
: NODE_CONFIG.avatarRadius}
{@const badgeOffset = isSelected
? NODE_CONFIG.selectedBadgeOffset
: NODE_CONFIG.badgeOffset}
<g
transform="translate({node.x ?? 0}, {node.y ?? 0})"
class="node"
class:selected={isSelected}
class:connected={isConnected && !isSelected}
class:dimmed={isDimmed}
onmousedown={(e) => handleDragStart(e, node)}
onclick={() => handleNodeClick(node)}
ondblclick={() => handleNodeDoubleClick(node)}
role="button"
tabindex="0"
aria-label={node.name}
>
<!-- Node circle -->
<circle r={nodeRadius} fill={stringToColor(node.name)} class="node-circle" />
<!-- Avatar image or initials -->
{#if node.photoUrl}
<clipPath id="clip-{node.id}">
<circle r={avatarRadius} />
</clipPath>
<image
href={node.photoUrl}
x={-avatarRadius}
y={-avatarRadius}
width={avatarRadius * 2}
height={avatarRadius * 2}
clip-path="url(#clip-{node.id})"
preserveAspectRatio="xMidYMid slice"
/>
{:else}
<text
class="node-initials"
text-anchor="middle"
dominant-baseline="central"
fill="white"
font-size={isSelected
? LABEL_CONFIG.selectedInitialsFontSize
: LABEL_CONFIG.initialsFontSize}
font-weight="600"
>
{getInitials(node.name)}
</text>
{/if}
<!-- Favorite indicator -->
{#if node.isFavorite}
<circle cx={badgeOffset} cy={-badgeOffset} r="10" fill="hsl(var(--background))" />
<text
x={badgeOffset}
y={-badgeOffset}
text-anchor="middle"
dominant-baseline="central"
font-size="12"
>
&#11088;
</text>
{/if}
<!-- Connection count badge -->
{#if node.connectionCount > 0}
<circle cx={-badgeOffset} cy={-badgeOffset} r="12" fill="hsl(var(--primary))" />
<text
x={-badgeOffset}
y={-badgeOffset}
text-anchor="middle"
dominant-baseline="central"
fill="white"
font-size="11"
font-weight="600"
>
{node.connectionCount}
</text>
{/if}
<!-- Node label (counter-scaled for zoom independence) -->
<g transform="scale({1 / transform.k})">
<text
y={(isSelected ? LABEL_CONFIG.selectedNameOffset : LABEL_CONFIG.nameOffset) *
transform.k}
class="node-label"
text-anchor="middle"
font-size={isSelected
? LABEL_CONFIG.selectedNameFontSize
: LABEL_CONFIG.nameFontSize}
font-weight={isSelected ? '600' : '500'}
>
{node.name}
</text>
<!-- Subtitle label (e.g., company) -->
{#if node.subtitle}
{@const labelOffset =
(isSelected ? LABEL_CONFIG.selectedNameOffset : LABEL_CONFIG.nameOffset) *
transform.k}
<text
y={labelOffset + LABEL_CONFIG.subtitleGap}
class="node-subtitle"
text-anchor="middle"
font-size={LABEL_CONFIG.subtitleFontSize}
>
{node.subtitle}
</text>
{/if}
</g>
</g>
{/each}
</g>
</g>
</svg>
<!-- Empty state -->
{#if nodes.length === 0}
<div class="empty-state">
<div class="empty-icon">&#128279;</div>
<p class="empty-title">Keine Verbindungen gefunden</p>
<p class="empty-description">Elemente werden verbunden, wenn sie gemeinsame Tags haben.</p>
</div>
{/if}
<!-- Link tooltip -->
{#if hoveredLink}
{@const names = getLinkNodeNames(hoveredLink)}
<div class="link-tooltip" style="left: {tooltipPosition.x}px; top: {tooltipPosition.y}px;">
<div class="tooltip-header">
<span class="tooltip-source">{names.source}</span>
<span class="tooltip-arrow"></span>
<span class="tooltip-target">{names.target}</span>
</div>
<div class="tooltip-strength">
<span class="strength-label">Stärke:</span>
<span class="strength-value">{hoveredLink.strength}%</span>
<div class="strength-bar">
<div class="strength-fill" style="width: {hoveredLink.strength}%"></div>
</div>
</div>
<div class="tooltip-tags">
{#each hoveredLink.sharedTags as tag}
<span class="tooltip-tag">{tag}</span>
{/each}
</div>
</div>
{/if}
</div>
<style>
.network-graph-container {
width: 100%;
height: 100%;
position: relative;
overflow: hidden;
background: hsl(var(--background));
}
.network-graph-svg {
display: block;
cursor: grab;
}
.network-graph-svg:active {
cursor: grabbing;
}
/* Links */
.link {
stroke: hsl(var(--muted-foreground) / 0.3);
transition:
stroke 0.2s,
stroke-width 0.2s,
opacity 0.2s;
}
.link.highlighted {
stroke: hsl(var(--primary));
stroke-width: 3 !important;
}
.link.dimmed {
opacity: 0.1;
}
.link.hovered {
stroke: hsl(var(--primary));
stroke-width: 3 !important;
}
.link-hitbox {
cursor: pointer;
}
/* Nodes */
.node {
cursor: pointer;
transition: opacity 0.2s;
}
.node:hover .node-circle {
filter: brightness(1.1);
}
.node.selected .node-circle {
stroke: hsl(var(--primary));
stroke-width: 4;
}
.node.connected .node-circle {
stroke: hsl(var(--primary) / 0.5);
stroke-width: 2;
}
.node.dimmed {
opacity: 0.3;
}
.node-circle {
transition:
r 0.2s,
stroke 0.2s,
stroke-width 0.2s,
filter 0.2s;
}
.node-initials {
pointer-events: none;
user-select: none;
}
.node-label {
fill: hsl(var(--foreground));
pointer-events: none;
user-select: none;
}
.node-subtitle {
fill: hsl(var(--muted-foreground));
pointer-events: none;
user-select: none;
}
/* Empty state */
.empty-state {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
text-align: center;
padding: 2rem;
}
.empty-icon {
font-size: 3rem;
margin-bottom: 1rem;
}
.empty-title {
font-size: 1.25rem;
font-weight: 600;
color: hsl(var(--foreground));
margin-bottom: 0.5rem;
}
.empty-description {
color: hsl(var(--muted-foreground));
max-width: 300px;
line-height: 1.5;
}
/* Link tooltip */
.link-tooltip {
position: absolute;
transform: translate(-50%, -100%) translateY(-12px);
padding: 0.75rem 1rem;
background: hsl(var(--popover));
border: 1px solid hsl(var(--border));
border-radius: 0.75rem;
box-shadow: 0 4px 12px hsl(var(--foreground) / 0.1);
pointer-events: none;
z-index: 100;
min-width: 200px;
max-width: 300px;
}
.tooltip-header {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
font-size: 0.875rem;
font-weight: 500;
color: hsl(var(--foreground));
}
.tooltip-arrow {
color: hsl(var(--muted-foreground));
}
.tooltip-strength {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.625rem;
font-size: 0.75rem;
}
.strength-label {
color: hsl(var(--muted-foreground));
}
.strength-value {
font-weight: 600;
color: hsl(var(--primary));
}
.strength-bar {
flex: 1;
height: 4px;
background: hsl(var(--muted));
border-radius: 2px;
overflow: hidden;
}
.strength-fill {
height: 100%;
background: hsl(var(--primary));
border-radius: 2px;
transition: width 0.2s;
}
.tooltip-tags {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
}
.tooltip-tag {
display: inline-flex;
align-items: center;
padding: 0.25rem 0.625rem;
background: hsl(var(--primary) / 0.1);
border: 1px solid hsl(var(--primary) / 0.2);
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 500;
color: hsl(var(--primary));
}
</style>

View file

@ -0,0 +1,86 @@
/**
* Generate a consistent HSL color from a string
* @param str - Input string (e.g., name)
* @returns HSL color string
*/
export function stringToColor(str: string): string {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash);
}
const hue = hash % 360;
return `hsl(${hue}, 70%, 50%)`;
}
/**
* Get initials from a name
* @param name - Full name
* @returns 1-2 character initials
*/
export function getInitials(name: string): string {
const parts = name.trim().split(' ').filter(Boolean);
if (parts.length >= 2) {
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
}
return name.substring(0, 2).toUpperCase();
}
/**
* D3 Force simulation default parameters
*/
export const SIMULATION_CONFIG = {
/** Distance between linked nodes */
linkDistance: 100,
/** Strength of links (0-1) */
linkStrength: 0.5,
/** Charge strength (negative = repulsion) */
chargeStrength: -300,
/** Collision radius for nodes */
collisionRadius: 50,
/** Initial alpha for simulation */
initialAlpha: 1,
/** Alpha for reheating simulation */
reheatAlpha: 0.3,
/** Zoom scale extent */
zoomExtent: [0.1, 4] as [number, number],
} as const;
/**
* Node size configuration
*/
export const NODE_CONFIG = {
/** Default node radius */
radius: 36,
/** Selected node radius */
selectedRadius: 40,
/** Avatar clip radius (slightly smaller than node) */
avatarRadius: 34,
/** Selected avatar clip radius */
selectedAvatarRadius: 38,
/** Badge offset from center */
badgeOffset: 25,
/** Selected badge offset */
selectedBadgeOffset: 28,
} as const;
/**
* Label configuration
*/
export const LABEL_CONFIG = {
/** Font size for name label */
nameFontSize: 18,
/** Selected name font size */
selectedNameFontSize: 20,
/** Font size for subtitle label */
subtitleFontSize: 14,
/** Y offset for name label */
nameOffset: 58,
/** Selected name Y offset */
selectedNameOffset: 62,
/** Gap between name and subtitle */
subtitleGap: 22,
/** Font size for initials */
initialsFontSize: 18,
/** Selected initials font size */
selectedInitialsFontSize: 20,
} as const;

View file

@ -0,0 +1,25 @@
// Components
export { default as NetworkGraph } from './NetworkGraph.svelte';
export { default as NetworkControls } from './NetworkControls.svelte';
// Types
export type {
NetworkNode,
NetworkLink,
NetworkTag,
NetworkTransform,
NetworkGraphProps,
NetworkControlsProps,
NetworkGraphResponse,
SimulationNode,
SimulationLink,
} from './network.types';
// Constants & Helpers
export {
stringToColor,
getInitials,
SIMULATION_CONFIG,
NODE_CONFIG,
LABEL_CONFIG,
} from './constants';

View file

@ -0,0 +1,112 @@
import type { SimulationNodeDatum } from 'd3-force';
/**
* Tag attached to a network node
*/
export interface NetworkTag {
id: string;
name: string;
color: string | null;
}
/**
* Base network node interface (before D3 simulation)
*/
export interface NetworkNode {
id: string;
name: string;
photoUrl?: string | null;
subtitle?: string | null; // e.g., Company, Project, Category
isFavorite?: boolean;
tags: NetworkTag[];
connectionCount: number;
}
/**
* Network node with D3 simulation properties
*/
export interface SimulationNode extends NetworkNode, SimulationNodeDatum {
x?: number;
y?: number;
vx?: number;
vy?: number;
fx?: number | null;
fy?: number | null;
}
/**
* Network link between nodes
*/
export interface NetworkLink {
source: string;
target: string;
type: 'tag';
strength: number; // 0-100, based on shared tag count
sharedTags: string[];
}
/**
* Network link with D3 simulation properties
* Note: After D3 simulation runs, source/target become SimulationNode objects
*/
export interface SimulationLink {
source: string | SimulationNode;
target: string | SimulationNode;
type: 'tag';
strength: number;
sharedTags: string[];
index?: number;
}
/**
* Zoom/pan transform state
*/
export interface NetworkTransform {
x: number;
y: number;
k: number; // scale factor
}
/**
* Props for NetworkGraph component
*/
export interface NetworkGraphProps {
nodes: SimulationNode[];
links: SimulationLink[];
selectedNodeId?: string | null;
onNodeClick?: (node: SimulationNode) => void;
onNodeDoubleClick?: (node: SimulationNode) => void;
onBackgroundClick?: () => void;
}
/**
* Props for NetworkControls component
*/
export interface NetworkControlsProps {
searchQuery?: string;
tags?: NetworkTag[];
selectedTagId?: string | null;
subtitles?: string[]; // e.g., companies, projects
selectedSubtitle?: string | null;
subtitleLabel?: string; // e.g., "Firma", "Projekt"
nodeCount?: number;
linkCount?: number;
nodeLabel?: string; // e.g., "Kontakte", "Tasks"
linkLabel?: string; // e.g., "Verbindungen"
searchPlaceholder?: string;
onSearch?: (query: string) => void;
onTagFilter?: (tagId: string | null) => void;
onSubtitleFilter?: (subtitle: string | null) => void;
onZoomIn?: () => void;
onZoomOut?: () => void;
onResetZoom?: () => void;
onClearFilters?: () => void;
}
/**
* API response structure for network graph
*/
export interface NetworkGraphResponse {
nodes: NetworkNode[];
links: NetworkLink[];
}

View file

@ -21,87 +21,42 @@
class: className = '',
children,
}: Props = $props();
// Base card classes using Tailwind
const baseCardClasses =
'rounded-2xl overflow-hidden shadow-md border backdrop-blur-xl ' +
'bg-white/85 border-black/10 ' +
'dark:bg-white/[0.06] dark:border-white/10 dark:shadow-lg';
const dangerCardClasses =
'rounded-2xl overflow-hidden shadow-md border backdrop-blur-xl ' +
'bg-red-500/[0.08] border-red-500/30 ' +
'dark:bg-red-500/[0.12] dark:border-red-500/25 dark:shadow-lg';
const headerClasses = 'px-5 py-4 border-b border-black/[0.08] dark:border-white/10';
const dangerHeaderClasses = 'px-5 py-4 border-b border-red-500/20 bg-red-500/10';
</script>
<div class="settings-card settings-card--{variant} {className}">
<div class="{variant === 'danger' ? dangerCardClasses : baseCardClasses} {className}">
{#if title || description}
<header class="settings-card__header">
<header class={variant === 'danger' ? dangerHeaderClasses : headerClasses}>
{#if title}
<h3 class="settings-card__title">{title}</h3>
<h3
class="text-base font-semibold text-foreground {variant === 'danger'
? 'text-red-500 dark:text-red-400'
: ''}"
>
{title}
</h3>
{/if}
{#if description}
<p class="settings-card__description">{description}</p>
<p class="text-sm text-muted-foreground mt-1">{description}</p>
{/if}
</header>
{/if}
<div class="settings-card__content">
<div class="flex flex-col">
{@render children()}
</div>
</div>
<style>
.settings-card {
/* Glass effect */
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);
border-radius: 1rem;
overflow: hidden;
box-shadow:
0 4px 6px -1px rgba(0, 0, 0, 0.1),
0 2px 4px -1px rgba(0, 0, 0, 0.06);
}
:global(.dark) .settings-card {
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.12);
}
.settings-card--danger {
border-color: hsl(var(--destructive) / 0.3);
background: rgba(239, 68, 68, 0.08);
}
:global(.dark) .settings-card--danger {
background: rgba(239, 68, 68, 0.12);
border-color: rgba(239, 68, 68, 0.25);
}
.settings-card__header {
padding: 1rem 1.25rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
}
:global(.dark) .settings-card__header {
border-bottom-color: rgba(255, 255, 255, 0.1);
}
.settings-card--danger .settings-card__header {
border-bottom-color: hsl(var(--destructive) / 0.2);
background: rgba(239, 68, 68, 0.1);
}
.settings-card__title {
font-size: 1rem;
font-weight: 600;
color: hsl(var(--foreground));
margin: 0;
}
.settings-card--danger .settings-card__title {
color: hsl(var(--destructive));
}
.settings-card__description {
font-size: 0.875rem;
color: hsl(var(--muted-foreground));
margin: 0.25rem 0 0 0;
}
.settings-card__content {
display: flex;
flex-direction: column;
}
</style>

View file

@ -30,148 +30,46 @@
disabled = false,
class: className = '',
}: Props = $props();
// Tailwind classes
const baseClasses = 'flex items-center justify-between gap-4 px-5 py-4';
const borderClasses = 'border-b border-red-500/[0.12] dark:border-red-500/[0.18] last:border-b-0';
const disabledClasses = 'opacity-50 cursor-not-allowed';
const iconClasses =
'flex items-center justify-center flex-shrink-0 w-9 h-9 rounded-[0.625rem] bg-red-500/10 dark:bg-red-500/[0.15] text-red-500 dark:text-red-400 [&>svg]:w-[1.125rem] [&>svg]:h-[1.125rem]';
</script>
<div
class="settings-danger-button {border ? 'settings-danger-button--border' : ''} {disabled
? 'settings-danger-button--disabled'
: ''} {className}"
class="{baseClasses} {border ? borderClasses : ''} {disabled ? disabledClasses : ''} {className}"
>
<div class="settings-danger-button__content">
<div class="flex items-center gap-3 flex-1 min-w-0">
{#if icon}
<span class="settings-danger-button__icon">
<span class={iconClasses}>
{@render icon()}
</span>
{/if}
<div class="settings-danger-button__text">
<span class="settings-danger-button__label">{label}</span>
<div class="flex flex-col gap-0.5 min-w-0">
<span class="text-[0.9375rem] font-medium text-gray-700 dark:text-gray-100">{label}</span>
{#if description}
<span class="settings-danger-button__description">{description}</span>
<span class="text-[0.8125rem] text-gray-500 dark:text-gray-400 leading-snug"
>{description}</span
>
{/if}
</div>
</div>
<button type="button" {onclick} class="settings-danger-button__button" {disabled}>
<button
type="button"
{onclick}
class="px-4 py-2 text-sm font-medium text-red-500 dark:text-red-400
bg-red-500/10 dark:bg-red-500/[0.15] border border-red-500/20 dark:border-red-500/25
rounded-lg cursor-pointer flex-shrink-0 transition-all duration-200
hover:bg-red-500/20 dark:hover:bg-red-500/25 hover:border-red-500/30 dark:hover:border-red-500/35
focus-visible:outline-2 focus-visible:outline-red-500/40 focus-visible:outline-offset-2
disabled:cursor-not-allowed"
{disabled}
>
{buttonText || label}
</button>
</div>
<style>
.settings-danger-button {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1rem 1.25rem;
}
.settings-danger-button--border {
border-bottom: 1px solid rgba(239, 68, 68, 0.12);
}
:global(.dark) .settings-danger-button--border {
border-bottom-color: rgba(239, 68, 68, 0.18);
}
.settings-danger-button--border:last-child {
border-bottom: none;
}
.settings-danger-button--disabled {
opacity: 0.5;
cursor: not-allowed;
}
.settings-danger-button__content {
display: flex;
align-items: center;
gap: 0.75rem;
flex: 1;
min-width: 0;
}
.settings-danger-button__icon {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 2.25rem;
height: 2.25rem;
border-radius: 0.625rem;
background: rgba(239, 68, 68, 0.1);
color: hsl(var(--destructive));
}
:global(.dark) .settings-danger-button__icon {
background: rgba(239, 68, 68, 0.15);
}
.settings-danger-button__icon :global(svg) {
width: 1.125rem;
height: 1.125rem;
}
.settings-danger-button__text {
display: flex;
flex-direction: column;
gap: 0.125rem;
min-width: 0;
}
.settings-danger-button__label {
font-size: 0.9375rem;
font-weight: 500;
color: #374151;
}
:global(.dark) .settings-danger-button__label {
color: #f3f4f6;
}
.settings-danger-button__description {
font-size: 0.8125rem;
color: #6b7280;
line-height: 1.4;
}
:global(.dark) .settings-danger-button__description {
color: #9ca3af;
}
.settings-danger-button__button {
padding: 0.5rem 1rem;
font-size: 0.875rem;
font-weight: 500;
color: hsl(var(--destructive));
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.2);
border-radius: 0.5rem;
cursor: pointer;
flex-shrink: 0;
transition: all 0.2s ease;
}
:global(.dark) .settings-danger-button__button {
background: rgba(239, 68, 68, 0.15);
border-color: rgba(239, 68, 68, 0.25);
}
.settings-danger-button__button:hover:not(:disabled) {
background: rgba(239, 68, 68, 0.2);
border-color: rgba(239, 68, 68, 0.3);
}
:global(.dark) .settings-danger-button__button:hover:not(:disabled) {
background: rgba(239, 68, 68, 0.25);
border-color: rgba(239, 68, 68, 0.35);
}
.settings-danger-button__button:disabled {
cursor: not-allowed;
}
.settings-danger-button__button:focus-visible {
outline: 2px solid rgba(239, 68, 68, 0.4);
outline-offset: 2px;
}
</style>

View file

@ -13,55 +13,16 @@
let { title = 'Danger Zone', class: className = '', children }: Props = $props();
</script>
<section class="settings-danger-zone {className}">
<header class="settings-danger-zone__header">
<h2 class="settings-danger-zone__title">{title}</h2>
<section
class="bg-red-500/[0.08] dark:bg-red-500/[0.12] backdrop-blur-xl border border-red-500/20 dark:border-red-500/25 rounded-2xl overflow-hidden shadow-md {className}"
>
<header
class="px-5 py-4 border-b border-red-500/[0.15] dark:border-red-500/20 bg-red-500/10 dark:bg-red-500/[0.15]"
>
<h2 class="text-base font-semibold text-red-500 dark:text-red-400 m-0">{title}</h2>
</header>
<div class="settings-danger-zone__content">
<div class="flex flex-col">
{@render children()}
</div>
</section>
<style>
.settings-danger-zone {
/* Glass effect with danger tint */
background: rgba(239, 68, 68, 0.08);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(239, 68, 68, 0.2);
border-radius: 1rem;
overflow: hidden;
box-shadow:
0 4px 6px -1px rgba(239, 68, 68, 0.1),
0 2px 4px -1px rgba(239, 68, 68, 0.06);
}
:global(.dark) .settings-danger-zone {
background: rgba(239, 68, 68, 0.12);
border-color: rgba(239, 68, 68, 0.25);
}
.settings-danger-zone__header {
padding: 1rem 1.25rem;
border-bottom: 1px solid rgba(239, 68, 68, 0.15);
background: rgba(239, 68, 68, 0.1);
}
:global(.dark) .settings-danger-zone__header {
border-bottom-color: rgba(239, 68, 68, 0.2);
background: rgba(239, 68, 68, 0.15);
}
.settings-danger-zone__title {
font-size: 1rem;
font-weight: 600;
color: hsl(var(--destructive));
margin: 0;
}
.settings-danger-zone__content {
display: flex;
flex-direction: column;
}
</style>

View file

@ -61,30 +61,45 @@
onchange(clampedValue);
}
}
// Tailwind classes
const baseClasses = 'flex items-center justify-between gap-4 px-5 py-4';
const borderClasses = 'border-b border-black/[0.08] dark:border-white/10 last:border-b-0';
const disabledClasses = 'opacity-50 cursor-not-allowed';
const iconClasses =
'flex items-center justify-center flex-shrink-0 w-9 h-9 rounded-[0.625rem] bg-black/[0.04] dark:bg-white/[0.08] text-primary [&>svg]:w-[1.125rem] [&>svg]:h-[1.125rem]';
</script>
<div
class="settings-number {border ? 'settings-number--border' : ''} {disabled
? 'settings-number--disabled'
: ''} {className}"
class="{baseClasses} {border ? borderClasses : ''} {disabled ? disabledClasses : ''} {className}"
>
<div class="settings-number__content">
<div class="flex items-center gap-3 flex-1 min-w-0">
{#if icon}
<span class="settings-number__icon">
<span class={iconClasses}>
{@render icon()}
</span>
{/if}
<div class="settings-number__text">
<span class="settings-number__label">{label}</span>
<div class="flex flex-col gap-0.5 min-w-0">
<span class="text-[0.9375rem] font-medium text-gray-700 dark:text-gray-100">{label}</span>
{#if description}
<span class="settings-number__description">{description}</span>
<span class="text-[0.8125rem] text-gray-500 dark:text-gray-400 leading-snug"
>{description}</span
>
{/if}
</div>
</div>
<input
type="number"
class="settings-number__input"
class="w-20 px-3 py-2 text-sm font-medium text-gray-700 dark:text-gray-100 text-center
bg-black/[0.04] dark:bg-white/[0.08] border border-black/10 dark:border-white/[0.15]
rounded-lg transition-all duration-200
hover:border-black/20 dark:hover:border-white/25 hover:bg-black/[0.06] dark:hover:bg-white/[0.12]
focus-visible:outline-2 focus-visible:outline-primary/40 focus-visible:outline-offset-2 focus-visible:border-primary
disabled:cursor-not-allowed disabled:opacity-60
placeholder:text-gray-400 dark:placeholder:text-gray-500 placeholder:font-normal
[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
value={value ?? ''}
oninput={handleInput}
{min}
@ -95,147 +110,3 @@
aria-label={label}
/>
</div>
<style>
.settings-number {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1rem 1.25rem;
}
.settings-number--border {
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
}
:global(.dark) .settings-number--border {
border-bottom-color: rgba(255, 255, 255, 0.1);
}
.settings-number--border:last-child {
border-bottom: none;
}
.settings-number--disabled {
opacity: 0.5;
cursor: not-allowed;
}
.settings-number__content {
display: flex;
align-items: center;
gap: 0.75rem;
flex: 1;
min-width: 0;
}
.settings-number__icon {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 2.25rem;
height: 2.25rem;
border-radius: 0.625rem;
background: rgba(0, 0, 0, 0.04);
color: hsl(var(--primary));
}
:global(.dark) .settings-number__icon {
background: rgba(255, 255, 255, 0.08);
}
.settings-number__icon :global(svg) {
width: 1.125rem;
height: 1.125rem;
}
.settings-number__text {
display: flex;
flex-direction: column;
gap: 0.125rem;
min-width: 0;
}
.settings-number__label {
font-size: 0.9375rem;
font-weight: 500;
color: #374151;
}
:global(.dark) .settings-number__label {
color: #f3f4f6;
}
.settings-number__description {
font-size: 0.8125rem;
color: #6b7280;
line-height: 1.4;
}
:global(.dark) .settings-number__description {
color: #9ca3af;
}
/* Number Input - Glass style */
.settings-number__input {
width: 5rem;
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
font-weight: 500;
color: #374151;
text-align: center;
background-color: rgba(0, 0, 0, 0.04);
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 0.5rem;
transition: all 0.2s ease;
}
:global(.dark) .settings-number__input {
color: #f3f4f6;
background-color: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.15);
}
.settings-number__input:hover:not(:disabled) {
border-color: rgba(0, 0, 0, 0.2);
background-color: rgba(0, 0, 0, 0.06);
}
:global(.dark) .settings-number__input:hover:not(:disabled) {
border-color: rgba(255, 255, 255, 0.25);
background-color: rgba(255, 255, 255, 0.12);
}
.settings-number__input:focus-visible {
outline: 2px solid hsl(var(--primary) / 0.4);
outline-offset: 2px;
border-color: hsl(var(--primary));
}
.settings-number__input:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.settings-number__input::placeholder {
color: #9ca3af;
font-weight: 400;
}
:global(.dark) .settings-number__input::placeholder {
color: #6b7280;
}
/* Hide spinner buttons */
.settings-number__input::-webkit-outer-spin-button,
.settings-number__input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.settings-number__input[type='number'] {
-moz-appearance: textfield;
}
</style>

View file

@ -1,5 +1,12 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { onMount } from 'svelte';
interface TocItem {
id: string;
title: string;
icon?: string;
}
interface Props {
/** Page title */
@ -22,65 +29,234 @@
lg: 'max-w-3xl',
xl: 'max-w-4xl',
};
let tocItems = $state<TocItem[]>([]);
let activeSection = $state<string>('');
let contentEl: HTMLElement;
onMount(() => {
// Collect all section headers
const sections = contentEl.querySelectorAll('[data-settings-section]');
const items: TocItem[] = [];
sections.forEach((section, index) => {
const id = section.getAttribute('data-settings-section') || `section-${index}`;
const titleEl = section.querySelector('.section-title');
const title = titleEl?.textContent || `Section ${index + 1}`;
items.push({ id, title });
});
tocItems = items;
// Find the currently active section based on scroll position
function updateActiveSection() {
const scrollPosition = window.scrollY + window.innerHeight;
const pageHeight = document.documentElement.scrollHeight;
const bottomThreshold = 50;
// If at bottom of page, activate last section
if (pageHeight - scrollPosition <= bottomThreshold && tocItems.length > 0) {
activeSection = tocItems[tocItems.length - 1].id;
return;
}
// Find which section is currently in view
const viewportTop = window.scrollY + 120; // Account for sticky header offset
let currentSection = '';
sections.forEach((section) => {
const rect = section.getBoundingClientRect();
const sectionTop = rect.top + window.scrollY;
// Section is active if its top is above our viewport check point
if (sectionTop <= viewportTop) {
const id = section.getAttribute('data-settings-section');
if (id) currentSection = id;
}
});
if (currentSection) {
activeSection = currentSection;
} else if (tocItems.length > 0) {
// Default to first section if nothing else matches
activeSection = tocItems[0].id;
}
}
// Initial check
updateActiveSection();
// Update on scroll
window.addEventListener('scroll', updateActiveSection, { passive: true });
return () => {
window.removeEventListener('scroll', updateActiveSection);
};
});
function scrollToSection(id: string) {
const section = contentEl.querySelector(`[data-settings-section="${id}"]`);
if (section) {
const y = section.getBoundingClientRect().top + window.scrollY - 100;
window.scrollTo({ top: y, behavior: 'smooth' });
}
}
</script>
<div class="settings-page {className}">
<div class="settings-page__container {maxWidthClasses[maxWidth]}">
<header class="settings-page__header">
<h1 class="settings-page__title">{title}</h1>
{#if subtitle}
<p class="settings-page__subtitle">{subtitle}</p>
{/if}
</header>
<div class="settings-page__content">
{@render children()}
<div class="settings-page bg-background {className}">
<!-- Table of Contents - Desktop only -->
<aside class="toc-sidebar">
<div class="toc-container">
<p class="toc-title">Inhalt</p>
<nav class="toc-nav">
{#each tocItems as item}
<button
class="toc-item"
class:active={activeSection === item.id}
onclick={() => scrollToSection(item.id)}
>
{item.title}
</button>
{/each}
</nav>
</div>
</div>
</aside>
<!-- Main Content -->
<main class="settings-main">
<div class="settings-content {maxWidthClasses[maxWidth]}">
<header class="settings-header">
<h1 class="text-2xl sm:text-[1.75rem] font-bold text-foreground m-0">{title}</h1>
{#if subtitle}
<p class="text-sm text-muted-foreground mt-1">{subtitle}</p>
{/if}
</header>
<div class="sections-container" bind:this={contentEl}>
{@render children()}
</div>
</div>
</main>
</div>
<style>
.settings-page {
min-height: calc(100vh - 4rem);
padding: 2rem 1rem;
background-color: hsl(var(--background));
position: relative;
}
.settings-page__container {
/* Table of Contents Sidebar - Fixed position on the left */
.toc-sidebar {
display: none;
}
@media (min-width: 1400px) {
.toc-sidebar {
display: block;
position: fixed;
left: 2rem;
top: 100px;
width: 240px;
max-height: calc(100vh - 140px);
overflow-y: auto;
z-index: 10;
}
}
.toc-container {
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);
border-radius: 1rem;
padding: 1.25rem;
box-shadow:
0 4px 6px -1px rgba(0, 0, 0, 0.1),
0 2px 4px -1px rgba(0, 0, 0, 0.06);
}
:global(.dark) .toc-container {
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.toc-title {
font-size: 0.875rem;
font-weight: 600;
color: hsl(var(--foreground));
margin: 0 0 1rem 0;
padding-bottom: 0.75rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
}
:global(.dark) .toc-title {
border-bottom-color: rgba(255, 255, 255, 0.1);
}
.toc-nav {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.toc-item {
display: block;
width: 100%;
text-align: left;
padding: 0.625rem 0.875rem;
font-size: 0.875rem;
color: hsl(var(--muted-foreground));
background: transparent;
border: none;
border-radius: 0.5rem;
cursor: pointer;
transition: all 0.15s ease;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.toc-item:hover {
color: hsl(var(--foreground));
background: hsl(var(--muted) / 0.5);
}
.toc-item.active {
color: hsl(var(--primary));
background: hsl(var(--primary) / 0.12);
font-weight: 600;
}
/* Main Content Area - Always centered */
.settings-main {
width: 100%;
padding: 2rem 1rem;
}
@media (min-width: 640px) {
.settings-main {
padding: 2rem 1.5rem;
}
}
@media (min-width: 1024px) {
.settings-main {
padding: 2rem;
}
}
.settings-content {
margin-left: auto;
margin-right: auto;
}
.settings-page__header {
.settings-header {
margin-bottom: 2rem;
}
.settings-page__title {
font-size: 1.5rem;
font-weight: 700;
color: hsl(var(--foreground));
margin: 0;
}
.settings-page__subtitle {
font-size: 0.875rem;
color: hsl(var(--muted-foreground));
margin: 0.25rem 0 0 0;
}
.settings-page__content {
.sections-container {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
@media (min-width: 640px) {
.settings-page {
padding: 2rem 1.5rem;
}
.settings-page__title {
font-size: 1.75rem;
}
}
</style>

View file

@ -35,33 +35,56 @@
}: Props = $props();
const isClickable = $derived(!!href || !!onclick);
// Tailwind classes
const baseRowClasses =
'flex items-center justify-between gap-4 px-5 py-4 bg-transparent w-full text-left no-underline transition-all duration-200';
const borderClasses = 'border-b border-black/[0.08] dark:border-white/10 last:border-b-0';
const clickableClasses = 'cursor-pointer hover:bg-black/[0.04] dark:hover:bg-white/[0.06]';
const disabledClasses = 'opacity-50 cursor-not-allowed pointer-events-none';
const iconClasses =
'flex items-center justify-center flex-shrink-0 w-9 h-9 rounded-[0.625rem] bg-black/[0.04] dark:bg-white/[0.08] text-primary';
function getRowClasses(isBordered: boolean, isClick: boolean, isDisabled: boolean): string {
let classes = baseRowClasses;
if (isBordered) classes += ' ' + borderClasses;
if (isClick) classes += ' ' + clickableClasses;
if (isDisabled) classes += ' ' + disabledClasses;
return classes;
}
</script>
{#if href}
<a
{href}
class="settings-row {border ? 'settings-row--border' : ''} settings-row--clickable {disabled
? 'settings-row--disabled'
: ''} {className}"
>
<div class="settings-row__content">
<a {href} class="{getRowClasses(border, true, disabled)} {className}">
<div class="flex items-center gap-3 flex-1 min-w-0">
{#if icon}
<span class="settings-row__icon">
<span class={iconClasses}>
{@render icon()}
</span>
{/if}
<div class="settings-row__text">
<span class="settings-row__label">{label}</span>
<div class="flex flex-col gap-0.5 min-w-0">
<span class="text-[0.9375rem] font-medium text-gray-700 dark:text-gray-100">{label}</span>
{#if description}
<span class="settings-row__description">{description}</span>
<span class="text-[0.8125rem] text-gray-500 dark:text-gray-400 leading-snug"
>{description}</span
>
{/if}
</div>
</div>
<div class="settings-row__control">
<div class="flex items-center flex-shrink-0">
{#if children}
{@render children()}
{:else}
<svg class="settings-row__chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg
class="w-5 h-5 text-gray-400 dark:text-gray-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
{/if}
@ -71,55 +94,58 @@
<button
type="button"
{onclick}
class="settings-row {border ? 'settings-row--border' : ''} settings-row--clickable {disabled
? 'settings-row--disabled'
: ''} {className}"
class="{getRowClasses(border, true, disabled)} {className}"
{disabled}
>
<div class="settings-row__content">
<div class="flex items-center gap-3 flex-1 min-w-0">
{#if icon}
<span class="settings-row__icon">
<span class={iconClasses}>
{@render icon()}
</span>
{/if}
<div class="settings-row__text">
<span class="settings-row__label">{label}</span>
<div class="flex flex-col gap-0.5 min-w-0">
<span class="text-[0.9375rem] font-medium text-gray-700 dark:text-gray-100">{label}</span>
{#if description}
<span class="settings-row__description">{description}</span>
<span class="text-[0.8125rem] text-gray-500 dark:text-gray-400 leading-snug"
>{description}</span
>
{/if}
</div>
</div>
<div class="settings-row__control">
<div class="flex items-center flex-shrink-0">
{#if children}
{@render children()}
{:else}
<svg class="settings-row__chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg
class="w-5 h-5 text-gray-400 dark:text-gray-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
{/if}
</div>
</button>
{:else}
<div
class="settings-row {border ? 'settings-row--border' : ''} {disabled
? 'settings-row--disabled'
: ''} {className}"
>
<div class="settings-row__content">
<div class="{getRowClasses(border, false, disabled)} {className}">
<div class="flex items-center gap-3 flex-1 min-w-0">
{#if icon}
<span class="settings-row__icon">
<span class={iconClasses}>
{@render icon()}
</span>
{/if}
<div class="settings-row__text">
<span class="settings-row__label">{label}</span>
<div class="flex flex-col gap-0.5 min-w-0">
<span class="text-[0.9375rem] font-medium text-gray-700 dark:text-gray-100">{label}</span>
{#if description}
<span class="settings-row__description">{description}</span>
<span class="text-[0.8125rem] text-gray-500 dark:text-gray-400 leading-snug"
>{description}</span
>
{/if}
</div>
</div>
{#if children}
<div class="settings-row__control">
<div class="flex items-center flex-shrink-0">
{@render children()}
</div>
{/if}
@ -127,120 +153,9 @@
{/if}
<style>
.settings-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1rem 1.25rem;
background: transparent;
border: none;
width: 100%;
text-align: left;
text-decoration: none;
color: inherit;
transition: all 0.2s ease;
}
.settings-row--border {
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
}
:global(.dark) .settings-row--border {
border-bottom-color: rgba(255, 255, 255, 0.1);
}
.settings-row--border:last-child {
border-bottom: none;
}
.settings-row--clickable {
cursor: pointer;
}
.settings-row--clickable:hover {
background: rgba(0, 0, 0, 0.04);
}
:global(.dark) .settings-row--clickable:hover {
background: rgba(255, 255, 255, 0.06);
}
.settings-row--disabled {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
}
.settings-row__content {
display: flex;
align-items: center;
gap: 0.75rem;
flex: 1;
min-width: 0;
}
.settings-row__icon {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 2.25rem;
height: 2.25rem;
border-radius: 0.625rem;
background: rgba(0, 0, 0, 0.04);
color: hsl(var(--primary));
}
:global(.dark) .settings-row__icon {
background: rgba(255, 255, 255, 0.08);
}
.settings-row__icon :global(svg) {
/* Keep SVG sizing for icons passed via snippet */
:global(svg) {
width: 1.125rem;
height: 1.125rem;
}
.settings-row__text {
display: flex;
flex-direction: column;
gap: 0.125rem;
min-width: 0;
}
.settings-row__label {
font-size: 0.9375rem;
font-weight: 500;
color: #374151;
}
:global(.dark) .settings-row__label {
color: #f3f4f6;
}
.settings-row__description {
font-size: 0.8125rem;
color: #6b7280;
line-height: 1.4;
}
:global(.dark) .settings-row__description {
color: #9ca3af;
}
.settings-row__control {
display: flex;
align-items: center;
flex-shrink: 0;
}
.settings-row__chevron {
width: 1.25rem;
height: 1.25rem;
color: #9ca3af;
}
:global(.dark) .settings-row__chevron {
color: #6b7280;
}
</style>

View file

@ -13,21 +13,32 @@
}
let { title, icon, class: className = '', children }: Props = $props();
// Generate a slug from title for TOC navigation
const sectionId =
title
?.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '') || '';
</script>
<section class="settings-section {className}">
<section class="settings-section {className}" data-settings-section={sectionId}>
{#if title}
<header class="settings-section__header">
{#if icon}
<span class="settings-section__icon">
{@render icon()}
</span>
{/if}
<h2 class="settings-section__title">{title}</h2>
<header class="section-header-wrapper">
<div class="section-header-pill">
{#if icon}
<span class="section-icon">
{@render icon()}
</span>
{/if}
<h2 class="section-title">
{title}
</h2>
</div>
</header>
{/if}
<div class="settings-section__content">
<div class="section-content">
{@render children()}
</div>
</section>
@ -39,48 +50,69 @@
gap: 0.75rem;
}
.settings-section__header {
display: flex;
.section-header-wrapper {
position: sticky;
top: 70px;
z-index: 20;
padding: 0.5rem 0;
}
.section-header-pill {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding-left: 0.25rem;
}
.settings-section__icon {
display: flex;
align-items: center;
justify-content: center;
width: 1.75rem;
height: 1.75rem;
border-radius: 0.5rem;
background: rgba(0, 0, 0, 0.04);
color: hsl(var(--primary));
}
:global(.dark) .settings-section__icon {
background: rgba(255, 255, 255, 0.08);
}
.settings-section__icon :global(svg) {
width: 1rem;
height: 1rem;
}
.settings-section__title {
padding: 0.5rem 1rem;
border-radius: 9999px;
font-size: 0.9375rem;
font-weight: 600;
white-space: nowrap;
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);
color: #374151;
margin: 0;
letter-spacing: -0.01em;
}
:global(.dark) .settings-section__title {
:global(.dark) .section-header-pill {
background: rgba(255, 255, 255, 0.12);
border: 1px solid rgba(255, 255, 255, 0.15);
color: #f3f4f6;
}
.settings-section__content {
.section-icon {
display: flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
color: hsl(var(--primary));
}
.section-icon :global(svg) {
width: 1.125rem;
height: 1.125rem;
}
.section-title {
margin: 0;
font-size: inherit;
font-weight: inherit;
color: inherit;
letter-spacing: -0.01em;
}
.section-content {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
@media (max-width: 768px) {
.section-header-wrapper {
top: 80px;
}
}
</style>

View file

@ -57,176 +57,55 @@
onchange(rawValue);
}
}
// Tailwind classes
const baseClasses = 'flex items-center justify-between gap-4 px-5 py-4';
const borderClasses = 'border-b border-black/[0.08] dark:border-white/10 last:border-b-0';
const disabledClasses = 'opacity-50 cursor-not-allowed';
const iconClasses =
'flex items-center justify-center flex-shrink-0 w-9 h-9 rounded-[0.625rem] bg-black/[0.04] dark:bg-white/[0.08] text-primary [&>svg]:w-[1.125rem] [&>svg]:h-[1.125rem]';
</script>
<div
class="settings-select {border ? 'settings-select--border' : ''} {disabled
? 'settings-select--disabled'
: ''} {className}"
class="{baseClasses} {border ? borderClasses : ''} {disabled ? disabledClasses : ''} {className}"
>
<div class="settings-select__content">
<div class="flex items-center gap-3 flex-1 min-w-0">
{#if icon}
<span class="settings-select__icon">
<span class={iconClasses}>
{@render icon()}
</span>
{/if}
<div class="settings-select__text">
<span class="settings-select__label">{label}</span>
<div class="flex flex-col gap-0.5 min-w-0">
<span class="text-[0.9375rem] font-medium text-gray-700 dark:text-gray-100">{label}</span>
{#if description}
<span class="settings-select__description">{description}</span>
<span class="text-[0.8125rem] text-gray-500 dark:text-gray-400 leading-snug"
>{description}</span
>
{/if}
</div>
</div>
<select
class="settings-select__dropdown"
class="min-w-32 px-3 pr-8 py-2 text-sm font-medium text-gray-700 dark:text-gray-100
bg-black/[0.04] dark:bg-white/[0.08] border border-black/10 dark:border-white/[0.15]
rounded-lg cursor-pointer appearance-none transition-all duration-200
hover:border-black/20 dark:hover:border-white/25 hover:bg-black/[0.06] dark:hover:bg-white/[0.12]
focus-visible:outline-2 focus-visible:outline-primary/40 focus-visible:outline-offset-2 focus-visible:border-primary
disabled:cursor-not-allowed disabled:opacity-60
bg-[url('data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2216%22%20height%3D%2216%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%236b7280%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpath%20d%3D%22m6%209%206%206%206-6%22%2F%3E%3C%2Fsvg%3E')]
dark:bg-[url('data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2216%22%20height%3D%2216%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%239ca3af%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpath%20d%3D%22m6%209%206%206%206-6%22%2F%3E%3C%2Fsvg%3E')]
bg-no-repeat bg-[right_0.5rem_center] bg-[length:1rem]"
value={value ?? ''}
onchange={handleChange}
{disabled}
aria-label={label}
>
{#each options as option}
<option value={option.value ?? ''}>{option.label}</option>
<option
value={option.value ?? ''}
class="bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-100">{option.label}</option
>
{/each}
</select>
</div>
<style>
.settings-select {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1rem 1.25rem;
}
.settings-select--border {
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
}
:global(.dark) .settings-select--border {
border-bottom-color: rgba(255, 255, 255, 0.1);
}
.settings-select--border:last-child {
border-bottom: none;
}
.settings-select--disabled {
opacity: 0.5;
cursor: not-allowed;
}
.settings-select__content {
display: flex;
align-items: center;
gap: 0.75rem;
flex: 1;
min-width: 0;
}
.settings-select__icon {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 2.25rem;
height: 2.25rem;
border-radius: 0.625rem;
background: rgba(0, 0, 0, 0.04);
color: hsl(var(--primary));
}
:global(.dark) .settings-select__icon {
background: rgba(255, 255, 255, 0.08);
}
.settings-select__icon :global(svg) {
width: 1.125rem;
height: 1.125rem;
}
.settings-select__text {
display: flex;
flex-direction: column;
gap: 0.125rem;
min-width: 0;
}
.settings-select__label {
font-size: 0.9375rem;
font-weight: 500;
color: #374151;
}
:global(.dark) .settings-select__label {
color: #f3f4f6;
}
.settings-select__description {
font-size: 0.8125rem;
color: #6b7280;
line-height: 1.4;
}
:global(.dark) .settings-select__description {
color: #9ca3af;
}
/* Select Dropdown - Glass style */
.settings-select__dropdown {
min-width: 8rem;
padding: 0.5rem 2rem 0.5rem 0.75rem;
font-size: 0.875rem;
font-weight: 500;
color: #374151;
background-color: rgba(0, 0, 0, 0.04);
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 0.5rem;
cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%236b7280' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.5rem center;
background-size: 1rem;
transition: all 0.2s ease;
}
:global(.dark) .settings-select__dropdown {
color: #f3f4f6;
background-color: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.15);
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%239ca3af' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
}
.settings-select__dropdown:hover:not(:disabled) {
border-color: rgba(0, 0, 0, 0.2);
background-color: rgba(0, 0, 0, 0.06);
}
:global(.dark) .settings-select__dropdown:hover:not(:disabled) {
border-color: rgba(255, 255, 255, 0.25);
background-color: rgba(255, 255, 255, 0.12);
}
.settings-select__dropdown:focus-visible {
outline: 2px solid hsl(var(--primary) / 0.4);
outline-offset: 2px;
border-color: hsl(var(--primary));
}
.settings-select__dropdown:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.settings-select__dropdown option {
background-color: white;
color: #374151;
}
:global(.dark) .settings-select__dropdown option {
background-color: #1f2937;
color: #f3f4f6;
}
</style>

View file

@ -52,31 +52,46 @@
function handleClear() {
onchange(null);
}
// Tailwind classes
const baseClasses = 'flex items-center justify-between gap-4 px-5 py-4';
const borderClasses = 'border-b border-black/[0.08] dark:border-white/10 last:border-b-0';
const disabledClasses = 'opacity-50 cursor-not-allowed';
const iconClasses =
'flex items-center justify-center flex-shrink-0 w-9 h-9 rounded-[0.625rem] bg-black/[0.04] dark:bg-white/[0.08] text-primary [&>svg]:w-[1.125rem] [&>svg]:h-[1.125rem]';
</script>
<div
class="settings-time {border ? 'settings-time--border' : ''} {disabled
? 'settings-time--disabled'
: ''} {className}"
class="{baseClasses} {border ? borderClasses : ''} {disabled ? disabledClasses : ''} {className}"
>
<div class="settings-time__content">
<div class="flex items-center gap-3 flex-1 min-w-0">
{#if icon}
<span class="settings-time__icon">
<span class={iconClasses}>
{@render icon()}
</span>
{/if}
<div class="settings-time__text">
<span class="settings-time__label">{label}</span>
<div class="flex flex-col gap-0.5 min-w-0">
<span class="text-[0.9375rem] font-medium text-gray-700 dark:text-gray-100">{label}</span>
{#if description}
<span class="settings-time__description">{description}</span>
<span class="text-[0.8125rem] text-gray-500 dark:text-gray-400 leading-snug"
>{description}</span
>
{/if}
</div>
</div>
<div class="settings-time__input-wrapper">
<div class="flex items-center gap-1">
<input
type="time"
class="settings-time__input"
class="w-24 px-3 py-2 text-sm font-medium text-gray-700 dark:text-gray-100 text-center
bg-black/[0.04] dark:bg-white/[0.08] border border-black/10 dark:border-white/[0.15]
rounded-lg transition-all duration-200
hover:border-black/20 dark:hover:border-white/25 hover:bg-black/[0.06] dark:hover:bg-white/[0.12]
focus-visible:outline-2 focus-visible:outline-primary/40 focus-visible:outline-offset-2 focus-visible:border-primary
disabled:cursor-not-allowed disabled:opacity-60
[&::-webkit-calendar-picker-indicator]:opacity-50 [&::-webkit-calendar-picker-indicator]:cursor-pointer
dark:[&::-webkit-calendar-picker-indicator]:invert dark:[&::-webkit-calendar-picker-indicator]:opacity-70"
value={value ?? ''}
oninput={handleInput}
{placeholder}
@ -86,7 +101,9 @@
{#if value}
<button
type="button"
class="settings-time__clear"
class="flex items-center justify-center w-7 h-7 p-0 text-gray-500 dark:text-gray-400 bg-transparent border-none rounded-md cursor-pointer transition-all duration-200
hover:text-red-500 hover:bg-red-500/10 dark:hover:bg-red-500/20
disabled:cursor-not-allowed disabled:opacity-50"
onclick={handleClear}
aria-label="Clear time"
{disabled}
@ -109,179 +126,3 @@
{/if}
</div>
</div>
<style>
.settings-time {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1rem 1.25rem;
}
.settings-time--border {
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
}
:global(.dark) .settings-time--border {
border-bottom-color: rgba(255, 255, 255, 0.1);
}
.settings-time--border:last-child {
border-bottom: none;
}
.settings-time--disabled {
opacity: 0.5;
cursor: not-allowed;
}
.settings-time__content {
display: flex;
align-items: center;
gap: 0.75rem;
flex: 1;
min-width: 0;
}
.settings-time__icon {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 2.25rem;
height: 2.25rem;
border-radius: 0.625rem;
background: rgba(0, 0, 0, 0.04);
color: hsl(var(--primary));
}
:global(.dark) .settings-time__icon {
background: rgba(255, 255, 255, 0.08);
}
.settings-time__icon :global(svg) {
width: 1.125rem;
height: 1.125rem;
}
.settings-time__text {
display: flex;
flex-direction: column;
gap: 0.125rem;
min-width: 0;
}
.settings-time__label {
font-size: 0.9375rem;
font-weight: 500;
color: #374151;
}
:global(.dark) .settings-time__label {
color: #f3f4f6;
}
.settings-time__description {
font-size: 0.8125rem;
color: #6b7280;
line-height: 1.4;
}
:global(.dark) .settings-time__description {
color: #9ca3af;
}
/* Input wrapper */
.settings-time__input-wrapper {
display: flex;
align-items: center;
gap: 0.25rem;
}
/* Time Input - Glass style */
.settings-time__input {
width: 6rem;
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
font-weight: 500;
color: #374151;
text-align: center;
background-color: rgba(0, 0, 0, 0.04);
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 0.5rem;
transition: all 0.2s ease;
}
:global(.dark) .settings-time__input {
color: #f3f4f6;
background-color: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.15);
}
.settings-time__input:hover:not(:disabled) {
border-color: rgba(0, 0, 0, 0.2);
background-color: rgba(0, 0, 0, 0.06);
}
:global(.dark) .settings-time__input:hover:not(:disabled) {
border-color: rgba(255, 255, 255, 0.25);
background-color: rgba(255, 255, 255, 0.12);
}
.settings-time__input:focus-visible {
outline: 2px solid hsl(var(--primary) / 0.4);
outline-offset: 2px;
border-color: hsl(var(--primary));
}
.settings-time__input:disabled {
cursor: not-allowed;
opacity: 0.6;
}
/* Clear button */
.settings-time__clear {
display: flex;
align-items: center;
justify-content: center;
width: 1.75rem;
height: 1.75rem;
padding: 0;
color: #6b7280;
background: transparent;
border: none;
border-radius: 0.375rem;
cursor: pointer;
transition: all 0.2s ease;
}
.settings-time__clear:hover:not(:disabled) {
color: #ef4444;
background-color: rgba(239, 68, 68, 0.1);
}
:global(.dark) .settings-time__clear {
color: #9ca3af;
}
:global(.dark) .settings-time__clear:hover:not(:disabled) {
color: #ef4444;
background-color: rgba(239, 68, 68, 0.2);
}
.settings-time__clear:disabled {
cursor: not-allowed;
opacity: 0.5;
}
/* Style the time input appearance */
.settings-time__input::-webkit-calendar-picker-indicator {
filter: invert(0.5);
cursor: pointer;
}
:global(.dark) .settings-time__input::-webkit-calendar-picker-indicator {
filter: invert(0.7);
}
</style>

View file

@ -36,23 +36,31 @@
onToggle(!isOn);
}
}
// Tailwind classes
const baseClasses = 'flex items-center justify-between gap-4 px-5 py-4';
const borderClasses = 'border-b border-black/[0.08] dark:border-white/10 last:border-b-0';
const disabledClasses = 'opacity-50 cursor-not-allowed';
const iconClasses =
'flex items-center justify-center flex-shrink-0 w-9 h-9 rounded-[0.625rem] bg-black/[0.04] dark:bg-white/[0.08] text-primary [&>svg]:w-[1.125rem] [&>svg]:h-[1.125rem]';
</script>
<div
class="settings-toggle {border ? 'settings-toggle--border' : ''} {disabled
? 'settings-toggle--disabled'
: ''} {className}"
class="{baseClasses} {border ? borderClasses : ''} {disabled ? disabledClasses : ''} {className}"
>
<div class="settings-toggle__content">
<div class="flex items-center gap-3 flex-1 min-w-0">
{#if icon}
<span class="settings-toggle__icon">
<span class={iconClasses}>
{@render icon()}
</span>
{/if}
<div class="settings-toggle__text">
<span class="settings-toggle__label">{label}</span>
<div class="flex flex-col gap-0.5 min-w-0">
<span class="text-[0.9375rem] font-medium text-gray-700 dark:text-gray-100">{label}</span>
{#if description}
<span class="settings-toggle__description">{description}</span>
<span class="text-[0.8125rem] text-gray-500 dark:text-gray-400 leading-snug"
>{description}</span
>
{/if}
</div>
</div>
@ -60,165 +68,22 @@
<button
type="button"
onclick={handleToggle}
class="settings-toggle__switch {isOn ? 'settings-toggle__switch--on' : ''}"
class="relative w-12 h-7 rounded-full border flex-shrink-0 transition-all duration-200
{isOn
? 'bg-primary border-primary shadow-[0_0_0_2px_hsl(var(--primary)/0.2)] dark:shadow-[0_0_0_2px_hsl(var(--primary)/0.3)]'
: 'bg-black/[0.08] border-black/10 dark:bg-white/[0.12] dark:border-white/[0.15]'}
{!disabled
? 'cursor-pointer hover:border-black/20 dark:hover:border-white/25'
: 'cursor-not-allowed'}
focus-visible:outline-2 focus-visible:outline-primary/40 focus-visible:outline-offset-2"
role="switch"
aria-checked={isOn}
aria-label={label}
{disabled}
>
<span class="settings-toggle__thumb"></span>
<span
class="absolute top-[1px] left-[1px] w-6 h-6 rounded-full bg-white shadow-md transition-transform duration-200
{isOn ? 'translate-x-5' : ''}"
></span>
</button>
</div>
<style>
.settings-toggle {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1rem 1.25rem;
}
.settings-toggle--border {
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
}
:global(.dark) .settings-toggle--border {
border-bottom-color: rgba(255, 255, 255, 0.1);
}
.settings-toggle--border:last-child {
border-bottom: none;
}
.settings-toggle--disabled {
opacity: 0.5;
cursor: not-allowed;
}
.settings-toggle__content {
display: flex;
align-items: center;
gap: 0.75rem;
flex: 1;
min-width: 0;
}
.settings-toggle__icon {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 2.25rem;
height: 2.25rem;
border-radius: 0.625rem;
background: rgba(0, 0, 0, 0.04);
color: hsl(var(--primary));
}
:global(.dark) .settings-toggle__icon {
background: rgba(255, 255, 255, 0.08);
}
.settings-toggle__icon :global(svg) {
width: 1.125rem;
height: 1.125rem;
}
.settings-toggle__text {
display: flex;
flex-direction: column;
gap: 0.125rem;
min-width: 0;
}
.settings-toggle__label {
font-size: 0.9375rem;
font-weight: 500;
color: #374151;
}
:global(.dark) .settings-toggle__label {
color: #f3f4f6;
}
.settings-toggle__description {
font-size: 0.8125rem;
color: #6b7280;
line-height: 1.4;
}
:global(.dark) .settings-toggle__description {
color: #9ca3af;
}
/* Toggle Switch - Glass style */
.settings-toggle__switch {
position: relative;
width: 3rem;
height: 1.75rem;
border-radius: 9999px;
border: 1px solid rgba(0, 0, 0, 0.1);
background: rgba(0, 0, 0, 0.08);
cursor: pointer;
flex-shrink: 0;
transition: all 0.2s ease;
}
:global(.dark) .settings-toggle__switch {
background: rgba(255, 255, 255, 0.12);
border-color: rgba(255, 255, 255, 0.15);
}
.settings-toggle__switch:disabled {
cursor: not-allowed;
}
.settings-toggle__switch--on {
background: hsl(var(--primary));
border-color: hsl(var(--primary));
box-shadow: 0 0 0 2px hsl(var(--primary) / 0.2);
}
:global(.dark) .settings-toggle__switch--on {
background: hsl(var(--primary));
border-color: hsl(var(--primary));
box-shadow: 0 0 0 2px hsl(var(--primary) / 0.3);
}
.settings-toggle__thumb {
position: absolute;
top: 0.0625rem;
left: 0.0625rem;
width: 1.5rem;
height: 1.5rem;
border-radius: 9999px;
background-color: white;
box-shadow:
0 2px 4px rgba(0, 0, 0, 0.15),
0 1px 2px rgba(0, 0, 0, 0.1);
transition: transform 0.2s ease;
}
.settings-toggle__switch--on .settings-toggle__thumb {
transform: translateX(1.25rem);
}
.settings-toggle__switch:hover:not(:disabled) {
border-color: rgba(0, 0, 0, 0.2);
}
:global(.dark) .settings-toggle__switch:hover:not(:disabled) {
border-color: rgba(255, 255, 255, 0.25);
}
.settings-toggle__switch--on:hover:not(:disabled) {
filter: brightness(1.1);
border-color: hsl(var(--primary));
}
.settings-toggle__switch:focus-visible {
outline: 2px solid hsl(var(--primary) / 0.4);
outline-offset: 2px;
}
</style>