// Reusable visual building blocks — Beachy Lila design system.
//
// All tokens come from CSS vars in colors_and_type.css (`--lila-500`,
// `--grad-sunset`, `--shadow-card` etc.). Never hand-type a hex here
// unless you're picking up a per-track colour passed in via props.
//
// Backward-compat aliases at the bottom of the file: the legacy names
// (`ApprovalBg`, `ApGlass`, `ApCover`, `ApAvatar`, …) all resolve to
// these new components so the migration doesn't have to touch every
// screen at once. New code should use the short names (`PageBg`,
// `Glass`, …).

/* =========================================================
 * PageBg — dusk plum + three drifting orbs
 * Wraps the entire app shell. Three blurred coloured circles
 * (lila, coral, aqua) drift slowly via the apBlob[ABC]
 * keyframes in styles.css — gives the page the "sunset behind
 * frosted glass" feeling without a single background image.
 * ========================================================= */

function PageBg({ children }) {
  return (
    <div style={{
      position: 'relative', minHeight: '100vh', width: '100%', overflow: 'hidden',
      background: 'var(--bg-page)', color: 'var(--fg-1)',
    }}>
      <div style={{
        position: 'fixed', width: '50%', aspectRatio: '1', borderRadius: '50%',
        top: '-15%', left: '-5%', background: 'var(--lila-500)',
        opacity: 0.55, filter: 'blur(80px)',
        animation: 'apBlobA 22s ease-in-out infinite alternate',
        pointerEvents: 'none',
      }}/>
      <div style={{
        position: 'fixed', width: '45%', aspectRatio: '1', borderRadius: '50%',
        bottom: '-15%', right: '-5%', background: 'var(--coral)',
        opacity: 0.45, filter: 'blur(90px)',
        animation: 'apBlobB 26s ease-in-out infinite alternate',
        pointerEvents: 'none',
      }}/>
      <div style={{
        position: 'fixed', width: '30%', aspectRatio: '1', borderRadius: '50%',
        top: '40%', left: '55%', background: 'var(--aqua)',
        opacity: 0.30, filter: 'blur(70px)',
        animation: 'apBlobC 30s ease-in-out infinite alternate',
        pointerEvents: 'none',
      }}/>
      <div style={{ position: 'relative', zIndex: 1 }}>{children}</div>
    </div>
  );
}

/* =========================================================
 * Glass — frosted card surface
 *
 * The canonical card chrome. Translucent dusk-plum background,
 * 18px backdrop-blur, warm-violet shadow, foam rim light.
 *
 * Props:
 *   • radius      — corner radius in px (default 20)
 *   • padding     — inner padding (number → px; string → as-is)
 *   • blur        — backdrop-filter blur in px (18 default)
 *   • style       — extra style merged on top
 *   • hover       — when true, card lifts -2px on mouseover
 *   • accent      — overrides border color (e.g. for "flagged" cards)
 *   • orb         — colour string; renders a soft blurred orb in the
 *                   corner so the card has its own atmospheric tint
 *                   (used by login, hero, flagged submissions)
 *   • onClick     — makes the card clickable + pointer-cursored
 * ========================================================= */

function Glass({
  children, radius = 20, padding = 0, blur = 18, style = {},
  hover = false, accent = null, orb = null, onClick,
}) {
  return (
    <div
      onClick={onClick}
      style={{
        position: 'relative',
        background: 'var(--bg-card)',
        backdropFilter: `blur(${blur}px) saturate(140%)`,
        WebkitBackdropFilter: `blur(${blur}px) saturate(140%)`,
        border: `1px solid ${accent || 'var(--border-soft)'}`,
        borderRadius: radius,
        padding,
        boxShadow: 'var(--shadow-card)',
        cursor: onClick ? 'pointer' : undefined,
        overflow: 'hidden',
        transition: 'transform 240ms var(--ease-out), background 240ms var(--ease-out)',
        ...style,
      }}
      onMouseEnter={hover ? (e) => { e.currentTarget.style.transform = 'translateY(-2px)'; } : undefined}
      onMouseLeave={hover ? (e) => { e.currentTarget.style.transform = 'translateY(0)'; } : undefined}
    >
      {orb && (
        <div style={{
          position: 'absolute', top: '-30%', right: '-25%',
          width: '60%', aspectRatio: '1', borderRadius: '50%',
          background: orb, filter: 'blur(50px)', opacity: 0.4, pointerEvents: 'none',
        }}/>
      )}
      <div style={{ position: 'relative' }}>{children}</div>
    </div>
  );
}

