mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-19 14:57:42 +02:00
feat: major update with network graphs, themes, todo extensions, and more
## New Features ### Network Graph Visualization (Contacts, Calendar, Todo) - D3.js force simulation for physics-based layout - Zoom & pan with mouse/touchpad - Keyboard shortcuts: +/- zoom, 0 reset, Esc deselect, / search, F focus - Filtering by tags, company/location/project, connection strength - Shared components in @manacore/shared-ui ### Central Tags API (mana-core-auth) - CRUD endpoints for tags - Schema: tags table with userId, name, color, app - Shared tag components in @manacore/shared-ui ### Custom Themes System - Theme editor with live preview and color picker - Community theme gallery - Theme sharing (public, unlisted, private) - Backend API in mana-core-auth ### Todo App Extensions - Glass-pill design for task input and items - Settings page with 20+ preferences - Task edit modal with inline editing - Statistics page with visualizations - PWA support with offline capabilities - Multiple kanban boards ### Contacts App Features - Duplicate detection - Photo upload - Batch operations - Enhanced favorites page with multiple view modes - Alphabet view improvements - Search modal ### Help System - @manacore/shared-help-content - @manacore/shared-help-ui - @manacore/shared-help-types ### Other Features - Themes page for all apps - Referral system frontend - CommandBar (global search) - Skeleton loaders - Settings page improvements ## Bug Fixes - Network graph simulation initialization - Database schema TEXT for user_id columns (Better Auth compatibility) - Various styling fixes ## Documentation - Daily report for 2025-12-10 - CI/CD deployment guide 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
e84371aa94
commit
ee42b6cc76
381 changed files with 39284 additions and 6275 deletions
601
packages/shared-ui/src/command-bar/CommandBar.svelte
Normal file
601
packages/shared-ui/src/command-bar/CommandBar.svelte
Normal 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>
|
||||
2
packages/shared-ui/src/command-bar/index.ts
Normal file
2
packages/shared-ui/src/command-bar/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { default as CommandBar } from './CommandBar.svelte';
|
||||
export type { CommandBarItem, QuickAction } from './CommandBar.svelte';
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -9,13 +9,32 @@ export type { SelectOption } from './Select.types';
|
|||
export { GlassCard, StatRow } from './stats';
|
||||
|
||||
// Tag components
|
||||
export { TagBadge } from './tags';
|
||||
export {
|
||||
TagBadge,
|
||||
TagColorPicker,
|
||||
TagEditModal,
|
||||
TagSelector,
|
||||
TagList,
|
||||
TAG_COLORS,
|
||||
DEFAULT_TAG_COLOR,
|
||||
getRandomTagColor,
|
||||
getTagColorByName,
|
||||
} from './tags';
|
||||
export type { Tag, TagData, TagColorName, TagColorHex } from './tags';
|
||||
|
||||
// Media components
|
||||
export { AudioPlayer } from './media';
|
||||
|
||||
// Loading components
|
||||
export { SkeletonBox, SkeletonText } from './loaders';
|
||||
export {
|
||||
SkeletonBox,
|
||||
SkeletonText,
|
||||
SkeletonAvatar,
|
||||
SkeletonRow,
|
||||
SkeletonList,
|
||||
SkeletonCard,
|
||||
SkeletonGrid,
|
||||
} from './loaders';
|
||||
|
||||
// Feedback components
|
||||
export { EmptyState } from './feedback';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,24 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* SkeletonAvatar - Circular skeleton for profile pictures/avatars
|
||||
*
|
||||
* @example
|
||||
* ```svelte
|
||||
* <SkeletonAvatar size="40px" />
|
||||
* <SkeletonAvatar size="64px" />
|
||||
* ```
|
||||
*/
|
||||
|
||||
import SkeletonBox from './SkeletonBox.svelte';
|
||||
|
||||
interface Props {
|
||||
/** Size of the avatar (width & height) */
|
||||
size?: string;
|
||||
/** Additional CSS classes */
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { size = '40px', class: className = '' }: Props = $props();
|
||||
</script>
|
||||
|
||||
<SkeletonBox width={size} height={size} circle class={className} />
|
||||
69
packages/shared-ui/src/molecules/loaders/SkeletonCard.svelte
Normal file
69
packages/shared-ui/src/molecules/loaders/SkeletonCard.svelte
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* SkeletonCard - Configurable card skeleton with avatar, title, body, footer
|
||||
*
|
||||
* @example
|
||||
* ```svelte
|
||||
* <SkeletonCard showAvatar titleLines={1} bodyLines={2} />
|
||||
* <SkeletonCard showFooter />
|
||||
* ```
|
||||
*/
|
||||
|
||||
import SkeletonBox from './SkeletonBox.svelte';
|
||||
import SkeletonText from './SkeletonText.svelte';
|
||||
import SkeletonAvatar from './SkeletonAvatar.svelte';
|
||||
|
||||
interface Props {
|
||||
/** Show avatar/image placeholder */
|
||||
showAvatar?: boolean;
|
||||
/** Avatar size */
|
||||
avatarSize?: string;
|
||||
/** Number of title lines */
|
||||
titleLines?: number;
|
||||
/** Number of body text lines */
|
||||
bodyLines?: number;
|
||||
/** Show footer section */
|
||||
showFooter?: boolean;
|
||||
/** Opacity for fade effect in lists */
|
||||
opacity?: number;
|
||||
/** Additional CSS classes */
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
showAvatar = false,
|
||||
avatarSize = '48px',
|
||||
titleLines = 1,
|
||||
bodyLines = 2,
|
||||
showFooter = false,
|
||||
opacity = 1,
|
||||
class: className = '',
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="skeleton-card rounded-lg border border-border bg-card p-4 {className}"
|
||||
style="opacity: {opacity};"
|
||||
>
|
||||
<div class="flex gap-3">
|
||||
{#if showAvatar}
|
||||
<SkeletonAvatar size={avatarSize} />
|
||||
{/if}
|
||||
<div class="flex-1 min-w-0">
|
||||
{#if titleLines > 0}
|
||||
<SkeletonText lines={titleLines} lineHeight="18px" gap="6px" lastLineWidth="60%" />
|
||||
{/if}
|
||||
{#if bodyLines > 0}
|
||||
<div class="mt-2">
|
||||
<SkeletonText lines={bodyLines} lineHeight="14px" gap="6px" lastLineWidth="80%" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if showFooter}
|
||||
<div class="mt-4 flex items-center justify-between border-t border-border pt-4">
|
||||
<SkeletonBox width="80px" height="14px" />
|
||||
<SkeletonBox width="60px" height="14px" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
61
packages/shared-ui/src/molecules/loaders/SkeletonGrid.svelte
Normal file
61
packages/shared-ui/src/molecules/loaders/SkeletonGrid.svelte
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* SkeletonGrid - Grid of skeleton cards with fade effect
|
||||
*
|
||||
* @example
|
||||
* ```svelte
|
||||
* <SkeletonGrid count={6} columns={3} />
|
||||
* <SkeletonGrid count={8} columns={4} showAvatar />
|
||||
* ```
|
||||
*/
|
||||
|
||||
import SkeletonCard from './SkeletonCard.svelte';
|
||||
|
||||
interface Props {
|
||||
/** Number of cards to show */
|
||||
count?: number;
|
||||
/** Number of columns (CSS grid) */
|
||||
columns?: number;
|
||||
/** Show avatar in cards */
|
||||
showAvatar?: boolean;
|
||||
/** Avatar size */
|
||||
avatarSize?: string;
|
||||
/** Number of body lines per card */
|
||||
bodyLines?: number;
|
||||
/** Apply cascading fade effect */
|
||||
fadeEffect?: boolean;
|
||||
/** Minimum opacity for fade effect */
|
||||
minOpacity?: number;
|
||||
/** Gap between cards */
|
||||
gap?: string;
|
||||
/** Additional CSS classes */
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
count = 6,
|
||||
columns = 3,
|
||||
showAvatar = true,
|
||||
avatarSize = '48px',
|
||||
bodyLines = 2,
|
||||
fadeEffect = true,
|
||||
minOpacity = 0.4,
|
||||
gap = '1rem',
|
||||
class: className = '',
|
||||
}: Props = $props();
|
||||
|
||||
function calculateOpacity(index: number): number {
|
||||
if (!fadeEffect) return 1;
|
||||
const fadeStep = (1 - minOpacity) / Math.max(count - 1, 1);
|
||||
return Math.max(minOpacity, 1 - index * fadeStep);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="skeleton-grid grid {className}"
|
||||
style="grid-template-columns: repeat({columns}, minmax(0, 1fr)); gap: {gap};"
|
||||
>
|
||||
{#each Array(count) as _, i}
|
||||
<SkeletonCard {showAvatar} {avatarSize} {bodyLines} opacity={calculateOpacity(i)} />
|
||||
{/each}
|
||||
</div>
|
||||
52
packages/shared-ui/src/molecules/loaders/SkeletonList.svelte
Normal file
52
packages/shared-ui/src/molecules/loaders/SkeletonList.svelte
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* SkeletonList - List of skeleton rows with cascading fade effect
|
||||
*
|
||||
* @example
|
||||
* ```svelte
|
||||
* <SkeletonList count={5} />
|
||||
* <SkeletonList count={10} showAvatar fadeEffect />
|
||||
* ```
|
||||
*/
|
||||
|
||||
import SkeletonRow from './SkeletonRow.svelte';
|
||||
|
||||
interface Props {
|
||||
/** Number of rows to show */
|
||||
count?: number;
|
||||
/** Show avatar in each row */
|
||||
showAvatar?: boolean;
|
||||
/** Avatar size */
|
||||
avatarSize?: string;
|
||||
/** Apply cascading fade effect (rows fade out towards bottom) */
|
||||
fadeEffect?: boolean;
|
||||
/** Minimum opacity for fade effect */
|
||||
minOpacity?: number;
|
||||
/** Gap between rows */
|
||||
gap?: string;
|
||||
/** Additional CSS classes */
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
count = 5,
|
||||
showAvatar = true,
|
||||
avatarSize = '40px',
|
||||
fadeEffect = true,
|
||||
minOpacity = 0.3,
|
||||
gap = '0',
|
||||
class: className = '',
|
||||
}: Props = $props();
|
||||
|
||||
function calculateOpacity(index: number): number {
|
||||
if (!fadeEffect) return 1;
|
||||
const fadeStep = (1 - minOpacity) / Math.max(count - 1, 1);
|
||||
return Math.max(minOpacity, 1 - index * fadeStep);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="skeleton-list flex flex-col {className}" style="gap: {gap};">
|
||||
{#each Array(count) as _, i}
|
||||
<SkeletonRow {showAvatar} {avatarSize} opacity={calculateOpacity(i)} />
|
||||
{/each}
|
||||
</div>
|
||||
60
packages/shared-ui/src/molecules/loaders/SkeletonRow.svelte
Normal file
60
packages/shared-ui/src/molecules/loaders/SkeletonRow.svelte
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* SkeletonRow - Single row skeleton with avatar and text
|
||||
*
|
||||
* @example
|
||||
* ```svelte
|
||||
* <SkeletonRow showAvatar />
|
||||
* <SkeletonRow opacity={0.5} />
|
||||
* ```
|
||||
*/
|
||||
|
||||
import SkeletonBox from './SkeletonBox.svelte';
|
||||
import SkeletonAvatar from './SkeletonAvatar.svelte';
|
||||
|
||||
interface Props {
|
||||
/** Show avatar placeholder */
|
||||
showAvatar?: boolean;
|
||||
/** Avatar size */
|
||||
avatarSize?: string;
|
||||
/** Opacity for fade effect */
|
||||
opacity?: number;
|
||||
/** Show secondary line */
|
||||
showSecondaryLine?: boolean;
|
||||
/** Show right-side content */
|
||||
showRightContent?: boolean;
|
||||
/** Additional CSS classes */
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
showAvatar = true,
|
||||
avatarSize = '40px',
|
||||
opacity = 1,
|
||||
showSecondaryLine = true,
|
||||
showRightContent = true,
|
||||
class: className = '',
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="skeleton-row flex items-center gap-3 border-b border-border px-4 py-3 {className}"
|
||||
style="opacity: {opacity};"
|
||||
role="status"
|
||||
aria-label="Loading"
|
||||
>
|
||||
{#if showAvatar}
|
||||
<SkeletonAvatar size={avatarSize} />
|
||||
{/if}
|
||||
<div class="flex-1 min-w-0">
|
||||
<SkeletonBox width="45%" height="16px" />
|
||||
{#if showSecondaryLine}
|
||||
<div class="mt-1.5">
|
||||
<SkeletonBox width="65%" height="13px" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if showRightContent}
|
||||
<SkeletonBox width="70px" height="13px" />
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -1,5 +1,25 @@
|
|||
/**
|
||||
* Loading state components
|
||||
*
|
||||
* Primitives:
|
||||
* - SkeletonBox: Base rectangular skeleton with shimmer
|
||||
* - SkeletonText: Multi-line text skeleton
|
||||
* - SkeletonAvatar: Circular avatar skeleton
|
||||
*
|
||||
* Composites:
|
||||
* - SkeletonRow: Single list row with avatar + text
|
||||
* - SkeletonList: Multiple rows with fade effect
|
||||
* - SkeletonCard: Card with avatar, title, body, footer
|
||||
* - SkeletonGrid: Grid of cards with fade effect
|
||||
*/
|
||||
|
||||
// Primitives
|
||||
export { default as SkeletonBox } from './SkeletonBox.svelte';
|
||||
export { default as SkeletonText } from './SkeletonText.svelte';
|
||||
export { default as SkeletonAvatar } from './SkeletonAvatar.svelte';
|
||||
|
||||
// Composites
|
||||
export { default as SkeletonRow } from './SkeletonRow.svelte';
|
||||
export { default as SkeletonList } from './SkeletonList.svelte';
|
||||
export { default as SkeletonCard } from './SkeletonCard.svelte';
|
||||
export { default as SkeletonGrid } from './SkeletonGrid.svelte';
|
||||
|
|
|
|||
70
packages/shared-ui/src/molecules/tags/TagColorPicker.svelte
Normal file
70
packages/shared-ui/src/molecules/tags/TagColorPicker.svelte
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
<script lang="ts">
|
||||
import { Check } from '@manacore/shared-icons';
|
||||
import { TAG_COLORS, DEFAULT_TAG_COLOR } from './constants';
|
||||
|
||||
interface Props {
|
||||
selectedColor?: string;
|
||||
onColorChange: (color: string) => void;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
let { selectedColor = DEFAULT_TAG_COLOR, onColorChange, size = 'md' }: Props = $props();
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'w-6 h-6',
|
||||
md: 'w-8 h-8',
|
||||
lg: 'w-10 h-10',
|
||||
};
|
||||
|
||||
const iconSizes = {
|
||||
sm: 12,
|
||||
md: 14,
|
||||
lg: 18,
|
||||
};
|
||||
|
||||
const gapClasses = {
|
||||
sm: 'gap-1.5',
|
||||
md: 'gap-2',
|
||||
lg: 'gap-2.5',
|
||||
};
|
||||
|
||||
function handleColorSelect(hex: string) {
|
||||
onColorChange(hex);
|
||||
}
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent, hex: string) {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleColorSelect(hex);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-wrap {gapClasses[size]}" role="radiogroup" aria-label="Tag color">
|
||||
{#each TAG_COLORS as color}
|
||||
{@const isSelected = selectedColor?.toLowerCase() === color.hex.toLowerCase()}
|
||||
<button
|
||||
type="button"
|
||||
class="
|
||||
{sizeClasses[size]}
|
||||
rounded-full
|
||||
flex items-center justify-center
|
||||
transition-all duration-150
|
||||
ring-offset-2 ring-offset-white dark:ring-offset-gray-900
|
||||
focus:outline-none focus:ring-2 focus:ring-primary
|
||||
{isSelected ? 'ring-2 ring-black/30 dark:ring-white/50 scale-110' : 'hover:scale-110'}
|
||||
"
|
||||
style="background-color: {color.hex}"
|
||||
onclick={() => handleColorSelect(color.hex)}
|
||||
onkeydown={(e) => handleKeyDown(e, color.hex)}
|
||||
role="radio"
|
||||
aria-checked={isSelected}
|
||||
aria-label={color.name}
|
||||
title={color.name}
|
||||
>
|
||||
{#if isSelected}
|
||||
<Check size={iconSizes[size]} weight="bold" class="text-white drop-shadow-sm" />
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
143
packages/shared-ui/src/molecules/tags/TagEditModal.svelte
Normal file
143
packages/shared-ui/src/molecules/tags/TagEditModal.svelte
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
<script lang="ts">
|
||||
import { Modal } from '../../organisms';
|
||||
import { Input } from '../../molecules';
|
||||
import TagBadge from './TagBadge.svelte';
|
||||
import TagColorPicker from './TagColorPicker.svelte';
|
||||
import { DEFAULT_TAG_COLOR } from './constants';
|
||||
import type { Tag } from './constants';
|
||||
|
||||
interface Props {
|
||||
tag?: Tag | null;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (name: string, color: string) => void;
|
||||
onDelete?: () => void;
|
||||
usageCount?: number;
|
||||
title?: string;
|
||||
saveLabel?: string;
|
||||
deleteLabel?: string;
|
||||
cancelLabel?: string;
|
||||
namePlaceholder?: string;
|
||||
colorLabel?: string;
|
||||
previewLabel?: string;
|
||||
usageLabel?: string;
|
||||
deleteConfirmMessage?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
tag = null,
|
||||
isOpen,
|
||||
onClose,
|
||||
onSave,
|
||||
onDelete,
|
||||
usageCount,
|
||||
title = 'Tag bearbeiten',
|
||||
saveLabel = 'Speichern',
|
||||
deleteLabel = 'Löschen',
|
||||
cancelLabel = 'Abbrechen',
|
||||
namePlaceholder = 'Tag Name',
|
||||
colorLabel = 'Farbe',
|
||||
previewLabel = 'Vorschau',
|
||||
usageLabel = 'Verwendung',
|
||||
deleteConfirmMessage = 'Möchtest du diesen Tag wirklich löschen?',
|
||||
}: Props = $props();
|
||||
|
||||
let name = $state(tag?.name ?? '');
|
||||
let color = $state(tag?.color ?? tag?.style?.color ?? DEFAULT_TAG_COLOR);
|
||||
|
||||
// Reset form when tag changes
|
||||
$effect(() => {
|
||||
if (isOpen) {
|
||||
name = tag?.name ?? '';
|
||||
color = tag?.color ?? tag?.style?.color ?? DEFAULT_TAG_COLOR;
|
||||
}
|
||||
});
|
||||
|
||||
function handleSave() {
|
||||
if (name.trim()) {
|
||||
onSave(name.trim(), color);
|
||||
}
|
||||
}
|
||||
|
||||
function handleDelete() {
|
||||
if (onDelete && confirm(deleteConfirmMessage)) {
|
||||
onDelete();
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter' && name.trim()) {
|
||||
e.preventDefault();
|
||||
handleSave();
|
||||
}
|
||||
}
|
||||
|
||||
const previewTag = $derived({ name: name || namePlaceholder, color });
|
||||
</script>
|
||||
|
||||
<Modal visible={isOpen} {onClose} {title} maxWidth="sm">
|
||||
<div class="space-y-6">
|
||||
<!-- Name Input -->
|
||||
<div>
|
||||
<Input bind:value={name} placeholder={namePlaceholder} onkeydown={handleKeyDown} autofocus />
|
||||
</div>
|
||||
|
||||
<!-- Color Picker -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-muted-foreground mb-3">
|
||||
{colorLabel}
|
||||
</label>
|
||||
<TagColorPicker selectedColor={color} onColorChange={(c) => (color = c)} />
|
||||
</div>
|
||||
|
||||
<!-- Preview -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-muted-foreground mb-3">
|
||||
{previewLabel}
|
||||
</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<TagBadge tag={previewTag} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Usage Count (optional) -->
|
||||
{#if usageCount !== undefined && usageCount > 0}
|
||||
<div class="text-sm text-muted-foreground">
|
||||
{usageLabel}: {usageCount}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#snippet footer()}
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
{#if onDelete && tag}
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleDelete}
|
||||
class="px-4 py-2 text-sm font-medium text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors"
|
||||
>
|
||||
{deleteLabel}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onclick={onClose}
|
||||
class="px-4 py-2 text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-black/5 dark:hover:bg-white/10 rounded-lg transition-colors"
|
||||
>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleSave}
|
||||
disabled={!name.trim()}
|
||||
class="px-4 py-2 text-sm font-medium bg-primary text-primary-foreground rounded-lg hover:opacity-90 transition-opacity disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{saveLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
166
packages/shared-ui/src/molecules/tags/TagList.svelte
Normal file
166
packages/shared-ui/src/molecules/tags/TagList.svelte
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
<script lang="ts">
|
||||
import { PencilSimple, Trash } from '@manacore/shared-icons';
|
||||
import { DEFAULT_TAG_COLOR } from './constants';
|
||||
import type { Tag } from './constants';
|
||||
|
||||
interface Props {
|
||||
tags: Tag[];
|
||||
onEdit?: (tag: Tag) => void;
|
||||
onDelete?: (tag: Tag) => void;
|
||||
onClick?: (tag: Tag) => void;
|
||||
layout?: 'grid' | 'list';
|
||||
emptyMessage?: string;
|
||||
emptyDescription?: string;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
tags,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onClick,
|
||||
layout = 'grid',
|
||||
emptyMessage = 'Keine Tags vorhanden',
|
||||
emptyDescription = 'Erstelle deinen ersten Tag',
|
||||
loading = false,
|
||||
}: Props = $props();
|
||||
|
||||
function getTagColor(tag: Tag): string {
|
||||
return tag.color ?? tag.style?.color ?? DEFAULT_TAG_COLOR;
|
||||
}
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent, tag: Tag, action: 'click' | 'edit' | 'delete') {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
if (action === 'click' && onClick) onClick(tag);
|
||||
if (action === 'edit' && onEdit) onEdit(tag);
|
||||
if (action === 'delete' && onDelete) onDelete(tag);
|
||||
}
|
||||
}
|
||||
|
||||
const gridClasses = 'grid grid-cols-1 sm:grid-cols-2 gap-4';
|
||||
const listClasses = 'flex flex-col gap-3';
|
||||
</script>
|
||||
|
||||
{#if loading}
|
||||
<!-- Loading Skeleton -->
|
||||
<div class={layout === 'grid' ? gridClasses : listClasses}>
|
||||
{#each Array(6) as _, i}
|
||||
<div
|
||||
class="
|
||||
flex items-center gap-3 p-4
|
||||
bg-gray-100 dark:bg-gray-800
|
||||
rounded-xl animate-pulse
|
||||
"
|
||||
>
|
||||
<div class="w-10 h-10 bg-gray-200 dark:bg-gray-700 rounded-lg"></div>
|
||||
<div class="flex-1">
|
||||
<div class="h-4 w-24 bg-gray-200 dark:bg-gray-700 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if tags.length === 0}
|
||||
<!-- Empty State -->
|
||||
<div class="flex flex-col items-center justify-center py-12 text-center">
|
||||
<div
|
||||
class="w-16 h-16 mb-4 rounded-full bg-gray-100 dark:bg-gray-800 flex items-center justify-center"
|
||||
>
|
||||
<svg class="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-medium text-foreground mb-1">{emptyMessage}</h3>
|
||||
<p class="text-sm text-muted-foreground">{emptyDescription}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Tag Grid/List -->
|
||||
<div class={layout === 'grid' ? gridClasses : listClasses}>
|
||||
{#each tags as tag (tag.id)}
|
||||
{@const color = getTagColor(tag)}
|
||||
<div
|
||||
class="
|
||||
group relative flex items-center gap-3 p-4
|
||||
bg-white dark:bg-white/5
|
||||
border border-gray-200 dark:border-white/10
|
||||
rounded-xl
|
||||
transition-all duration-200
|
||||
hover:shadow-md hover:border-gray-300 dark:hover:border-white/20
|
||||
{onClick ? 'cursor-pointer' : ''}
|
||||
"
|
||||
onclick={() => onClick?.(tag)}
|
||||
onkeydown={(e) => handleKeyDown(e, tag, 'click')}
|
||||
role={onClick ? 'button' : undefined}
|
||||
tabindex={onClick ? 0 : undefined}
|
||||
>
|
||||
<!-- Color Icon -->
|
||||
<div
|
||||
class="w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0"
|
||||
style="background-color: {color}20"
|
||||
>
|
||||
<div class="w-4 h-4 rounded-full" style="background-color: {color}"></div>
|
||||
</div>
|
||||
|
||||
<!-- Tag Name -->
|
||||
<span class="flex-1 font-medium text-foreground min-w-0">
|
||||
{tag.name}
|
||||
</span>
|
||||
|
||||
<!-- Actions -->
|
||||
{#if onEdit || onDelete}
|
||||
<div class="flex items-center gap-1 flex-shrink-0">
|
||||
{#if onEdit}
|
||||
<button
|
||||
type="button"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit(tag);
|
||||
}}
|
||||
onkeydown={(e) => {
|
||||
e.stopPropagation();
|
||||
handleKeyDown(e, tag, 'edit');
|
||||
}}
|
||||
class="
|
||||
p-2 rounded-lg
|
||||
text-muted-foreground hover:text-foreground
|
||||
hover:bg-gray-100 dark:hover:bg-white/10
|
||||
transition-colors
|
||||
"
|
||||
aria-label="Tag bearbeiten"
|
||||
>
|
||||
<PencilSimple size={16} />
|
||||
</button>
|
||||
{/if}
|
||||
{#if onDelete}
|
||||
<button
|
||||
type="button"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(tag);
|
||||
}}
|
||||
onkeydown={(e) => {
|
||||
e.stopPropagation();
|
||||
handleKeyDown(e, tag, 'delete');
|
||||
}}
|
||||
class="
|
||||
p-2 rounded-lg
|
||||
text-muted-foreground hover:text-red-500
|
||||
hover:bg-red-50 dark:hover:bg-red-900/20
|
||||
transition-colors
|
||||
"
|
||||
aria-label="Tag löschen"
|
||||
>
|
||||
<Trash size={16} />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
257
packages/shared-ui/src/molecules/tags/TagSelector.svelte
Normal file
257
packages/shared-ui/src/molecules/tags/TagSelector.svelte
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
<script lang="ts">
|
||||
import { Plus, MagnifyingGlass, X } from '@manacore/shared-icons';
|
||||
import TagBadge from './TagBadge.svelte';
|
||||
import TagColorPicker from './TagColorPicker.svelte';
|
||||
import { DEFAULT_TAG_COLOR } from './constants';
|
||||
import type { Tag } from './constants';
|
||||
|
||||
interface Props {
|
||||
tags: Tag[];
|
||||
selectedTags: Tag[];
|
||||
onTagsChange: (tags: Tag[]) => void;
|
||||
onCreateTag?: (name: string, color: string) => Promise<Tag>;
|
||||
placeholder?: string;
|
||||
addTagLabel?: string;
|
||||
searchPlaceholder?: string;
|
||||
createLabel?: string;
|
||||
maxTags?: number;
|
||||
}
|
||||
|
||||
let {
|
||||
tags,
|
||||
selectedTags,
|
||||
onTagsChange,
|
||||
onCreateTag,
|
||||
placeholder = 'Tags hinzufügen...',
|
||||
addTagLabel = 'Tag hinzufügen',
|
||||
searchPlaceholder = 'Tag suchen...',
|
||||
createLabel = 'Erstellen',
|
||||
maxTags,
|
||||
}: Props = $props();
|
||||
|
||||
let isOpen = $state(false);
|
||||
let searchQuery = $state('');
|
||||
let isCreating = $state(false);
|
||||
let newTagName = $state('');
|
||||
let newTagColor = $state(DEFAULT_TAG_COLOR);
|
||||
|
||||
const filteredTags = $derived.by(() => {
|
||||
const selectedIds = new Set(selectedTags.map((t) => t.id));
|
||||
return tags
|
||||
.filter((tag) => !selectedIds.has(tag.id))
|
||||
.filter((tag) => tag.name.toLowerCase().includes(searchQuery.toLowerCase()));
|
||||
});
|
||||
|
||||
const canAddMore = $derived(!maxTags || selectedTags.length < maxTags);
|
||||
|
||||
function handleSelectTag(tag: Tag) {
|
||||
if (canAddMore) {
|
||||
onTagsChange([...selectedTags, tag]);
|
||||
searchQuery = '';
|
||||
}
|
||||
}
|
||||
|
||||
function handleRemoveTag(tag: Tag) {
|
||||
onTagsChange(selectedTags.filter((t) => t.id !== tag.id));
|
||||
}
|
||||
|
||||
async function handleCreateTag() {
|
||||
if (!onCreateTag || !newTagName.trim()) return;
|
||||
|
||||
try {
|
||||
const createdTag = await onCreateTag(newTagName.trim(), newTagColor);
|
||||
onTagsChange([...selectedTags, createdTag]);
|
||||
newTagName = '';
|
||||
newTagColor = DEFAULT_TAG_COLOR;
|
||||
isCreating = false;
|
||||
searchQuery = '';
|
||||
} catch (error) {
|
||||
console.error('Failed to create tag:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
const target = e.target as HTMLElement;
|
||||
if (!target.closest('.tag-selector-container')) {
|
||||
isOpen = false;
|
||||
isCreating = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
isOpen = false;
|
||||
isCreating = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onclick={handleClickOutside} onkeydown={handleKeyDown} />
|
||||
|
||||
<div class="tag-selector-container relative">
|
||||
<!-- Selected Tags Display -->
|
||||
<div class="flex flex-wrap items-center gap-2 mb-2">
|
||||
{#each selectedTags as tag (tag.id)}
|
||||
<TagBadge {tag} removable onRemove={() => handleRemoveTag(tag)} />
|
||||
{/each}
|
||||
|
||||
{#if canAddMore}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (isOpen = !isOpen)}
|
||||
class="
|
||||
inline-flex items-center gap-1.5 px-3 py-1.5
|
||||
text-sm text-muted-foreground
|
||||
border border-dashed border-gray-300 dark:border-gray-600
|
||||
rounded-full
|
||||
hover:border-gray-400 dark:hover:border-gray-500
|
||||
hover:text-foreground
|
||||
transition-colors
|
||||
"
|
||||
>
|
||||
<Plus size={14} weight="bold" />
|
||||
<span>{addTagLabel}</span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Dropdown -->
|
||||
{#if isOpen}
|
||||
<div
|
||||
class="
|
||||
absolute z-50 mt-1 w-full min-w-[280px]
|
||||
bg-white dark:bg-gray-800
|
||||
border border-gray-200 dark:border-gray-700
|
||||
rounded-xl shadow-lg
|
||||
overflow-hidden
|
||||
"
|
||||
>
|
||||
<!-- Search Input -->
|
||||
<div class="p-3 border-b border-gray-100 dark:border-gray-700">
|
||||
<div class="relative">
|
||||
<MagnifyingGlass
|
||||
size={16}
|
||||
class="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={searchQuery}
|
||||
placeholder={searchPlaceholder}
|
||||
class="
|
||||
w-full pl-9 pr-3 py-2
|
||||
text-sm
|
||||
bg-gray-50 dark:bg-gray-900
|
||||
border border-gray-200 dark:border-gray-700
|
||||
rounded-lg
|
||||
focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tag List -->
|
||||
<div class="max-h-48 overflow-y-auto p-2">
|
||||
{#if filteredTags.length > 0}
|
||||
{#each filteredTags as tag (tag.id)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => handleSelectTag(tag)}
|
||||
class="
|
||||
w-full flex items-center gap-2 px-3 py-2
|
||||
text-sm text-left
|
||||
rounded-lg
|
||||
hover:bg-gray-100 dark:hover:bg-gray-700
|
||||
transition-colors
|
||||
"
|
||||
>
|
||||
<span
|
||||
class="w-3 h-3 rounded-full flex-shrink-0"
|
||||
style="background-color: {tag.color ?? tag.style?.color ?? DEFAULT_TAG_COLOR}"
|
||||
></span>
|
||||
<span class="truncate">{tag.name}</span>
|
||||
</button>
|
||||
{/each}
|
||||
{:else if searchQuery && !isCreating}
|
||||
<div class="px-3 py-2 text-sm text-muted-foreground text-center">Kein Tag gefunden</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Create New Tag -->
|
||||
{#if onCreateTag}
|
||||
<div class="p-3 border-t border-gray-100 dark:border-gray-700">
|
||||
{#if isCreating}
|
||||
<div class="space-y-3">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newTagName}
|
||||
{placeholder}
|
||||
class="
|
||||
w-full px-3 py-2
|
||||
text-sm
|
||||
bg-gray-50 dark:bg-gray-900
|
||||
border border-gray-200 dark:border-gray-700
|
||||
rounded-lg
|
||||
focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent
|
||||
"
|
||||
onkeydown={(e) => e.key === 'Enter' && handleCreateTag()}
|
||||
/>
|
||||
<TagColorPicker
|
||||
selectedColor={newTagColor}
|
||||
onColorChange={(c) => (newTagColor = c)}
|
||||
size="sm"
|
||||
/>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (isCreating = false)}
|
||||
class="
|
||||
flex-1 px-3 py-1.5
|
||||
text-sm text-muted-foreground
|
||||
hover:bg-gray-100 dark:hover:bg-gray-700
|
||||
rounded-lg transition-colors
|
||||
"
|
||||
>
|
||||
<X size={14} class="inline mr-1" />
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleCreateTag}
|
||||
disabled={!newTagName.trim()}
|
||||
class="
|
||||
flex-1 px-3 py-1.5
|
||||
text-sm font-medium
|
||||
bg-primary text-primary-foreground
|
||||
rounded-lg
|
||||
hover:opacity-90 transition-opacity
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
"
|
||||
>
|
||||
{createLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => {
|
||||
isCreating = true;
|
||||
newTagName = searchQuery;
|
||||
}}
|
||||
class="
|
||||
w-full flex items-center justify-center gap-2 px-3 py-2
|
||||
text-sm font-medium
|
||||
text-primary
|
||||
hover:bg-primary/10
|
||||
rounded-lg transition-colors
|
||||
"
|
||||
>
|
||||
<Plus size={16} weight="bold" />
|
||||
<span>Neuen Tag erstellen</span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
56
packages/shared-ui/src/molecules/tags/constants.ts
Normal file
56
packages/shared-ui/src/molecules/tags/constants.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
/**
|
||||
* Centralized tag constants and types for @manacore/shared-ui
|
||||
*/
|
||||
|
||||
export const TAG_COLORS = [
|
||||
{ name: 'red', hex: '#ef4444' },
|
||||
{ name: 'orange', hex: '#f97316' },
|
||||
{ name: 'amber', hex: '#f59e0b' },
|
||||
{ name: 'lime', hex: '#84cc16' },
|
||||
{ name: 'green', hex: '#22c55e' },
|
||||
{ name: 'teal', hex: '#14b8a6' },
|
||||
{ name: 'cyan', hex: '#06b6d4' },
|
||||
{ name: 'blue', hex: '#3b82f6' },
|
||||
{ name: 'indigo', hex: '#6366f1' },
|
||||
{ name: 'violet', hex: '#8b5cf6' },
|
||||
{ name: 'pink', hex: '#ec4899' },
|
||||
{ name: 'slate', hex: '#64748b' },
|
||||
] as const;
|
||||
|
||||
export const DEFAULT_TAG_COLOR = '#3b82f6'; // blue
|
||||
|
||||
export type TagColorName = (typeof TAG_COLORS)[number]['name'];
|
||||
export type TagColorHex = (typeof TAG_COLORS)[number]['hex'];
|
||||
|
||||
export interface Tag {
|
||||
id: string;
|
||||
name: string;
|
||||
color?: string | null;
|
||||
style?: { color?: string };
|
||||
}
|
||||
|
||||
export interface TagData {
|
||||
name?: string;
|
||||
text?: string;
|
||||
color?: string;
|
||||
style?: { color?: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a random color from the palette
|
||||
*/
|
||||
export function getRandomTagColor(): string {
|
||||
return TAG_COLORS[Math.floor(Math.random() * TAG_COLORS.length)].hex;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get color by name
|
||||
*/
|
||||
export function getTagColorByName(name: TagColorName): string {
|
||||
for (const color of TAG_COLORS) {
|
||||
if (color.name === name) {
|
||||
return color.hex;
|
||||
}
|
||||
}
|
||||
return DEFAULT_TAG_COLOR;
|
||||
}
|
||||
|
|
@ -1 +1,10 @@
|
|||
// Components
|
||||
export { default as TagBadge } from './TagBadge.svelte';
|
||||
export { default as TagColorPicker } from './TagColorPicker.svelte';
|
||||
export { default as TagEditModal } from './TagEditModal.svelte';
|
||||
export { default as TagSelector } from './TagSelector.svelte';
|
||||
export { default as TagList } from './TagList.svelte';
|
||||
|
||||
// Constants and Types
|
||||
export { TAG_COLORS, DEFAULT_TAG_COLOR, getRandomTagColor, getTagColorByName } from './constants';
|
||||
export type { Tag, TagData, TagColorName, TagColorHex } from './constants';
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
604
packages/shared-ui/src/organisms/network/NetworkControls.svelte
Normal file
604
packages/shared-ui/src/organisms/network/NetworkControls.svelte
Normal 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>
|
||||
671
packages/shared-ui/src/organisms/network/NetworkGraph.svelte
Normal file
671
packages/shared-ui/src/organisms/network/NetworkGraph.svelte
Normal 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"
|
||||
>
|
||||
⭐
|
||||
</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">🔗</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>
|
||||
86
packages/shared-ui/src/organisms/network/constants.ts
Normal file
86
packages/shared-ui/src/organisms/network/constants.ts
Normal 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;
|
||||
25
packages/shared-ui/src/organisms/network/index.ts
Normal file
25
packages/shared-ui/src/organisms/network/index.ts
Normal 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';
|
||||
112
packages/shared-ui/src/organisms/network/network.types.ts
Normal file
112
packages/shared-ui/src/organisms/network/network.types.ts
Normal 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[];
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue