// Admin.jsx — tile catalogue management
// Access via  /#admin   (password from .env ADMIN_PASSWORD)

const { useState, useEffect, useRef } = React;

const ADMIN_TOKEN_KEY = 'venoraa-admin-token';
const CATS = ['Bath', 'Wall', 'Floor', 'Outdoor', 'Decor'];
const PATTERNS = ['grid', 'brick', 'hex'];
// Finish master list — now SERVER-PERSISTED.
//
// The list lives in data/finishes.json on the server, exposed at
// GET /api/finishes (public) and edited via POST/DELETE/PUT
// /api/admin/finishes (admin-gated). The Admin component fetches it
// on mount and writes the current list into a module-level mutable
// store (`finishesStore`). Any component that needs to RE-RENDER
// when the list changes uses the `useFinishes()` hook below; any
// component that just needs to read the current array at submit
// time (e.g. final form validation) can read `getFinishes()`.
//
// Seed = same defaults the server seeds finishes.json with, so
// pills aren't empty during the initial fetch round-trip.
//
// Each tile can be tagged with up to 5 of these on upload/edit.
const FINISHES_SEED = [
  'Matt', 'Polished', 'Natural', 'Honed', 'Handglazed',
  'Gloss', 'Natural Matt', 'Carving', 'Sugar Finish', 'Silk Matt Smooth',
];
const finishesStore = { list: [...FINISHES_SEED], listeners: new Set() };
function getFinishes() { return finishesStore.list; }
function setFinishesGlobal(next) {
  finishesStore.list = Array.isArray(next) ? next : [];
  for (const fn of finishesStore.listeners) fn();
}
function useFinishes() {
  const [, tick] = React.useState(0);
  React.useEffect(() => {
    const fn = () => tick(n => n + 1);
    finishesStore.listeners.add(fn);
    return () => { finishesStore.listeners.delete(fn); };
  }, []);
  return finishesStore.list;
}
// Legacy alias — kept so existing references inside this file that
// read `FINISHES` for one-shot iteration (e.g. seed-form defaults)
// keep working without a rename pass. Always reads the LIVE store.
const FINISHES = new Proxy({}, {
  get(_t, prop) {
    const live = finishesStore.list;
    if (prop === 'length') return live.length;
    if (prop === Symbol.iterator) return live[Symbol.iterator].bind(live);
    if (typeof prop === 'string' && /^\d+$/.test(prop)) return live[Number(prop)];
    const v = live[prop];
    return typeof v === 'function' ? v.bind(live) : v;
  },
});

// ─── Sizes — admin-editable list (same pattern as finishes) ─────────
// The studio can add/remove sizes from the dedicated Sizes admin tab;
// what they add appears live as pills on every tile upload + edit form.
// SIZES_SEED is the initial list used until /api/sizes hydrates the
// real one on Admin mount. Mirrors DEFAULT_SIZES on the server.
const SIZES_SEED = [
  '30×30cm',  '60×60cm',  '80×80cm',
  '90×90cm', '120×120cm', '30×60cm',
  '60×120cm','75×150cm', '120×240cm',
  '20×120cm','15×90cm',  '7.5×30cm',
];
const sizesStore = { list: [...SIZES_SEED], listeners: new Set() };
function setSizesGlobal(next) {
  sizesStore.list = Array.isArray(next) ? next : [];
  for (const fn of sizesStore.listeners) fn();
}
function useSizes() {
  const [, tick] = React.useState(0);
  React.useEffect(() => {
    const fn = () => tick(n => n + 1);
    sizesStore.listeners.add(fn);
    return () => { sizesStore.listeners.delete(fn); };
  }, []);
  return sizesStore.list;
}
// Legacy alias — every existing reference to STANDARD_SIZES (pill row
// options, "is this a custom size?" filter check) keeps working
// transparently and always sees the LIVE list. Equivalent to the
// FINISHES Proxy above.
const STANDARD_SIZES = new Proxy({}, {
  get(_t, prop) {
    const live = sizesStore.list;
    if (prop === 'length') return live.length;
    if (prop === Symbol.iterator) return live[Symbol.iterator].bind(live);
    if (typeof prop === 'string' && /^\d+$/.test(prop)) return live[Number(prop)];
    const v = live[prop];
    return typeof v === 'function' ? v.bind(live) : v;
  },
});

// ─── Taxonomies (rooms / colours / styles / shapes) ──────────────────
// SERVER-PERSISTED, same store-and-hook pattern as finishes/sizes.
// Each entry is { id, label } where:
//   · id    is the slug stored on every tile (`tile.colours = ['beige']`)
//   · label is the human-readable display ("Beige & Cream")
//
// First-boot seeds below match the previously-hardcoded constants so
// the storefront looks identical on day one; from then on the studio
// adds/removes entries via the "Filters" admin tab and the pill rows
// update everywhere live.
const TAXONOMY_SEEDS = {
  rooms: [
    { id: 'bathroom',     label: 'Bathroom' },
    { id: 'kitchen',      label: 'Kitchen' },
    { id: 'living',       label: 'Living Room' },
    { id: 'bedroom',      label: 'Bedroom' },
    { id: 'hallway',      label: 'Hallway' },
    { id: 'outdoor',      label: 'Outdoor' },
    { id: 'fireplace',    label: 'Fireplace' },
    { id: 'feature-wall', label: 'Feature Wall' },
  ],
  colours: [
    { id: 'white',      label: 'White' },
    { id: 'beige',      label: 'Beige & Cream' },
    { id: 'grey',       label: 'Grey' },
    { id: 'black',      label: 'Black' },
    { id: 'brown',      label: 'Brown' },
    { id: 'green',      label: 'Green' },
    { id: 'blue',       label: 'Blue' },
    { id: 'pink',       label: 'Pink' },
    { id: 'terracotta', label: 'Terracotta' },
    { id: 'metallic',   label: 'Metallic & Gold' },
  ],
  styles: [
    { id: 'marble',    label: 'Marble Effect' },
    { id: 'onyx',      label: 'Onyx Effect' },
    { id: 'stone',     label: 'Stone Effect' },
    { id: 'wood',      label: 'Wood Effect' },
    { id: 'concrete',  label: 'Concrete' },
    { id: 'terrazzo',  label: 'Terrazzo' },
    { id: 'patterned', label: 'Patterned' },
    { id: 'handmade',  label: 'Zellige & Handmade' },
    { id: 'metallic',  label: 'Metallic' },
    { id: 'plain',     label: 'Plain' },
  ],
  shapes: [
    { id: 'mosaic',       label: 'Mosaic' },
    { id: 'subway',       label: 'Subway' },
    { id: 'square-60',    label: 'Square 60' },
    { id: 'square-large', label: 'Square Large' },
    { id: 'plank',        label: 'Plank' },
  ],
};

// Factory: creates a store + hook + Proxy alias for a single
// taxonomy. The Proxy lets old code that read e.g. `ROOMS[0].label`
// or `for (const r of ROOMS)` keep working without changes —
// every read goes through the live store.
function makeTaxonomyStore(name) {
  const store = { list: [...(TAXONOMY_SEEDS[name] || [])], listeners: new Set() };
  function set(next) {
    store.list = Array.isArray(next) ? next.filter(Boolean) : [];
    for (const fn of store.listeners) fn();
  }
  function use() {
    const [, tick] = React.useState(0);
    React.useEffect(() => {
      const fn = () => tick(n => n + 1);
      store.listeners.add(fn);
      return () => { store.listeners.delete(fn); };
    }, []);
    return store.list;
  }
  // Proxy alias for legacy references like ROOMS.map / ROOMS[i]
  const alias = new Proxy({}, {
    get(_t, prop) {
      const live = store.list;
      if (prop === 'length') return live.length;
      if (prop === Symbol.iterator) return live[Symbol.iterator].bind(live);
      if (typeof prop === 'string' && /^\d+$/.test(prop)) return live[Number(prop)];
      const v = live[prop];
      return typeof v === 'function' ? v.bind(live) : v;
    },
  });
  return { store, set, use, alias };
}

const _roomsT   = makeTaxonomyStore('rooms');
const _coloursT = makeTaxonomyStore('colours');
const _stylesT  = makeTaxonomyStore('styles');
const _shapesT  = makeTaxonomyStore('shapes');

const setRoomsGlobal   = _roomsT.set;
const setColoursGlobal = _coloursT.set;
const setStylesGlobal  = _stylesT.set;
const setShapesGlobal  = _shapesT.set;
const useRooms   = _roomsT.use;
const useColours = _coloursT.use;
const useStyles  = _stylesT.use;
const useShapes  = _shapesT.use;
const ROOMS   = _roomsT.alias;
const COLOURS = _coloursT.alias;
const STYLES  = _stylesT.alias;
const SHAPES  = _shapesT.alias;

const MAX_SIGHT_IMAGES  = 5;  // 1-5 cycling hero shots (matches server cap)
const MAX_EXTRA_IMAGES  = 15; // 0-15 gallery / preview shots
const MAX_COLOUR_IMAGES = 15; // 0-15 colour swatches per tile (matches server cap)

// Shared style for buttons that live in the dark bulk-action bar.
const bulkBtn = {
  background: 'none', border: '1px solid rgba(255,255,255,0.35)',
  color: 'var(--cream)', padding: '7px 14px',
  fontFamily: 'var(--sans)', fontSize: '10px',
  letterSpacing: '0.16em', textTransform: 'uppercase', cursor: 'pointer',
};

// ─── Image dropzone — extracted so we can reuse it for sight + extras ──
function ImageDropZone({ file, onChange, height = 200, hint = 'Drop image' }) {
  const ref = React.useRef(null);
  const [preview, setPreview] = React.useState('');
  React.useEffect(() => {
    if (!file) { setPreview(''); return; }
    const reader = new FileReader();
    reader.onload = ev => setPreview(ev.target.result);
    reader.readAsDataURL(file);
  }, [file]);
  return (
    <div>
      <div
        onClick={() => ref.current?.click()}
        onDragOver={e => e.preventDefault()}
        onDrop={e => { e.preventDefault(); const f = e.dataTransfer.files?.[0]; if (f && f.type.startsWith('image/')) onChange(f); }}
        style={{
          width: '100%', height: `${height}px`,
          border: `1px dashed ${preview ? 'var(--cream-deep)' : 'var(--dark-mid)'}`,
          background: preview ? 'var(--cream-mid)' : 'var(--cream)',
          display: 'flex', alignItems: 'center', justifyContent: 'center',
          cursor: 'pointer', overflow: 'hidden', position: 'relative',
        }}
      >
        {preview
          ? <img src={preview} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }}/>
          : (
            <div style={{ textAlign: 'center', padding: '12px' }}>
              <p className="t-label" style={{ color: 'var(--dark-mid)', fontSize: '10px' }}>{hint}</p>
              <p style={{ fontFamily: 'var(--sans)', fontSize: '10px', color: 'var(--dark-mid)', marginTop: '6px' }}>
                JPG · PNG · WebP
              </p>
            </div>
          )}
        {preview && (
          <button
            type="button"
            onClick={(e) => { e.stopPropagation(); onChange(null); }}
            aria-label="Remove image"
            style={{
              position: 'absolute', top: 6, right: 6, width: 22, height: 22,
              background: 'rgba(255,255,255,0.92)', border: 'none', cursor: 'pointer',
              fontSize: '14px', lineHeight: 1, color: 'var(--dark)',
              display: 'flex', alignItems: 'center', justifyContent: 'center',
            }}
          >×</button>
        )}
      </div>
      <input ref={ref} type="file" accept="image/*" style={{ display: 'none' }}
             onChange={e => { onChange(e.target.files?.[0]); e.target.value = ''; }}/>
    </div>
  );
}

// AdminLogin — magic-link primary, password fallback.
//
// Default mode is "magic" (email → click link → in). The legacy
// password form is hidden behind a small "Use password instead" toggle
// so it stays available as a break-glass option if Resend / email
// delivery is down, but isn't the first thing a returning admin sees.
//
// On success either mode hands the session token to onSuccess(), which
// stashes it in localStorage and switches Admin out of the login view.
function AdminLogin({ onSuccess, initialError }) {
  const [mode, setMode] = useState('magic');  // 'magic' | 'password'

  // ── magic-link state
  const [email, setEmail]   = useState('');
  const [sent, setSent]     = useState(false);
  const [mErr, setMErr]     = useState(initialError || '');
  const [mBusy, setMBusy]   = useState(false);

  // ── password fallback state
  const [userId, setUserId]     = useState('');
  const [password, setPassword] = useState('');
  const [pErr, setPErr]         = useState('');
  const [pBusy, setPBusy]       = useState(false);

  async function submitMagic(e) {
    e.preventDefault();
    setMErr(''); setMBusy(true);
    try {
      const r = await fetch('/api/admin/magic-request', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ email }),
      });
      // The server intentionally responds 200 whether or not the
      // email is allow-listed (anti-enumeration). So we always show
      // the same "check your inbox" state.
      await r.json().catch(() => ({}));
      setSent(true);
    } catch {
      setMErr('Network error. Check your connection and try again.');
    } finally {
      setMBusy(false);
    }
  }

  async function submitPassword(e) {
    e.preventDefault();
    setPErr(''); setPBusy(true);
    try {
      const r = await fetch('/api/admin/login', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ userId, password }),
      });
      if (!r.ok) {
        const d = await r.json().catch(() => ({}));
        setPErr(d.error || 'Login failed');
        setPBusy(false);
        return;
      }
      const { token } = await r.json();
      localStorage.setItem(ADMIN_TOKEN_KEY, token);
      onSuccess(token);
    } catch {
      setPErr('Network error');
      setPBusy(false);
    }
  }

  const card = {
    maxWidth: '440px', width: '100%', background: 'white',
    padding: '48px 44px', border: '1px solid var(--cream-deep)',
    boxShadow: '0 2px 20px rgba(0,0,0,0.04)',
  };
  const wrap = {
    minHeight: 'calc(100vh - var(--nav-h))', marginTop: 'var(--nav-h)',
    display: 'flex', alignItems: 'center', justifyContent: 'center',
    background: 'var(--cream)', padding: '40px 20px',
  };

  // ── "Check your inbox" state — shown after a magic-link request
  if (mode === 'magic' && sent) {
    return (
      <div style={wrap}>
        <div style={card}>
          <p className="t-label" style={{ color: 'var(--terracotta)', marginBottom: '12px' }}>Catalogue Admin</p>
          <h1 className="t-display" style={{ fontSize: '34px', marginBottom: '16px', lineHeight: 1.15 }}>
            Check your inbox
          </h1>
          <p className="t-body" style={{ fontSize: '14px', marginBottom: '20px' }}>
            If <strong>{email}</strong> is registered as an admin, we've sent a sign-in link. Click the link in the email to finish signing in. The link is valid for 15 minutes and can be used once.
          </p>
          <p style={{ fontFamily: 'var(--sans)', fontSize: '12px', color: 'var(--dark-mid)', marginBottom: '24px' }}>
            Didn't get it? Check your spam folder, or wait 60 seconds and request another.
          </p>
          <button
            onClick={() => { setSent(false); setEmail(''); }}
            className="btn btn-dark"
            style={{ width: '100%', justifyContent: 'center' }}
          >
            Use a different email
          </button>
        </div>
      </div>
    );
  }

  // ── Magic-link form (primary)
  if (mode === 'magic') {
    return (
      <div style={wrap}>
        <form onSubmit={submitMagic} style={card}>
          <p className="t-label" style={{ color: 'var(--terracotta)', marginBottom: '12px' }}>Catalogue Admin</p>
          <h1 className="t-display" style={{ fontSize: '40px', marginBottom: '8px' }}>Catalogue</h1>
          <p className="t-body" style={{ fontSize: '14px', marginBottom: '32px' }}>
            Enter your admin email. We'll send a sign-in link.
          </p>

          <label>Email</label>
          <input
            type="email"
            autoFocus
            autoComplete="email"
            value={email}
            onChange={e => setEmail(e.target.value)}
            placeholder="you@thetilescompany.com"
            style={{ marginBottom: '16px' }}
            required
          />

          {mErr && (
            <p style={{ fontFamily: 'var(--sans)', fontSize: '12px', color: 'var(--terracotta)', marginBottom: '16px' }}>
              {mErr}
            </p>
          )}

          <button type="submit" className="btn btn-dark" disabled={mBusy || !email} style={{ width: '100%', justifyContent: 'center', opacity: mBusy ? 0.6 : 1, marginBottom: '16px' }}>
            {mBusy ? 'Sending link…' : 'Send sign-in link'}
          </button>

          <button
            type="button"
            onClick={() => setMode('password')}
            style={{
              background: 'none', border: 'none', cursor: 'pointer',
              padding: 0, width: '100%',
              fontFamily: 'var(--sans)', fontSize: '11px',
              letterSpacing: '0.14em', textTransform: 'uppercase',
              color: 'var(--dark-mid)',
            }}
          >
            Use password instead
          </button>
        </form>
      </div>
    );
  }

  // ── Password fallback (break-glass)
  return (
    <div style={wrap}>
      <form onSubmit={submitPassword} style={card}>
        <p className="t-label" style={{ color: 'var(--terracotta)', marginBottom: '12px' }}>Catalogue Admin</p>
        <h1 className="t-display" style={{ fontSize: '40px', marginBottom: '8px' }}>Catalogue</h1>
        <p className="t-body" style={{ fontSize: '14px', marginBottom: '32px' }}>
          Password sign-in — fallback when email delivery is down.
        </p>

        <label>User ID</label>
        <input
          type="text"
          autoFocus
          autoComplete="username"
          value={userId}
          onChange={e => setUserId(e.target.value)}
          placeholder="venoraa-admin-…"
          style={{ marginBottom: '16px' }}
        />

        <label>Password</label>
        <input
          type="password"
          autoComplete="current-password"
          value={password}
          onChange={e => setPassword(e.target.value)}
          placeholder="••••••••"
          style={{ marginBottom: '16px' }}
        />

        {pErr && (
          <p style={{ fontFamily: 'var(--sans)', fontSize: '12px', color: 'var(--terracotta)', marginBottom: '16px' }}>
            {pErr}
          </p>
        )}

        <button type="submit" className="btn btn-dark" disabled={pBusy || !userId || !password} style={{ width: '100%', justifyContent: 'center', opacity: pBusy ? 0.6 : 1, marginBottom: '16px' }}>
          {pBusy ? 'Signing in…' : 'Sign in'}
        </button>

        <button
          type="button"
          onClick={() => setMode('magic')}
          style={{
            background: 'none', border: 'none', cursor: 'pointer',
            padding: 0, width: '100%',
            fontFamily: 'var(--sans)', fontSize: '11px',
            letterSpacing: '0.14em', textTransform: 'uppercase',
            color: 'var(--dark-mid)',
          }}
        >
          Use email link instead
        </button>
      </form>
    </div>
  );
}

function TileRow({ tile, selected, onToggleSelect, onDelete, onEdit, onDuplicate, onToggleStatus }) {
  const [deleting, setDeleting] = useState(false);
  const [togglingStatus, setTogglingStatus] = useState(false);
  const isUploaded = tile.id?.startsWith('u_');
  const isPublished = (tile.status || 'published') === 'published';

  // New-schema readers with legacy fallbacks.
  const thumb = (Array.isArray(tile.sightImages) && tile.sightImages[0])
             || (Array.isArray(tile.images)      && tile.images[0])
             || tile.img || '';
  const finish = (Array.isArray(tile.finishes) && tile.finishes[0]) || tile.finish || '—';
  const size   = (Array.isArray(tile.sizes)    && tile.sizes[0])    || tile.size   || '—';
  const colour = (Array.isArray(tile.colours)  && tile.colours[0])  || tile.colour || '';

  async function handleDelete() {
    if (!confirm(`Delete "${tile.name}"? This cannot be undone.`)) return;
    setDeleting(true);
    await onDelete(tile.id);
  }
  async function handleToggleStatus() {
    setTogglingStatus(true);
    await onToggleStatus(tile);
    setTogglingStatus(false);
  }

  return (
    <div style={{
      display: 'grid',
      gridTemplateColumns: '32px 64px 1.3fr 0.9fr 1fr 0.7fr 0.7fr 22px auto auto auto',
      gap: '10px', alignItems: 'center',
      padding: '14px 20px', borderBottom: '1px solid var(--cream-deep)',
      background: selected ? 'var(--cream-mid)' : 'white',
      opacity: isPublished ? 1 : 0.6,
      transition: 'background 0.15s, opacity 0.15s',
    }}>
      <input
        type="checkbox"
        checked={!!selected}
        onChange={() => onToggleSelect(tile.id)}
        style={{ width: 'auto', margin: 0, accentColor: 'var(--terracotta)' }}
        aria-label={`Select ${tile.name}`}
      />
      <div style={{ width: '64px', height: '64px', background: 'var(--cream-mid)', overflow: 'hidden' }}>
        <img src={thumb} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }}
             onError={e => { e.target.style.display = 'none'; }}/>
      </div>
      <div>
        <p style={{ fontFamily: 'var(--serif)', fontSize: '18px', fontWeight: 400, color: 'var(--dark)' }}>{tile.name}</p>
        <p style={{ fontFamily: 'var(--sans)', fontSize: '10px', letterSpacing: '0.14em', textTransform: 'uppercase', color: 'var(--dark-mid)', marginTop: '2px' }}>
          {isUploaded ? 'Uploaded' : 'Seeded'} · {tile.id}
        </p>
      </div>
      <p style={{ fontFamily: 'var(--sans)', fontSize: '12px', color: 'var(--dark)' }}>{tile.collection || '—'}</p>
      <p style={{ fontFamily: 'var(--sans)', fontSize: '12px', color: 'var(--dark-mid)' }}>{finish} · {size}</p>
      <p style={{ fontFamily: 'var(--sans)', fontSize: '11px', textTransform: 'uppercase', letterSpacing: '0.1em', color: 'var(--dark-mid)' }}>{colour}</p>
      <p style={{ fontFamily: 'var(--sans)', fontSize: '12px', color: 'var(--dark)' }}>{tile.price}</p>
      {/* Status dot — click to toggle published/draft */}
      <button
        type="button"
        onClick={handleToggleStatus}
        disabled={togglingStatus}
        aria-label={isPublished ? 'Click to unpublish' : 'Click to publish'}
        title={isPublished ? 'Published — click to unpublish' : 'Draft — click to publish'}
        style={{
          width: 14, height: 14, padding: 0, borderRadius: '50%',
          background: isPublished ? '#4f8a5c' : 'var(--dark-mid)',
          border: '2px solid white',
          boxShadow: '0 0 0 1px ' + (isPublished ? '#4f8a5c' : 'var(--dark-mid)'),
          cursor: togglingStatus ? 'wait' : 'pointer',
          opacity: togglingStatus ? 0.5 : 1,
        }}
      />
      <button onClick={() => onDuplicate(tile)} title="Duplicate" style={{
        background: 'none', border: '1px solid var(--cream-deep)', color: 'var(--dark-mid)',
        padding: '6px 12px', fontFamily: 'var(--sans)', fontSize: '10px',
        letterSpacing: '0.14em', textTransform: 'uppercase', cursor: 'pointer',
      }}
      onMouseEnter={e => { e.currentTarget.style.background = 'var(--cream)'; }}
      onMouseLeave={e => { e.currentTarget.style.background = 'none'; }}
      >Dupe</button>
      <button onClick={() => onEdit(tile)} style={{
        background: 'none', border: '1px solid var(--cream-deep)', color: 'var(--dark-mid)',
        padding: '6px 14px', fontFamily: 'var(--sans)', fontSize: '10px',
        letterSpacing: '0.14em', textTransform: 'uppercase', cursor: 'pointer',
        transition: 'all 0.2s',
      }}
      onMouseEnter={e => { e.currentTarget.style.background = 'var(--dark)'; e.currentTarget.style.color = 'white'; e.currentTarget.style.borderColor = 'var(--dark)'; }}
      onMouseLeave={e => { e.currentTarget.style.background = 'none'; e.currentTarget.style.color = 'var(--dark-mid)'; e.currentTarget.style.borderColor = 'var(--cream-deep)'; }}
      >Edit</button>
      <button onClick={handleDelete} disabled={deleting} style={{
        background: 'none', border: '1px solid var(--cream-deep)', color: 'var(--dark-mid)',
        padding: '6px 14px', fontFamily: 'var(--sans)', fontSize: '10px',
        letterSpacing: '0.14em', textTransform: 'uppercase', cursor: 'pointer',
        transition: 'all 0.2s', opacity: deleting ? 0.5 : 1,
      }}
      onMouseEnter={e => { if (!deleting) { e.currentTarget.style.background = 'var(--terracotta)'; e.currentTarget.style.color = 'white'; e.currentTarget.style.borderColor = 'var(--terracotta)'; } }}
      onMouseLeave={e => { e.currentTarget.style.background = 'none'; e.currentTarget.style.color = 'var(--dark-mid)'; e.currentTarget.style.borderColor = 'var(--cream-deep)'; }}
      >{deleting ? '…' : 'Delete'}</button>
    </div>
  );
}

// ─── Reusable form bits ────────────────────────────────────────────
// PillRow — generic multi-toggle pill selector. `options` can be a
// flat array of strings OR an array of {id, label} pairs.
function PillRow({ options, active, onToggle }) {
  const norm = options.map(o => typeof o === 'string' ? { id: o, label: o } : o);
  return (
    <div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px', marginTop: '6px' }}>
      {norm.map(opt => {
        const isActive = active.includes(opt.id);
        return (
          <button
            key={opt.id}
            type="button"
            onClick={() => onToggle(opt.id)}
            style={{
              padding: '8px 14px',
              background: isActive ? 'var(--dark)' : 'white',
              color: isActive ? 'white' : 'var(--dark)',
              border: `1px solid ${isActive ? 'var(--dark)' : 'var(--cream-deep)'}`,
              fontFamily: 'var(--sans)', fontSize: '11px',
              letterSpacing: '0.06em',
              cursor: 'pointer',
              transition: 'all 0.15s',
            }}
          >{opt.label}</button>
        );
      })}
    </div>
  );
}

// SizeTagInput — free-text tag input for tile sizes. Type a size, press
// Enter (or comma) to add it as a chip; click × on a chip to remove.
function SizeTagInput({ sizes, onAdd, onRemove }) {
  const [draft, setDraft] = useState('');
  function commit() {
    const v = draft.trim();
    if (v) onAdd(v);
    setDraft('');
  }
  function onKey(e) {
    if (e.key === 'Enter' || e.key === ',') {
      e.preventDefault();
      commit();
    }
  }
  return (
    <div style={{
      display: 'flex', flexWrap: 'wrap', gap: '6px',
      padding: '8px', marginTop: '6px',
      border: '1px solid var(--cream-deep)', background: 'white',
      alignItems: 'center',
    }}>
      {sizes.map(s => (
        <span key={s} style={{
          display: 'inline-flex', alignItems: 'center', gap: '6px',
          background: 'var(--cream-mid)',
          padding: '4px 4px 4px 10px',
          fontFamily: 'var(--sans)', fontSize: '11px',
          color: 'var(--dark)',
        }}>
          {s}
          <button
            type="button"
            onClick={() => onRemove(s)}
            aria-label={`Remove ${s}`}
            style={{
              background: 'none', border: 'none', cursor: 'pointer',
              padding: '0 6px', color: 'var(--dark-mid)',
              fontSize: '14px', lineHeight: 1,
            }}
          >×</button>
        </span>
      ))}
      <input
        type="text"
        value={draft}
        onChange={e => setDraft(e.target.value)}
        onKeyDown={onKey}
        onBlur={commit}
        placeholder={sizes.length === 0 ? 'e.g. 30×60cm  (Enter to add)' : 'Add another…'}
        style={{
          flex: 1, minWidth: '160px',
          border: 'none', outline: 'none', padding: '4px 6px',
          fontFamily: 'var(--sans)', fontSize: '12px',
          background: 'transparent',
        }}
      />
    </div>
  );
}

// CollectionPicker — visible grid of every existing collection (so the
// admin can see what's already in the catalogue) + an inline "+ New
// collection" pill that reveals a text input. Typed names commit on
// Enter / blur, become the active pick, and are passed up. A new
// collection added here will appear in the picker for future uploads
// automatically (since the catalogue list is its source).
function CollectionPicker({ value, onChange, existing }) {
  const [adding, setAdding] = useState(false);
  const [draft, setDraft]   = useState('');
  const inputRef = useRef(null);
  React.useEffect(() => { if (adding && inputRef.current) inputRef.current.focus(); }, [adding]);

  function commitNew() {
    const v = draft.trim();
    setDraft('');
    setAdding(false);
    if (v) onChange(v);
  }
  function onKey(e) {
    if (e.key === 'Enter') { e.preventDefault(); commitNew(); }
    if (e.key === 'Escape') { e.preventDefault(); setAdding(false); setDraft(''); }
  }

  // Show the typed-but-not-yet-in-list value as a virtual pill so it
  // reads as selected even when adding a brand-new collection name.
  const all = Array.from(new Set([...(existing || []), value].filter(Boolean)))
                .sort((a, b) => a.localeCompare(b));

  return (
    <div style={{
      marginTop: '6px',
      padding: '12px',
      border: '1px solid var(--cream-deep)',
      background: 'var(--cream)',
      maxHeight: '220px',
      overflowY: 'auto',
    }}>
      <div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
        {all.map(c => {
          const active = c === value;
          return (
            <button
              key={c}
              type="button"
              onClick={() => onChange(c)}
              style={{
                padding: '7px 12px',
                background: active ? 'var(--dark)' : 'white',
                color:      active ? 'white' : 'var(--dark)',
                border: `1px solid ${active ? 'var(--dark)' : 'var(--cream-deep)'}`,
                fontFamily: 'var(--sans)', fontSize: '11px',
                letterSpacing: '0.04em', cursor: 'pointer',
                transition: 'all 0.15s',
              }}
            >{c}</button>
          );
        })}
        {!adding && (
          <button
            type="button"
            onClick={() => setAdding(true)}
            style={{
              padding: '7px 14px',
              background: 'transparent',
              color: 'var(--terracotta)',
              border: '1px dashed var(--terracotta)',
              fontFamily: 'var(--sans)', fontSize: '11px',
              letterSpacing: '0.06em', cursor: 'pointer',
            }}
          >+ New collection</button>
        )}
        {adding && (
          <input
            ref={inputRef}
            type="text"
            value={draft}
            onChange={e => setDraft(e.target.value)}
            onKeyDown={onKey}
            onBlur={commitNew}
            placeholder="e.g. ALLOY"
            style={{
              padding: '7px 12px',
              border: '1px solid var(--terracotta)',
              background: 'white',
              fontFamily: 'var(--sans)', fontSize: '12px',
              outline: 'none', minWidth: '160px',
            }}
          />
        )}
      </div>
      {all.length === 0 && (
        <p style={{
          fontFamily: 'var(--sans)', fontSize: '11px', color: 'var(--dark-mid)',
          marginTop: '8px',
        }}>No collections yet. Click "+ New collection" to add the first one.</p>
      )}
    </div>
  );
}

// LuxuryToggle — visible card-style switch that flags a tile as part
// of the curated Luxury edit (the wide feature card on /collections).
// Sits in the Identity tab. State is a plain boolean on `form.luxury`.
function LuxuryToggle({ value, onChange }) {
  const on = !!value;
  return (
    <div
      role="button"
      tabIndex={0}
      onClick={() => onChange(!on)}
      onKeyDown={(e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); onChange(!on); } }}
      style={{
        marginTop: '6px',
        padding: '12px 14px',
        border: `1px solid ${on ? 'var(--terracotta)' : 'var(--cream-deep)'}`,
        background: on ? 'rgba(176, 85, 42, 0.06)' : 'var(--cream)',
        cursor: 'pointer',
        display: 'flex', alignItems: 'center', gap: '12px',
        transition: 'border-color 0.2s, background 0.2s',
      }}
    >
      {/* Switch */}
      <span aria-hidden style={{
        flexShrink: 0,
        position: 'relative',
        width: '34px', height: '20px',
        borderRadius: '999px',
        background: on ? 'var(--terracotta)' : 'rgba(15,15,15,0.18)',
        transition: 'background 0.2s',
      }}>
        <span style={{
          position: 'absolute',
          top: '2px', left: on ? '16px' : '2px',
          width: '16px', height: '16px',
          borderRadius: '50%',
          background: 'white',
          transition: 'left 0.2s',
          boxShadow: '0 1px 3px rgba(0,0,0,0.18)',
        }}/>
      </span>
      {/* Label */}
      <div style={{ display: 'flex', flexDirection: 'column', gap: '2px' }}>
        <span style={{
          fontFamily: 'var(--sans)', fontSize: '12px', fontWeight: 600,
          letterSpacing: '0.06em',
          color: on ? 'var(--terracotta)' : 'var(--dark)',
        }}>
          Luxury edit
        </span>
        <span style={{
          fontFamily: 'var(--sans)', fontSize: '11px',
          color: 'var(--dark-mid)',
        }}>
          {on
            ? 'This tile appears in the Luxury card on /collections.'
            : 'Toggle on to surface this tile in the curated Luxury edit.'}
        </span>
      </div>
    </div>
  );
}

// ReorderArrows — small ◀ ▶ buttons floating bottom-right on an image
// slot. Used to reorder sight or gallery images without re-uploading.
// `canLeft` / `canRight` disable the relevant button at edges.
function ReorderArrows({ canLeft, canRight, onLeft, onRight }) {
  const btn = (dir, enabled, onClick) => (
    <button
      type="button"
      onClick={(e) => { e.stopPropagation(); onClick(); }}
      disabled={!enabled}
      aria-label={dir === 'left' ? 'Move earlier' : 'Move later'}
      style={{
        width: 22, height: 22, display: 'flex',
        alignItems: 'center', justifyContent: 'center',
        background: 'rgba(255,255,255,0.94)', border: 'none',
        cursor: enabled ? 'pointer' : 'not-allowed',
        fontSize: 12, lineHeight: 1, color: 'var(--dark)',
        opacity: enabled ? 1 : 0.35,
      }}
    >{dir === 'left' ? '◀' : '▶'}</button>
  );
  return (
    <div style={{
      position: 'absolute', bottom: 6, left: 6, display: 'flex', gap: 4,
      pointerEvents: 'auto', zIndex: 2,
    }}>
      {btn('left',  canLeft,  onLeft)}
      {btn('right', canRight, onRight)}
    </div>
  );
}

// TabBar — horizontal tab strip used by the upload + edit forms.
function TabBar({ tabs, active, onChange }) {
  return (
    <div style={{
      display: 'flex', gap: 0, borderBottom: '1px solid var(--cream-deep)',
      marginBottom: 22,
    }}>
      {tabs.map(t => {
        const on = active === t.id;
        return (
          <button
            key={t.id}
            type="button"
            onClick={() => onChange(t.id)}
            style={{
              padding: '12px 22px',
              background: 'none', border: 'none', cursor: 'pointer',
              fontFamily: 'var(--sans)', fontSize: '11px',
              letterSpacing: '0.18em', textTransform: 'uppercase',
              color: on ? 'var(--dark)' : 'var(--dark-mid)',
              borderBottom: `2px solid ${on ? 'var(--terracotta)' : 'transparent'}`,
              marginBottom: '-1px',
              transition: 'color 0.15s, border-color 0.15s',
            }}
          >{t.label}</button>
        );
      })}
    </div>
  );
}

function UploadForm({ token, onUploaded, onUnauthorized, existingCollections = [], template = null, onTemplateConsumed }) {
  // Subscribe to the live finishes list so adding/removing a finish
  // in the "Finishes" admin tab re-renders the pill row here.
  useFinishes();
  // Same for sizes — any add/remove in the Sizes admin tab re-renders
  // the size pills on this form.
  useSizes();
  // ...and the four taxonomies (rooms/colours/styles/shapes), each
  // editable from the new "Filters" admin tab.
  useRooms();
  useColours();
  useStyles();
  useShapes();
  // ─── Image state ─────────────────────────────────────────────────
  // sightFiles[]:   required ≥1, max 5 — cycle in the product hero.
  // galleryFiles[]: 0-15 — "In sight" preview gallery on the product page.
  // colourFiles[]:  0-5 — colour swatches in "The colours" grid. Was
  //                 single previously; now multi-slot so a tile that
  //                 ships in several colour treatments can show every
  //                 swatch on the product page.
  const [sightFiles,   setSightFiles]   = useState([null]);
  const [galleryFiles, setGalleryFiles] = useState([]);
  const [colourFiles,  setColourFiles]  = useState([]);
  // Parallel array of name strings — colourNames[i] is the
  // friendly name for colourFiles[i]. Kept positionally in sync
  // by setColourAt/addColourSlot/etc. so we never have to write
  // reconciliation logic.
  const [colourNames,  setColourNames]  = useState([]);

  // ─── Metadata state (new schema) ─────────────────────────────────
  // No more cats / color / grout / pattern. Style + Shape are
  // multi-value pills (not single dropdowns).
  const [form, setForm] = useState({
    name: '', collection: '', variantLabel: '',
    price: '', description: '', status: 'published',
    luxury:   false,        // surfaces tile in the Luxury card on /collections
    rooms:    [],
    colours:  [],
    sizes:    [],
    finishes: ['Matt'],
    styles:   [],
    shapes:   [],
  });
  // Duplicate-tile template — when Admin sets it, we prefill the form
  // and notify the parent to clear its slot. Images are NOT copied.
  React.useEffect(() => {
    if (!template) return;
    setForm(prev => ({ ...prev, ...template }));
    setSightFiles([null]);
    setGalleryFiles([]);
    setColourFiles([]);
    setColourNames([]);
    if (onTemplateConsumed) onTemplateConsumed();
    setActiveTab('identity');
  }, [template]);
  const [saving, setSaving] = useState(false);
  const [err, setErr] = useState('');
  const [ok, setOk]   = useState('');

  function reset() {
    setSightFiles([null]);
    setGalleryFiles([]);
    setColourFiles([]);
    setColourNames([]);
    setForm({
      name: '', collection: '', variantLabel: '',
      price: '', description: '', status: 'published',
      luxury: false,
      rooms: [], colours: [], sizes: [], finishes: ['Matt'], styles: [], shapes: [],
    });
  }

  // Generic multi-toggle. minOne keeps at least one selected when set.
  // No upper cap by default — taxonomies have ~10 entries each and the
  // client wanted the freedom to tag tiles with all of them. minOne
  // (used by Finishes) prevents the last item being unchecked.
  function toggleArrayField(field, value, { minOne = false, cap = Infinity } = {}) {
    setForm(prev => {
      const arr = prev[field] || [];
      const has = arr.includes(value);
      if (has) {
        if (minOne && arr.length === 1) return prev;
        return { ...prev, [field]: arr.filter(x => x !== value) };
      }
      if (arr.length >= cap) return prev;
      return { ...prev, [field]: [...arr, value] };
    });
  }
  const toggleColour = (c) => toggleArrayField('colours',  c);
  const toggleFinish = (f) => toggleArrayField('finishes', f, { minOne: true });
  const toggleStyle  = (s) => toggleArrayField('styles',   s);
  const toggleShape  = (s) => toggleArrayField('shapes',   s);
  // Sizes — picker is hybrid: 12 standard pills above + free-text tag
  // input below for unique shapes. Both write into the same `sizes`
  // array (cap 8). `toggleSize` handles pills (toggle on/off);
  // `addSize` is used by the tag input; `removeSize` is shared.
  function toggleSize(value) {
    const v = String(value || '').trim();
    if (!v) return;
    setForm(prev => {
      const has = prev.sizes.includes(v);
      if (has) return { ...prev, sizes: prev.sizes.filter(x => x !== v) };
      if (prev.sizes.length >= 8) return prev;
      return { ...prev, sizes: [...prev.sizes, v] };
    });
  }
  function addSize(value) {
    const v = String(value || '').trim();
    if (!v) return;
    setForm(prev => {
      if (prev.sizes.includes(v)) return prev;
      if (prev.sizes.length >= 8) return prev;
      return { ...prev, sizes: [...prev.sizes, v] };
    });
  }
  function removeSize(v) {
    setForm(prev => ({ ...prev, sizes: prev.sizes.filter(x => x !== v) }));
  }

  // Generic slot-array setter shared by sight + extra grids. Removing a
  // slot collapses the gap (so there's no empty box between filled ones).
  function setSlotAt(setter, idx, file, { keepFirstSlot = false } = {}) {
    setter(prev => {
      const next = [...prev];
      if (file === null) {
        if (keepFirstSlot && idx === 0 && prev.length === 1) {
          next[0] = null; // keep the first slot visible (sight requires 1)
        } else {
          next.splice(idx, 1);
        }
      } else {
        next[idx] = file;
      }
      return next;
    });
  }
  const setSightAt   = (i, file) => setSlotAt(setSightFiles,   i, file, { keepFirstSlot: true });
  const setGalleryAt = (i, file) => setSlotAt(setGalleryFiles, i, file);
  // Setting a colour file at index `i` MUST also keep the parallel
  // `colourNames` array the same length, otherwise the name input
  // for slot i+1 could appear above the wrong file when a middle
  // slot is removed (setSlotAt splices files when file===null).
  const setColourAt  = (i, file) => {
    setColourFiles(prev => {
      const next = [...prev];
      if (file === null) {
        next.splice(i, 1);
      } else {
        while (next.length <= i) next.push(null);
        next[i] = file;
      }
      return next;
    });
    if (file === null) {
      setColourNames(prev => {
        const next = [...prev];
        next.splice(i, 1);
        return next;
      });
    }
  };
  const setColourNameAt = (i, name) => {
    setColourNames(prev => {
      const next = [...prev];
      while (next.length <= i) next.push('');
      next[i] = name;
      return next;
    });
  };
  function addSightSlot() {
    setSightFiles(prev => prev.length >= MAX_SIGHT_IMAGES ? prev : [...prev, null]);
  }
  function addGallerySlot() {
    setGalleryFiles(prev => prev.length >= MAX_EXTRA_IMAGES ? prev : [...prev, null]);
  }
  function addColourSlot() {
    // Cap from MAX_COLOUR_IMAGES (currently 15). Generous because
    // some tile patterns ship in many colourways.
    setColourFiles(prev => prev.length >= MAX_COLOUR_IMAGES ? prev : [...prev, null]);
    setColourNames(prev => [...prev, '']);
  }
  // Swap two slots in an image list — used by the ↑↓ reorder buttons
  // on every uploaded image cell. The first sight slot is the "primary"
  // (hero, big tile thumbnail) so reordering it visibly matters.
  function swapSlots(setter, i, j) {
    setter(prev => {
      if (i < 0 || j < 0 || i >= prev.length || j >= prev.length || i === j) return prev;
      const next = [...prev];
      [next[i], next[j]] = [next[j], next[i]];
      return next;
    });
  }
  const moveSight   = (i, dir) => swapSlots(setSightFiles,   i, i + dir);
  const moveColour  = (i, dir) => {
    swapSlots(setColourFiles, i, i + dir);
    swapSlots(setColourNames, i, i + dir);
  };
  const moveGallery = (i, dir) => swapSlots(setGalleryFiles, i, i + dir);

  // ─── Form tab state ──────────────────────────────────────────────
  // Tabs slice the form into four sections (Identity / Imagery /
  // Specs / Filters). Reduces the visual weight when adding a tile
  // and lets the admin focus on one block at a time.
  const [activeTab, setActiveTab] = useState('identity');
  const TABS = [
    { id: 'identity', label: 'Identity' },
    { id: 'imagery',  label: 'Imagery'  },
    { id: 'specs',    label: 'Specs'    },
    { id: 'filters',  label: 'Filters'  },
  ];

  function toggleRoom(id) {
    setForm(f => {
      const has = f.rooms.includes(id);
      return { ...f, rooms: has ? f.rooms.filter(r => r !== id) : [...f.rooms, id] };
    });
  }

  async function submit(e) {
    e.preventDefault();
    setErr(''); setOk('');
    const sightSelected = sightFiles.filter(Boolean);
    if (sightSelected.length === 0) {
      setErr('Choose at least one sight image — the first becomes the dominant view, the rest cycle in the product hero.');
      return;
    }
    if (!form.name.trim())       { setErr('Name is required'); return; }
    if (!form.collection.trim()) { setErr('Collection is required'); return; }

    setSaving(true);
    const fd = new FormData();
    // Identity
    fd.append('name',         form.name.trim());
    fd.append('collection',   form.collection.trim());
    fd.append('variantLabel', (form.variantLabel || form.name).trim());
    fd.append('description',  form.description);
    fd.append('price',        form.price);
    fd.append('status',       form.status);
    // Luxury edit flag — boolean, surfaces in /collections Luxury card.
    fd.append('luxury',       form.luxury ? 'true' : 'false');
    // Multi-value taxonomy (new schema — no single twins)
    form.rooms.forEach(r    => fd.append('rooms',    r));
    form.colours.forEach(c  => fd.append('colours',  c));
    form.sizes.forEach(s    => fd.append('sizes',    s));
    form.finishes.forEach(f => fd.append('finishes', f));
    form.styles.forEach(s   => fd.append('styles',   s));
    form.shapes.forEach(s   => fd.append('shapes',   s));
    // Imagery
    sightSelected.forEach(f => fd.append('sightImages', f));
    galleryFiles.filter(Boolean).forEach(f => fd.append('galleryImages', f));
    // Each non-null colour swatch gets sent as `colourImage` (singular
    // fieldname for multer compatibility — see server.js) PAIRED with
    // a `colourImageName` entry at the same index. Empty names are
    // sent as empty strings so the server can position-match files
    // to names even when some are blank.
    colourFiles.forEach((f, i) => {
      if (!f) return;
      fd.append('colourImage', f);
      fd.append('colourImageName', (colourNames[i] || '').trim());
    });

    try {
      const r = await fetch('/api/admin/tiles', {
        method: 'POST',
        headers: { Authorization: `Bearer ${token}` },
        body: fd,
      });
      if (r.status === 401) { onUnauthorized(); return; }
      // Server might return JSON (normal path / handled multer error)
      // or, in rare crashes, HTML. Prefer JSON, fall back to text so
      // the admin always sees a real message, never a silent fail.
      let data = {};
      const ct = r.headers.get('content-type') || '';
      if (ct.includes('application/json')) {
        data = await r.json().catch(() => ({}));
      } else {
        const txt = await r.text().catch(() => '');
        data = { error: txt.slice(0, 200) || `HTTP ${r.status}` };
      }
      if (!r.ok) {
        setErr(data.error || `Upload failed (HTTP ${r.status})`);
        setSaving(false);
        return;
      }
      onUploaded(data.tile);
      const nImages = (data.tile.sightImages || []).length + (data.tile.galleryImages || []).length + (data.tile.colourImage ? 1 : 0);
      setOk(`"${data.tile.name}" added to the catalogue (${nImages} image${nImages === 1 ? '' : 's'}).`);
      reset();
    } catch (e) {
      setErr(`Network error — ${e?.message || 'check that the dev server is running and reachable'}.`);
    }
    setSaving(false);
  }

  const input = (key, props = {}) => (
    <input
      value={form[key]}
      onChange={e => setForm(f => ({ ...f, [key]: e.target.value }))}
      {...props}
    />
  );

  return (
    <form onSubmit={submit} style={{
      background: 'white', border: '1px solid var(--cream-deep)', padding: '32px',
    }}>
      <p className="t-label" style={{ color: 'var(--terracotta)', marginBottom: '10px' }}>Add tile</p>
      <h2 className="t-headline" style={{ fontSize: '28px', marginBottom: '18px' }}>New catalogue entry</h2>

      <TabBar tabs={TABS} active={activeTab} onChange={setActiveTab}/>

      {/* ══ IMAGERY TAB ══════════════════════════════════════════ */}
      {activeTab === 'imagery' && (<>
      <p style={{ fontFamily: 'var(--sans)', fontSize: '12px', color: 'var(--dark-mid)', marginBottom: '20px' }}>
        Sight images cycle in the product hero (auto-advance + click left/right to flip). Gallery images appear in the "In sight" section below the hero. The colour image represents the tile in the "The colours" cell — never reuse a sight image there.
      </p>

      {/* ── Sight images (cycling hero) ─────────────────────────── */}
      <div style={{ marginBottom: '24px' }}>
        <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: '8px' }}>
          <label style={{ margin: 0 }}>
            Sight images *{' '}
            <span style={{ color: 'var(--dark-mid)', fontWeight: 400 }}>
              (1 required, up to {MAX_SIGHT_IMAGES} — they cycle in the product hero)
            </span>
          </label>
          <p style={{ fontFamily: 'var(--sans)', fontSize: '10px', color: 'var(--dark-mid)', letterSpacing: '0.1em', textTransform: 'uppercase' }}>
            {sightFiles.filter(Boolean).length} / {MAX_SIGHT_IMAGES}
          </p>
        </div>
        <div style={{
          display: 'grid',
          gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))',
          gap: '10px',
        }}>
          {sightFiles.map((f, i) => (
            <div key={i} style={{ position: 'relative' }}>
              <p style={{
                fontFamily: 'var(--sans)', fontSize: '9px', fontWeight: 500,
                letterSpacing: '0.18em', textTransform: 'uppercase',
                color: i === 0 ? 'var(--terracotta)' : 'var(--dark-mid)', marginBottom: '4px',
              }}>{i === 0 ? 'Sight 1 · primary' : `Sight ${i + 1}`}</p>
              <div style={{ position: 'relative' }}>
                <ImageDropZone
                  file={f}
                  onChange={(file) => setSightAt(i, file)}
                  height={170}
                  hint={i === 0 ? 'Drop the primary sight' : `Sight ${i + 1}`}
                />
                {sightFiles.filter(Boolean).length > 1 && f && (
                  <ReorderArrows
                    canLeft={i > 0}
                    canRight={i < sightFiles.length - 1 && sightFiles[i + 1]}
                    onLeft={() => moveSight(i, -1)}
                    onRight={() => moveSight(i, +1)}
                  />
                )}
              </div>
            </div>
          ))}
          {sightFiles.length < MAX_SIGHT_IMAGES && (
            <button
              type="button"
              onClick={addSightSlot}
              style={{
                height: '195px', marginTop: '15px',
                background: 'var(--cream)', border: '1px dashed var(--dark-mid)',
                cursor: 'pointer', color: 'var(--dark-mid)',
                fontFamily: 'var(--sans)', fontSize: '11px',
                letterSpacing: '0.14em', textTransform: 'uppercase',
              }}
            >+ Add sight</button>
          )}
        </div>
      </div>

      {/* ── Gallery images ───────────────────────────────────────── */}
      <div style={{ marginBottom: '28px' }}>
        <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: '8px' }}>
          <label style={{ margin: 0 }}>
            Gallery images <span style={{ color: 'var(--dark-mid)', fontWeight: 400 }}>(the "In sight" section on the product page — order: 1, 2, 3, …)</span>
          </label>
          <p style={{ fontFamily: 'var(--sans)', fontSize: '10px', color: 'var(--dark-mid)', letterSpacing: '0.1em', textTransform: 'uppercase' }}>
            {galleryFiles.filter(Boolean).length} / {MAX_EXTRA_IMAGES}
          </p>
        </div>
        <div style={{
          display: 'grid',
          gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))',
          gap: '10px',
        }}>
          {galleryFiles.map((f, i) => (
            <div key={i} style={{ position: 'relative' }}>
              <p style={{
                fontFamily: 'var(--sans)', fontSize: '9px', fontWeight: 500,
                letterSpacing: '0.18em', textTransform: 'uppercase',
                color: 'var(--dark-mid)', marginBottom: '4px',
              }}>Image {i + 1}</p>
              <div style={{ position: 'relative' }}>
                <ImageDropZone
                  file={f}
                  onChange={(file) => setGalleryAt(i, file)}
                  height={130}
                  hint={`Image ${i + 1}`}
                />
                {galleryFiles.filter(Boolean).length > 1 && f && (
                  <ReorderArrows
                    canLeft={i > 0}
                    canRight={i < galleryFiles.length - 1 && galleryFiles[i + 1]}
                    onLeft={() => moveGallery(i, -1)}
                    onRight={() => moveGallery(i, +1)}
                  />
                )}
              </div>
            </div>
          ))}
          {galleryFiles.length < MAX_EXTRA_IMAGES && (
            <button
              type="button"
              onClick={addGallerySlot}
              style={{
                height: '160px', marginTop: '15px',
                background: 'var(--cream)', border: '1px dashed var(--dark-mid)',
                cursor: 'pointer', color: 'var(--dark-mid)',
                fontFamily: 'var(--sans)', fontSize: '11px',
                letterSpacing: '0.14em', textTransform: 'uppercase',
              }}
            >+ Add image</button>
          )}
        </div>
      </div>

      {/* ── Colour images ───────────────────────────────────────── */}
      <div style={{ marginBottom: '28px' }}>
        <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: '8px' }}>
          <label style={{ margin: 0 }}>
            Colour images{' '}
            <span style={{ color: 'var(--dark-mid)', fontWeight: 400 }}>
              (optional — each one appears as a swatch in "The colours" grid on the product page)
            </span>
          </label>
          <p style={{ fontFamily: 'var(--sans)', fontSize: '10px', color: 'var(--dark-mid)', letterSpacing: '0.1em', textTransform: 'uppercase' }}>
            {colourFiles.filter(Boolean).length} / {MAX_COLOUR_IMAGES}
          </p>
        </div>
        <p style={{ fontFamily: 'var(--sans)', fontSize: '11px', color: 'var(--dark-mid)', margin: '0 0 8px' }}>
          Flat, well-lit shots of the tile face — one per colour treatment this tile ships in. The first swatch shows on the product page as <em>Current</em> with the tile's own name; subsequent swatches show the name you type next to them (e.g. <em>Beige</em>, <em>Gold</em>). If the name is blank for an additional swatch, the storefront falls back to "Variant {`{n}`}".
        </p>
        <div style={{
          display: 'grid',
          gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))',
          gap: '10px',
        }}>
          {colourFiles.map((f, i) => (
            <div key={i} style={{ position: 'relative' }}>
              <p style={{
                fontFamily: 'var(--sans)', fontSize: '9px', fontWeight: 500,
                letterSpacing: '0.18em', textTransform: 'uppercase',
                color: i === 0 ? 'var(--terracotta)' : 'var(--dark-mid)', marginBottom: '4px',
              }}>{i === 0 ? 'Colour 1 · current' : `Colour ${i + 1}`}</p>
              <div style={{ position: 'relative' }}>
                <ImageDropZone
                  file={f}
                  onChange={(file) => setColourAt(i, file)}
                  height={140}
                  hint={`Colour ${i + 1}`}
                />
                {colourFiles.filter(Boolean).length > 1 && f && (
                  <ReorderArrows
                    canLeft={i > 0}
                    canRight={i < colourFiles.length - 1 && colourFiles[i + 1]}
                    onLeft={() => moveColour(i, -1)}
                    onRight={() => moveColour(i, +1)}
                  />
                )}
              </div>
              {/* Per-swatch name input. First slot's name is optional
                  (the storefront uses the tile name for it); subsequent
                  slots should be named (Beige / Gold / etc.). */}
              <input
                type="text"
                value={colourNames[i] || ''}
                onChange={(e) => setColourNameAt(i, e.target.value)}
                placeholder={i === 0 ? '(uses tile name)' : 'e.g. Beige, Gold'}
                maxLength={60}
                style={{
                  width: '100%', marginTop: '4px',
                  padding: '6px 8px',
                  fontFamily: 'var(--sans)', fontSize: '12px',
                  border: '1px solid var(--cream-deep)', background: 'white',
                }}
              />
            </div>
          ))}
          {colourFiles.length < MAX_COLOUR_IMAGES && (
            <button
              type="button"
              onClick={addColourSlot}
              style={{
                height: '170px', marginTop: '15px',
                background: 'var(--cream)', border: '1px dashed var(--dark-mid)',
                cursor: 'pointer', color: 'var(--dark-mid)',
                fontFamily: 'var(--sans)', fontSize: '11px',
                letterSpacing: '0.14em', textTransform: 'uppercase',
              }}
            >+ Add colour</button>
          )}
        </div>
      </div>
      </>)}{/* /imagery tab */}

      {/* ══ IDENTITY TAB ═════════════════════════════════════════ */}
      {activeTab === 'identity' && (
      <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '14px' }}>
        <div style={{ gridColumn: '1 / -1' }}>
          <label>Collection *{' '}
            <span style={{ color: 'var(--dark-mid)', fontWeight: 400 }}>(pick from below or click "+ New collection" — drives "The colours" siblings)</span>
          </label>
          <CollectionPicker
            value={form.collection}
            onChange={(c) => setForm(f => ({ ...f, collection: c }))}
            existing={existingCollections}
          />
        </div>
        <div>
          <label>Tile name *</label>
          {input('name', { placeholder: 'e.g. ALLOY AZZURRO' })}
        </div>
        <div>
          <label>Variant label{' '}
            <span style={{ color: 'var(--dark-mid)', fontWeight: 400 }}>(optional — defaults to tile name)</span>
          </label>
          {input('variantLabel', { placeholder: 'e.g. Azzurro' })}
        </div>
        <div>
          <label>Status</label>
          <select value={form.status} onChange={e => setForm(f => ({ ...f, status: e.target.value }))}>
            <option value="published">Published</option>
            <option value="draft">Draft</option>
          </select>
        </div>
        <div style={{ gridColumn: '1 / -1' }}>
          <LuxuryToggle
            value={form.luxury}
            onChange={(v) => setForm(f => ({ ...f, luxury: v }))}
          />
        </div>
        <div style={{ gridColumn: '1 / -1' }}>
          <label>Description</label>
          <textarea
            value={form.description}
            onChange={e => setForm(f => ({ ...f, description: e.target.value }))}
            placeholder="A few sentences describing the tile — material, character, where it works best."
            rows={4}
            style={{
              width: '100%', resize: 'vertical',
              fontFamily: 'var(--sans)', fontSize: '13px',
              padding: '10px 12px',
              border: '1px solid var(--cream-deep)',
              background: 'white',
              outline: 'none',
            }}
          />
        </div>
      </div>
      )}{/* /identity tab */}

      {/* ══ SPECS TAB ════════════════════════════════════════════ */}
      {activeTab === 'specs' && (
      <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '14px' }}>
        <div style={{ gridColumn: '1 / -1' }}>
          <label>
            Sizes{' '}
            <span style={{ color: 'var(--dark-mid)', fontWeight: 400 }}>
              ({form.sizes.length}/8 selected — pick standards, add custom below)
            </span>
          </label>
          <PillRow
            options={STANDARD_SIZES}
            active={form.sizes}
            onToggle={toggleSize}
          />
          <p style={{
            fontFamily: 'var(--sans)', fontSize: '10px',
            letterSpacing: '0.18em', textTransform: 'uppercase',
            color: 'var(--dark-mid)', marginTop: '14px', marginBottom: '0',
          }}>Custom size <span style={{ textTransform: 'none', letterSpacing: 0, color: 'var(--dark-mid)' }}>— type and press Enter or comma</span></p>
          <SizeTagInput
            sizes={form.sizes.filter(s => !STANDARD_SIZES.includes(s))}
            onAdd={addSize}
            onRemove={removeSize}
          />
        </div>
        <div style={{ gridColumn: '1 / -1' }}>
          <label>
            Finishes <span style={{ color: 'var(--dark-mid)', fontWeight: 400 }}>
              ({form.finishes.length} selected — tap to toggle)
            </span>
          </label>
          <div style={{
            display: 'flex', flexWrap: 'wrap', gap: '6px', marginTop: '6px',
          }}>
            {FINISHES.map(f => {
              const active = form.finishes.includes(f);
              return (
                <button
                  key={f}
                  type="button"
                  onClick={() => toggleFinish(f)}
                  style={{
                    padding: '8px 14px',
                    background: active ? 'var(--dark)' : 'white',
                    color: active ? 'white' : 'var(--dark)',
                    border: `1px solid ${active ? 'var(--dark)' : 'var(--cream-deep)'}`,
                    fontFamily: 'var(--sans)', fontSize: '11px',
                    letterSpacing: '0.06em',
                    cursor: 'pointer',
                    transition: 'all 0.15s',
                  }}
                >{f}</button>
              );
            })}
          </div>
        </div>
        <div>
          <label>Price <span style={{ color: 'var(--dark-mid)', fontWeight: 400 }}>(optional)</span></label>
          {input('price', { placeholder: '£68/m² or POA' })}
        </div>
      </div>
      )}{/* /specs tab */}

      {/* ══ FILTERS TAB ══════════════════════════════════════════ */}
      {activeTab === 'filters' && (
      <div>
        <p style={{ fontFamily: 'var(--sans)', fontSize: '11px', color: 'var(--dark-mid)', marginBottom: '18px' }}>
          These power the customer-facing filters in the nav mega-menu and the listing sidebar.
        </p>

        <div style={{ marginBottom: '18px' }}>
          <label>
            Colours{' '}
            <span style={{ color: 'var(--dark-mid)', fontWeight: 400 }}>
              ({form.colours.length} selected — tap to toggle)
            </span>
          </label>
          <PillRow
            options={COLOURS.map(c => ({ id: c.id, label: c.label }))}
            active={form.colours}
            onToggle={toggleColour}
          />
        </div>

        <div style={{ marginBottom: '18px' }}>
          <label>
            Styles{' '}
            <span style={{ color: 'var(--dark-mid)', fontWeight: 400 }}>
              ({form.styles.length} selected — multi-pick)
            </span>
          </label>
          <PillRow
            options={STYLES.map(s => ({ id: s.id, label: s.label }))}
            active={form.styles}
            onToggle={toggleStyle}
          />
        </div>

        <div style={{ marginBottom: '18px' }}>
          <label>
            Shapes{' '}
            <span style={{ color: 'var(--dark-mid)', fontWeight: 400 }}>
              ({form.shapes.length} selected — multi-pick)
            </span>
          </label>
          <PillRow
            options={SHAPES.map(s => ({ id: s.id, label: s.label }))}
            active={form.shapes}
            onToggle={toggleShape}
          />
        </div>

        <div>
          <label>Rooms <span style={{ color: 'var(--dark-mid)', fontWeight: 400 }}>(tick all that apply)</span></label>
          <div style={{
            display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))',
            gap: '8px', marginTop: '6px',
          }}>
            {ROOMS.map(r => {
              const checked = form.rooms.includes(r.id);
              return (
                <label key={r.id} style={{
                  display: 'flex', alignItems: 'center', gap: '8px',
                  padding: '8px 10px', cursor: 'pointer',
                  background: checked ? 'var(--dark)' : 'white',
                  border: `1px solid ${checked ? 'var(--dark)' : 'var(--cream-deep)'}`,
                  color: checked ? 'white' : 'var(--dark)',
                  fontFamily: 'var(--sans)', fontSize: '11px',
                  letterSpacing: '0.06em',
                  transition: 'background 0.15s, color 0.15s, border-color 0.15s',
                }}>
                  <input
                    type="checkbox"
                    checked={checked}
                    onChange={() => toggleRoom(r.id)}
                    style={{ width: 'auto', margin: 0, accentColor: 'var(--terracotta)' }}
                  />
                  <span>{r.label}</span>
                </label>
              );
            })}
          </div>
        </div>
      </div>
      )}{/* /filters tab */}

      {err && <p style={{ fontFamily: 'var(--sans)', fontSize: '12px', color: 'var(--terracotta)', marginTop: '20px' }}>{err}</p>}
      {ok  && <p style={{ fontFamily: 'var(--sans)', fontSize: '12px', color: 'var(--sage, #5a8a6a)', marginTop: '20px' }}>{ok}</p>}

      <div style={{ display: 'flex', gap: '12px', marginTop: '28px' }}>
        <button type="submit" className="btn btn-dark" disabled={saving} style={{ opacity: saving ? 0.6 : 1 }}>
          {saving ? 'Uploading…' : 'Add tile to catalogue'}
        </button>
        <button type="button" onClick={reset} className="btn btn-outline">Reset</button>
      </div>
    </form>
  );
}

// ─── Edit tile form ────────────────────────────────────────────────
// Inline edit panel that replaces a TileRow when "Edit" is clicked.
// Pre-populated from the tile's existing data; submits via PATCH so the
// server only changes what was actually edited. Existing images are
// shown as small thumbnails with × to mark for removal; new image
// dropzones below let the admin add more (up to the server caps:
// 5 sights, 5 extras, 1 colour image).
function EditTileForm({ token, tile, onUpdated, onUnauthorized, onCancel, existingCollections = [] }) {
  // Subscribe to the live finishes list so the pill row reflects
  // any add/remove the studio does in the "Finishes" admin tab while
  // an edit form is open.
  useFinishes();
  useSizes();
  useRooms();
  useColours();
  useStyles();
  useShapes();
  // Pre-populate every field from the tile in the NEW schema. Multi-value
  // fields fall back to their legacy single-value twins for any tile
  // that hasn't been re-saved since the schema migration.
  const arrOr = (arr, single) => Array.isArray(arr) && arr.length ? arr : (single ? [single] : []);
  const [form, setForm] = useState({
    name:         tile.name         || '',
    collection:   tile.collection   || '',
    variantLabel: tile.variantLabel || tile.name || '',
    price:        tile.price        || '',
    description:  tile.description  || '',
    status:       tile.status       || 'published',
    luxury:       tile.luxury === true,
    rooms:        Array.isArray(tile.rooms) ? tile.rooms
                  : (Array.isArray(tile.room) ? tile.room : (tile.room ? [tile.room] : [])),
    colours:      arrOr(tile.colours,  tile.colour),
    sizes:        arrOr(tile.sizes,    tile.size),
    finishes:     arrOr(tile.finishes, tile.finish),
    styles:       arrOr(tile.styles,   tile.style),
    shapes:       arrOr(tile.shapes,   tile.shape),
  });

  // Existing images — start with all of them; × removes from this list.
  // Reads both new (galleryImages) and legacy (extraImages) field names.
  const [keptSights,  setKeptSights]  = useState(() =>
    Array.isArray(tile.sightImages) ? [...tile.sightImages]
    : (Array.isArray(tile.images) ? [tile.images[0]].filter(Boolean) : (tile.img ? [tile.img] : []))
  );
  const [keptGallery, setKeptGallery] = useState(() =>
    Array.isArray(tile.galleryImages) ? [...tile.galleryImages]
    : Array.isArray(tile.extraImages) ? [...tile.extraImages]
    : []
  );
  // Colour images — normalised at load into TWO parallel arrays:
  //   · keptColours[]      — URLs in order
  //   · keptColourNames[]  — names in matching order (blank string
  //                          where the swatch has no name yet)
  // Accepts ALL THREE historical schemas:
  //   1. tile.colourImages = [{url, name?}, …]   (current)
  //   2. tile.colourImages = [url, …]             (mid-state)
  //   3. tile.colourImage  = "url"                (legacy single)
  const initialKept = (() => {
    if (Array.isArray(tile.colourImages) && tile.colourImages.length) {
      return tile.colourImages.map(item => (
        typeof item === 'string'
          ? { url: item, name: '' }
          : { url: item.url, name: item.name || '' }
      )).filter(o => o.url);
    }
    if (tile.colourImage) return [{ url: tile.colourImage, name: '' }];
    return [];
  })();
  const [keptColours,     setKeptColours]     = useState(initialKept.map(o => o.url));
  const [keptColourNames, setKeptColourNames] = useState(initialKept.map(o => o.name));

  // New files to upload alongside the kept ones.
  const [newSightFiles,   setNewSightFiles]   = useState([]);
  const [newGalleryFiles, setNewGalleryFiles] = useState([]);
  const [newColourFiles,  setNewColourFiles]  = useState([]);
  // Names for the new uploads (parallel to newColourFiles).
  const [newColourNames,  setNewColourNames]  = useState([]);

  const [saving, setSaving] = useState(false);
  const [err, setErr] = useState('');

  // ─── Multi-value helpers (same as UploadForm) ────────────────────
  function toggleArrayField(field, value, { minOne = false, cap = 5 } = {}) {
    setForm(prev => {
      const arr = prev[field] || [];
      const has = arr.includes(value);
      if (has) {
        if (minOne && arr.length === 1) return prev;
        return { ...prev, [field]: arr.filter(x => x !== value) };
      }
      if (arr.length >= cap) return prev;
      return { ...prev, [field]: [...arr, value] };
    });
  }
  const toggleColour = (c) => toggleArrayField('colours', c);
  const toggleFinish = (f) => toggleArrayField('finishes',f, { minOne: true });
  const toggleStyle  = (s) => toggleArrayField('styles',  s);
  const toggleShape  = (s) => toggleArrayField('shapes',  s);
  function toggleSize(value) {
    const v = String(value || '').trim();
    if (!v) return;
    setForm(prev => {
      const has = prev.sizes.includes(v);
      if (has) return { ...prev, sizes: prev.sizes.filter(x => x !== v) };
      if (prev.sizes.length >= 8) return prev;
      return { ...prev, sizes: [...prev.sizes, v] };
    });
  }
  function addSize(v) {
    v = String(v || '').trim();
    if (!v) return;
    setForm(prev => {
      if (prev.sizes.includes(v)) return prev;
      if (prev.sizes.length >= 8) return prev;
      return { ...prev, sizes: [...prev.sizes, v] };
    });
  }
  function removeSize(v) { setForm(prev => ({ ...prev, sizes: prev.sizes.filter(x => x !== v) })); }
  function toggleRoom(id) {
    setForm(f => {
      const has = f.rooms.includes(id);
      return { ...f, rooms: has ? f.rooms.filter(r => r !== id) : [...f.rooms, id] };
    });
  }

  // ─── Image slot helpers (same shape as UploadForm) ───────────────
  function setSlotAt(setter, idx, file) {
    setter(prev => {
      const next = [...prev];
      if (file === null) next.splice(idx, 1); else next[idx] = file;
      return next;
    });
  }
  const setNewSightAt   = (i, f) => setSlotAt(setNewSightFiles,   i, f);
  const setNewGalleryAt = (i, f) => setSlotAt(setNewGalleryFiles, i, f);
  // Colour-specific helpers — keep the parallel name arrays in
  // lockstep with the file/url arrays so we never have to reconcile
  // them at submit time.
  const setNewColourAt  = (i, f) => {
    setNewColourFiles(prev => {
      const next = [...prev];
      if (f === null) { next.splice(i, 1); }
      else { while (next.length <= i) next.push(null); next[i] = f; }
      return next;
    });
    if (f === null) {
      setNewColourNames(prev => {
        const next = [...prev]; next.splice(i, 1); return next;
      });
    }
  };
  const setNewColourNameAt = (i, name) => {
    setNewColourNames(prev => {
      const next = [...prev];
      while (next.length <= i) next.push('');
      next[i] = name;
      return next;
    });
  };
  const setKeptColourNameAt = (i, name) => {
    setKeptColourNames(prev => {
      const next = [...prev];
      while (next.length <= i) next.push('');
      next[i] = name;
      return next;
    });
  };
  function addNewSightSlot() {
    setNewSightFiles(prev => prev.length + keptSights.length >= MAX_SIGHT_IMAGES ? prev : [...prev, null]);
  }
  function addNewGallerySlot() {
    setNewGalleryFiles(prev => prev.length + keptGallery.length >= MAX_EXTRA_IMAGES ? prev : [...prev, null]);
  }
  function addNewColourSlot() {
    if (newColourFiles.length + keptColours.length >= MAX_COLOUR_IMAGES) return;
    setNewColourFiles(prev => [...prev, null]);
    setNewColourNames(prev => [...prev, '']);
  }

  function removeKeptSight(url)   { setKeptSights(prev => prev.filter(u => u !== url)); }
  function removeKeptGallery(url) { setKeptGallery(prev => prev.filter(u => u !== url)); }
  function removeKeptColour(url)  {
    // Also drop the matching name so we don't keep an orphan label
    // entry that would shift names off-by-one in the parallel array.
    const idx = keptColours.indexOf(url);
    setKeptColours(prev => prev.filter(u => u !== url));
    if (idx >= 0) {
      setKeptColourNames(prev => {
        const next = [...prev]; next.splice(idx, 1); return next;
      });
    }
  }
  // Reorder existing-image lists (kept arrays). New uploads stay at
  // the end and are reorderable within their own block by setNewSightFiles.
  function swap(setter, i, j) {
    setter(prev => {
      if (i < 0 || j < 0 || i >= prev.length || j >= prev.length || i === j) return prev;
      const n = [...prev]; [n[i], n[j]] = [n[j], n[i]]; return n;
    });
  }
  const moveKeptSight    = (i, dir) => swap(setKeptSights,    i, i + dir);
  const moveKeptGallery  = (i, dir) => swap(setKeptGallery,   i, i + dir);
  const moveKeptColour   = (i, dir) => {
    swap(setKeptColours,     i, i + dir);
    swap(setKeptColourNames, i, i + dir);
  };
  const moveNewSight     = (i, dir) => swap(setNewSightFiles,   i, i + dir);
  const moveNewGallery   = (i, dir) => swap(setNewGalleryFiles, i, i + dir);
  const moveNewColour    = (i, dir) => swap(setNewColourFiles,  i, i + dir);

  // Tabbed form state (same as UploadForm).
  const [activeTab, setActiveTab] = useState('identity');
  const TABS = [
    { id: 'identity', label: 'Identity' },
    { id: 'imagery',  label: 'Imagery'  },
    { id: 'specs',    label: 'Specs'    },
    { id: 'filters',  label: 'Filters'  },
  ];

  async function submit(e) {
    e.preventDefault();
    setErr('');
    if (!form.name.trim())       { setErr('Name is required'); return; }
    if (!form.collection.trim()) { setErr('Collection is required'); return; }
    const totalSights = keptSights.length + newSightFiles.filter(Boolean).length;
    if (totalSights === 0) { setErr('At least one sight image is required.'); return; }

    setSaving(true);
    const fd = new FormData();
    // Identity / metadata
    fd.append('name',         form.name.trim());
    fd.append('collection',   form.collection.trim());
    fd.append('variantLabel', (form.variantLabel || form.name).trim());
    fd.append('price',        form.price);
    fd.append('description',  form.description);
    fd.append('status',       form.status);
    // Luxury edit flag — boolean.
    fd.append('luxury',       form.luxury ? 'true' : 'false');
    // Multi-values — `__clear_X = '1'` lets the server distinguish
    // "intentionally empty" from "untouched / keep existing".
    const sendList = (field, list) => {
      if (list.length === 0) {
        fd.append(`__clear_${field}`, '1');
      } else {
        list.forEach(v => fd.append(field, v));
      }
    };
    sendList('rooms',    form.rooms);
    sendList('colours',  form.colours);
    sendList('sizes',    form.sizes);
    sendList('finishes', form.finishes);
    sendList('styles',   form.styles);
    sendList('shapes',   form.shapes);
    // Kept-from-existing image URLs.
    if (keptSights.length === 0)  fd.append('keptSightImages',   '');
    keptSights.forEach(u =>       fd.append('keptSightImages',   u));
    if (keptGallery.length === 0) fd.append('keptGalleryImages', '');
    keptGallery.forEach(u =>      fd.append('keptGalleryImages', u));
    // Colour images — kept URLs + names sent as parallel arrays,
    // then new files + names appended in matching order. The server
    // pairs them positionally and writes [{url, name?}, …].
    if (keptColours.length === 0) {
      fd.append('keptColourImages',     '');
      fd.append('keptColourImageNames', '');
    } else {
      keptColours.forEach((u, i) => {
        fd.append('keptColourImages',     u);
        fd.append('keptColourImageNames', (keptColourNames[i] || '').trim());
      });
    }
    // New files.
    newSightFiles.filter(Boolean).forEach(f =>   fd.append('sightImages',   f));
    newGalleryFiles.filter(Boolean).forEach(f => fd.append('galleryImages', f));
    newColourFiles.forEach((f, i) => {
      if (!f) return;
      fd.append('colourImage',     f);
      fd.append('colourImageName', (newColourNames[i] || '').trim());
    });

    try {
      const r = await fetch(`/api/admin/tiles/${tile.id}`, {
        method: 'PATCH',
        headers: { Authorization: `Bearer ${token}` },
        body: fd,
      });
      if (r.status === 401) { onUnauthorized(); return; }
      const ct = r.headers.get('content-type') || '';
      const data = ct.includes('application/json') ? await r.json().catch(() => ({})) : { error: (await r.text()).slice(0, 200) };
      if (!r.ok) { setErr(data.error || `Save failed (HTTP ${r.status})`); setSaving(false); return; }
      onUpdated(data.tile);
    } catch (e) {
      setErr(`Network error — ${e?.message || 'check the server is running'}.`);
    }
    setSaving(false);
  }

  return (
    <form onSubmit={submit} style={{
      gridColumn: '1 / -1',
      background: 'var(--cream)', borderTop: '2px solid var(--terracotta)',
      borderBottom: '1px solid var(--cream-deep)',
      padding: '24px 28px',
    }}>
      <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: '18px' }}>
        <div>
          <p className="t-label" style={{ color: 'var(--terracotta)' }}>Editing</p>
          <h3 style={{ fontFamily: 'var(--serif)', fontSize: '22px', color: 'var(--dark)', margin: '4px 0 0' }}>{tile.name}</h3>
        </div>
        <button type="button" onClick={onCancel} style={{
          background: 'none', border: '1px solid var(--cream-deep)', color: 'var(--dark-mid)',
          padding: '8px 16px', fontFamily: 'var(--sans)', fontSize: '11px',
          letterSpacing: '0.16em', textTransform: 'uppercase', cursor: 'pointer',
        }}>Cancel</button>
      </div>

      <TabBar tabs={TABS} active={activeTab} onChange={setActiveTab}/>

      {/* ══ IMAGERY TAB ══════════════════════════════════════════ */}
      {activeTab === 'imagery' && (<>

      {/* ── Existing sight images ─────────────────────────────── */}
      <div style={{ marginBottom: '18px' }}>
        <label>
          Sight images{' '}
          <span style={{ color: 'var(--dark-mid)', fontWeight: 400 }}>
            ({keptSights.length + newSightFiles.filter(Boolean).length}/{MAX_SIGHT_IMAGES} — × to remove an existing one)
          </span>
        </label>
        <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))', gap: '10px', marginTop: '6px' }}>
          {keptSights.map((url, i) => (
            <div key={url} style={{ position: 'relative' }}>
              <p style={{ fontFamily: 'var(--sans)', fontSize: '9px', letterSpacing: '0.18em', textTransform: 'uppercase', color: i === 0 ? 'var(--terracotta)' : 'var(--dark-mid)', marginBottom: '4px' }}>{i === 0 ? 'Sight 1 · primary' : `Sight ${i + 1}`}</p>
              <div style={{ position: 'relative', height: '130px', background: 'var(--cream-mid)', overflow: 'hidden', border: '1px solid var(--cream-deep)' }}>
                <img src={url} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }}/>
                <button type="button" onClick={() => removeKeptSight(url)} aria-label="Remove sight" style={{
                  position: 'absolute', top: 6, right: 6, width: 22, height: 22,
                  background: 'rgba(255,255,255,0.94)', border: 'none', cursor: 'pointer',
                  fontSize: '14px', color: 'var(--dark)', display: 'flex', alignItems: 'center', justifyContent: 'center',
                }}>×</button>
                {keptSights.length > 1 && (
                  <ReorderArrows
                    canLeft={i > 0}
                    canRight={i < keptSights.length - 1}
                    onLeft={() => moveKeptSight(i, -1)}
                    onRight={() => moveKeptSight(i, +1)}
                  />
                )}
              </div>
            </div>
          ))}
          {newSightFiles.map((f, i) => (
            <div key={'new-' + i}>
              <p style={{ fontFamily: 'var(--sans)', fontSize: '9px', letterSpacing: '0.18em', textTransform: 'uppercase', color: 'var(--terracotta)', marginBottom: '4px' }}>+ New {i + 1}</p>
              <div style={{ position: 'relative' }}>
                <ImageDropZone file={f} onChange={(file) => setNewSightAt(i, file)} height={130} hint={`New sight ${i + 1}`}/>
                {newSightFiles.filter(Boolean).length > 1 && f && (
                  <ReorderArrows
                    canLeft={i > 0}
                    canRight={i < newSightFiles.length - 1 && newSightFiles[i + 1]}
                    onLeft={() => moveNewSight(i, -1)}
                    onRight={() => moveNewSight(i, +1)}
                  />
                )}
              </div>
            </div>
          ))}
          {(keptSights.length + newSightFiles.length) < MAX_SIGHT_IMAGES && (
            <button type="button" onClick={addNewSightSlot} style={{
              height: '160px', marginTop: '15px',
              background: 'var(--cream)', border: '1px dashed var(--dark-mid)',
              cursor: 'pointer', color: 'var(--dark-mid)',
              fontFamily: 'var(--sans)', fontSize: '11px', letterSpacing: '0.14em', textTransform: 'uppercase',
            }}>+ Add sight</button>
          )}
        </div>
      </div>

      {/* ── Gallery images ──────────────────────────────────── */}
      <div style={{ marginBottom: '18px' }}>
        <label>
          Gallery images{' '}
          <span style={{ color: 'var(--dark-mid)', fontWeight: 400 }}>
            ({keptGallery.length + newGalleryFiles.filter(Boolean).length}/{MAX_EXTRA_IMAGES})
          </span>
        </label>
        <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(120px, 1fr))', gap: '10px', marginTop: '6px' }}>
          {keptGallery.map((url, i) => (
            <div key={url}>
              <p style={{ fontFamily: 'var(--sans)', fontSize: '9px', letterSpacing: '0.18em', textTransform: 'uppercase', color: 'var(--dark-mid)', marginBottom: '4px' }}>{i + 1}</p>
              <div style={{ position: 'relative', height: '110px', background: 'var(--cream-mid)', overflow: 'hidden', border: '1px solid var(--cream-deep)' }}>
                <img src={url} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }}/>
                <button type="button" onClick={() => removeKeptGallery(url)} aria-label="Remove gallery image" style={{
                  position: 'absolute', top: 4, right: 4, width: 20, height: 20,
                  background: 'rgba(255,255,255,0.94)', border: 'none', cursor: 'pointer',
                  fontSize: '13px', color: 'var(--dark)', display: 'flex', alignItems: 'center', justifyContent: 'center',
                }}>×</button>
                {keptGallery.length > 1 && (
                  <ReorderArrows
                    canLeft={i > 0}
                    canRight={i < keptGallery.length - 1}
                    onLeft={() => moveKeptGallery(i, -1)}
                    onRight={() => moveKeptGallery(i, +1)}
                  />
                )}
              </div>
            </div>
          ))}
          {newGalleryFiles.map((f, i) => (
            <div key={'newgal-' + i}>
              <p style={{ fontFamily: 'var(--sans)', fontSize: '9px', letterSpacing: '0.18em', textTransform: 'uppercase', color: 'var(--terracotta)', marginBottom: '4px' }}>+ New {i + 1}</p>
              <div style={{ position: 'relative' }}>
                <ImageDropZone file={f} onChange={(file) => setNewGalleryAt(i, file)} height={110} hint={`New ${i + 1}`}/>
                {newGalleryFiles.filter(Boolean).length > 1 && f && (
                  <ReorderArrows
                    canLeft={i > 0}
                    canRight={i < newGalleryFiles.length - 1 && newGalleryFiles[i + 1]}
                    onLeft={() => moveNewGallery(i, -1)}
                    onRight={() => moveNewGallery(i, +1)}
                  />
                )}
              </div>
            </div>
          ))}
          {(keptGallery.length + newGalleryFiles.length) < MAX_EXTRA_IMAGES && (
            <button type="button" onClick={addNewGallerySlot} style={{
              height: '136px', marginTop: '15px',
              background: 'var(--cream)', border: '1px dashed var(--dark-mid)',
              cursor: 'pointer', color: 'var(--dark-mid)',
              fontFamily: 'var(--sans)', fontSize: '11px', letterSpacing: '0.14em', textTransform: 'uppercase',
            }}>+ Add image</button>
          )}
        </div>
      </div>

      {/* ── Colour images ──────────────────────────────────────
           Existing swatches list first, then any new-upload slots,
           then an "Add colour" trigger. Same kept-+-new pattern as
           the sight and gallery sections above. */}
      <div style={{ marginBottom: '18px' }}>
        <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: '8px' }}>
          <label style={{ margin: 0 }}>
            Colour images <span style={{ color: 'var(--dark-mid)', fontWeight: 400 }}>(swatches in "The colours" grid)</span>
          </label>
          <p style={{ fontFamily: 'var(--sans)', fontSize: '10px', color: 'var(--dark-mid)', letterSpacing: '0.1em', textTransform: 'uppercase' }}>
            {keptColours.length + newColourFiles.filter(Boolean).length} / {MAX_COLOUR_IMAGES}
          </p>
        </div>
        <div style={{
          display: 'grid',
          gridTemplateColumns: 'repeat(auto-fill, minmax(160px, 1fr))',
          gap: '10px',
        }}>
          {/* Existing colour swatches (kept) — each gets a name input
              below the thumb. First slot (i === 0) is the "Current"
              swatch; its name input is optional (falls back to the
              tile's own name on the storefront). */}
          {keptColours.map((url, i) => (
            <div key={'kept-c-' + url} style={{ position: 'relative' }}>
              <p style={{
                fontFamily: 'var(--sans)', fontSize: '9px', fontWeight: 500,
                letterSpacing: '0.18em', textTransform: 'uppercase',
                color: i === 0 ? 'var(--terracotta)' : 'var(--dark-mid)', marginBottom: '4px',
              }}>{i === 0 ? `Colour ${i + 1} · current` : `Colour ${i + 1}`}</p>
              <div style={{ position: 'relative', width: '100%', height: '136px', background: 'var(--cream-mid)', overflow: 'hidden', border: '1px solid var(--cream-deep)' }}>
                <img src={url} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }}/>
                <button
                  type="button"
                  onClick={() => removeKeptColour(url)}
                  aria-label="Remove this colour image"
                  style={{
                    position: 'absolute', top: 6, right: 6, width: 22, height: 22,
                    background: 'rgba(255,255,255,0.94)', border: 'none', cursor: 'pointer',
                    fontSize: '14px', color: 'var(--dark)', display: 'flex', alignItems: 'center', justifyContent: 'center',
                  }}
                >×</button>
                {keptColours.length > 1 && (
                  <ReorderArrows
                    canLeft={i > 0}
                    canRight={i < keptColours.length - 1}
                    onLeft={() => moveKeptColour(i, -1)}
                    onRight={() => moveKeptColour(i, +1)}
                  />
                )}
              </div>
              <input
                type="text"
                value={keptColourNames[i] || ''}
                onChange={(e) => setKeptColourNameAt(i, e.target.value)}
                placeholder={i === 0 ? '(uses tile name)' : 'e.g. Beige, Gold'}
                maxLength={60}
                style={{
                  width: '100%', marginTop: '4px',
                  padding: '6px 8px',
                  fontFamily: 'var(--sans)', fontSize: '12px',
                  border: '1px solid var(--cream-deep)', background: 'white',
                }}
              />
            </div>
          ))}

          {/* New uploads (slots) — same name input pattern. The
              "New" prefix distinguishes them from the kept-existing
              swatches above. Their index for naming purposes is
              keptColours.length + i so the placeholder doesn't say
              "current" if there are already kept swatches. */}
          {newColourFiles.map((f, i) => {
            const visualIndex = keptColours.length + i; // 0-based across the merged list
            return (
              <div key={'new-c-' + i} style={{ position: 'relative' }}>
                <p style={{
                  fontFamily: 'var(--sans)', fontSize: '9px', fontWeight: 500,
                  letterSpacing: '0.18em', textTransform: 'uppercase',
                  color: 'var(--terracotta)', marginBottom: '4px',
                }}>New · colour {visualIndex + 1}</p>
                <div style={{ position: 'relative' }}>
                  <ImageDropZone
                    file={f}
                    onChange={(file) => setNewColourAt(i, file)}
                    height={136}
                    hint={`New ${i + 1}`}
                  />
                  {newColourFiles.filter(Boolean).length > 1 && f && (
                    <ReorderArrows
                      canLeft={i > 0}
                      canRight={i < newColourFiles.length - 1 && newColourFiles[i + 1]}
                      onLeft={() => moveNewColour(i, -1)}
                      onRight={() => moveNewColour(i, +1)}
                    />
                  )}
                </div>
                <input
                  type="text"
                  value={newColourNames[i] || ''}
                  onChange={(e) => setNewColourNameAt(i, e.target.value)}
                  placeholder={visualIndex === 0 ? '(uses tile name)' : 'e.g. Beige, Gold'}
                  maxLength={60}
                  style={{
                    width: '100%', marginTop: '4px',
                    padding: '6px 8px',
                    fontFamily: 'var(--sans)', fontSize: '12px',
                    border: '1px solid var(--cream-deep)', background: 'white',
                  }}
                />
              </div>
            );
          })}

          {/* Add-slot trigger */}
          {(keptColours.length + newColourFiles.length) < MAX_COLOUR_IMAGES && (
            <button
              type="button"
              onClick={addNewColourSlot}
              style={{
                height: '160px', marginTop: '15px',
                background: 'var(--cream)', border: '1px dashed var(--dark-mid)',
                cursor: 'pointer', color: 'var(--dark-mid)',
                fontFamily: 'var(--sans)', fontSize: '11px',
                letterSpacing: '0.14em', textTransform: 'uppercase',
              }}
            >+ Add colour</button>
          )}
        </div>
      </div>
      </>)}{/* /imagery tab */}

      {/* ══ IDENTITY TAB ═════════════════════════════════════════ */}
      {activeTab === 'identity' && (
      <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '14px' }}>
        <div style={{ gridColumn: '1 / -1' }}>
          <label>Collection *{' '}
            <span style={{ color: 'var(--dark-mid)', fontWeight: 400 }}>(pick from below or "+ New collection")</span>
          </label>
          <CollectionPicker
            value={form.collection}
            onChange={(c) => setForm(f => ({ ...f, collection: c }))}
            existing={existingCollections}
          />
        </div>
        <div>
          <label>Tile name *</label>
          <input value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))}/>
        </div>
        <div>
          <label>Variant label{' '}
            <span style={{ color: 'var(--dark-mid)', fontWeight: 400 }}>(optional)</span>
          </label>
          <input value={form.variantLabel} onChange={e => setForm(f => ({ ...f, variantLabel: e.target.value }))} placeholder="e.g. Azzurro"/>
        </div>
        <div>
          <label>Status</label>
          <select value={form.status} onChange={e => setForm(f => ({ ...f, status: e.target.value }))}>
            <option value="published">Published</option>
            <option value="draft">Draft</option>
          </select>
        </div>
        <div style={{ gridColumn: '1 / -1' }}>
          <LuxuryToggle
            value={form.luxury}
            onChange={(v) => setForm(f => ({ ...f, luxury: v }))}
          />
        </div>
        <div style={{ gridColumn: '1 / -1' }}>
          <label>Description</label>
          <textarea value={form.description} onChange={e => setForm(f => ({ ...f, description: e.target.value }))} rows={3}
            style={{ width: '100%', resize: 'vertical', fontFamily: 'var(--sans)', fontSize: '13px', padding: '10px 12px', border: '1px solid var(--cream-deep)', background: 'white', outline: 'none' }}/>
        </div>
      </div>
      )}{/* /identity tab */}

      {/* ══ SPECS TAB ════════════════════════════════════════════ */}
      {activeTab === 'specs' && (
      <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '14px' }}>
        <div style={{ gridColumn: '1 / -1' }}>
          <label>Sizes <span style={{ color: 'var(--dark-mid)', fontWeight: 400 }}>({form.sizes.length}/8)</span></label>
          <PillRow options={STANDARD_SIZES} active={form.sizes} onToggle={toggleSize}/>
          <p style={{ fontFamily: 'var(--sans)', fontSize: '10px', letterSpacing: '0.18em', textTransform: 'uppercase', color: 'var(--dark-mid)', marginTop: '14px' }}>Custom size</p>
          <SizeTagInput sizes={form.sizes.filter(s => !STANDARD_SIZES.includes(s))} onAdd={addSize} onRemove={removeSize}/>
        </div>
        <div style={{ gridColumn: '1 / -1' }}>
          <label>Finishes</label>
          <div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px', marginTop: '6px' }}>
            {FINISHES.map(f => {
              const active = form.finishes.includes(f);
              return (
                <button key={f} type="button" onClick={() => toggleFinish(f)} style={{
                  padding: '8px 14px', background: active ? 'var(--dark)' : 'white', color: active ? 'white' : 'var(--dark)',
                  border: `1px solid ${active ? 'var(--dark)' : 'var(--cream-deep)'}`,
                  fontFamily: 'var(--sans)', fontSize: '11px', letterSpacing: '0.06em', cursor: 'pointer',
                }}>{f}</button>
              );
            })}
          </div>
        </div>
        <div>
          <label>Price</label>
          <input value={form.price} onChange={e => setForm(f => ({ ...f, price: e.target.value }))} placeholder="£68/m² or POA"/>
        </div>
      </div>
      )}{/* /specs tab */}

      {/* ══ FILTERS TAB ══════════════════════════════════════════ */}
      {activeTab === 'filters' && (
      <div>
        <div style={{ marginBottom: '14px' }}>
          <label>Colours <span style={{ color: 'var(--dark-mid)', fontWeight: 400 }}>({form.colours.length} selected)</span></label>
          <PillRow options={COLOURS.map(c => ({ id: c.id, label: c.label }))} active={form.colours} onToggle={toggleColour}/>
        </div>
        <div style={{ marginBottom: '14px' }}>
          <label>Styles <span style={{ color: 'var(--dark-mid)', fontWeight: 400 }}>({form.styles.length} selected)</span></label>
          <PillRow options={STYLES.map(s => ({ id: s.id, label: s.label }))} active={form.styles} onToggle={toggleStyle}/>
        </div>
        <div style={{ marginBottom: '14px' }}>
          <label>Shapes <span style={{ color: 'var(--dark-mid)', fontWeight: 400 }}>({form.shapes.length} selected)</span></label>
          <PillRow options={SHAPES.map(s => ({ id: s.id, label: s.label }))} active={form.shapes} onToggle={toggleShape}/>
        </div>
        <div>
          <label>Rooms</label>
          <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))', gap: '8px', marginTop: '6px' }}>
            {ROOMS.map(r => {
              const checked = form.rooms.includes(r.id);
              return (
                <label key={r.id} style={{
                  display: 'flex', alignItems: 'center', gap: '8px', padding: '8px 10px', cursor: 'pointer',
                  background: checked ? 'var(--dark)' : 'white',
                  border: `1px solid ${checked ? 'var(--dark)' : 'var(--cream-deep)'}`,
                  color: checked ? 'white' : 'var(--dark)',
                  fontFamily: 'var(--sans)', fontSize: '11px', letterSpacing: '0.06em',
                }}>
                  <input type="checkbox" checked={checked} onChange={() => toggleRoom(r.id)}
                    style={{ width: 'auto', margin: 0, accentColor: 'var(--terracotta)' }}/>
                  <span>{r.label}</span>
                </label>
              );
            })}
          </div>
        </div>
      </div>
      )}{/* /filters tab */}

      {err && <p style={{ fontFamily: 'var(--sans)', fontSize: '12px', color: 'var(--terracotta)', marginTop: '16px' }}>{err}</p>}

      <div style={{ display: 'flex', gap: '12px', marginTop: '22px' }}>
        <button type="submit" className="btn btn-dark" disabled={saving} style={{ opacity: saving ? 0.6 : 1 }}>
          {saving ? 'Saving…' : 'Save changes'}
        </button>
        <button type="button" onClick={onCancel} className="btn btn-outline">Cancel</button>
      </div>
    </form>
  );
}

// ─── Stage 3: Admin power tools ────────────────────────────────────
// AdminFilterPills — compact toggle row (no labels) used for the filter
// chips above the catalogue table. `active` is a Set; toggleId mutates it.
function AdminFilterPills({ options, active, onToggle }) {
  return (
    <div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px' }}>
      {options.map(opt => {
        const id = typeof opt === 'string' ? opt : opt.id;
        const label = typeof opt === 'string' ? opt : opt.label;
        const isOn = active.has(id);
        return (
          <button
            key={id}
            type="button"
            onClick={() => onToggle(id)}
            style={{
              padding: '6px 11px',
              background: isOn ? 'var(--dark)' : 'white',
              color: isOn ? 'white' : 'var(--dark-mid)',
              border: `1px solid ${isOn ? 'var(--dark)' : 'var(--cream-deep)'}`,
              fontFamily: 'var(--sans)', fontSize: '10px',
              letterSpacing: '0.1em', textTransform: 'uppercase',
              cursor: 'pointer', transition: 'all 0.15s',
            }}
          >{label}</button>
        );
      })}
    </div>
  );
}

const ADMIN_PAGE_SIZE = 50;

// ─── Collections admin page ─────────────────────────────────────────
// Manages the metadata next to tiles: per-collection variant ORDER and
// HIDDEN list. Reads/writes via /api/collections endpoints. Lives as a
// sub-view inside the Admin component (toggled via tab bar).
function CollectionsAdmin({ token, tiles, meta, onMetaSaved, onUnauthorized }) {
  // Distinct collections in the catalogue, alphabetised.
  const collections = useMemo(() => {
    const set = new Set();
    for (const t of tiles) if (t.collection) set.add(t.collection);
    return Array.from(set).sort((a, b) => a.localeCompare(b));
  }, [tiles]);

  // Local edit state — a snapshot of meta the admin is currently
  // editing. Save persists to server; Cancel reverts to `meta` prop.
  const [draft, setDraft] = useState(() => ({}));
  const [savingName, setSavingName] = useState('');
  const [err, setErr] = useState('');

  // Helper: ordered tile-id list for a collection — uses saved/draft
  // order first, then appends any tiles that aren't in the list
  // (newly added since the order was last saved).
  function variantsFor(collName) {
    const tilesIn = tiles.filter(t => t.collection === collName);
    const m = draft[collName] || meta[collName] || {};
    const orderedIds = (m.order || []).filter(id => tilesIn.some(t => t.id === id));
    const remaining = tilesIn.filter(t => !orderedIds.includes(t.id));
    const ids = [...orderedIds, ...remaining.map(t => t.id)];
    return ids.map(id => tilesIn.find(t => t.id === id)).filter(Boolean);
  }
  function isHidden(collName, id) {
    const m = draft[collName] || meta[collName] || {};
    return Array.isArray(m.hidden) && m.hidden.includes(id);
  }
  function hasDraftFor(collName) {
    return draft[collName] !== undefined;
  }

  function ensureDraft(collName) {
    setDraft(prev => {
      if (prev[collName]) return prev;
      const cur = meta[collName] || {};
      return {
        ...prev,
        [collName]: {
          order:  Array.isArray(cur.order)  ? [...cur.order]  : variantsFor(collName).map(t => t.id),
          hidden: Array.isArray(cur.hidden) ? [...cur.hidden] : [],
        },
      };
    });
  }

  function moveVariant(collName, id, dir) {
    ensureDraft(collName);
    setDraft(prev => {
      const cur = prev[collName] || { order: variantsFor(collName).map(t => t.id), hidden: [] };
      const order = [...cur.order];
      const i = order.indexOf(id);
      if (i < 0) return prev;
      const j = i + dir;
      if (j < 0 || j >= order.length) return prev;
      [order[i], order[j]] = [order[j], order[i]];
      return { ...prev, [collName]: { ...cur, order } };
    });
  }
  function toggleHide(collName, id) {
    ensureDraft(collName);
    setDraft(prev => {
      const cur = prev[collName] || { order: variantsFor(collName).map(t => t.id), hidden: [] };
      const hidden = cur.hidden.includes(id)
        ? cur.hidden.filter(x => x !== id)
        : [...cur.hidden, id];
      return { ...prev, [collName]: { ...cur, hidden } };
    });
  }
  function cancelDraft(collName) {
    setDraft(prev => {
      const next = { ...prev };
      delete next[collName];
      return next;
    });
  }
  async function save(collName) {
    const d = draft[collName];
    if (!d) return;
    setSavingName(collName); setErr('');
    try {
      const r = await fetch(`/api/admin/collections/${encodeURIComponent(collName)}`, {
        method: 'PATCH',
        headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
        body: JSON.stringify({ order: d.order, hidden: d.hidden }),
      });
      if (r.status === 401) { onUnauthorized(); return; }
      if (!r.ok) { setErr(`Save failed (HTTP ${r.status})`); return; }
      // Server returns the saved collection — refresh meta and drop draft.
      onMetaSaved();
      setDraft(prev => { const n = { ...prev }; delete n[collName]; return n; });
    } catch (e) {
      setErr(`Network error — ${e?.message || 'check the server'}.`);
    }
    setSavingName('');
  }

  return (
    <div>
      <div style={{ marginBottom: '24px' }}>
        <p className="t-label" style={{ color: 'var(--terracotta)', marginBottom: '8px' }}>Collections admin</p>
        <h2 className="t-headline" style={{ fontSize: '28px', marginBottom: '6px' }}>Variant order &amp; visibility</h2>
        <p style={{ fontFamily: 'var(--sans)', fontSize: '13px', color: 'var(--dark-mid)', maxWidth: '720px' }}>
          Each collection's variants appear as colour cells on every product page in that range. Reorder them with the ↑/↓ buttons, or uncheck "Show" to hide a variant from the colour grid (the tile's own product page stays reachable).
        </p>
        {err && <p style={{ fontFamily: 'var(--sans)', fontSize: '12px', color: 'var(--terracotta)', marginTop: '8px' }}>{err}</p>}
      </div>

      {collections.length === 0 && (
        <p style={{ fontFamily: 'var(--sans)', fontSize: '13px', color: 'var(--dark-mid)' }}>
          No collections yet. Upload a tile and assign it a collection first.
        </p>
      )}

      <div style={{ display: 'grid', gap: '14px' }}>
        {collections.map(c => {
          const variants = variantsFor(c);
          const dirty = hasDraftFor(c);
          return (
            <div key={c} style={{
              background: 'white', border: '1px solid var(--cream-deep)',
              padding: '18px 22px',
            }}>
              <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: '12px', gap: '12px' }}>
                <div>
                  <p style={{ fontFamily: 'var(--serif)', fontSize: '20px', color: 'var(--dark)', margin: 0 }}>{c}</p>
                  <p style={{ fontFamily: 'var(--sans)', fontSize: '10px', letterSpacing: '0.18em', textTransform: 'uppercase', color: 'var(--dark-mid)', margin: '2px 0 0' }}>
                    {variants.length} variant{variants.length === 1 ? '' : 's'}
                  </p>
                </div>
                {dirty && (
                  <div style={{ display: 'flex', gap: '8px' }}>
                    <button type="button" onClick={() => cancelDraft(c)} style={collBtnGhost}>Cancel</button>
                    <button type="button" disabled={savingName === c} onClick={() => save(c)} style={collBtnDark}>
                      {savingName === c ? 'Saving…' : 'Save'}
                    </button>
                  </div>
                )}
              </div>

              <div style={{ display: 'grid', gap: '6px' }}>
                {variants.map((t, idx) => {
                  const thumb = (Array.isArray(t.sightImages) && t.sightImages[0]) || t.img || '';
                  const hidden = isHidden(c, t.id);
                  return (
                    <div key={t.id} style={{
                      display: 'grid',
                      gridTemplateColumns: '44px 1fr auto auto auto',
                      gap: '12px', alignItems: 'center',
                      padding: '8px 10px',
                      background: hidden ? 'var(--cream)' : 'transparent',
                      border: '1px solid var(--cream-deep)',
                      opacity: hidden ? 0.55 : 1,
                    }}>
                      <div style={{ width: '44px', height: '44px', background: 'var(--cream-mid)', overflow: 'hidden' }}>
                        {thumb && <img src={thumb} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }}/>}
                      </div>
                      <div style={{ minWidth: 0 }}>
                        <p style={{ fontFamily: 'var(--serif)', fontSize: '14px', color: 'var(--dark)', margin: 0, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
                          {t.variantLabel || t.name}
                        </p>
                        <p style={{ fontFamily: 'var(--sans)', fontSize: '10px', letterSpacing: '0.14em', textTransform: 'uppercase', color: 'var(--dark-mid)', margin: '2px 0 0' }}>
                          #{idx + 1} · {t.id}
                        </p>
                      </div>
                      <button
                        type="button"
                        onClick={() => moveVariant(c, t.id, -1)}
                        disabled={idx === 0}
                        title="Move up"
                        style={{ ...collArrowBtn, opacity: idx === 0 ? 0.3 : 1 }}
                      >↑</button>
                      <button
                        type="button"
                        onClick={() => moveVariant(c, t.id, +1)}
                        disabled={idx === variants.length - 1}
                        title="Move down"
                        style={{ ...collArrowBtn, opacity: idx === variants.length - 1 ? 0.3 : 1 }}
                      >↓</button>
                      <label style={{
                        display: 'inline-flex', alignItems: 'center', gap: '6px', cursor: 'pointer',
                        fontFamily: 'var(--sans)', fontSize: '10px', letterSpacing: '0.14em',
                        textTransform: 'uppercase', color: 'var(--dark-mid)',
                      }}>
                        <input
                          type="checkbox"
                          checked={!hidden}
                          onChange={() => toggleHide(c, t.id)}
                          style={{ width: '14px', height: '14px', accentColor: 'var(--terracotta)' }}
                        />
                        Show
                      </label>
                    </div>
                  );
                })}
              </div>
            </div>
          );
        })}
      </div>
    </div>
  );
}

const collArrowBtn = {
  width: '30px', height: '30px',
  background: 'white', border: '1px solid var(--cream-deep)',
  color: 'var(--dark)', cursor: 'pointer',
  fontFamily: 'var(--sans)', fontSize: '14px',
  display: 'flex', alignItems: 'center', justifyContent: 'center',
};
const collBtnDark = {
  padding: '8px 18px', background: 'var(--dark)', color: 'white', border: 'none',
  fontFamily: 'var(--sans)', fontSize: '11px', letterSpacing: '0.16em',
  textTransform: 'uppercase', cursor: 'pointer',
};
const collBtnGhost = {
  padding: '8px 18px', background: 'none', color: 'var(--dark-mid)',
  border: '1px solid var(--cream-deep)',
  fontFamily: 'var(--sans)', fontSize: '11px', letterSpacing: '0.16em',
  textTransform: 'uppercase', cursor: 'pointer',
};

// Admin (thin outer auth gate). All the dashboard's many useState /
// useEffect / useMemo hooks now live on AdminPanel below — keeping
// them off the auth wrapper avoids the classic Rules-of-Hooks crash
// (*"Rendered more hooks than during the previous render"*) that
// happens when an early return like `if (!token) return <Login/>` sits
// in the middle of a function body that still has hooks after it.
// Here the conditional is on *component identity* — when `token`
// flips, we mount a different component, and React only counts the
// hooks inside the currently-mounted one. No mismatched render counts.
function Admin({ navigate }) {
  const [token, setToken] = useState(() => localStorage.getItem(ADMIN_TOKEN_KEY) || '');
  // If the magic-link verify step fails (expired / replayed token),
  // we surface the reason on the login screen so the admin knows to
  // request a new one rather than staring at an empty form.
  const [magicError, setMagicError] = useState('');

  // Magic-link redemption — runs once on mount. If the admin clicked
  // a magic link from email, the URL fragment looks like
  //   #admin?magic=<token>
  // and we trade that token for a real session via /api/admin/magic-verify.
  // On success we clean the magic param out of the URL so a back/
  // forward navigation can't accidentally re-submit a now-used token.
  useEffect(() => {
    const hash = window.location.hash || '';
    const q    = hash.indexOf('?');
    if (q < 0) return;
    const params = new URLSearchParams(hash.slice(q + 1));
    const magic  = params.get('magic');
    if (!magic) return;

    // Strip the magic param out of the URL immediately so refreshing
    // doesn't keep re-posting an already-spent token. We pass through
    // App's existing `history.state` so the SPA router doesn't lose
    // its breadcrumb of which page we're on.
    params.delete('magic');
    const route = hash.slice(0, q);
    const rest  = params.toString();
    window.history.replaceState(
      window.history.state,
      '',
      route + (rest ? '?' + rest : '')
    );

    let mounted = true;
    (async () => {
      try {
        const r = await fetch('/api/admin/magic-verify', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ token: magic }),
        });
        if (!mounted) return;
        if (!r.ok) {
          const d = await r.json().catch(() => ({}));
          setMagicError(d.error || 'That sign-in link is no longer valid. Request a new one.');
          return;
        }
        const { token: newToken } = await r.json();
        if (!mounted) return;
        localStorage.setItem(ADMIN_TOKEN_KEY, newToken);
        setToken(newToken);
      } catch {
        if (mounted) setMagicError('Network error while signing in. Try again.');
      }
    })();
    return () => { mounted = false; };
  }, []);

  if (!token) return <AdminLogin onSuccess={setToken} initialError={magicError}/>;
  return <AdminPanel navigate={navigate} token={token} setToken={setToken}/>;
}