/* =========================================================
 * Cover — square gradient placeholder OR uploaded image.
 *
 * Submissions carry a `cover` field — either a [colorA, colorB]
 * gradient palette (default) or an `src` URL pointing at the
 * uploaded cover image. Both render through this primitive.
 * ========================================================= */

function Cover({ colors, src, size = 80, radius = 14, style = {} }) {
  const [a, b] = colors || ['#A972F4', '#FF7E8B'];
  return (
    <div style={{
      width: size, height: size, borderRadius: radius, flexShrink: 0,
      background: src ? `url(${src}) center/cover` : `linear-gradient(135deg, ${a}, ${b})`,
      position: 'relative', overflow: 'hidden',
      boxShadow: `0 6px 20px ${a}55`,
      ...style,
    }}>
      {!src && (
        <div style={{
          position: 'absolute', top: '-20%', right: '-20%',
          width: '60%', height: '60%', borderRadius: '50%',
          background: 'rgba(255,247,236,0.25)', filter: 'blur(12px)',
        }}/>
      )}
    </div>
  );
}

/* =========================================================
 * Avatar — circular gradient initial chip
 *
 * Uses `player.avatar` ([colorA, colorB]) if present, otherwise
 * deterministically picks a palette from the player identifier
 * via fallbackAvatar() in helpers.jsx — same hash so the same
 * person always gets the same colours.
 * ========================================================= */

function Avatar({ player, size = 36, style = {} }) {
  if (!player) return null;
  const colors = player.avatar || (typeof fallbackAvatar === 'function' ? fallbackAvatar(player.identifier) : ['#A972F4', '#FF7E8B']);
  const [a, b] = colors;
  const initial = (player.name || '?').split(/\s+/).map(s => s[0]).slice(0, 2).join('').toUpperCase();
  return (
    <div style={{
      width: size, height: size, borderRadius: '50%', flexShrink: 0,
      background: `linear-gradient(135deg, ${a}, ${b})`,
      display: 'flex', alignItems: 'center', justifyContent: 'center',
      fontSize: Math.round(size * 0.38), fontWeight: 800, color: 'var(--foam)',
      boxShadow: `0 4px 12px ${a}55`,
      ...style,
    }}>{initial}</div>
  );
}

/* =========================================================
 * PlayerName — name + role/verified badges
 *
 * Shrinks to fit. Verified = aqua circle-check, admin = gold
 * shield. Both badges sized relative to the text size so the
 * cluster reads as one line regardless of where it's used.
 * ========================================================= */

function PlayerName({ player, size = 13, opacity = 0.85, color, badge = true, style = {}, clickable }) {
  if (!player) return null;
  const verified = typeof isVerifiedArtist === 'function' ? isVerifiedArtist(player.identifier) : !!player.verified;
  const adm = player.group === 'admin';

  // Click-to-open-artist-detail behaviour. Default ON for admins
  // viewing the site (they can investigate anyone); explicitly
  // overridable via the `clickable` prop. Skipped if there's no
  // identifier (e.g. anonymous votes) since we have nothing to
  // navigate to.
  const me = typeof Store !== 'undefined' ? Store.get().me : null;
  const isAdmin = me?.group === 'admin';
  const canOpen = (clickable !== false) && isAdmin && !!player.identifier;

  const content = (
    <>
      <span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', minWidth: 0 }}>{player.name}</span>
      {badge && verified && <ApIcon name="verified" size={Math.round(size * 1.05)} color="var(--aqua)"/>}
      {badge && adm && <ApIcon name="shield" size={Math.round(size * 0.95)} color="var(--gold)"/>}
    </>
  );

  // Common style block so the clickable + plain variants render
  // identically at rest. The button gets a hover affordance via
  // the global button:hover filter rule in styles.css.
  const base = {
    display: 'inline-flex', alignItems: 'center', gap: 4,
    fontSize: size, opacity, color, minWidth: 0, ...style,
  };

  if (canOpen) {
    return (
      <button
        onClick={(e) => { e.stopPropagation(); window.openArtist?.(player.identifier); }}
        title="Künstler-Details öffnen"
        style={{
          ...base,
          background: 'transparent', border: 'none', padding: 0,
          cursor: 'pointer', fontFamily: 'inherit', textAlign: 'left',
          textDecoration: 'underline', textDecorationColor: 'var(--border-soft)',
          textDecorationThickness: 1, textUnderlineOffset: 3,
        }}
      >
        {content}
      </button>
    );
  }

  return <span style={base}>{content}</span>;
}

