Zum Inhalt

src/ui/PresetCard.tsx

import { type Component, For, Show } from 'solid-js';
import type { Preset } from '~/engine/types';
import { SIGNAL_CSS_VAR } from '~/presets/builtIn';
import { thumbnails } from '~/stores/thumbnailStore';
import { ChevDown, ChevUp, Trash } from './icons/Icon';

interface Props {
  preset: Preset;
  active: boolean;
  onClick: () => void;
  onDelete?: () => void;
  /** Reorder hooks — when provided render up/down arrows on the card. */
  onMoveUp?: () => void;
  onMoveDown?: () => void;
  /** Disable the corresponding arrow at the list boundary. */
  canMoveUp?: boolean;
  canMoveDown?: boolean;
}

export const PresetCard: Component<Props> = (props) => {
  const signal = () => SIGNAL_CSS_VAR[props.preset.tagSignal];
  const toHex = (c: readonly number[]) =>
    `#${c
      .map((v) =>
        Math.round(v * 255)
          .toString(16)
          .padStart(2, '0'),
      )
      .join('')}`;
  const trailHex = () => toHex(props.preset.params.species[0].color);
  const bgHex = () => toHex(props.preset.params.backgroundColor);
  // Prefer the shipped baked thumbnail (built-ins). Fall back to the
  // per-session capture from the runtime thumbnailStore (custom presets
  // or any built-in that hasn't been baked yet).
  const thumb = () => props.preset.thumbnail ?? thumbnails()[props.preset.id];
  const activeSpecies = () => props.preset.params.species.filter((s) => s.populationShare > 0);

  return (
    <button
      type="button"
      onClick={props.onClick}
      style={{
        background: 'var(--surface-raised)',
        border: '1px solid var(--border-subtle)',
        'border-radius': 'var(--radius-lg)',
        overflow: 'hidden',
        cursor: 'pointer',
        position: 'relative',
        // Explicit colour so descendants don't inherit the browser's
        // ButtonText system colour (the original cause of the name
        // rendering dim grey on dark surface).
        color: 'var(--fg-primary)',
        'box-shadow': props.active
          ? `0 0 0 1px ${signal()}, 0 0 24px -8px ${signal()}, var(--shadow-card)`
          : 'var(--shadow-card)',
        transition:
          'transform var(--dur-fast) var(--ease-signal), box-shadow var(--dur-base) var(--ease-signal), border-color var(--dur-base) var(--ease-signal)',
        'text-align': 'left',
        padding: '0',
      }}
    >
      <div
        style={{
          position: 'absolute',
          top: '0',
          left: '0',
          right: '0',
          height: '2px',
          background: signal(),
          opacity: props.active ? '1' : '0.7',
          'z-index': '2',
        }}
      />
      <div
        style={{
          position: 'relative',
          'aspect-ratio': '16 / 9',
          background: `radial-gradient(ellipse at 50% 50%, ${trailHex()}55, ${bgHex()} 70%)`,
        }}
      >
        <Show when={thumb()}>
          {(url) => (
            <img
              src={url()}
              alt=""
              style={{
                position: 'absolute',
                inset: '0',
                width: '100%',
                height: '100%',
                'object-fit': 'cover',
                display: 'block',
              }}
            />
          )}
        </Show>
        <div
          style={{
            position: 'absolute',
            inset: '0',
            'box-shadow':
              'inset 0 0 0 1px rgba(255,255,255,0.04), inset 0 -20px 40px -20px rgba(0,0,0,0.6)',
          }}
        />
        <Show when={props.active}>
          <div
            style={{
              position: 'absolute',
              top: '8px',
              right: '8px',
              padding: '2px 7px',
              'border-radius': '999px',
              background: signal(),
              color: 'var(--fg-on-accent)',
              'font-family': 'var(--font-mono)',
              'font-size': '8px',
              'font-weight': '700',
              'letter-spacing': '0.14em',
              'text-transform': 'uppercase',
            }}
          >
            Active
          </div>
        </Show>
        <Show when={props.onDelete}>
          <button
            type="button"
            title={`Delete "${props.preset.name}"`}
            aria-label={`Delete preset ${props.preset.name}`}
            onClick={(e) => {
              e.stopPropagation();
              if (window.confirm(`Delete "${props.preset.name}"? This can't be undone.`)) {
                props.onDelete?.();
              }
            }}
            style={{
              position: 'absolute',
              bottom: '8px',
              right: '8px',
              width: '28px',
              height: '28px',
              display: 'flex',
              'align-items': 'center',
              'justify-content': 'center',
              background: 'color-mix(in oklab, var(--void-000) 60%, transparent)',
              border: '1px solid var(--border-subtle)',
              'border-radius': 'var(--radius-md)',
              color: 'var(--fg-secondary, var(--fg-primary))',
              cursor: 'pointer',
              padding: '0',
              'backdrop-filter': 'blur(4px)',
            }}
          >
            <Trash size={13} />
          </button>
        </Show>
        <Show when={props.onMoveUp || props.onMoveDown}>
          <div
            style={{
              position: 'absolute',
              bottom: '8px',
              left: '8px',
              display: 'flex',
              gap: '4px',
            }}
          >
            <Show when={props.onMoveUp}>
              <button
                type="button"
                title="Move earlier in library"
                aria-label={`Move preset ${props.preset.name} earlier`}
                disabled={!props.canMoveUp}
                onClick={(e) => {
                  e.stopPropagation();
                  props.onMoveUp?.();
                }}
                style={{
                  width: '28px',
                  height: '28px',
                  display: 'flex',
                  'align-items': 'center',
                  'justify-content': 'center',
                  background: 'color-mix(in oklab, var(--void-000) 60%, transparent)',
                  border: '1px solid var(--border-subtle)',
                  'border-radius': 'var(--radius-md)',
                  color: 'var(--fg-secondary, var(--fg-primary))',
                  cursor: props.canMoveUp ? 'pointer' : 'not-allowed',
                  opacity: props.canMoveUp ? '1' : '0.35',
                  padding: '0',
                  'backdrop-filter': 'blur(4px)',
                }}
              >
                <ChevUp size={13} />
              </button>
            </Show>
            <Show when={props.onMoveDown}>
              <button
                type="button"
                title="Move later in library"
                aria-label={`Move preset ${props.preset.name} later`}
                disabled={!props.canMoveDown}
                onClick={(e) => {
                  e.stopPropagation();
                  props.onMoveDown?.();
                }}
                style={{
                  width: '28px',
                  height: '28px',
                  display: 'flex',
                  'align-items': 'center',
                  'justify-content': 'center',
                  background: 'color-mix(in oklab, var(--void-000) 60%, transparent)',
                  border: '1px solid var(--border-subtle)',
                  'border-radius': 'var(--radius-md)',
                  color: 'var(--fg-secondary, var(--fg-primary))',
                  cursor: props.canMoveDown ? 'pointer' : 'not-allowed',
                  opacity: props.canMoveDown ? '1' : '0.35',
                  padding: '0',
                  'backdrop-filter': 'blur(4px)',
                }}
              >
                <ChevDown size={13} />
              </button>
            </Show>
          </div>
        </Show>
      </div>
      <div style={{ padding: '12px 14px 14px' }}>
        <div
          style={{
            display: 'flex',
            'align-items': 'baseline',
            'justify-content': 'space-between',
            gap: '8px',
            'margin-bottom': '6px',
          }}
        >
          <span
            style={{
              'font-family': 'var(--font-display)',
              'font-size': '17px',
              'font-weight': '600',
              color: 'var(--fg-primary)',
            }}
          >
            {props.preset.name}
          </span>
          <span
            class="phys-mono"
            style={{
              'font-size': '10px',
              color: 'var(--fg-secondary)',
              'letter-spacing': '0.12em',
              'text-transform': 'uppercase',
            }}
          >
            {(props.preset.params.agentCount / 1000).toFixed(0)}k
          </span>
        </div>
        <div style={{ display: 'flex', gap: '5px', 'margin-bottom': '8px' }}>
          <For each={activeSpecies()}>
            {(s) => (
              <span
                style={{
                  width: '10px',
                  height: '10px',
                  'border-radius': '50%',
                  background: toHex(s.color),
                  'box-shadow': `0 0 6px ${toHex(s.color)}`,
                }}
              />
            )}
          </For>
        </div>
        <div style={{ display: 'flex', 'flex-wrap': 'wrap', gap: '4px' }}>
          <For each={props.preset.tags}>
            {(t) => (
              <span
                class="phys-mono"
                style={{
                  'font-size': '10px',
                  color: 'var(--fg-secondary)',
                  'letter-spacing': '0.06em',
                  padding: '3px 7px',
                  border: '1px solid var(--border-default)',
                  'border-radius': '3px',
                  background: 'var(--surface-inset)',
                }}
              >
                {t}
              </span>
            )}
          </For>
        </div>
      </div>
    </button>
  );
};