// AdminPanel — the catalogue dashboard. Only mounts AFTER successful
// authentication, so all of its hooks always run together. Token is
// passed in by Admin; setToken is needed so 401 responses (expired
// session token) can sign the admin out cleanly.
function AdminPanel({ navigate, token, setToken }) {
  // Subscribe to the live finishes list so the Finish filter pills row
  // above the catalogue table re-renders when the studio adds/removes
  // a finish in the "Finishes" admin tab. Same for sizes + the four
  // taxonomies (rooms/colours/styles/shapes).
  useFinishes();
  useSizes();
  useRooms();
  useColours();
  useStyles();
  useShapes();

  const [tiles, setTiles] = useState([]);
  const [loading, setLoading] = useState(false);
  // Currently-being-edited tile id. When set, that row renders an
  // EditTileForm panel below it instead of the regular TileRow only.
  const [editingId, setEditingId] = useState(null);
  // Bulk-select state — Set of tile ids the admin has ticked.
  const [selectedIds, setSelectedIds] = useState(() => new Set());
  // Duplicate template — when set, the UploadForm prefills with the
  // tile's metadata (images are NOT copied; admin has to upload fresh).
  const [duplicateTemplate, setDuplicateTemplate] = useState(null);
  const uploadFormRef = useRef(null);
  // Collection currently being renamed (inline). null = nobody.
  const [renamingCollection, setRenamingCollection] = useState(null);
  const [renameDraft, setRenameDraft]               = useState('');
  const [collectionsOpen, setCollectionsOpen]       = useState(false);
  // Top-level admin view: 'tiles' (the catalogue table) or 'collections'
  // (the Collections admin page — manage variant order + visibility).
  const [adminView, setAdminView] = useState('tiles');
  // Collection metadata fetched from /api/collections.
  // Shape: { "ALLOY": { order: [tileId,…], hidden: [tileId,…] }, … }
  const [collectionsMeta, setCollectionsMeta] = useState({});
  async function refreshCollectionsMeta() {
    try {
      const r = await fetch('/api/collections.json');
      const d = await r.json();
      setCollectionsMeta(d.collections || {});
    } catch {}
  }
  useEffect(() => { if (token) refreshCollectionsMeta(); }, [token]);

  // Pull the live finishes list on mount so the pills + filter
  // strip reflect what's actually in data/finishes.json (not the
  // hard-coded seed). Anyone subscribed via useFinishes() re-renders.
  useEffect(() => {
    fetch('/api/finishes.json')
      .then(r => r.json())
      .then(d => { if (Array.isArray(d.finishes)) setFinishesGlobal(d.finishes); })
      .catch(() => {});
  }, []);
  // Same for sizes — pull data/sizes.json so the pill row + size
  // filter reflect the studio's live list, not the hard-coded seed.
  useEffect(() => {
    fetch('/api/sizes.json')
      .then(r => r.json())
      .then(d => { if (Array.isArray(d.sizes)) setSizesGlobal(d.sizes); })
      .catch(() => {});
  }, []);
  // Same for the four taxonomies — fired in parallel on mount.
  // Each populates its module-level store; any pill row subscribed
  // via useRooms/useColours/etc. re-renders the moment its data
  // arrives.
  useEffect(() => {
    const TX = [
      ['rooms',   setRoomsGlobal],
      ['colours', setColoursGlobal],
      ['styles',  setStylesGlobal],
      ['shapes',  setShapesGlobal],
    ];
    for (const [name, setter] of TX) {
      fetch(`/api/taxonomy_${name}.json`)
        .then(r => r.json())
        .then(d => { if (Array.isArray(d.items)) setter(d.items); })
        .catch(() => {});
    }
  }, []);

  // ─── Stage 3 filter / search / sort / pagination state ──────────
  const [search, setSearch]   = useState('');
  const [filter, setFilter]   = useState('All');           // category quick-tab
  const [fColour, setFColour] = useState(() => new Set()); // multi
  const [fStyle,  setFStyle]  = useState(() => new Set()); // multi
  const [fShape,  setFShape]  = useState(() => new Set()); // multi
  const [fFinish, setFFinish] = useState(() => new Set()); // multi
  const [fRoom,   setFRoom]   = useState(() => new Set()); // multi
  const [sort,    setSort]    = useState('newest');        // newest | oldest | name | price-asc | price-desc
  const [page,    setPage]    = useState(1);
  const [showFilters, setShowFilters] = useState(false);

  const toggleSet = (setter) => (id) => {
    setter(prev => {
      const next = new Set(prev);
      next.has(id) ? next.delete(id) : next.add(id);
      return next;
    });
    setPage(1);
  };

  function clearAllFilters() {
    setSearch(''); setFilter('All');
    setFColour(new Set()); setFStyle(new Set());
    setFShape(new Set());  setFFinish(new Set());
    setFRoom(new Set());   setPage(1);
  }

  async function fetchTiles() {
    setLoading(true);
    try {
      const r = await fetch('/api/tiles.json');
      const d = await r.json();
      setTiles(d.tiles || []);
    } catch {}
    setLoading(false);
  }

  useEffect(() => { if (token) fetchTiles(); }, [token]);

  // Whenever filter inputs change, snap back to page 1 so the user
  // doesn't end up on an empty trailing page.
  useEffect(() => { setPage(1); }, [search, filter, sort]);

  async function handleDelete(id) {
    const r = await fetch(`/api/admin/tiles/${id}`, {
      method: 'DELETE',
      headers: { Authorization: `Bearer ${token}` },
    });
    if (r.status === 401) { handleUnauthorized(); return; }
    if (r.ok) setTiles(prev => prev.filter(t => t.id !== id));
  }

  function handleUnauthorized() {
    setToken('');
    localStorage.removeItem(ADMIN_TOKEN_KEY);
  }

  async function logout() {
    try {
      await fetch('/api/admin/logout', { method: 'POST', headers: { Authorization: `Bearer ${token}` } });
    } catch {}
    handleUnauthorized();
  }

  // ─── Tile-row action handlers ─────────────────────────────────
  function toggleSelect(id) {
    setSelectedIds(prev => {
      const next = new Set(prev);
      next.has(id) ? next.delete(id) : next.add(id);
      return next;
    });
  }
  function clearSelection() { setSelectedIds(new Set()); }

  // Quick publish/unpublish from the row — PATCH just the status field.
  async function handleToggleStatus(tile) {
    const newStatus = (tile.status || 'published') === 'published' ? 'draft' : 'published';
    const fd = new FormData();
    fd.append('status', newStatus);
    try {
      const r = await fetch(`/api/admin/tiles/${tile.id}`, {
        method: 'PATCH',
        headers: { Authorization: `Bearer ${token}` },
        body: fd,
      });
      if (r.status === 401) { handleUnauthorized(); return; }
      if (r.ok) {
        const data = await r.json().catch(() => ({}));
        setTiles(prev => prev.map(t => t.id === tile.id ? (data.tile || { ...t, status: newStatus }) : t));
      }
    } catch {}
  }

  // Duplicate — server-side full clone (metadata + all images copied
  // to fresh disk files). The new tile lands at the top of the list
  // as a draft, and we automatically open it in the edit form so the
  // admin can tweak name/images before publishing.
  //
  // Previous behaviour just pre-filled the upload form with metadata
  // and forced re-upload of every image — useful for "new tile, same
  // family" but tedious when you genuinely want a copy.
  async function handleDuplicate(tile) {
    try {
      const r = await fetch(`/api/admin/tiles/${tile.id}/duplicate`, {
        method: 'POST',
        headers: { Authorization: `Bearer ${token}` },
      });
      if (r.status === 401) { handleUnauthorized(); return; }
      const d = await r.json();
      if (!r.ok || !d.tile) {
        alert(d.error || 'Could not duplicate tile.');
        return;
      }
      // Insert the new tile at the top of the table and open its
      // edit form so the studio can immediately rename / swap an
      // image / publish.
      setTiles(prev => [d.tile, ...prev]);
      setEditingId(d.tile.id);
      // Soft scroll to the new row so it's visible.
      setTimeout(() => {
        const el = document.querySelector(`[data-tile-id="${d.tile.id}"]`);
        if (el && el.scrollIntoView) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
      }, 80);
    } catch (err) {
      alert('Network error while duplicating. Try again.');
    }
  }

  // ─── Bulk action handlers ─────────────────────────────────────
  async function bulkDelete() {
    if (selectedIds.size === 0) return;
    if (!confirm(`Delete ${selectedIds.size} tile${selectedIds.size === 1 ? '' : 's'}? This cannot be undone.`)) return;
    const ids = Array.from(selectedIds);
    for (const id of ids) {
      try {
        await fetch(`/api/admin/tiles/${id}`, { method: 'DELETE', headers: { Authorization: `Bearer ${token}` } });
      } catch {}
    }
    setTiles(prev => prev.filter(t => !selectedIds.has(t.id)));
    clearSelection();
  }
  // Rename every tile in a collection from `from` → `to`.
  async function renameCollection(from, to) {
    if (!from || !to || from === to) return;
    try {
      const r = await fetch('/api/admin/collections/rename', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
        body: JSON.stringify({ from, to }),
      });
      if (r.status === 401) { handleUnauthorized(); return; }
      if (!r.ok) return;
      setTiles(prev => prev.map(t => t.collection === from ? { ...t, collection: to } : t));
    } catch {}
  }

  async function bulkSetStatus(newStatus) {
    if (selectedIds.size === 0) return;
    const ids = Array.from(selectedIds);
    for (const id of ids) {
      const fd = new FormData();
      fd.append('status', newStatus);
      try {
        await fetch(`/api/admin/tiles/${id}`, {
          method: 'PATCH',
          headers: { Authorization: `Bearer ${token}` },
          body: fd,
        });
      } catch {}
    }
    setTiles(prev => prev.map(t => selectedIds.has(t.id) ? { ...t, status: newStatus } : t));
    clearSelection();
  }

  // (Auth check + magic-link redemption happen in the outer Admin
  // wrapper. By the time AdminPanel renders, `token` is guaranteed
  // truthy. setToken is wired in so a 401 response from any admin
  // API call can sign the user back out cleanly via handleUnauthorized.)

  // ─── Filtering pipeline ─────────────────────────────────────────
  // Read a multi-value field with legacy single-value fallback.
  const tileArr = (tile, field) => {
    const plural = field === 'room' ? tile.rooms : tile[field + 's'];
    if (Array.isArray(plural) && plural.length) return plural;
    const legacy = field === 'room' ? tile.room : tile[field];
    return Array.isArray(legacy) ? legacy : (legacy ? [legacy] : []);
  };
  const matchesMulti = (tile, field, set) => {
    if (set.size === 0) return true;
    return tileArr(tile, field).some(v => set.has(v));
  };

  const searchTerm = search.trim().toLowerCase();

  // Top-level quick-filter is now COLLECTION (was Categories, which is
  // gone from the schema). Each tile belongs to exactly one collection.
  const filtered = tiles.filter(t => {
    if (filter !== 'All' && (t.collection || '') !== filter) return false;
    if (searchTerm) {
      const hay = [t.name, t.id, t.collection, t.variantLabel, t.description,
                   ...tileArr(t, 'style'), ...tileArr(t, 'shape')]
        .filter(Boolean).join(' ').toLowerCase();
      if (!hay.includes(searchTerm)) return false;
    }
    if (!matchesMulti(t, 'colour', fColour)) return false;
    if (!matchesMulti(t, 'style',  fStyle))  return false;
    if (!matchesMulti(t, 'shape',  fShape))  return false;
    if (!matchesMulti(t, 'finish', fFinish)) return false;
    if (!matchesMulti(t, 'room',   fRoom))   return false;
    return true;
  });

  // ─── Sort ───────────────────────────────────────────────────────
  const priceNum = (s) => {
    const m = String(s || '').match(/[\d.]+/);
    return m ? parseFloat(m[0]) : Number.POSITIVE_INFINITY;
  };
  const sorted = [...filtered].sort((a, b) => {
    switch (sort) {
      case 'name':       return (a.name || '').localeCompare(b.name || '');
      case 'price-asc':  return priceNum(a.price) - priceNum(b.price);
      case 'price-desc': return priceNum(b.price) - priceNum(a.price);
      case 'oldest':     return (a.createdAt || 0) - (b.createdAt || 0);
      case 'newest':
      default:           return (b.createdAt || 0) - (a.createdAt || 0);
    }
  });

  // ─── Pagination ─────────────────────────────────────────────────
  const totalPages = Math.max(1, Math.ceil(sorted.length / ADMIN_PAGE_SIZE));
  const safePage   = Math.min(page, totalPages);
  const pageStart  = (safePage - 1) * ADMIN_PAGE_SIZE;
  const pageEnd    = Math.min(pageStart + ADMIN_PAGE_SIZE, sorted.length);
  const visible    = sorted.slice(pageStart, pageEnd);

  // Collection counts — every distinct collection in the catalogue,
  // alphabetised. Drives the top-of-list quick-filter row + the
  // collection autocomplete in the upload/edit forms.
  const allCollections = useMemo(() => {
    const set = new Set();
    for (const t of tiles) if (t.collection) set.add(t.collection);
    return Array.from(set).sort((a, b) => a.localeCompare(b));
  }, [tiles]);
  const counts = ['All', ...allCollections].map(c => ({
    cat: c,
    n: c === 'All' ? tiles.length : tiles.filter(t => (t.collection || '') === c).length,
  }));

  const activeFilterCount =
    (filter !== 'All' ? 1 : 0) +
    fColour.size + fStyle.size + fShape.size + fFinish.size + fRoom.size +
    (searchTerm ? 1 : 0);

  return (
    <div style={{
      minHeight: 'calc(100vh - var(--nav-h))', marginTop: 'var(--nav-h)',
      background: 'var(--cream)', padding: '48px 40px 80px',
    }}>
      <div style={{ maxWidth: '1240px', margin: '0 auto' }}>
        {/* Header */}
        <div style={{ display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between', marginBottom: '48px', flexWrap: 'wrap', gap: '20px' }}>
          <div>
            <p className="t-label" style={{ color: 'var(--terracotta)', marginBottom: '10px' }}>Catalogue Admin</p>
            <h1 className="t-display" style={{ fontSize: 'clamp(36px, 5vw, 56px)' }}>Tile Catalogue</h1>
            <p className="t-body" style={{ fontSize: '14px', marginTop: '8px' }}>
              Tiles added here appear on the storefront immediately — no rebuild needed.
            </p>
          </div>
          <div style={{ display: 'flex', gap: '12px' }}>
            <button onClick={() => navigate('collections')} className="btn btn-outline">View storefront</button>
            <button onClick={logout} className="btn btn-ghost">Sign out</button>
          </div>
        </div>

        {/* ── Admin view tab bar ─────────────────────────────────
            Top-level switcher between the Tiles catalogue (upload +
            edit + delete) and the Collections admin (variant order +
            visibility per collection). */}
        <div style={{
          display: 'inline-flex',
          background: 'white', border: '1px solid var(--cream-deep)',
          marginBottom: '32px',
        }}>
          {[
            { id: 'tiles',       label: 'Tiles' },
            { id: 'collections', label: 'Collections' },
            { id: 'finishes',    label: 'Finishes' },
            { id: 'sizes',       label: 'Sizes' },
            { id: 'filters',     label: 'Filters' },
            { id: 'journal',     label: 'Journal' },
          ].map((t, i, arr) => (
            <button
              key={t.id}
              type="button"
              onClick={() => setAdminView(t.id)}
              style={{
                padding: '11px 22px',
                background: adminView === t.id ? 'var(--dark)' : 'white',
                color:      adminView === t.id ? 'white' : 'var(--dark-mid)',
                border: 'none',
                borderRight: i < arr.length - 1 ? '1px solid var(--cream-deep)' : 'none',
                fontFamily: 'var(--sans)', fontSize: '11px',
                letterSpacing: '0.18em', textTransform: 'uppercase',
                cursor: 'pointer', transition: 'background 0.2s, color 0.2s',
              }}
            >{t.label}</button>
          ))}
        </div>

        {adminView === 'collections' && (
          <CollectionsAdmin
            token={token}
            tiles={tiles}
            meta={collectionsMeta}
            onMetaSaved={refreshCollectionsMeta}
            onUnauthorized={handleUnauthorized}
          />
        )}

        {adminView === 'finishes' && (
          <FinishesAdmin
            token={token}
            onUnauthorized={handleUnauthorized}
          />
        )}

        {adminView === 'sizes' && (
          <SizesAdmin
            token={token}
            onUnauthorized={handleUnauthorized}
          />
        )}

        {adminView === 'filters' && (
          <FiltersAdmin
            token={token}
            onUnauthorized={handleUnauthorized}
          />
        )}

        {adminView === 'journal' && (
          window.JournalAdmin
            ? <window.JournalAdmin token={token} onUnauthorized={handleUnauthorized}/>
            : <p style={{ fontFamily: 'var(--sans)', fontSize: '13px', color: 'var(--dark-mid)' }}>Journal admin failed to load.</p>
        )}

        {adminView === 'tiles' && <>

        {/* Upload form */}
        <div ref={uploadFormRef} style={{ marginBottom: '56px' }}>
          <UploadForm
            token={token}
            existingCollections={allCollections}
            template={duplicateTemplate}
            onTemplateConsumed={() => setDuplicateTemplate(null)}
            onUploaded={t => setTiles(prev => [t, ...prev])}
            onUnauthorized={handleUnauthorized}
          />
        </div>

        {/* ── Collection manager (collapsible) ─────────────────── */}
        <div style={{ marginBottom: '32px', background: 'white', border: '1px solid var(--cream-deep)' }}>
          <button
            type="button"
            onClick={() => setCollectionsOpen(o => !o)}
            style={{
              width: '100%', background: 'none', border: 'none',
              padding: '16px 22px', cursor: 'pointer',
              display: 'flex', alignItems: 'center', justifyContent: 'space-between',
              textAlign: 'left',
            }}
          >
            <span>
              <span className="t-label" style={{ color: 'var(--terracotta)' }}>Collections</span>
              <span style={{ fontFamily: 'var(--serif)', fontSize: '20px', color: 'var(--dark)', marginLeft: '10px' }}>
                Manage ({allCollections.length})
              </span>
            </span>
            <span style={{ fontFamily: 'var(--sans)', fontSize: '11px', letterSpacing: '0.16em', textTransform: 'uppercase', color: 'var(--dark-mid)' }}>
              {collectionsOpen ? 'Hide' : 'Show'}
            </span>
          </button>
          {collectionsOpen && (
            <div style={{ padding: '4px 22px 20px', borderTop: '1px solid var(--cream-deep)' }}>
              <p style={{ fontFamily: 'var(--sans)', fontSize: '11px', color: 'var(--dark-mid)', margin: '14px 0 16px' }}>
                Renaming a collection updates the name on every tile inside it. Filter by a collection or click "Edit" to rename inline.
              </p>
              <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: '10px' }}>
                {allCollections.map(c => {
                  const tilesInColl = tiles.filter(t => t.collection === c);
                  const isRenaming = renamingCollection === c;
                  return (
                    <div key={c} style={{
                      display: 'flex', alignItems: 'center', justifyContent: 'space-between',
                      gap: '10px', padding: '10px 14px',
                      background: 'var(--cream)', border: '1px solid var(--cream-deep)',
                    }}>
                      {isRenaming ? (
                        <>
                          <input
                            type="text"
                            value={renameDraft}
                            onChange={e => setRenameDraft(e.target.value)}
                            onKeyDown={e => {
                              if (e.key === 'Enter') { e.preventDefault(); renameCollection(c, renameDraft.trim()); setRenamingCollection(null); }
                              if (e.key === 'Escape') { setRenamingCollection(null); }
                            }}
                            autoFocus
                            style={{ flex: 1, minWidth: 0, fontFamily: 'var(--sans)', fontSize: '12px' }}
                          />
                          <button type="button" onClick={() => { renameCollection(c, renameDraft.trim()); setRenamingCollection(null); }} style={{ padding: '6px 10px', background: 'var(--dark)', color: 'white', border: 'none', fontFamily: 'var(--sans)', fontSize: '10px', letterSpacing: '0.14em', textTransform: 'uppercase', cursor: 'pointer' }}>Save</button>
                          <button type="button" onClick={() => setRenamingCollection(null)} style={{ padding: '6px 10px', background: 'none', color: 'var(--dark-mid)', border: '1px solid var(--cream-deep)', fontFamily: 'var(--sans)', fontSize: '10px', letterSpacing: '0.14em', textTransform: 'uppercase', cursor: 'pointer' }}>Cancel</button>
                        </>
                      ) : (
                        <>
                          <div style={{ flex: 1, minWidth: 0 }}>
                            <p style={{ fontFamily: 'var(--serif)', fontSize: '15px', color: 'var(--dark)', margin: 0 }}>{c}</p>
                            <p style={{ fontFamily: 'var(--sans)', fontSize: '10px', letterSpacing: '0.14em', textTransform: 'uppercase', color: 'var(--dark-mid)', margin: '2px 0 0' }}>
                              {tilesInColl.length} tile{tilesInColl.length === 1 ? '' : 's'}
                            </p>
                          </div>
                          <button type="button" onClick={() => setFilter(c)} style={{ padding: '6px 10px', background: 'none', border: '1px solid var(--cream-deep)', color: 'var(--dark-mid)', fontFamily: 'var(--sans)', fontSize: '10px', letterSpacing: '0.14em', textTransform: 'uppercase', cursor: 'pointer' }}>Filter</button>
                          <button type="button" onClick={() => { setRenamingCollection(c); setRenameDraft(c); }} style={{ padding: '6px 10px', background: 'none', border: '1px solid var(--cream-deep)', color: 'var(--dark-mid)', fontFamily: 'var(--sans)', fontSize: '10px', letterSpacing: '0.14em', textTransform: 'uppercase', cursor: 'pointer' }}>Rename</button>
                        </>
                      )}
                    </div>
                  );
                })}
                {allCollections.length === 0 && (
                  <p style={{ fontFamily: 'var(--sans)', fontSize: '12px', color: 'var(--dark-mid)' }}>
                    No collections yet. Upload your first tile to create one.
                  </p>
                )}
              </div>
            </div>
          )}
        </div>

        {/* ── Catalogue header + count ─────────────────────────── */}
        <div style={{
          display: 'flex', justifyContent: 'space-between', alignItems: 'baseline',
          marginBottom: '14px', flexWrap: 'wrap', gap: '12px',
        }}>
          <h2 className="t-headline" style={{ fontSize: '26px' }}>
            Current catalogue{' '}
            <span style={{ color: 'var(--dark-mid)', fontSize: '18px' }}>
              ({sorted.length}{sorted.length !== tiles.length && ` of ${tiles.length}`})
            </span>
          </h2>
          <p style={{
            fontFamily: 'var(--sans)', fontSize: '11px',
            letterSpacing: '0.14em', textTransform: 'uppercase',
            color: 'var(--dark-mid)',
          }}>
            {sorted.length === 0 ? 'No tiles' : `Showing ${pageStart + 1}–${pageEnd} of ${sorted.length}`}
          </p>
        </div>

        {/* ── Search + sort + filter toggle ────────────────────── */}
        <div style={{
          display: 'grid', gridTemplateColumns: '1fr auto auto', gap: '10px',
          alignItems: 'stretch', marginBottom: '12px',
        }}>
          <div style={{ position: 'relative' }}>
            <input
              type="search"
              value={search}
              onChange={e => setSearch(e.target.value)}
              placeholder="Search by name, id, style, description…"
              style={{
                width: '100%', padding: '11px 14px 11px 36px',
                border: '1px solid var(--cream-deep)', background: 'white',
                fontFamily: 'var(--sans)', fontSize: '13px', outline: 'none',
              }}
            />
            <span style={{
              position: 'absolute', left: '12px', top: '50%', transform: 'translateY(-50%)',
              color: 'var(--dark-mid)', fontSize: '14px', pointerEvents: 'none',
            }}>⌕</span>
          </div>
          <select
            value={sort}
            onChange={e => setSort(e.target.value)}
            style={{
              padding: '11px 14px', border: '1px solid var(--cream-deep)', background: 'white',
              fontFamily: 'var(--sans)', fontSize: '12px', outline: 'none', cursor: 'pointer',
              minWidth: '170px',
            }}
          >
            <option value="newest">Sort · Newest first</option>
            <option value="oldest">Sort · Oldest first</option>
            <option value="name">Sort · Name (A–Z)</option>
            <option value="price-asc">Sort · Price (low→high)</option>
            <option value="price-desc">Sort · Price (high→low)</option>
          </select>
          <button
            type="button"
            onClick={() => setShowFilters(s => !s)}
            style={{
              padding: '11px 18px', background: showFilters ? 'var(--dark)' : 'white',
              color: showFilters ? 'white' : 'var(--dark)',
              border: `1px solid ${showFilters ? 'var(--dark)' : 'var(--cream-deep)'}`,
              fontFamily: 'var(--sans)', fontSize: '11px',
              letterSpacing: '0.14em', textTransform: 'uppercase', cursor: 'pointer',
            }}
          >
            Filters{activeFilterCount > 0 ? ` · ${activeFilterCount}` : ''}
          </button>
        </div>

        {/* ── Category quick tabs (always visible) ─────────────── */}
        <div style={{ display: 'flex', gap: 0, border: '1px solid var(--cream-deep)', marginBottom: '12px', flexWrap: 'wrap' }}>
          {counts.map(({ cat, n }) => (
            <button key={cat} onClick={() => setFilter(cat)} style={{
              padding: '9px 16px', background: filter === cat ? 'var(--dark)' : 'white',
              color: filter === cat ? 'var(--cream)' : 'var(--dark-mid)',
              border: 'none', borderLeft: cat === 'All' ? 'none' : '1px solid var(--cream-deep)',
              fontFamily: 'var(--sans)', fontSize: '11px', letterSpacing: '0.12em', textTransform: 'uppercase',
              cursor: 'pointer', transition: 'all 0.2s',
            }}>{cat} <span style={{ opacity: 0.6, marginLeft: '4px' }}>{n}</span></button>
          ))}
        </div>

        {/* ── Multi-axis filter panel ──────────────────────────── */}
        {showFilters && (
          <div style={{
            background: 'white', border: '1px solid var(--cream-deep)',
            padding: '20px', marginBottom: '14px',
            display: 'grid', gap: '16px',
          }}>
            <div>
              <p className="t-label" style={{ color: 'var(--dark-mid)', marginBottom: '8px' }}>Colour</p>
              <AdminFilterPills options={COLOURS.map(c => ({ id: c.id, label: c.label }))} active={fColour} onToggle={toggleSet(setFColour)}/>
            </div>
            <div>
              <p className="t-label" style={{ color: 'var(--dark-mid)', marginBottom: '8px' }}>Style</p>
              <AdminFilterPills options={STYLES.map(s => ({ id: s.id, label: s.label }))} active={fStyle} onToggle={toggleSet(setFStyle)}/>
            </div>
            <div>
              <p className="t-label" style={{ color: 'var(--dark-mid)', marginBottom: '8px' }}>Shape</p>
              <AdminFilterPills options={SHAPES.map(s => ({ id: s.id, label: s.label }))} active={fShape} onToggle={toggleSet(setFShape)}/>
            </div>
            <div>
              <p className="t-label" style={{ color: 'var(--dark-mid)', marginBottom: '8px' }}>Finish</p>
              <AdminFilterPills options={FINISHES} active={fFinish} onToggle={toggleSet(setFFinish)}/>
            </div>
            <div>
              <p className="t-label" style={{ color: 'var(--dark-mid)', marginBottom: '8px' }}>Room</p>
              <AdminFilterPills options={ROOMS.map(r => ({ id: r.id, label: r.label }))} active={fRoom} onToggle={toggleSet(setFRoom)}/>
            </div>
            {activeFilterCount > 0 && (
              <div>
                <button
                  type="button"
                  onClick={clearAllFilters}
                  style={{
                    padding: '8px 14px', background: 'none',
                    border: '1px solid var(--terracotta)', color: 'var(--terracotta)',
                    fontFamily: 'var(--sans)', fontSize: '10px',
                    letterSpacing: '0.16em', textTransform: 'uppercase', cursor: 'pointer',
                  }}
                >Clear all filters</button>
              </div>
            )}
          </div>
        )}

        <div style={{ border: '1px solid var(--cream-deep)', background: 'white' }}>
          {/* Bulk action bar — only shows when at least one row ticked. */}
          {selectedIds.size > 0 && (
            <div style={{
              display: 'flex', alignItems: 'center', gap: '14px',
              padding: '12px 20px', background: 'var(--dark)', color: 'var(--cream)',
              borderBottom: '1px solid var(--cream-deep)',
            }}>
              <p style={{ fontFamily: 'var(--sans)', fontSize: '11px', letterSpacing: '0.14em', textTransform: 'uppercase', margin: 0, flex: 1 }}>
                {selectedIds.size} selected
              </p>
              <button type="button" onClick={() => bulkSetStatus('published')} style={bulkBtn}>Publish</button>
              <button type="button" onClick={() => bulkSetStatus('draft')}     style={bulkBtn}>Unpublish</button>
              <button type="button" onClick={bulkDelete} style={{ ...bulkBtn, borderColor: 'var(--terracotta)', color: 'var(--terracotta)' }}>Delete</button>
              <button type="button" onClick={clearSelection} style={{ ...bulkBtn, borderColor: 'transparent' }}>Clear</button>
            </div>
          )}

          {/* Header row */}
          <div style={{
            display: 'grid',
            gridTemplateColumns: '32px 64px 1.3fr 0.9fr 1fr 0.7fr 0.7fr 22px auto auto auto',
            gap: '10px', alignItems: 'center',
            padding: '12px 20px', background: 'var(--cream)',
            borderBottom: '1px solid var(--cream-deep)',
          }}>
            <input
              type="checkbox"
              checked={visible.length > 0 && visible.every(t => selectedIds.has(t.id))}
              onChange={(e) => {
                if (e.target.checked) setSelectedIds(prev => { const n = new Set(prev); visible.forEach(t => n.add(t.id)); return n; });
                else                  setSelectedIds(prev => { const n = new Set(prev); visible.forEach(t => n.delete(t.id)); return n; });
              }}
              style={{ width: 'auto', margin: 0, accentColor: 'var(--terracotta)' }}
              aria-label="Select all on this page"
            />
            <span/>
            <span className="t-label" style={{ color: 'var(--dark-mid)' }}>Name</span>
            <span className="t-label" style={{ color: 'var(--dark-mid)' }}>Collection</span>
            <span className="t-label" style={{ color: 'var(--dark-mid)' }}>Finish / Size</span>
            <span className="t-label" style={{ color: 'var(--dark-mid)' }}>Colour</span>
            <span className="t-label" style={{ color: 'var(--dark-mid)' }}>Price</span>
            <span/>
            <span/>
            <span/>
            <span/>
          </div>

          {loading && visible.length === 0 && (
            <div style={{ padding: '40px', textAlign: 'center' }}>
              <p className="t-body" style={{ fontSize: '13px' }}>Loading catalogue…</p>
            </div>
          )}
          {!loading && sorted.length === 0 && (
            <div style={{ padding: '60px 20px', textAlign: 'center' }}>
              <p className="t-body" style={{ fontSize: '14px' }}>
                No tiles match the current filters.{' '}
                {activeFilterCount > 0 && (
                  <button onClick={clearAllFilters} style={{
                    background: 'none', border: 'none', color: 'var(--terracotta)',
                    cursor: 'pointer', textDecoration: 'underline',
                    fontFamily: 'var(--sans)', fontSize: '14px', padding: 0,
                  }}>Clear filters</button>
                )}
              </p>
            </div>
          )}
          {visible.map(t => (
            <React.Fragment key={t.id}>
              <TileRow
                tile={t}
                selected={selectedIds.has(t.id)}
                onToggleSelect={toggleSelect}
                onDelete={handleDelete}
                onEdit={() => setEditingId(t.id === editingId ? null : t.id)}
                onDuplicate={handleDuplicate}
                onToggleStatus={handleToggleStatus}
              />
              {editingId === t.id && (
                <EditTileForm
                  token={token}
                  tile={t}
                  existingCollections={allCollections}
                  onUpdated={(updated) => {
                    setTiles(prev => prev.map(x => x.id === updated.id ? updated : x));
                    setEditingId(null);
                  }}
                  onUnauthorized={handleUnauthorized}
                  onCancel={() => setEditingId(null)}
                />
              )}
            </React.Fragment>
          ))}
        </div>

        {/* ── Pagination footer ────────────────────────────────── */}
        {totalPages > 1 && (
          <div style={{
            display: 'flex', justifyContent: 'space-between', alignItems: 'center',
            marginTop: '20px', flexWrap: 'wrap', gap: '12px',
          }}>
            <p style={{ fontFamily: 'var(--sans)', fontSize: '11px', color: 'var(--dark-mid)', letterSpacing: '0.14em', textTransform: 'uppercase' }}>
              Page {safePage} of {totalPages}
            </p>
            <div style={{ display: 'flex', gap: '6px', alignItems: 'center' }}>
              <button
                type="button"
                disabled={safePage <= 1}
                onClick={() => setPage(p => Math.max(1, p - 1))}
                style={{
                  padding: '8px 16px', background: 'white',
                  border: '1px solid var(--cream-deep)', color: 'var(--dark)',
                  fontFamily: 'var(--sans)', fontSize: '11px',
                  letterSpacing: '0.12em', textTransform: 'uppercase',
                  cursor: safePage <= 1 ? 'not-allowed' : 'pointer',
                  opacity: safePage <= 1 ? 0.4 : 1,
                }}
              >← Prev</button>
              <span style={{ padding: '0 8px', fontFamily: 'var(--sans)', fontSize: '12px', color: 'var(--dark)' }}>
                {safePage} / {totalPages}
              </span>
              <button
                type="button"
                disabled={safePage >= totalPages}
                onClick={() => setPage(p => Math.min(totalPages, p + 1))}
                style={{
                  padding: '8px 16px', background: 'white',
                  border: '1px solid var(--cream-deep)', color: 'var(--dark)',
                  fontFamily: 'var(--sans)', fontSize: '11px',
                  letterSpacing: '0.12em', textTransform: 'uppercase',
                  cursor: safePage >= totalPages ? 'not-allowed' : 'pointer',
                  opacity: safePage >= totalPages ? 0.4 : 1,
                }}
              >Next →</button>
            </div>
          </div>
        )}

        </>}
      </div>
    </div>
  );
}