/* =========================================================
 * ArtistLink — clickable artist name (for admins) by identifier.
 *
 * Used when we have an identifier handy but not a full player
 * object — e.g. a track row showing `track.artist` (string) +
 * `track.identifier`. Renders plain inline text for non-admins,
 * a click-through button for admins.
 *
 * Use this when you can't go through PlayerName because the
 * roster lookup doesn't have a record (e.g. the player left the
 * server, or the artist is using a stage name not tied to a
 * current online identity).
 * ========================================================= */

function ArtistLink({ identifier, name, size, color, style = {} }) {
  if (!name) return null;
  const me = typeof Store !== 'undefined' ? Store.get().me : null;
  const canOpen = me?.group === 'admin' && !!identifier;

  const base = {
    fontSize: size,
    color: color || 'inherit',
    whiteSpace: 'nowrap',
    overflow: 'hidden',
    textOverflow: 'ellipsis',
    ...style,
  };

  if (canOpen) {
    return (
      <button
        onClick={(e) => { e.stopPropagation(); window.openArtist?.(identifier); }}
        title="Künstler-Details öffnen"
        style={{
          ...base,
          background: 'transparent', border: 'none', padding: 0,
          cursor: 'pointer', fontFamily: 'inherit', fontWeight: 'inherit',
          textAlign: 'left',
          textDecoration: 'underline', textDecorationColor: 'var(--border-soft)',
          textDecorationThickness: 1, textUnderlineOffset: 3,
        }}
      >
        {name}
      </button>
    );
  }
  return <span style={base}>{name}</span>;
}

/* =========================================================
 * IdentifierMono — clickable mono-formatted FiveM identifier
 *
 * Used everywhere we display a raw player identifier (profile
 * screen, audit log, blocks list, artist directory). For admins,
 * clicking jumps to that player's artist detail page. For
 * non-admins it renders as inert mono text — they have nothing
 * to navigate to.
 * ========================================================= */

function IdentifierMono({ identifier, size = 11.5, style = {} }) {
  if (!identifier) return null;
  const me = typeof Store !== 'undefined' ? Store.get().me : null;
  const canOpen = me?.group === 'admin';

  const base = {
    fontFamily: 'var(--font-mono)', fontSize: size, color: 'var(--fg-3)',
    ...style,
  };

  if (canOpen) {
    return (
      <button
        onClick={(e) => { e.stopPropagation(); window.openArtist?.(identifier); }}
        title="Künstler-Details öffnen"
        style={{
          ...base,
          background: 'transparent', border: 'none', padding: 0,
          cursor: 'pointer', textAlign: 'left',
          textDecoration: 'underline', textDecorationColor: 'var(--border-soft)',
          textDecorationThickness: 1, textUnderlineOffset: 3,
        }}
      >
        {identifier}
      </button>
    );
  }
  return <span style={base}>{identifier}</span>;
}

/* =========================================================
 * StatusPill — submission status (pending/accepted/rejected)
 *
 * Three states plus a defensive fallback for corrupted data —
 * an earlier bug had peaks arrays accidentally assigned to
 * status and the raw payload was leaking into the UI as text.
 * Unknown values now render a neutral "?" badge.
 * ========================================================= */

