managarten/packages/shared-ui/src/organisms/network/NetworkControls.svelte
Till JS 6da317d071 refactor(theme): migrate shared packages to --color-* token naming
Several shared-ui / shared-auth-ui / subscriptions / credits
components used shadcn-style bare CSS variables (--muted, --primary,
--foreground, etc.), but the Mana theme system standardized on
--color-*. The mismatch meant bg-[hsl(var(--muted))] classes
resolved to an invalid color and rendered transparent — most
visible on the Allgemein settings tab where language and week-start
buttons had no background.

Mechanical prefix across ~30 files. Two semantic renames:
- --destructive → --color-error (Mana uses "error" as the token name)
- --popover     → --color-card  (no popover token; card is the closest)

With shared packages on the correct naming, drop the shadcn-compat
alias shim from app.css.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 22:11:42 +02:00

619 lines
13 KiB
Svelte

<script lang="ts">
import {
MagnifyingGlass,
MagnifyingGlassPlus,
MagnifyingGlassMinus,
ArrowCounterClockwise,
Funnel,
X,
Crosshair,
Keyboard,
} from '@mana/shared-icons';
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;
showSearch?: boolean;
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,
showSearch = true,
onSearch,
onTagFilter,
onSubtitleFilter,
onStrengthFilter,
onZoomIn,
onZoomOut,
onResetZoom,
onFocusSelected,
onClearFilters,
}: Props = $props();
// svelte-ignore state_referenced_locally
let searchInput = $state(searchQuery);
let showFilters = $state(false);
let showKeyboardHelp = $state(false);
// svelte-ignore state_referenced_locally
let strengthValue = $state(minStrength);
let searchInputElement = $state<HTMLInputElement | undefined>(undefined);
// 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 -->
{#if showSearch}
<div class="search-container">
<MagnifyingGlass 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>
{/if}
<!-- 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"
>
<Funnel 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 (+)">
<MagnifyingGlassPlus size={18} />
</button>
<button
onclick={onZoomOut}
class="control-btn"
aria-label="Verkleinern"
title="Verkleinern (-)"
>
<MagnifyingGlassMinus size={18} />
</button>
<button
onclick={onResetZoom}
class="control-btn"
aria-label="Ansicht zurücksetzen"
title="Zurücksetzen (0)"
>
<ArrowCounterClockwise size={18} />
</button>
<button
onclick={onFocusSelected}
class="control-btn"
aria-label="Auf Auswahl fokussieren"
title="Fokus auf Auswahl (F)"
>
<Crosshair 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(--color-card) / 0.8);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid hsl(var(--color-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(--color-muted-foreground));
pointer-events: none;
}
.search-input {
width: 100%;
padding: 0.5rem 2rem 0.5rem 2.5rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.5rem;
background: hsl(var(--color-background));
color: hsl(var(--color-foreground));
font-size: 0.875rem;
transition:
border-color 0.2s,
box-shadow 0.2s;
}
.search-input:focus {
outline: none;
border-color: hsl(var(--color-primary));
box-shadow: 0 0 0 2px hsl(var(--color-primary) / 0.1);
}
.search-input::placeholder {
color: hsl(var(--color-muted-foreground));
}
.clear-btn {
position: absolute;
right: 0.5rem;
top: 50%;
transform: translateY(-50%);
padding: 0.25rem;
background: none;
border: none;
color: hsl(var(--color-muted-foreground));
cursor: pointer;
border-radius: 0.25rem;
display: flex;
align-items: center;
justify-content: center;
}
.clear-btn:hover {
color: hsl(var(--color-foreground));
background: hsl(var(--color-muted));
}
.control-btn {
position: relative;
padding: 0.5rem;
background: hsl(var(--color-background));
border: 1px solid hsl(var(--color-border));
border-radius: 0.5rem;
color: hsl(var(--color-muted-foreground));
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.control-btn:hover {
background: hsl(var(--color-muted));
color: hsl(var(--color-foreground));
}
.control-btn.active {
background: hsl(var(--color-primary) / 0.1);
border-color: hsl(var(--color-primary));
color: hsl(var(--color-primary));
}
.filter-badge {
position: absolute;
top: -2px;
right: -2px;
width: 8px;
height: 8px;
background: hsl(var(--color-primary));
border-radius: 50%;
}
.zoom-controls {
display: flex;
gap: 0.25rem;
padding-left: 0.5rem;
border-left: 1px solid hsl(var(--color-border));
}
.stats {
display: flex;
align-items: center;
gap: 0.5rem;
margin-left: auto;
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
}
.stat-divider {
opacity: 0.5;
}
/* Filter panel */
.filter-panel {
margin-top: 0.5rem;
padding: 0.75rem 1rem;
background: hsl(var(--color-card) / 0.8);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid hsl(var(--color-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(--color-muted-foreground));
}
.filter-select {
padding: 0.5rem 0.75rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.5rem;
background: hsl(var(--color-background));
color: hsl(var(--color-foreground));
font-size: 0.875rem;
cursor: pointer;
}
.filter-select:focus {
outline: none;
border-color: hsl(var(--color-primary));
}
.clear-filters-btn {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.5rem 0.75rem;
background: hsl(var(--color-error) / 0.1);
border: 1px solid hsl(var(--color-error) / 0.2);
border-radius: 0.5rem;
color: hsl(var(--color-error));
font-size: 0.875rem;
cursor: pointer;
transition: all 0.2s;
}
.clear-filters-btn:hover {
background: hsl(var(--color-error) / 0.15);
}
/* Strength slider */
.strength-group {
min-width: 180px;
}
.strength-slider {
width: 100%;
height: 6px;
border-radius: 3px;
background: hsl(var(--color-muted));
appearance: none;
cursor: pointer;
}
.strength-slider::-webkit-slider-thumb {
appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: hsl(var(--color-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(--color-primary));
cursor: pointer;
}
/* Keyboard help panel */
.keyboard-help {
margin-top: 0.5rem;
padding: 0.75rem 1rem;
background: hsl(var(--color-card) / 0.8);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid hsl(var(--color-border) / 0.5);
border-radius: 1rem;
}
.keyboard-help-title {
font-size: 0.75rem;
font-weight: 600;
color: hsl(var(--color-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(--color-muted));
border: 1px solid hsl(var(--color-border));
border-radius: 0.375rem;
font-family: monospace;
font-size: 0.75rem;
font-weight: 600;
color: hsl(var(--color-foreground));
}
.shortcut-desc {
font-size: 0.8125rem;
color: hsl(var(--color-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>