import { type Component, For, Show, createMemo, createSignal } from 'solid-js';
import type { Preset } from '~/engine/types';
import { BUILT_IN_PRESETS } from '~/presets/builtIn';
import { deleteCustom, moveCustom, presetState, selectPreset } from '~/stores/presetStore';
import { closePanel, openPanel, uiState } from '~/stores/uiStore';
import { PresetCard } from './PresetCard';
import { Plus, Search, Sparkle, X } from './icons/Icon';
type Tab = 'builtin' | 'custom';
const matches = (preset: Preset, q: string): boolean => {
if (!q) return true;
const needle = q.toLowerCase();
if (preset.name.toLowerCase().includes(needle)) return true;
if (preset.tags.some((t) => t.toLowerCase().includes(needle))) return true;
return preset.tagSignal.toLowerCase().includes(needle);
};
export const PresetLibrary: Component = () => {
const [tab, setTab] = createSignal<Tab>('builtin');
const [query, setQuery] = createSignal('');
// Active tag filters: a preset must carry *all* selected tags to show.
// Empty set = no filter. Stored as a sorted string for cheap signal
// equality (Set identity would always read as "changed").
const [activeTags, setActiveTags] = createSignal<readonly string[]>([]);
// Reset tag filter when switching tabs — built-in and custom presets
// have different tag vocabularies; carrying selection across tabs
// produces a confusing empty grid.
const setTabAndClearTags = (next: Tab) => {
setTab(next);
setActiveTags([]);
};
const tabPresets = createMemo(() =>
tab() === 'builtin' ? BUILT_IN_PRESETS : presetState.customPresets,
);
const availableTags = createMemo(() => {
const seen = new Map<string, number>();
for (const p of tabPresets()) {
for (const t of p.tags) seen.set(t, (seen.get(t) ?? 0) + 1);
}
return [...seen.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
});
const matchesTags = (p: Preset): boolean => {
const active = activeTags();
if (active.length === 0) return true;
return active.every((t) => p.tags.includes(t));
};
const toggleTag = (t: string) => {
setActiveTags((prev) => (prev.includes(t) ? prev.filter((x) => x !== t) : [...prev, t]));
};
const filteredBuiltIn = createMemo(() =>
BUILT_IN_PRESETS.filter((p) => matches(p, query()) && matchesTags(p)),
);
const filteredCustom = createMemo(() =>
presetState.customPresets.filter((p) => matches(p, query()) && matchesTags(p)),
);
const onSelect = (p: Preset) => {
selectPreset(p);
closePanel();
};
return (
// biome-ignore lint/a11y/useKeyWithClickEvents: backdrop dismissal — Escape key handled by useHotkeys
<div
style={{
position: 'absolute',
inset: '0',
'z-index': '30',
display: 'flex',
'align-items': 'flex-start',
'justify-content': 'center',
padding: uiState.isNarrow
? 'calc(env(safe-area-inset-top, 0px) + 70px) calc(env(safe-area-inset-right, 0px) + 8px) calc(env(safe-area-inset-bottom, 0px) + 8px) calc(env(safe-area-inset-left, 0px) + 8px)'
: 'calc(env(safe-area-inset-top, 0px) + 80px) calc(env(safe-area-inset-right, 0px) + 80px) calc(env(safe-area-inset-bottom, 0px) + 80px) calc(env(safe-area-inset-left, 0px) + 80px)',
background: 'color-mix(in oklab, var(--void-000) 60%, transparent)',
'backdrop-filter': 'blur(8px)',
animation: 'phys-fade-in 160ms var(--ease-signal)',
}}
onClick={(e) => {
if (e.currentTarget === e.target) closePanel();
}}
>
<div
style={{
width: '100%',
'max-width': '1280px',
background: 'var(--surface-overlay)',
border: '1px solid var(--border-default)',
'border-radius': 'var(--radius-xl)',
'box-shadow': 'var(--shadow-modal)',
overflow: 'hidden',
display: 'flex',
'flex-direction': 'column',
'max-height': '85vh',
}}
>
<div
style={{
padding: uiState.isNarrow ? '14px 14px 10px' : '20px 24px',
'border-bottom': '1px solid var(--border-subtle)',
display: 'flex',
'flex-direction': uiState.isNarrow ? 'column' : 'row',
'align-items': uiState.isNarrow ? 'stretch' : 'center',
gap: uiState.isNarrow ? '10px' : '24px',
}}
>
<div
style={{
display: 'flex',
'align-items': uiState.isNarrow ? 'center' : 'flex-start',
gap: '12px',
'flex-shrink': '0',
}}
>
<div style={{ flex: '1', display: 'flex', 'flex-direction': 'column', gap: '2px' }}>
<span class="phys-eyebrow">Preset Library</span>
<span
style={{
'font-family': 'var(--font-display)',
'font-size': uiState.isNarrow ? '17px' : '22px',
'font-weight': '600',
}}
>
Pick a preset
</span>
</div>
<Show when={uiState.isNarrow}>
<button type="button" class="phys-iconbtn" onClick={() => closePanel()}>
<X />
</button>
</Show>
</div>
<div style={{ flex: uiState.isNarrow ? 'initial' : '1' }} />
<div
style={{
display: 'flex',
'align-items': 'center',
gap: '8px',
padding: '6px 12px',
background: 'var(--surface-inset)',
border: '1px solid var(--border-subtle)',
'border-radius': 'var(--radius-md)',
width: uiState.isNarrow ? 'auto' : '260px',
color: 'var(--fg-tertiary)',
}}
>
<Search size={14} />
<input
type="search"
value={query()}
onInput={(e) => setQuery(e.currentTarget.value)}
placeholder="Search by name, tag, signal"
class="phys-mono"
style={{
flex: '1',
background: 'transparent',
border: '0',
outline: 'none',
color: 'var(--fg-primary)',
'font-size': '12px',
'min-width': '0',
}}
/>
<Show when={query()}>
<button
type="button"
onClick={() => setQuery('')}
title="Clear search"
style={{
background: 'transparent',
border: '0',
padding: '0',
color: 'var(--fg-tertiary)',
cursor: 'pointer',
display: 'flex',
}}
>
<X size={11} />
</button>
</Show>
</div>
<Show when={!uiState.isNarrow}>
<button type="button" class="phys-iconbtn" onClick={() => closePanel()}>
<X />
</button>
</Show>
</div>
<div
style={{
display: 'flex',
'align-items': 'center',
padding: '0 24px',
'border-bottom': '1px solid var(--border-subtle)',
gap: '4px',
}}
>
<For each={['builtin', 'custom'] as const}>
{(t) => (
<button
type="button"
onClick={() => setTabAndClearTags(t)}
style={{
background: 'transparent',
border: '0',
padding: '14px 14px 12px',
color: tab() === t ? 'var(--fg-primary)' : 'var(--fg-tertiary)',
'border-bottom': `2px solid ${tab() === t ? 'var(--accent)' : 'transparent'}`,
'margin-bottom': '-1px',
cursor: 'pointer',
'font-family': 'var(--font-mono)',
'font-size': '11px',
'letter-spacing': '0.12em',
'text-transform': 'uppercase',
'font-weight': '600',
display: 'inline-flex',
'align-items': 'center',
gap: '8px',
}}
>
{t === 'builtin' ? 'Built-in' : 'My Presets'}
<span style={{ color: 'var(--fg-disabled)' }}>
{t === 'builtin' ? filteredBuiltIn().length : filteredCustom().length}
</span>
</button>
)}
</For>
<div style={{ flex: '1' }} />
<button
type="button"
class="phys-btn"
data-variant="ghost"
onClick={() => openPanel('save')}
>
<Plus size={12} /> New preset
</button>
</div>
<Show when={availableTags().length > 0}>
<div
style={{
padding: uiState.isNarrow ? '8px 14px' : '12px 24px',
'border-bottom': '1px solid var(--border-subtle)',
display: 'flex',
// On mobile, wrap eats half the screen with 30+ tags. Scroll
// horizontally instead — single row, swipe to discover.
'flex-wrap': uiState.isNarrow ? 'nowrap' : 'wrap',
'overflow-x': uiState.isNarrow ? 'auto' : 'visible',
'align-items': 'center',
gap: '6px',
background: 'var(--surface-primary)',
'scrollbar-width': 'thin',
}}
>
<span
class="phys-mono"
style={{
'font-size': '9px',
color: 'var(--fg-tertiary)',
'letter-spacing': '0.14em',
'text-transform': 'uppercase',
'margin-right': '4px',
'flex-shrink': '0',
}}
>
Tags
</span>
<For each={availableTags()}>
{([tag, count]) => {
const isActive = () => activeTags().includes(tag);
return (
<button
type="button"
onClick={() => toggleTag(tag)}
class="phys-mono"
style={{
'font-size': '10px',
padding: '3px 8px',
'border-radius': '999px',
cursor: 'pointer',
'letter-spacing': '0.06em',
'flex-shrink': '0',
background: isActive() ? 'var(--accent)' : 'var(--surface-inset)',
color: isActive() ? 'var(--fg-on-accent)' : 'var(--fg-secondary)',
border: `1px solid ${isActive() ? 'var(--accent)' : 'var(--border-default)'}`,
}}
>
{tag} <span style={{ opacity: '0.55', 'margin-left': '2px' }}>{count}</span>
</button>
);
}}
</For>
<Show when={activeTags().length > 0}>
<button
type="button"
onClick={() => setActiveTags([])}
class="phys-mono"
style={{
'font-size': '10px',
padding: '3px 8px',
'margin-left': '4px',
background: 'transparent',
border: '0',
color: 'var(--fg-tertiary)',
cursor: 'pointer',
}}
>
clear
</button>
</Show>
</div>
</Show>
<div
style={{
padding: uiState.isNarrow ? '14px' : '24px',
'overflow-y': 'auto',
flex: '1',
}}
>
<Show
when={tab() === 'builtin'}
fallback={
<Show
when={filteredCustom().length > 0}
fallback={<EmptyCustom hasQuery={!!query()} />}
>
{customGrid()}
</Show>
}
>
<Show when={filteredBuiltIn().length > 0} fallback={<EmptyMatch query={query()} />}>
<div
style={{
display: 'grid',
'grid-template-columns': uiState.isNarrow
? 'repeat(auto-fill, minmax(160px, 1fr))'
: 'repeat(auto-fill, minmax(240px, 1fr))',
gap: uiState.isNarrow ? '10px' : '16px',
}}
>
<For each={filteredBuiltIn()}>
{(p) => (
<PresetCard
preset={p}
active={p.id === presetState.current.id}
onClick={() => onSelect(p)}
/>
)}
</For>
</div>
</Show>
</Show>
</div>
<div
style={{
padding: '14px 24px',
'border-top': '1px solid var(--border-subtle)',
background: 'var(--surface-primary)',
display: 'flex',
'align-items': 'center',
gap: '14px',
}}
>
<span
class="phys-mono"
style={{
'font-size': '10px',
color: 'var(--fg-tertiary)',
'letter-spacing': '0.12em',
'text-transform': 'uppercase',
}}
>
↑ ↓ ← → navigate · ↵ load · esc dismiss
</span>
</div>
</div>
</div>
);
function customGrid() {
return (
<div
style={{
display: 'grid',
'grid-template-columns': uiState.isNarrow
? 'repeat(auto-fill, minmax(160px, 1fr))'
: 'repeat(auto-fill, minmax(240px, 1fr))',
gap: uiState.isNarrow ? '10px' : '16px',
}}
>
<For each={filteredCustom()}>
{(p) => {
// Reorder operates on the full (unfiltered) list — neighbours
// inside the filter could differ from neighbours in storage.
const indexInAll = () => presetState.customPresets.findIndex((q) => q.id === p.id);
return (
<PresetCard
preset={p}
active={p.id === presetState.current.id}
onClick={() => onSelect(p)}
onDelete={() => deleteCustom(p.id)}
onMoveUp={() => moveCustom(p.id, 'up')}
onMoveDown={() => moveCustom(p.id, 'down')}
canMoveUp={indexInAll() > 0}
canMoveDown={indexInAll() < presetState.customPresets.length - 1}
/>
);
}}
</For>
</div>
);
}
};
const EmptyCustom: Component<{ hasQuery: boolean }> = (p) => (
<div
style={{
padding: '64px 24px',
display: 'flex',
'flex-direction': 'column',
'align-items': 'center',
gap: '16px',
'text-align': 'center',
}}
>
<div
style={{
width: '64px',
height: '64px',
'border-radius': 'var(--radius-lg)',
border: '1px dashed var(--border-default)',
display: 'grid',
'place-items': 'center',
color: 'var(--fg-tertiary)',
}}
>
<Sparkle size={28} />
</div>
<div>
<div
style={{
'font-family': 'var(--font-display)',
'font-size': '22px',
'font-weight': '500',
'margin-bottom': '6px',
}}
>
{p.hasQuery ? 'No matches in your presets.' : 'No presets yet.'}
</div>
<div
style={{
color: 'var(--fg-secondary)',
'font-size': '13px',
'max-width': '380px',
'line-height': '1.6',
}}
>
{p.hasQuery
? 'Try a different search, or clear the search to see all your saved presets.'
: "Open the editor, tweak parameters, and save them here. Saved presets live in your browser's local storage."}
</div>
</div>
</div>
);
const EmptyMatch: Component<{ query: string }> = (p) => (
<div
style={{
padding: '64px 24px',
display: 'flex',
'flex-direction': 'column',
'align-items': 'center',
gap: '12px',
'text-align': 'center',
color: 'var(--fg-secondary)',
}}
>
<div style={{ 'font-family': 'var(--font-display)', 'font-size': '18px' }}>
No built-in preset matches "{p.query}"
</div>
<div style={{ 'font-size': '13px' }}>Try a different search, or switch to "My Presets".</div>
</div>
);