function StatusPill({ status }) {
  const cfg = {
    pending:  { bg: 'rgba(255,194,103,0.16)', fg: 'var(--warning)', label: 'In Prüfung',     icon: 'clock' },
    accepted: { bg: 'rgba(95,216,166,0.18)',  fg: 'var(--success)', label: 'Veröffentlicht', icon: 'check' },
    rejected: { bg: 'rgba(255,110,138,0.18)', fg: 'var(--danger)',  label: 'Abgelehnt',      icon: 'x' },
  }[status];
  if (!cfg) {
    return (
      <span style={{
        display: 'inline-flex', alignItems: 'center', gap: 4, padding: '4px 10px',
        borderRadius: 999, background: 'rgba(196,154,255,0.08)', color: 'var(--fg-3)',
        fontSize: 11, fontWeight: 700, letterSpacing: 0.3, textTransform: 'uppercase',
      }}>?</span>
    );
  }
  return (
    <span style={{
      display: 'inline-flex', alignItems: 'center', gap: 5, padding: '4px 10px',
      borderRadius: 999, background: cfg.bg, color: cfg.fg,
      fontSize: 11, fontWeight: 700, letterSpacing: 0.3, textTransform: 'uppercase',
    }}>
      <ApIcon name={cfg.icon} size={10}/> {cfg.label}
    </span>
  );
}

/* =========================================================
 * RolePill — admin / mod / whitelist / verified / player
 *
 * Coloured chip showing the player's permission tier. Used in
 * the header next to the avatar and on profile/submission
 * cards. Verified artists get the aqua "Verifiziert" pill when
 * they aren't already admin / mod / whitelist.
 * ========================================================= */

function RolePill({ player }) {
  if (!player) return null;
  const verified = typeof isVerifiedArtist === 'function' ? isVerifiedArtist(player.identifier) : !!player.verified;
  let label, color, bg;
  if (player.group === 'admin')           { label = 'Admin';       color = 'var(--gold)';     bg = 'rgba(255,210,122,0.14)'; }
  else if (player.group === 'mod')        { label = 'Moderator';   color = 'var(--lila-300)'; bg = 'rgba(196,154,255,0.14)'; }
  else if (player.group === 'whitelist')  { label = 'Whitelist';   color = 'var(--info)';     bg = 'rgba(143,183,255,0.14)'; }
  else if (verified)                      { label = 'Verifiziert'; color = 'var(--aqua)';     bg = 'rgba(110,224,210,0.16)'; }
  else                                    { label = 'Spieler';     color = 'var(--fg-3)';     bg = 'rgba(196,154,255,0.08)'; }
  return (
    <span style={{
      display: 'inline-flex', alignItems: 'center', gap: 4, padding: '3px 10px',
      borderRadius: 999, fontSize: 10.5, fontWeight: 700, letterSpacing: 0.4,
      textTransform: 'uppercase', background: bg, color,
      border: `1px solid ${color}55`,
    }}>{label}</span>
  );
}

/* =========================================================
 * Button — variant + size, with optional left/right icon.
 *
 * Variants (intent-driven, never colour-driven on the callsite):
 *   • primary  — lila gradient, the default "do the thing" CTA
 *   • reward   — sunset gradient + coral glow, used for hero
 *                "Veröffentlichen" / "Jetzt hören"
 *   • secondary — translucent lilac, the workhorse for non-CTA
 *                actions (edit, save, filter)
 *   • ghost    — transparent with hairline border, for tertiary
 *                actions (CSV export, "Alle ansehen")
 *   • danger   — coral, destructive (reject, block, delete)
 *   • warning  — gold, attention-required (copyright skip)
 *   • success  — green, confirming success (vote accepted)
 *
 * Sizes: sm (filter chips), md (most buttons), lg (hero CTAs).
 *
 * `disabled` ghosts the button to 45% and disables the pointer.
 * The pure-CSS press feedback from styles.css still works on
 * the underlying <button>.
 * ========================================================= */