// ─── FinishesAdmin ─────────────────────────────────────────────────────
// Manage the master Finishes list (data/finishes.json on the server).
// Lets the studio add new finish names (e.g. "Sugar Finish",
// "Silk Matt Smooth") that immediately appear as pill options on the
// tile upload + edit forms, and as filter pills in the catalogue
// strip. Removal is destructive only on the master list — existing
// tiles keep their previously-assigned finish strings until re-saved.
function FinishesAdmin({ token, onUnauthorized }) {
  const finishes = useFinishes();
  const [draft, setDraft] = useState('');
  const [saving, setSaving] = useState(false);
  const [error, setError] = useState(null);
  const [removingIdx, setRemovingIdx] = useState(null);

  async function api(path, opts = {}) {
    const r = await fetch(path, {
      ...opts,
      headers: {
        ...(opts.headers || {}),
        Authorization: `Bearer ${token}`,
        ...(opts.body ? { 'Content-Type': 'application/json' } : {}),
      },
    });
    if (r.status === 401) { onUnauthorized?.(); throw new Error('Session expired — please log in again.'); }
    const d = await r.json().catch(() => ({}));
    if (!r.ok) throw new Error(d.error || `HTTP ${r.status}`);
    return d;
  }

  async function addFinish(e) {
    e?.preventDefault();
    const name = draft.trim();
    if (!name) return;
    setSaving(true);
    setError(null);
    try {
      const d = await api('/api/admin/finishes', { method: 'POST', body: JSON.stringify({ name }) });
      if (Array.isArray(d.finishes)) setFinishesGlobal(d.finishes);
      setDraft('');
    } catch (err) {
      setError(err.message || 'Could not add finish.');
    } finally {
      setSaving(false);
    }
  }

  async function removeFinish(name, idx) {
    if (!confirm(`Remove "${name}" from the finishes list?\n\nTiles already tagged with this finish will keep it until you re-edit them.`)) return;
    setRemovingIdx(idx);
    setError(null);
    try {
      const d = await api(`/api/admin/finishes/${encodeURIComponent(name)}`, { method: 'DELETE' });
      if (Array.isArray(d.finishes)) setFinishesGlobal(d.finishes);
    } catch (err) {
      setError(err.message || 'Could not remove finish.');
    } finally {
      setRemovingIdx(null);
    }
  }

  async function move(idx, dir) {
    const next = [...finishes];
    const target = idx + dir;
    if (target < 0 || target >= next.length) return;
    [next[idx], next[target]] = [next[target], next[idx]];
    try {
      const d = await api('/api/admin/finishes', { method: 'PUT', body: JSON.stringify({ finishes: next }) });
      if (Array.isArray(d.finishes)) setFinishesGlobal(d.finishes);
    } catch (err) {
      setError(err.message || 'Could not reorder.');
    }
  }

  return (
    <div style={{ maxWidth: '680px' }}>
      <p className="t-label" style={{ color: 'var(--terracotta)', marginBottom: '8px' }}>Catalogue settings</p>
      <h2 style={{ fontFamily: 'var(--serif)', fontWeight: 300, fontSize: '32px', marginBottom: '8px' }}>Finishes</h2>
      <p style={{ fontFamily: 'var(--sans)', fontSize: '13px', color: 'var(--dark-mid)', lineHeight: 1.6, marginBottom: '24px' }}>
        The options the upload + edit forms show as finish pills, and the filter strip above the catalogue uses for the Finish facet. Add new options here whenever a supplier ships a finish you haven't catalogued before — every tile form picks it up automatically.
      </p>

      <form onSubmit={addFinish} style={{ display: 'flex', gap: '10px', marginBottom: '14px' }}>
        <input
          type="text"
          value={draft}
          onChange={e => setDraft(e.target.value)}
          placeholder="New finish name (e.g. Sugar Finish)"
          maxLength={60}
          style={{ flex: 1 }}
        />
        <button
          type="submit"
          className="btn btn-dark"
          disabled={saving || !draft.trim()}
          style={{ padding: '12px 22px', opacity: saving || !draft.trim() ? 0.5 : 1 }}
        >{saving ? 'Adding…' : '+ Add finish'}</button>
      </form>

      {error && (
        <p style={{ fontFamily: 'var(--sans)', fontSize: '12px', color: 'var(--terracotta)', marginBottom: '14px' }}>{error}</p>
      )}

      <div style={{ border: '1px solid var(--cream-deep)', background: 'white' }}>
        {finishes.length === 0 && (
          <p style={{ padding: '24px', fontFamily: 'var(--sans)', fontSize: '13px', color: 'var(--dark-mid)', textAlign: 'center' }}>
            No finishes yet. Add the first one above.
          </p>
        )}
        {finishes.map((f, idx) => (
          <div
            key={f}
            style={{
              display: 'flex', alignItems: 'center', justifyContent: 'space-between',
              padding: '12px 16px',
              borderBottom: idx < finishes.length - 1 ? '1px solid var(--cream-deep)' : 'none',
            }}
          >
            <span style={{ fontFamily: 'var(--sans)', fontSize: '14px', color: 'var(--dark)' }}>{f}</span>
            <div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
              <button
                type="button"
                onClick={() => move(idx, -1)}
                disabled={idx === 0}
                aria-label="Move up"
                style={miniBtn(idx === 0)}
              >↑</button>
              <button
                type="button"
                onClick={() => move(idx, +1)}
                disabled={idx === finishes.length - 1}
                aria-label="Move down"
                style={miniBtn(idx === finishes.length - 1)}
              >↓</button>
              <button
                type="button"
                onClick={() => removeFinish(f, idx)}
                disabled={removingIdx === idx}
                style={{
                  marginLeft: '8px', padding: '6px 12px',
                  background: 'none', border: '1px solid var(--cream-deep)',
                  color: 'var(--dark-mid)',
                  fontFamily: 'var(--sans)', fontSize: '11px',
                  letterSpacing: '0.08em', textTransform: 'uppercase',
                  cursor: removingIdx === idx ? 'wait' : 'pointer',
                }}
              >{removingIdx === idx ? '…' : 'Remove'}</button>
            </div>
          </div>
        ))}
      </div>
    </div>
  );
}

const miniBtn = (disabled) => ({
  width: '28px', height: '28px',
  background: 'none', border: '1px solid var(--cream-deep)',
  color: disabled ? 'var(--cream-deep)' : 'var(--dark-mid)',
  fontFamily: 'var(--sans)', fontSize: '14px',
  cursor: disabled ? 'not-allowed' : 'pointer',
  display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
});

// ─── SizesAdmin ─────────────────────────────────────────────────────
// Direct mirror of FinishesAdmin — same UX, different list. Manages
// the master Sizes list (data/sizes.json on the server). What the
// studio adds here appears immediately as pills on the tile upload +
// edit forms. Removing a size from the master list does NOT touch
// any tile already tagged with it — the value lives on the tile
// record until the tile is re-saved.
function SizesAdmin({ token, onUnauthorized }) {
  const sizes = useSizes();
  const [draft, setDraft] = useState('');
  const [saving, setSaving] = useState(false);
  const [error, setError] = useState(null);
  const [removingIdx, setRemovingIdx] = useState(null);

  async function api(path, opts = {}) {
    const r = await fetch(path, {
      ...opts,
      headers: {
        ...(opts.headers || {}),
        Authorization: `Bearer ${token}`,
        ...(opts.body ? { 'Content-Type': 'application/json' } : {}),
      },
    });
    if (r.status === 401) { onUnauthorized?.(); throw new Error('Session expired — please log in again.'); }
    const d = await r.json().catch(() => ({}));
    if (!r.ok) throw new Error(d.error || `HTTP ${r.status}`);
    return d;
  }

  async function addSize(e) {
    e?.preventDefault();
    const name = draft.trim();
    if (!name) return;
    setSaving(true);
    setError(null);
    try {
      const d = await api('/api/admin/sizes', { method: 'POST', body: JSON.stringify({ name }) });
      if (Array.isArray(d.sizes)) setSizesGlobal(d.sizes);
      setDraft('');
    } catch (err) {
      setError(err.message || 'Could not add size.');
    } finally {
      setSaving(false);
    }
  }

  async function removeSize(name, idx) {
    if (!confirm(`Remove "${name}" from the sizes list?\n\nTiles already tagged with this size will keep it until you re-edit them.`)) return;
    setRemovingIdx(idx);
    setError(null);
    try {
      const d = await api(`/api/admin/sizes/${encodeURIComponent(name)}`, { method: 'DELETE' });
      if (Array.isArray(d.sizes)) setSizesGlobal(d.sizes);
    } catch (err) {
      setError(err.message || 'Could not remove size.');
    } finally {
      setRemovingIdx(null);
    }
  }

  async function move(idx, dir) {
    const next = [...sizes];
    const target = idx + dir;
    if (target < 0 || target >= next.length) return;
    [next[idx], next[target]] = [next[target], next[idx]];
    try {
      const d = await api('/api/admin/sizes', { method: 'PUT', body: JSON.stringify({ sizes: next }) });
      if (Array.isArray(d.sizes)) setSizesGlobal(d.sizes);
    } catch (err) {
      setError(err.message || 'Could not reorder.');
    }
  }

  return (
    <div style={{ maxWidth: '680px' }}>
      <p className="t-label" style={{ color: 'var(--terracotta)', marginBottom: '8px' }}>Catalogue settings</p>
      <h2 style={{ fontFamily: 'var(--serif)', fontWeight: 300, fontSize: '32px', marginBottom: '8px' }}>Sizes</h2>
      <p style={{ fontFamily: 'var(--sans)', fontSize: '13px', color: 'var(--dark-mid)', lineHeight: 1.6, marginBottom: '24px' }}>
        The options the upload + edit forms show as size pills, and the filter strip above the catalogue uses for the Size facet. Add new options here whenever a supplier ships a tile in a format you haven't catalogued before (e.g. <span style={{ fontStyle: 'italic' }}>"33.3×100cm"</span>) — every tile form picks it up automatically. Use the standard "WxHcm" or "WxHmm" syntax so the storefront's auto-derived "m² per piece" maths still works.
      </p>

      <form onSubmit={addSize} style={{ display: 'flex', gap: '10px', marginBottom: '14px' }}>
        <input
          type="text"
          value={draft}
          onChange={e => setDraft(e.target.value)}
          placeholder="New size (e.g. 33.3×100cm)"
          maxLength={60}
          style={{ flex: 1 }}
        />
        <button
          type="submit"
          className="btn btn-dark"
          disabled={saving || !draft.trim()}
          style={{ padding: '12px 22px', opacity: saving || !draft.trim() ? 0.5 : 1 }}
        >{saving ? 'Adding…' : '+ Add size'}</button>
      </form>

      {error && (
        <p style={{ fontFamily: 'var(--sans)', fontSize: '12px', color: 'var(--terracotta)', marginBottom: '14px' }}>{error}</p>
      )}

      <div style={{ border: '1px solid var(--cream-deep)', background: 'white' }}>
        {sizes.length === 0 && (
          <p style={{ padding: '24px', fontFamily: 'var(--sans)', fontSize: '13px', color: 'var(--dark-mid)', textAlign: 'center' }}>
            No sizes yet. Add the first one above.
          </p>
        )}
        {sizes.map((s, idx) => (
          <div
            key={s}
            style={{
              display: 'flex', alignItems: 'center', justifyContent: 'space-between',
              padding: '12px 16px',
              borderBottom: idx < sizes.length - 1 ? '1px solid var(--cream-deep)' : 'none',
            }}
          >
            <span style={{ fontFamily: 'var(--sans)', fontSize: '14px', color: 'var(--dark)' }}>{s}</span>
            <div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
              <button
                type="button"
                onClick={() => move(idx, -1)}
                disabled={idx === 0}
                aria-label="Move up"
                style={miniBtn(idx === 0)}
              >↑</button>
              <button
                type="button"
                onClick={() => move(idx, +1)}
                disabled={idx === sizes.length - 1}
                aria-label="Move down"
                style={miniBtn(idx === sizes.length - 1)}
              >↓</button>
              <button
                type="button"
                onClick={() => removeSize(s, idx)}
                disabled={removingIdx === idx}
                style={{
                  marginLeft: '8px', padding: '6px 12px',
                  background: 'none', border: '1px solid var(--cream-deep)',
                  color: 'var(--dark-mid)',
                  fontFamily: 'var(--sans)', fontSize: '11px',
                  letterSpacing: '0.08em', textTransform: 'uppercase',
                  cursor: removingIdx === idx ? 'wait' : 'pointer',
                }}
              >{removingIdx === idx ? '…' : 'Remove'}</button>
            </div>
          </div>
        ))}
      </div>
    </div>
  );
}

// ─── FiltersAdmin ───────────────────────────────────────────────────
// Single panel that hosts FOUR sub-editors (rooms, colours, styles,
// shapes) — the same admin-controlled taxonomies the storefront uses
// for its filter strip and the tile forms use for their pill rows.
//
// Each row in any editor stores { id, label }:
//   · id    → URL/storage-safe slug saved on every tile record
//   · label → human-readable display string
//
// Adding a value: type the label, we auto-derive the slug (id) so the
// admin doesn't have to think about URL formatting.
// Removing a value: existing tiles tagged with that id keep it on
// their record — the value just stops appearing in the pill rows
// + filter strip for new tagging.
function FiltersAdmin({ token, onUnauthorized }) {
  return (
    <div style={{ maxWidth: '720px' }}>
      <p className="t-label" style={{ color: 'var(--terracotta)', marginBottom: '8px' }}>Catalogue settings</p>
      <h2 style={{ fontFamily: 'var(--serif)', fontWeight: 300, fontSize: '32px', marginBottom: '8px' }}>Filters</h2>
      <p style={{ fontFamily: 'var(--sans)', fontSize: '13px', color: 'var(--dark-mid)', lineHeight: 1.6, marginBottom: '28px' }}>
        The four lists the storefront uses for the customer filter strip <em>and</em> the tile forms use for the pill rows. Add a new value here whenever a tile arrives that doesn't fit the existing options. Removing a value never touches tiles already tagged with it — they keep the value on their record until re-edited.
      </p>

      <TaxonomyEditor name="rooms"   title="Rooms"   hint="Where a tile can live — drives the 'Shop by Room' filter."  token={token} useFn={useRooms}   setGlobal={setRoomsGlobal}   onUnauthorized={onUnauthorized}/>
      <TaxonomyEditor name="colours" title="Colours" hint="Tile palettes — drives the 'Shop by Colour' filter."        token={token} useFn={useColours} setGlobal={setColoursGlobal} onUnauthorized={onUnauthorized}/>
      <TaxonomyEditor name="styles"  title="Styles"  hint="The look — drives the 'Shop by Style' filter."               token={token} useFn={useStyles}  setGlobal={setStylesGlobal}  onUnauthorized={onUnauthorized}/>
      <TaxonomyEditor name="shapes"  title="Shapes"  hint="Tile format — drives the 'Shop by Shape & Size' filter."     token={token} useFn={useShapes}  setGlobal={setShapesGlobal}  onUnauthorized={onUnauthorized}/>
    </div>
  );
}

// Generic editor — same UX as FinishesAdmin/SizesAdmin but for
// taxonomies that store { id, label } pairs. The admin types a
// label; the slug is auto-derived. Reorder + remove work the same.
function TaxonomyEditor({ name, title, hint, token, useFn, setGlobal, onUnauthorized }) {
  const values = useFn();
  const [draft, setDraft] = useState('');
  const [saving, setSaving] = useState(false);
  const [error, setError] = useState(null);
  const [removingId, setRemovingId] = useState(null);

  async function api(path, opts = {}) {
    const r = await fetch(path, {
      ...opts,
      headers: {
        ...(opts.headers || {}),
        Authorization: `Bearer ${token}`,
        ...(opts.body ? { 'Content-Type': 'application/json' } : {}),
      },
    });
    if (r.status === 401) { onUnauthorized?.(); throw new Error('Session expired — please log in again.'); }
    const d = await r.json().catch(() => ({}));
    if (!r.ok) throw new Error(d.error || `HTTP ${r.status}`);
    return d;
  }

  async function add(e) {
    e?.preventDefault();
    const label = draft.trim();
    if (!label) return;
    setSaving(true);
    setError(null);
    try {
      const d = await api(`/api/admin/taxonomy/${name}`, { method: 'POST', body: JSON.stringify({ label }) });
      if (Array.isArray(d.values)) setGlobal(d.values);
      setDraft('');
    } catch (err) {
      setError(err.message || `Could not add ${title.toLowerCase().replace(/s$/, '')}.`);
    } finally {
      setSaving(false);
    }
  }

  async function remove(entry) {
    if (!confirm(`Remove "${entry.label}" from ${title}?\n\nTiles already tagged with this value keep it until re-edited.`)) return;
    setRemovingId(entry.id);
    setError(null);
    try {
      const d = await api(`/api/admin/taxonomy/${name}/${encodeURIComponent(entry.id)}`, { method: 'DELETE' });
      if (Array.isArray(d.values)) setGlobal(d.values);
    } catch (err) {
      setError(err.message || 'Could not remove.');
    } finally {
      setRemovingId(null);
    }
  }

  async function move(idx, dir) {
    const next = [...values];
    const target = idx + dir;
    if (target < 0 || target >= next.length) return;
    [next[idx], next[target]] = [next[target], next[idx]];
    try {
      const d = await api(`/api/admin/taxonomy/${name}`, { method: 'PUT', body: JSON.stringify({ values: next }) });
      if (Array.isArray(d.values)) setGlobal(d.values);
    } catch (err) {
      setError(err.message || 'Could not reorder.');
    }
  }

  return (
    <section style={{ marginBottom: '40px', padding: '20px 22px', border: '1px solid var(--cream-deep)', background: 'white' }}>
      <h3 style={{ fontFamily: 'var(--serif)', fontWeight: 400, fontSize: '22px', marginBottom: '4px' }}>{title}</h3>
      <p style={{ fontFamily: 'var(--sans)', fontSize: '12px', color: 'var(--dark-mid)', marginBottom: '14px' }}>{hint}</p>

      <form onSubmit={add} style={{ display: 'flex', gap: '8px', marginBottom: '12px' }}>
        <input
          type="text"
          value={draft}
          onChange={e => setDraft(e.target.value)}
          placeholder={`New ${title.toLowerCase().replace(/s$/, '')} (e.g. "Outdoor")`}
          maxLength={60}
          style={{ flex: 1 }}
        />
        <button type="submit" className="btn btn-dark" disabled={saving || !draft.trim()} style={{ padding: '10px 18px', opacity: saving || !draft.trim() ? 0.5 : 1 }}>
          {saving ? 'Adding…' : '+ Add'}
        </button>
      </form>

      {error && (
        <p style={{ fontFamily: 'var(--sans)', fontSize: '12px', color: 'var(--terracotta)', marginBottom: '10px' }}>{error}</p>
      )}

      <div style={{ border: '1px solid var(--cream-deep)' }}>
        {values.length === 0 && (
          <p style={{ padding: '18px', fontFamily: 'var(--sans)', fontSize: '12px', color: 'var(--dark-mid)', textAlign: 'center' }}>
            No values yet. Add the first one above.
          </p>
        )}
        {values.map((entry, idx) => (
          <div
            key={entry.id}
            style={{
              display: 'flex', alignItems: 'center', justifyContent: 'space-between',
              padding: '10px 14px',
              borderBottom: idx < values.length - 1 ? '1px solid var(--cream-deep)' : 'none',
            }}
          >
            <div>
              <span style={{ fontFamily: 'var(--sans)', fontSize: '14px', color: 'var(--dark)' }}>{entry.label}</span>
              <span style={{ fontFamily: 'var(--sans)', fontSize: '11px', color: 'var(--dark-mid)', marginLeft: '10px', letterSpacing: '0.04em' }}>id: {entry.id}</span>
            </div>
            <div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
              <button type="button" onClick={() => move(idx, -1)} disabled={idx === 0} aria-label="Move up" style={miniBtn(idx === 0)}>↑</button>
              <button type="button" onClick={() => move(idx, +1)} disabled={idx === values.length - 1} aria-label="Move down" style={miniBtn(idx === values.length - 1)}>↓</button>
              <button
                type="button"
                onClick={() => remove(entry)}
                disabled={removingId === entry.id}
                style={{
                  marginLeft: '8px', padding: '5px 11px',
                  background: 'none', border: '1px solid var(--cream-deep)',
                  color: 'var(--dark-mid)',
                  fontFamily: 'var(--sans)', fontSize: '11px',
                  letterSpacing: '0.08em', textTransform: 'uppercase',
                  cursor: removingId === entry.id ? 'wait' : 'pointer',
                }}
              >{removingId === entry.id ? '…' : 'Remove'}</button>
            </div>
          </div>
        ))}
      </div>
    </section>
  );
}

Object.assign(window, { Admin, CollectionsAdmin, FinishesAdmin, SizesAdmin, FiltersAdmin });