function Button({ children, variant = 'primary', size = 'md', icon, iconRight, disabled, onClick, type = 'button', style = {}, title }) {
  const variants = {
    primary: {
      background: 'var(--grad-lila)', color: 'var(--foam)',
      boxShadow: 'var(--glow-lila)', border: 'none',
    },
    reward: {
      background: 'var(--grad-sunset)', color: '#3d0011',
      boxShadow: 'var(--glow-coral)', border: 'none',
    },
    secondary: {
      background: 'rgba(196,154,255,0.10)', color: 'var(--foam)',
      border: '1px solid var(--border-medium)',
    },
    ghost: {
      background: 'transparent', color: 'var(--fg-2)',
      border: '1px solid var(--border-soft)',
    },
    danger: {
      background: 'rgba(255,110,138,0.16)', color: 'var(--danger)',
      border: '1px solid rgba(255,110,138,0.45)',
    },
    warning: {
      background: 'rgba(255,194,103,0.16)', color: 'var(--warning)',
      border: '1px solid rgba(255,194,103,0.4)',
    },
    success: {
      background: 'var(--success)', color: '#002a17',
      border: 'none', boxShadow: '0 6px 18px rgba(95,216,166,0.35)',
    },
  };
  const sizes = {
    sm: { padding: '7px 14px',  fontSize: 12.5, borderRadius: 10 },
    md: { padding: '10px 18px', fontSize: 13.5, borderRadius: 12 },
    lg: { padding: '14px 22px', fontSize: 15,   borderRadius: 14 },
  };
  return (
    <button
      type={type}
      onClick={onClick}
      disabled={disabled}
      title={title}
      style={{
        display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 8,
        fontFamily: 'var(--font-sans)', fontWeight: 700, cursor: disabled ? 'not-allowed' : 'pointer',
        opacity: disabled ? 0.45 : 1, lineHeight: 1.1, whiteSpace: 'nowrap',
        ...variants[variant], ...sizes[size], ...style,
      }}
    >
      {icon && <ApIcon name={icon} size="1em"/>}
      {children}
      {iconRight && <ApIcon name={iconRight} size="1em"/>}
    </button>
  );
}

/* =========================================================
 * TextInput — single-line input with optional leading icon
 *
 * Lila focus ring + brightening border. Used for search,
 * identifier fields, single-line free-text. Multiline edits
 * still go through raw <textarea> in the screens that need them.
 * ========================================================= */

function TextInput({ value, onChange, placeholder, type = 'text', icon, style = {}, ...rest }) {
  return (
    <div style={{ position: 'relative' }}>
      {icon && (
        <ApIcon name={icon} size={14}
          style={{ position: 'absolute', left: 14, top: '50%', transform: 'translateY(-50%)', color: 'var(--fg-3)' }}/>
      )}
      <input
        type={type}
        value={value || ''}
        onChange={onChange}
        placeholder={placeholder}
        style={{
          width: '100%', padding: icon ? '12px 14px 12px 38px' : '12px 14px',
          borderRadius: 14, background: 'var(--bg-input)',
          border: '1px solid var(--border-soft)',
          color: 'var(--foam)', fontSize: 14, fontFamily: 'inherit',
          outline: 'none', boxSizing: 'border-box',
          transition: 'border 150ms var(--ease-out), box-shadow 150ms var(--ease-out)',
          ...style,
        }}
        onFocus={(e) => {
          e.target.style.borderColor = 'var(--border-bright)';
          e.target.style.boxShadow = '0 0 0 3px rgba(169,114,244,0.18)';
        }}
        onBlur={(e) => {
          e.target.style.borderColor = 'var(--border-soft)';
          e.target.style.boxShadow = 'none';
        }}
        {...rest}
      />
    </div>
  );
}

/* =========================================================
 * Label — eyebrow label + optional hint
 *
 * Used above form fields. The eyebrow text is uppercased
 * tracking-0.18em (per the type-scale spec), the hint sits
 * below it muted at 11.5px.
 * ========================================================= */

function Label({ children, hint, style = {} }) {
  return (
    <div style={{ marginBottom: 8, ...style }}>
      <div style={{
        fontSize: 12, fontWeight: 600, letterSpacing: '0.18em',
        textTransform: 'uppercase', color: 'var(--fg-3)',
      }}>{children}</div>
      {hint && <div style={{ fontSize: 11.5, color: 'var(--fg-4)', marginTop: 4 }}>{hint}</div>}
    </div>
  );
}

/* =========================================================
 * Eyebrow — small uppercase tracking label, no surrounding box
 *
 * For section headers (Eyebrow + h1 below it) and stat-card
 * labels. The CONTENT layer; Label is the FORM-FIELD layer.
 * ========================================================= */

function Eyebrow({ children, style = {} }) {
  return (
    <div style={{
      fontSize: 12, fontWeight: 600, letterSpacing: '0.18em',
      textTransform: 'uppercase', color: 'var(--fg-3)', ...style,
    }}>{children}</div>
  );
}

/* =========================================================
 * Slider — generic value bar with thumb
 *
 * Used by volume (NowPlayingBar) and anywhere we want a
 * clickable progress visualisation. Click anywhere on the
 * track to jump there; the thumb follows. The accent colour
 * is configurable so the same primitive serves the lila
 * volume slider AND the sunset scrub bar.
 * ========================================================= */

function Slider({ value, max = 1, onChange, accent = 'var(--lila-400)', style = {}, onSeek }) {
  const pct = Math.max(0, Math.min(1, value / max)) * 100;
  return (
    <div
      onClick={(e) => {
        const rect = e.currentTarget.getBoundingClientRect();
        const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
        (onSeek || onChange)?.(ratio * max);
      }}
      style={{
        position: 'relative', height: 6, borderRadius: 3,
        background: 'rgba(196,154,255,0.16)', cursor: 'pointer', ...style,
      }}
    >
      <div style={{
        position: 'absolute', left: 0, top: 0, bottom: 0,
        width: `${pct}%`, background: accent, borderRadius: 3,
        transition: 'width 200ms linear',
      }}/>
      <div style={{
        position: 'absolute', left: `${pct}%`, top: '50%',
        transform: 'translate(-50%,-50%)', width: 12, height: 12,
        borderRadius: '50%', background: 'var(--foam)',
        boxShadow: '0 2px 8px rgba(20,8,40,0.6)',
      }}/>
    </div>
  );
}

/* =========================================================
 * EmptyState — icon + title + description (+ optional CTA)
 *
 * Used everywhere a list is empty. Renders inside its own
 * Glass card by default (queue / reports / bulk-upload /
 * playlists-list / stats-verlauf). Pass `bare` when the
 * caller is already inside a Glass and just needs the inner
 * content block (playlists-detail tracks list, profile-screen
 * subcards). Pass `iconCircle` for the "hero" variant the
 * home screen uses for the catalog-is-empty state.
 *
 * Props:
 *   • icon          — ApIcon name (omit to skip the icon)
 *   • iconColor     — colour for the icon glyph
 *   • iconCircle    — wrap the icon in a lila-tinted bordered
 *                     76px circle (the home-hero variant)
 *   • title         — bold headline string
 *   • description   — muted line below the title
 *   • cta           — optional element rendered below description
 *   • bare          — drop the Glass wrapper (caller wraps)
 *   • orb           — passed through to Glass when not bare
 *   • radius        — Glass radius (default 20)
 *   • padding       — Glass padding (default 48)
 *   • size          — 'sm' / 'md' / 'lg' (controls type scale)
 * ========================================================= */

function EmptyState({
  icon, iconColor, iconCircle = false,
  title, description, cta,
  bare = false, orb, radius = 20, padding = 48,
  size = 'md', style = {},
}) {
  const sizing = ({
    sm: { iconSize: 32, titleSize: 15, descSize: 13 },
    md: { iconSize: 36, titleSize: 17, descSize: 13 },
    lg: { iconSize: 32, titleSize: 26, descSize: 14.5 },
  })[size] || { iconSize: 36, titleSize: 17, descSize: 13 };

  const inner = (
    <>
      {iconCircle ? (
        <div style={{
          width: 76, height: 76, borderRadius: '50%', margin: '0 auto 18px',
          background: 'rgba(196,154,255,0.16)',
          display: 'flex', alignItems: 'center', justifyContent: 'center',
          border: '1px solid var(--border-medium)',
        }}>
          <ApIcon name={icon || 'music'} size={32} color={iconColor || 'var(--lila-300)'}/>
        </div>
      ) : icon ? (
        <ApIcon name={icon} size={sizing.iconSize} color={iconColor || 'var(--fg-3)'}/>
      ) : null}
      {title && (
        <div style={{
          fontWeight: size === 'lg' ? 800 : 700,
          fontSize: sizing.titleSize,
          letterSpacing: size === 'lg' ? '-0.02em' : 0,
          marginTop: (iconCircle || !icon) ? 0 : 12,
          color: 'var(--foam)',
        }}>{title}</div>
      )}
      {description && (
        <div style={{
          fontSize: sizing.descSize,
          color: size === 'lg' ? 'var(--fg-2)' : 'var(--fg-3)',
          marginTop: title ? 4 : 0,
          lineHeight: size === 'lg' ? 1.55 : undefined,
          opacity: size === 'lg' ? 0.85 : undefined,
        }}>{description}</div>
      )}
      {cta && <div style={{ marginTop: 18 }}>{cta}</div>}
    </>
  );

  if (bare) {
    return <div style={{ padding, textAlign: 'center', ...style }}>{inner}</div>;
  }
  return (
    <Glass radius={radius} padding={padding} orb={orb} style={{ textAlign: 'center', ...style }}>
      {inner}
    </Glass>
  );
}

/* =========================================================
 * ShareButton — copy a "open this track" URL to clipboard
 *
 * Lives next to AdminMenu on every track row. One click copies
 * `<origin>/#/track/<id>` to the clipboard; the receiver opens
 * the link, the app boot path notices the hash, fetches the
 * track via Api.track(), starts playback, and pops the big
 * player open. A toast confirms the copy so the user knows it
 * landed in the clipboard.
 *
 * Falls back to a hidden <textarea> + execCommand if the modern
 * clipboard API is unavailable (rare, but happens on insecure
 * origins or restrictive browser configs).
 * ========================================================= */

function ShareButton({ track, size = 30, style = {} }) {
  if (!track?.id) return null;

  const handleShare = async (e) => {
    e.stopPropagation();
    const url = `${window.location.origin}/#/track/${encodeURIComponent(track.id)}`;
    try {
      if (navigator.clipboard?.writeText) {
        await navigator.clipboard.writeText(url);
      } else {
        // Legacy fallback — keep around for HTTP / non-secure origins
        // where navigator.clipboard isn't exposed.
        const ta = document.createElement('textarea');
        ta.value = url; ta.style.position = 'fixed'; ta.style.opacity = '0';
        document.body.appendChild(ta); ta.select();
        document.execCommand('copy');
        document.body.removeChild(ta);
      }
      // `window.toast` is a function with `.success` / `.error` props
      // (see toast.jsx); prefer the typed variant, fall back to the
      // bare form if it's missing.
      (window.toast?.success || window.toast)?.('Link kopiert');
    } catch {
      (window.toast?.error || window.toast)?.('Konnte Link nicht kopieren');
    }
  };

  return (
    <button
      onClick={handleShare}
      title="Direkt-Link zum Track kopieren"
      aria-label="Track-Link kopieren"
      style={{
        width: size, height: size, borderRadius: '50%',
        background: 'transparent', border: '1px solid var(--border-soft)',
        color: 'var(--fg-3)', cursor: 'pointer',
        display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
        flexShrink: 0,
        transition: 'background 150ms var(--ease-out), color 150ms var(--ease-out)',
        ...style,
      }}
      onMouseEnter={(e) => {
        e.currentTarget.style.background = 'rgba(196,154,255,0.10)';
        e.currentTarget.style.color = 'var(--lila-300)';
      }}
      onMouseLeave={(e) => {
        e.currentTarget.style.background = 'transparent';
        e.currentTarget.style.color = 'var(--fg-3)';
      }}
    >
      <ApIcon name="share" size={12}/>
    </button>
  );
}

/* =========================================================
 * Backward-compat aliases
 *
 * The screens currently import (via window globals) the old
 * `Ap*` names. Aliasing here keeps them rendering correctly
 * mid-migration. Once every screen is touched to use the
 * short names, these can be deleted.
 * ========================================================= */

const ApprovalBg  = ({ dark, children }) => <PageBg>{children}</PageBg>;
const ApGlass     = ({ dark, hover, ...rest }) => <Glass hover={hover} {...rest}/>;
const ApCover     = (props) => <Cover {...props}/>;
const ApAvatar    = (props) => <Avatar {...props}/>;
const ApPlayerName = (props) => <PlayerName {...props}/>;
const ApStatusPill = (props) => <StatusPill {...props}/>;
const ApRolePill  = ({ dark, ...rest }) => <RolePill {...rest}/>;

Object.assign(window, {
  // New names
  PageBg, Glass, Cover, Avatar, PlayerName, IdentifierMono, ArtistLink,
  StatusPill, RolePill, Button, TextInput, Label, Eyebrow, Slider, EmptyState, ShareButton,
  // Legacy aliases
  ApprovalBg, ApGlass, ApCover, ApAvatar, ApPlayerName, ApStatusPill, ApRolePill,
});
