// PHYSIO SAMOS — admin console: writers. The names and photos that sign the articles (owner, 2026-09-14).
// Writers are profiles, not accounts: nobody signs in as one. This file holds the list page, the create
// and edit dialog with its photo cropper, the hand-over dialog a writer who signs posts is deleted
// through, the avatar the other pages show, and the data hook.
//
// Things this file depends on that are easy to undo:
//  - Escape reaches PSA.Dialog through a `cancel` listener bound once, at mount, to the first render's
//    onClose. A dialog that asks before closing therefore passes a stable function that reads the
//    current state from a ref; otherwise Escape would close with the state the dialog opened with.
//  - Photos load from the admin host through the relative /blog-media/… URLs the API returns. A draft's
//    images 404 on the public host (writers-backend), so never prefix them with publicOrigin.
//  - The photo is cropped, resized and re-encoded here. The canvas re-encode is what removes EXIF and
//    GPS; both copies share one type (WebP, or JPEG where the browser cannot encode WebP). The Worker
//    re-checks the type and the exact 400x400 and 96x96 sizes from the bytes.
// One function scope, like every admin file: nothing is declared at the top level.
(() => {
  const { useState, useEffect, useLayoutEffect, useRef, useCallback, useId } = React;
  const PSA = window.PSA;
  const { Icon, api, toast, confirm } = PSA;

  // ---------- wording ----------
  // The writer error codes, word for word as integration adds them to admin-core.jsx (writers spec §5.5).
  // Until then this map answers first and PSA.errorText covers everything else.
  const WRITER_ERRORS = {
    bad_writer: "Ο συντάκτης δεν βρέθηκε. Διαλέξτε άλλον.",
    bad_name_latin: "Μόνο λατινικοί χαρακτήρες, κενά, τελείες, απόστροφοι και παύλες.",
    writer_name_taken: "Υπάρχει ήδη συντάκτης με αυτό το όνομα.",
    photo_required: "Διαλέξτε φωτογραφία.",
    bad_photo: "Η φωτογραφία δεν επεξεργάστηκε σωστά. Δοκιμάστε ξανά.",
    writer_in_use: "Ο συντάκτης υπογράφει άρθρα. Διαλέξτε ποιος θα τα αναλάβει.",
    bad_reassign: "Διαλέξτε άλλον συντάκτη για τα άρθρα.",
  };
  const errText = (err) => (err instanceof PSA.ApiError && WRITER_ERRORS[err.code]) || PSA.errorText(err);

  const PHOTO_TEXT = {
    tooBig: "Η εικόνα είναι μεγαλύτερη από 30 MB.",
    heic: "Ο φυλλομετρητής δεν ανοίγει φωτογραφίες HEIC. Αποθηκεύστε την ως JPEG και δοκιμάστε ξανά.",
    unreadable: "Το αρχείο δεν διαβάζεται ως εικόνα.",
    tooSmall: "Η φωτογραφία είναι πολύ μικρή. Χρειάζεται τουλάχιστον 200 × 200 pixel.",
    small: "Μικρή φωτογραφία: μπορεί να φαίνεται θολή.",
  };

  // The same cleaning and the same Latin rule as worker/admin/api-writers.js, so the live error and
  // the server's answer cannot disagree.
  const NAME_MAX = 80;
  const LATIN_NAME = /^[\p{Script=Latin}\p{M}\s.'’-]*$/u;
  const cleanText = (v) => String(v ?? "").replace(/\s+/gu, " ").replace(/\p{Cc}/gu, "").trim();
  const MAX_INPUT = 30 * 1024 * 1024;
  const ACCEPT = "image/jpeg,image/png,image/webp,image/heic,image/heif";
  const isImageFile = (f) => !!f && (/^image\//.test(f.type) || /\.(heic|heif)$/i.test(f.name));
  const writersChanged = () => window.dispatchEvent(new Event("psa:writers-changed"));
  const useDomId = (prefix) => `${prefix}-${useId().replace(/:/g, "")}`;

  // ---------- avatar ----------
  // The small copy for anything up to 48px, the 400px copy above that; srcSet lets a dense screen
  // choose for itself. No writer: a neutral circle with a person glyph.
  PSA.WriterAvatar = ({ writer, size = 32, className = "" }) => {
    const [broken, setBroken] = useState(false);
    const src = writer ? (size <= 48 ? writer.photoSmallUrl : writer.photoUrl) || writer.photoUrl : null;
    useEffect(() => setBroken(false), [src]);
    const box = { width: size, height: size };
    if (!writer || !src) {
      return (
        <span className={`avatar writer-avatar writer-avatar--empty${className ? ` ${className}` : ""}`} style={box} aria-hidden="true">
          <Icon name="userRound" />
        </span>
      );
    }
    if (broken) {
      return (
        <span className={`avatar writer-avatar${className ? ` ${className}` : ""}`} aria-hidden="true"
              style={{ ...box, fontSize: Math.max(9, Math.round(size * 0.38)) }}>{PSA.initials(writer.name)}</span>
      );
    }
    return (
      <img className={`writer-avatar${className ? ` ${className}` : ""}`} src={src}
           srcSet={writer.photoSmallUrl && writer.photoUrl ? `${writer.photoSmallUrl} 96w, ${writer.photoUrl} 400w` : undefined}
           sizes={`${size}px`} width={size} height={size} alt="" loading="lazy" decoding="async" draggable="false"
           onError={() => setBroken(true)} />
    );
  };

  // ---------- data ----------
  PSA.useWriters = () => {
    const writers = PSA.useAsync((signal) => api("/api/writers", { signal }), []);
    const { reload } = writers;
    useEffect(() => {
      const on = () => reload();
      window.addEventListener("psa:writers-changed", on);
      return () => window.removeEventListener("psa:writers-changed", on);
    }, [reload]);
    return writers;
  };

  const usageText = (p) => {
    if (!p || !p.total) return "Δεν υπογράφει κανένα άρθρο";
    const parts = [];
    if (p.published) parts.push(PSA.plural(p.published, "δημοσιευμένο", "δημοσιευμένα"));
    if (p.scheduled) parts.push(PSA.plural(p.scheduled, "προγραμματισμένο", "προγραμματισμένα"));
    if (p.drafts) parts.push(PSA.plural(p.drafts, "πρόχειρο", "πρόχειρα"));
    if (p.trash) parts.push(`${p.trash} στον κάδο`);
    return parts.join(" · ");
  };

  // ---------- photo cropper ----------
  // State is the square being cut out of the source: its centre (cx, cy) in source pixels and a zoom,
  // where zoom 1 is the largest square the photo holds. Max zoom keeps at least 160 source pixels.
  const shortOf = (b) => Math.min(b.width, b.height);
  const zMaxOf = (b) => Math.max(1, Math.min(6, shortOf(b) / 160));
  const centred = (b) => ({ zoom: 1, cx: b.width / 2, cy: b.height / 2 });
  const clampView = (v, b) => {
    const zoom = Math.min(zMaxOf(b), Math.max(1, v.zoom));
    const side = shortOf(b) / zoom;
    return {
      zoom,
      cx: Math.min(b.width - side / 2, Math.max(side / 2, v.cx)),
      cy: Math.min(b.height - side / 2, Math.max(side / 2, v.cy)),
    };
  };
  // Zoom to `zoom`, keeping the source point under (px, py) — CSS pixels inside the viewport — in place.
  const zoomAt = (v, b, zoom, px, py, size) => {
    const short = shortOf(b);
    const side = short / v.zoom;
    const ux = v.cx - side / 2 + (px / size) * side;
    const uy = v.cy - side / 2 + (py / size) * side;
    const z = Math.min(zMaxOf(b), Math.max(1, zoom));
    const s2 = short / z;
    return clampView({ zoom: z, cx: ux - (px / size) * s2 + s2 / 2, cy: uy - (py / size) * s2 + s2 / 2 }, b);
  };

  // Decode with the camera's orientation applied. Anything with a long side over 2048px is scaled down
  // first, but never so far that its short side drops under 400px (a panorama would otherwise lose the
  // pixels its round crop needs). The minimum is judged on the photo as it came.
  const decodePhoto = async (file) => {
    if (file.size > MAX_INPUT) throw new Error(PHOTO_TEXT.tooBig);
    let bitmap;
    try {
      bitmap = await createImageBitmap(file, { imageOrientation: "from-image" });
    } catch (e) {
      throw new Error(/heic|heif/i.test(`${file.type} ${file.name}`) ? PHOTO_TEXT.heic : PHOTO_TEXT.unreadable);
    }
    const short = shortOf(bitmap), long = Math.max(bitmap.width, bitmap.height);
    if (short < 200) { if (bitmap.close) bitmap.close(); throw new Error(PHOTO_TEXT.tooSmall); }
    if (long > 2048) {
      const scale = Math.max(2048 / long, Math.min(1, 400 / short));
      if (scale < 1) {
        try {
          const resized = await createImageBitmap(bitmap, {
            resizeWidth: Math.round(bitmap.width * scale), resizeHeight: Math.round(bitmap.height * scale), resizeQuality: "high",
          });
          if (bitmap.close) bitmap.close();
          bitmap = resized;
        } catch (e) { /* keep the full-size bitmap */ }
      }
    }
    return { bitmap, originalShort: short };
  };

  const drawCrop = (canvas, px, b, v) => {
    if (!canvas || !b || !v) return;
    const dpr = window.devicePixelRatio || 1;
    const w = Math.max(1, Math.round(px * dpr));
    if (canvas.width !== w) { canvas.width = w; canvas.height = w; }
    const ctx = canvas.getContext("2d");
    ctx.imageSmoothingQuality = "high";
    ctx.clearRect(0, 0, w, w);
    const side = shortOf(b) / v.zoom;
    ctx.drawImage(b, v.cx - side / 2, v.cy - side / 2, side, side, 0, 0, w, w);
  };

  const toBlob = (canvas, type, quality) => new Promise((resolve) => canvas.toBlob(resolve, type, quality));
  // Both sizes are painted before the first await, so a cropper that unmounts (and closes its bitmap)
  // mid-encode cannot break the result. A browser that cannot encode WebP hands back PNG or nothing:
  // then both copies become JPEG, painted over white from the canvases already drawn.
  const encodeCrop = async (b, v) => {
    if (!b || !v) throw new Error(WRITER_ERRORS.bad_photo);
    const side = shortOf(b) / v.zoom, sx = v.cx - side / 2, sy = v.cy - side / 2;
    const paint = (px, source) => {
      const c = document.createElement("canvas");
      c.width = px; c.height = px;
      const ctx = c.getContext("2d");
      ctx.imageSmoothingQuality = "high";
      if (source) { ctx.fillStyle = "#ffffff"; ctx.fillRect(0, 0, px, px); ctx.drawImage(source, 0, 0); }
      else ctx.drawImage(b, sx, sy, side, side, 0, 0, px, px);
      return c;
    };
    const large = paint(400), small = paint(96);
    let type = "image/webp";
    let photo = await toBlob(large, type, 0.86);
    let photoSmall = await toBlob(small, type, 0.86);
    if (!photo || !photoSmall || photo.type !== type || photoSmall.type !== type) {
      type = "image/jpeg";
      photo = await toBlob(paint(400, large), type, 0.88);
      photoSmall = await toBlob(paint(96, small), type, 0.88);
    }
    if (!photo || !photoSmall || photo.type !== type || photoSmall.type !== type) throw new Error(WRITER_ERRORS.bad_photo);
    return { photo, photoSmall, type };
  };

  // Props: { file, onChange(crop), onError(message) }.
  // onChange({ ready: false }) while decoding; then, after every change of the frame,
  // onChange({ ready: true, render, draw, reset, source: { width, height, short }, zoom }):
  //   render() resolves { photo, photoSmall, type } (400x400 and 96x96 blobs of one type);
  //   draw(canvas, cssPx) paints the current crop into a canvas (the dialog's previews);
  //   reset() centres the frame at zoom 1; source.short is the photo's short side as it came.
  PSA.PhotoCropper = ({ file, onChange, onError }) => {
    const [decoded, setDecoded] = useState(null);
    const [view, setView] = useState(null);
    const [size, setSize] = useState(280);
    const [dragging, setDragging] = useState(false);
    const hintId = useDomId("crop-hint");
    const vpRef = useRef(null), canvasRef = useRef(null), trackRef = useRef(null);
    const live = useRef({ bitmap: null, view: null, size: 280 });
    const cb = useRef({ onChange, onError });
    cb.current = { onChange, onError };
    const pointers = useRef(new Map());
    const pending = useRef(null);
    const raf = useRef(0);
    const handles = useRef(null);
    if (!handles.current) {
      handles.current = {
        draw: (canvas, px) => drawCrop(canvas, px, live.current.bitmap, pending.current || live.current.view),
        render: () => encodeCrop(live.current.bitmap, pending.current || live.current.view),
        reset: () => { const b = live.current.bitmap; if (b) { pending.current = null; setView(centred(b)); } },
      };
    }

    useEffect(() => {
      let alive = true, bitmap = null;
      setDecoded(null);
      setView(null);
      if (cb.current.onChange) cb.current.onChange({ ready: false });
      if (!file) return undefined;
      decodePhoto(file).then(
        (r) => {
          if (!alive) { if (r.bitmap.close) r.bitmap.close(); return; }
          bitmap = r.bitmap;
          live.current.bitmap = r.bitmap;
          setDecoded(r);
          setView(centred(r.bitmap));
        },
        (err) => { if (alive && cb.current.onError) cb.current.onError(err.message || PHOTO_TEXT.unreadable); },
      );
      return () => {
        alive = false;
        live.current.bitmap = null;
        if (bitmap && bitmap.close) bitmap.close();
      };
    }, [file]);

    useEffect(() => () => { if (raf.current) cancelAnimationFrame(raf.current); }, []);

    // The viewport is min(280px, 100%): measured, so a narrow dialog still drags one-to-one.
    useLayoutEffect(() => {
      const vp = vpRef.current;
      if (!vp) return undefined;
      const measure = () => { const w = vp.getBoundingClientRect().width; if (w > 0) { live.current.size = w; setSize(w); } };
      measure();
      const ro = typeof ResizeObserver === "function" ? new ResizeObserver(measure) : null;
      if (ro) ro.observe(vp);
      return () => { if (ro) ro.disconnect(); };
    }, [!!decoded]);

    useLayoutEffect(() => {
      if (!decoded || !view) return;
      live.current.view = view;
      drawCrop(canvasRef.current, live.current.size, decoded.bitmap, view);
      const b = decoded.bitmap;
      if (cb.current.onChange) {
        cb.current.onChange({ ready: true, ...handles.current, source: { width: b.width, height: b.height, short: decoded.originalShort }, zoom: view.zoom });
      }
    }, [decoded, view, size]);

    const current = () => pending.current || live.current.view;
    const schedule = () => {
      if (raf.current) return;
      raf.current = requestAnimationFrame(() => {
        raf.current = 0;
        const v = pending.current;
        pending.current = null;
        if (v) setView(v);
      });
    };
    const commitNow = (v) => { pending.current = null; setView(v); };

    // Wheel zoom has to prevent the page scroll, so it is bound as a non-passive listener.
    useEffect(() => {
      const vp = vpRef.current;
      if (!vp || !decoded) return undefined;
      const onWheel = (e) => {
        e.preventDefault();
        const b = live.current.bitmap, v = pending.current || live.current.view;
        if (!b || !v) return;
        const dy = e.deltaMode === 1 ? e.deltaY * 16 : e.deltaMode === 2 ? e.deltaY * 400 : e.deltaY;
        const r = vp.getBoundingClientRect();
        pending.current = zoomAt(v, b, v.zoom * Math.exp(-dy * 0.0015), e.clientX - r.left, e.clientY - r.top, live.current.size);
        if (!raf.current) {
          raf.current = requestAnimationFrame(() => { raf.current = 0; const n = pending.current; pending.current = null; if (n) setView(n); });
        }
      };
      vp.addEventListener("wheel", onWheel, { passive: false });
      return () => vp.removeEventListener("wheel", onWheel);
    }, [decoded]);

    const onPointerDown = (e) => {
      if (!decoded || (e.pointerType === "mouse" && e.button !== 0)) return;
      try { e.currentTarget.setPointerCapture(e.pointerId); } catch (err) {}
      pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
      setDragging(true);
    };
    const onPointerMove = (e) => {
      const ps = pointers.current;
      const prev = ps.get(e.pointerId);
      const b = live.current.bitmap, v = current();
      if (!prev || !b || !v) return;
      const size = live.current.size, short = shortOf(b);
      if (ps.size === 1) {
        const k = short / v.zoom / size;
        ps.set(e.pointerId, { x: e.clientX, y: e.clientY });
        pending.current = clampView({ zoom: v.zoom, cx: v.cx - (e.clientX - prev.x) * k, cy: v.cy - (e.clientY - prev.y) * k }, b);
      } else {
        // Two fingers: follow the midpoint, and zoom by the change in their distance around it.
        const before = [...ps.values()].slice(0, 2);
        ps.set(e.pointerId, { x: e.clientX, y: e.clientY });
        const after = [...ps.values()].slice(0, 2);
        const mid = (p) => ({ x: (p[0].x + p[1].x) / 2, y: (p[0].y + p[1].y) / 2 });
        const dist = (p) => Math.hypot(p[0].x - p[1].x, p[0].y - p[1].y) || 1;
        const m0 = mid(before), m1 = mid(after);
        const k = short / v.zoom / size;
        const r = vpRef.current.getBoundingClientRect();
        const panned = clampView({ zoom: v.zoom, cx: v.cx - (m1.x - m0.x) * k, cy: v.cy - (m1.y - m0.y) * k }, b);
        pending.current = zoomAt(panned, b, panned.zoom * (dist(after) / dist(before)), m1.x - r.left, m1.y - r.top, size);
      }
      schedule();
    };
    const onPointerEnd = (e) => {
      pointers.current.delete(e.pointerId);
      if (!pointers.current.size) setDragging(false);
    };

    // Arrows move the photo the way a drag does; + and − zoom around the centre; 0 starts over.
    const onViewportKey = (e) => {
      const b = live.current.bitmap, v = current();
      if (!b || !v || e.altKey || e.ctrlKey || e.metaKey) return;
      const step = (shortOf(b) / v.zoom) * (e.shiftKey ? 0.1 : 0.02);
      const size = live.current.size, mid = size / 2;
      let next;
      switch (e.key) {
        case "ArrowLeft": next = { ...v, cx: v.cx + step }; break;
        case "ArrowRight": next = { ...v, cx: v.cx - step }; break;
        case "ArrowUp": next = { ...v, cy: v.cy + step }; break;
        case "ArrowDown": next = { ...v, cy: v.cy - step }; break;
        case "+": case "=": next = zoomAt(v, b, v.zoom * 1.1, mid, mid, size); break;
        case "-": case "−": next = zoomAt(v, b, v.zoom / 1.1, mid, mid, size); break;
        case "0": next = centred(b); break;
        default: return;
      }
      e.preventDefault();
      commitNow(clampView(next, b));
    };

    // ---------- zoom slider (no native range input) ----------
    const zMax = decoded ? zMaxOf(decoded.bitmap) : 1;
    const maxPct = Math.round(zMax * 100);
    const nowPct = view ? Math.round(view.zoom * 100) : 100;
    const locked = zMax === 1;
    const frac = maxPct > 100 ? (nowPct - 100) / (maxPct - 100) : 0;
    const setPct = (pct) => {
      const b = live.current.bitmap, v = current();
      if (!b || !v) return;
      const size = live.current.size;
      commitNow(zoomAt(v, b, Math.min(maxPct, Math.max(100, pct)) / 100, size / 2, size / 2, size));
    };
    const pctNow = () => { const v = current(); return v ? Math.round(v.zoom * 100) : 100; };
    const onSliderKey = (e) => {
      if (locked || e.altKey || e.ctrlKey || e.metaKey) return;
      const steps = { ArrowLeft: -5, ArrowDown: -5, ArrowRight: 5, ArrowUp: 5, PageUp: 25, PageDown: -25 };
      if (e.key in steps) setPct(pctNow() + steps[e.key]);
      else if (e.key === "Home") setPct(100);
      else if (e.key === "End") setPct(maxPct);
      else return;
      e.preventDefault();
    };
    const pctAt = (clientX) => {
      const r = trackRef.current.getBoundingClientRect();
      const f = r.width ? Math.min(1, Math.max(0, (clientX - r.left) / r.width)) : 0;
      return Math.round(100 + f * (maxPct - 100));
    };
    const onSliderDown = (e) => {
      if (locked || (e.pointerType === "mouse" && e.button !== 0)) return;
      try { e.currentTarget.setPointerCapture(e.pointerId); } catch (err) {}
      setPct(pctAt(e.clientX));
    };
    const onSliderMove = (e) => {
      let held = false;
      try { held = e.currentTarget.hasPointerCapture(e.pointerId); } catch (err) {}
      if (held && !locked) setPct(pctAt(e.clientX));
    };

    if (!decoded) {
      return (
        <div className="cropper">
          <div className="cropper__loading"><PSA.Spinner label="Άνοιγμα φωτογραφίας…" /></div>
        </div>
      );
    }
    return (
      <div className="cropper">
        <div ref={vpRef} className={`cropper__viewport${dragging ? " is-dragging" : ""}`} tabIndex={0} role="group"
             aria-label="Κάδρο φωτογραφίας" aria-describedby={hintId}
             onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={onPointerEnd} onPointerCancel={onPointerEnd}
             onKeyDown={onViewportKey}>
          <canvas ref={canvasRef} className="cropper__canvas" aria-hidden="true" />
          <span className="cropper__mask" aria-hidden="true" />
          <span className="cropper__ring" aria-hidden="true" />
        </div>
        <p id={hintId} className="hint cropper__hint">Σύρετε ή χρησιμοποιήστε τα βέλη για μετακίνηση, τα + και − για μεγέθυνση.</p>
        <div className="cropper__zoom">
          <button type="button" className="btn btn--quiet btn--icon btn--sm" aria-label="Σμίκρυνση"
                  disabled={locked || nowPct <= 100} onClick={() => setPct(Math.floor(pctNow() / 1.25))}>
            <Icon name="zoomOut" size="sm" />
          </button>
          <div className={`slider${locked ? " is-disabled" : ""}`} role="slider" tabIndex={locked ? -1 : 0}
               aria-label="Μεγέθυνση" aria-valuemin={100} aria-valuemax={maxPct} aria-valuenow={nowPct} aria-valuetext={`${nowPct}%`}
               aria-disabled={locked || undefined} style={{ "--slider-f": frac }}
               onKeyDown={onSliderKey} onPointerDown={onSliderDown} onPointerMove={onSliderMove}>
            <span ref={trackRef} className="slider__track">
              <span className="slider__fill" />
              <span className="slider__thumb" />
            </span>
          </div>
          <button type="button" className="btn btn--quiet btn--icon btn--sm" aria-label="Μεγέθυνση"
                  disabled={locked || nowPct >= maxPct} onClick={() => setPct(Math.ceil(pctNow() * 1.25))}>
            <Icon name="zoomIn" size="sm" />
          </button>
        </div>
      </div>
    );
  };

  // ---------- create / edit dialog ----------
  // Props: { writer?, onClose(), onSaved(writer) }. Without `writer` it creates one.
  PSA.WriterDialog = ({ writer: initial, onClose, onSaved }) => {
    const isNew = !initial;
    const [base, setBase] = useState(initial || null);
    const [name, setName] = useState(initial ? initial.name : "");
    const [nameLatin, setNameLatin] = useState(initial ? initial.nameLatin || "" : "");
    const [file, setFile] = useState(null);
    const [fileKey, setFileKey] = useState(0);
    const [cropReady, setCropReady] = useState(false);
    const [smallPhoto, setSmallPhoto] = useState(false);
    const [photoError, setPhotoError] = useState(null);
    const [fieldErrors, setFieldErrors] = useState({});
    const [formError, setFormError] = useState(null);
    const [busy, setBusy] = useState(false);
    const [over, setOver] = useState(false);
    const crop = useRef(null);
    const inputRef = useRef(null), nameRef = useRef(null);
    const previewBig = useRef(null), previewSmall = useRef(null);
    const asking = useRef(false);
    const uid = useDomId("wd");

    const cleanName = cleanText(name), cleanLatin = cleanText(nameLatin);
    const nameOk = cleanName.length > 0 && cleanName.length <= NAME_MAX;
    const latinBad = !LATIN_NAME.test(cleanLatin) || cleanLatin.length > NAME_MAX;
    const hasCrop = !!file && cropReady;
    const fieldsChanged = !base || cleanName !== base.name || cleanLatin !== (base.nameLatin || "");
    const canSave = !busy && nameOk && !latinBad && (isNew ? hasCrop : hasCrop || fieldsChanged);
    const dirty = !!file || (isNew ? !!(cleanName || cleanLatin) : fieldsChanged);

    // Focus the name once the dialog is open: this effect runs after PSA.Dialog's own showModal().
    useEffect(() => { if (nameRef.current) nameRef.current.focus(); }, []);

    const requestClose = async () => {
      if (busy || asking.current) return;
      if (dirty) {
        asking.current = true;
        const ok = await confirm({ title: "Κλείσιμο χωρίς αποθήκευση;", confirmLabel: "Κλείσιμο", danger: true });
        asking.current = false;
        if (!ok) return;
      }
      onClose();
    };
    const closeRef = useRef(requestClose);
    closeRef.current = requestClose;
    const close = useCallback(() => closeRef.current(), []);

    const drawPreviews = useCallback(() => {
      if (!crop.current) return;
      crop.current.draw(previewBig.current, 56);
      crop.current.draw(previewSmall.current, 28);
    }, []);
    useLayoutEffect(() => { if (cropReady) drawPreviews(); }, [cropReady]);

    const onCropChange = useCallback((c) => {
      if (!c.ready) { crop.current = null; setCropReady(false); return; }
      crop.current = c;
      setCropReady(true);
      setSmallPhoto(c.source.short < 400);
      drawPreviews();
    }, []);
    const onCropError = useCallback((message) => {
      crop.current = null;
      setCropReady(false);
      setFile(null);
      setPhotoError(message);
    }, []);

    const choose = () => { if (!busy && inputRef.current) inputRef.current.click(); };
    const pick = (f) => {
      if (!f || busy) return;
      setPhotoError(null);
      setFormError(null);
      if (f.size > MAX_INPUT) { setPhotoError(PHOTO_TEXT.tooBig); return; }
      crop.current = null;
      setCropReady(false);
      setSmallPhoto(false);
      setFile(f);
      setFileKey((k) => k + 1);
    };

    const submit = async () => {
      if (!canSave) return;
      setBusy(true);
      setFormError(null);
      setFieldErrors({});
      setPhotoError(null);
      try {
        const photos = hasCrop ? await crop.current.render() : null;
        let result;
        if (!base || photos) {
          const form = new FormData();
          form.append("name", cleanName);
          form.append("nameLatin", cleanLatin);
          if (photos) {
            const ext = photos.type === "image/webp" ? "webp" : "jpg";
            form.append("photo", photos.photo, `writer-400.${ext}`);
            form.append("photoSmall", photos.photoSmall, `writer-96.${ext}`);
          }
          if (base) form.append("version", String(base.version));
          result = await api(base ? `/api/writers/${base.id}` : "/api/writers", { method: base ? "PUT" : "POST", form });
        } else {
          result = await api(`/api/writers/${base.id}`, { method: "PUT", body: { version: base.version, name: cleanName, nameLatin: cleanLatin } });
        }
        setBusy(false);
        writersChanged();
        toast("Ο συντάκτης αποθηκεύτηκε.");
        if (onSaved) onSaved(result.writer);
      } catch (err) {
        setBusy(false);
        if (err instanceof PSA.ApiError && err.code === "version_conflict") {
          // Another tab saved first: continue from its version, keeping what was typed here.
          if (err.data && err.data.writer) setBase(err.data.writer);
          writersChanged();
          toast("Ο συντάκτης άλλαξε σε άλλη καρτέλα.", { kind: "error" });
          return;
        }
        if (err instanceof PSA.ApiError && err.status === 404) writersChanged();
        const field = err && err.data && err.data.field;
        const text = errText(err);
        if (field === "name" || field === "nameLatin") setFieldErrors({ [field]: text });
        else if (field === "photo") setPhotoError(text);
        else setFormError(text);
      }
    };
    const onFieldKey = (e) => { if (e.key === "Enter" && !e.isComposing) { e.preventDefault(); submit(); } };

    const shownName = cleanName || null;
    const previewCircle = (px, ref) => (hasCrop
      ? <canvas ref={ref} className="writer-preview__img" style={{ width: px, height: px }} aria-hidden="true" />
      : <PSA.WriterAvatar writer={base} size={px} />);
    const latinError = fieldErrors.nameLatin || (latinBad && cleanLatin ? WRITER_ERRORS.bad_name_latin : null);

    return (
      <PSA.Dialog open wide title={isNew ? "Νέος συντάκτης" : "Επεξεργασία συντάκτη"} onClose={close} footer={<>
        <button type="button" className="btn" onClick={close} disabled={busy}>Άκυρο</button>
        <button type="button" className="btn btn--primary" disabled={!canSave} onClick={submit}>
          {busy ? "Αποθήκευση…" : isNew ? "Δημιουργία" : "Αποθήκευση"}
        </button>
      </>}>
        {formError && <div className="notice notice--danger" role="alert"><Icon name="warning" size="sm" /><div>{formError}</div></div>}
        <div className="writer-form">
          <div className={`writer-form__photo${over ? " is-over" : ""}`}
               onDragOver={(e) => { if (busy) return; e.preventDefault(); if (!over) setOver(true); }}
               onDragLeave={(e) => { if (!e.currentTarget.contains(e.relatedTarget)) setOver(false); }}
               onDrop={(e) => { e.preventDefault(); setOver(false); pick([...e.dataTransfer.files].find(isImageFile)); }}>
            <input ref={inputRef} type="file" hidden accept={ACCEPT} className="writer-form__file"
                   onChange={(e) => { const f = e.target.files && e.target.files[0]; e.target.value = ""; pick(f); }} />
            {file ? (
              <>
                <PSA.PhotoCropper key={fileKey} file={file} onChange={onCropChange} onError={onCropError} />
                {cropReady && (
                  <div className="writer-form__photo-actions">
                    <button type="button" className="btn btn--sm" onClick={choose} disabled={busy}><Icon name="upload" size="sm" />Άλλη φωτογραφία</button>
                    <button type="button" className="btn btn--quiet btn--sm" onClick={() => crop.current && crop.current.reset()} disabled={busy}>
                      <Icon name="restore" size="sm" />Επαναφορά κάδρου
                    </button>
                  </div>
                )}
              </>
            ) : base && base.photoUrl ? (
              <div className="writer-photo-current">
                <img src={base.photoUrl} alt="" width={280} height={280} draggable="false" />
                <button type="button" className="btn btn--sm" onClick={choose} disabled={busy}><Icon name="upload" size="sm" />Αλλαγή φωτογραφίας</button>
              </div>
            ) : (
              <>
                <div className={`photo-drop${over ? " is-over" : ""}`}>
                  <Icon name="userRound" size="lg" />
                  <p>Σύρετε μια φωτογραφία εδώ ή</p>
                  <button type="button" className="btn btn--sm" onClick={choose} disabled={busy}>Επιλογή φωτογραφίας</button>
                </div>
                <p className="hint">Κόβεται τετράγωνη, μικραίνει και χάνει τα στοιχεία τοποθεσίας πριν ανέβει.</p>
              </>
            )}
            {photoError && <div className="notice notice--danger" role="alert"><Icon name="warning" size="sm" /><div>{photoError}</div></div>}
            {hasCrop && smallPhoto && <div className="notice notice--warn"><Icon name="warning" size="sm" /><div>{PHOTO_TEXT.small}</div></div>}
          </div>

          <div className="writer-form__fields">
            <PSA.Field label="Όνομα" htmlFor={`${uid}-name`} count={name.length} max={NAME_MAX}
                       hint="Όπως εμφανίζεται στα ελληνικά άρθρα." error={fieldErrors.name}>
              <input ref={nameRef} id={`${uid}-name`} className="input" value={name} maxLength={NAME_MAX} required aria-required="true"
                     aria-invalid={fieldErrors.name ? "true" : undefined} autoComplete="off" onKeyDown={onFieldKey}
                     onChange={(e) => { setName(e.target.value); if (fieldErrors.name) setFieldErrors((x) => ({ ...x, name: null })); }} />
            </PSA.Field>
            <PSA.Field label="Όνομα με λατινικούς χαρακτήρες" htmlFor={`${uid}-latin`} optional count={nameLatin.length} max={NAME_MAX}
                       hint="Για τις σελίδες σε όλες τις γλώσσες εκτός από τα ελληνικά. Αν μείνει κενό, εμφανίζεται το ελληνικό."
                       error={latinError}>
              <input id={`${uid}-latin`} className="input" lang="en" spellCheck={false} autoComplete="off" value={nameLatin} maxLength={NAME_MAX}
                     placeholder="π.χ. Maria Atzemian" aria-invalid={latinError ? "true" : undefined} onKeyDown={onFieldKey}
                     onChange={(e) => { setNameLatin(e.target.value); if (fieldErrors.nameLatin) setFieldErrors((x) => ({ ...x, nameLatin: null })); }} />
            </PSA.Field>
            <section className="writer-preview" aria-labelledby={`${uid}-preview`}>
              <h3 id={`${uid}-preview`} className="writer-preview__title">Έτσι εμφανίζεται</h3>
              <div className="writer-preview__row">
                {previewCircle(56, previewBig)}
                <span className="writer-preview__text">
                  <span className="writer-preview__where">Στο τέλος του άρθρου</span>
                  <strong className={shownName ? "" : "is-empty"}>{shownName || "Όνομα"}</strong>
                </span>
              </div>
              <div className="writer-preview__row writer-preview__row--list">
                {previewCircle(28, previewSmall)}
                <span className="writer-preview__text">
                  <span className="writer-preview__where">Στη λίστα των άρθρων</span>
                  <strong className={shownName ? "" : "is-empty"}>{shownName || "Όνομα"}</strong>
                </span>
              </div>
            </section>
          </div>
        </div>
      </PSA.Dialog>
    );
  };

  // ---------- hand-over and delete ----------
  // Props: { writer, writers?, onClose(), onDeleted({ ok, reassigned }) }. `writers` is the list the
  // caller already holds; without it the dialog loads its own.
  PSA.ReassignDialog = ({ writer, writers: given, onClose, onDeleted }) => {
    const detail = PSA.useAsync((signal) => api(`/api/writers/${writer.id}`, { signal }), [writer.id]);
    const all = PSA.useAsync((signal) => (given ? Promise.resolve({ writers: given }) : api("/api/writers", { signal })), []);
    const [to, setTo] = useState(null);
    const [busy, setBusy] = useState(false);
    const [error, setError] = useState(null);
    const labelId = useDomId("reassign-label");
    const selectId = useDomId("reassign-to");

    const posts = detail.data ? detail.data.posts : null;
    const n = posts ? posts.length : (writer.posts && writer.posts.total) || 0;
    const others = ((all.data && all.data.writers) || []).filter((w) => w.id !== writer.id);
    const target = others.find((w) => w.id === to) || null;
    const live = !!posts && posts.some((p) => p.state === "published" || p.state === "scheduled");
    const none = !!posts && posts.length === 0;

    const submit = async () => {
      if (busy || (!none && !target)) return;
      setBusy(true);
      setError(null);
      try {
        const r = await api(none ? `/api/writers/${writer.id}` : `/api/writers/${writer.id}?reassignTo=${target.id}`, { method: "DELETE" });
        writersChanged();
        toast(!r.reassigned ? "Ο συντάκτης διαγράφηκε."
          : r.reassigned === 1 ? `Ο συντάκτης διαγράφηκε. 1 άρθρο υπογράφεται πλέον από «${target.name}».`
          : `Ο συντάκτης διαγράφηκε. ${r.reassigned} άρθρα υπογράφονται πλέον από «${target.name}».`);
        if (onDeleted) onDeleted(r);
      } catch (err) {
        setBusy(false);
        setError(err);
        if (err instanceof PSA.ApiError) {
          if (err.code === "writer_in_use") detail.reload();
          if (err.code === "bad_reassign") { setTo(null); writersChanged(); }
          if (err.status === 404) writersChanged();
        }
      }
    };
    const closeRef = useRef(null);
    closeRef.current = () => { if (!busy) onClose(); };
    const close = useCallback(() => closeRef.current(), []);

    const title = none ? "Ο συντάκτης δεν υπογράφει πια άρθρα" : `Ο συντάκτης υπογράφει ${PSA.plural(n, "άρθρο", "άρθρα")}`;
    return (
      <PSA.Dialog open title={title} onClose={close} footer={<>
        <button type="button" className="btn" onClick={close} disabled={busy}>Άκυρο</button>
        <button type="button" className="btn btn--danger" disabled={busy || !posts || (!none && !target)} onClick={submit}>
          {none ? "Διαγραφή" : "Μεταφορά και διαγραφή"}
        </button>
      </>}>
        {detail.error && !detail.data && <PSA.ErrorNotice error={detail.error} onRetry={detail.reload} />}
        {!posts && !detail.error && <PSA.Spinner />}
        {posts && (
          <>
            {none ? (
              <p>Ο συντάκτης «{writer.name}» και η φωτογραφία του σβήνονται οριστικά. Δεν αναιρείται.</p>
            ) : (
              <>
                <ul className="writer-posts" aria-label={`Άρθρα του συντάκτη «${writer.name}»`}>
                  {posts.map((p) => (
                    <li key={p.id}>
                      <span className={`writer-posts__title${p.title ? "" : " is-empty"}`}>{p.title || "Άρθρο χωρίς τίτλο"}</span>
                      <PSA.StateBadge state={p.state} />
                    </li>
                  ))}
                </ul>
                {all.error && !all.data && <PSA.ErrorNotice error={all.error} onRetry={all.reload} />}
                {all.data && (others.length ? (
                  // PSA.Field has no labelId yet (pickers spec §2.5, integration), so the field is written out.
                  <div className="field">
                    <span className="label" id={labelId} onClick={() => { const el = document.getElementById(selectId); if (el) el.focus(); }}>
                      Τα άρθρα περνούν στον συντάκτη
                    </span>
                    <PSA.Select id={selectId} labelId={labelId} value={to} disabled={busy} placeholder="Διαλέξτε συντάκτη"
                                sheetTitle="Τα άρθρα περνούν στον συντάκτη" onChange={(v) => { setTo(v); setError(null); }}
                                options={others.map((w) => ({ value: w.id, label: w.name, description: w.nameLatin || undefined, avatarUrl: w.photoSmallUrl }))} />
                  </div>
                ) : (
                  <div className="notice notice--info"><Icon name="info" size="sm" /><div>Προσθέστε πρώτα άλλον συντάκτη, ή αλλάξτε τον συντάκτη μέσα σε κάθε άρθρο.</div></div>
                ))}
                {live && <div className="notice notice--warn"><Icon name="warning" size="sm" /><div>Τα δημοσιευμένα άρθρα θα δείχνουν αμέσως τον νέο συντάκτη.</div></div>}
              </>
            )}
            {error && <div className="notice notice--danger" role="alert"><Icon name="warning" size="sm" /><div>{errText(error)}</div></div>}
          </>
        )}
      </PSA.Dialog>
    );
  };

  // ---------- page ----------
  PSA.WritersPage = () => {
    const writers = PSA.useWriters();
    const [editing, setEditing] = useState(null);
    const [reassigning, setReassigning] = useState(null);
    const list = writers.data ? writers.data.writers : [];

    const remove = async (w) => {
      if (w.posts && w.posts.total > 0) { setReassigning(w); return; }
      const ok = await confirm({
        title: "Διαγραφή συντάκτη;", danger: true, confirmLabel: "Διαγραφή",
        body: `Ο συντάκτης «${w.name}» και η φωτογραφία του σβήνονται οριστικά. Δεν αναιρείται.`,
      });
      if (!ok) return;
      try {
        await api(`/api/writers/${w.id}`, { method: "DELETE" });
        writersChanged();
        toast("Ο συντάκτης διαγράφηκε.");
      } catch (err) {
        // An article was given this writer in the meantime: hand them over instead.
        if (err instanceof PSA.ApiError && err.code === "writer_in_use") { writersChanged(); setReassigning(w); return; }
        if (err instanceof PSA.ApiError && err.status === 404) writersChanged();
        toast(errText(err), { kind: "error" });
      }
    };
    const create = () => setEditing({ writer: null });

    return (
      <div className="page">
        <div className="page-head">
          <div>
            <h1>Συντάκτες</h1>
            <p>Τα ονόματα και οι φωτογραφίες που υπογράφουν τα άρθρα. Δεν είναι λογαριασμοί: στη διαχείριση συνδέεστε μόνο εσείς.</p>
          </div>
          <div className="page-head__actions">
            <button type="button" className="btn btn--primary" onClick={create}><Icon name="plus" size="sm" />Νέος συντάκτης</button>
          </div>
        </div>
        <div className="sheet sheet--rows" aria-busy={writers.loading}>
          {writers.error && <div style={{ padding: 12 }}><PSA.ErrorNotice error={writers.error} onRetry={writers.reload} /></div>}
          {!writers.data && writers.loading && (
            <div style={{ padding: 14, display: "grid", gap: 14 }}>{[0, 1, 2].map((i) => <div key={i} className="skeleton" style={{ height: 56 }} />)}</div>
          )}
          {writers.data && list.length === 0 && (
            <div className="empty">
              <Icon name="users" size="lg" />
              <h3>Δεν υπάρχει ακόμη συντάκτης</h3>
              <p>Κάθε άρθρο υπογράφεται από έναν συντάκτη με φωτογραφία. Προσθέστε τον πρώτο για να μπορείτε να δημοσιεύετε.</p>
              <button type="button" className="btn btn--primary" onClick={create}><Icon name="plus" size="sm" />Νέος συντάκτης</button>
            </div>
          )}
          {list.length > 0 && (
            <ul className="writer-rows">
              {list.map((w) => (
                <li key={w.id} className="writer-row">
                  <PSA.WriterAvatar writer={w} size={48} />
                  <div className="writer-row__body">
                    <div className="writer-row__name">
                      <strong>{w.name}</strong>
                      {w.nameLatin && <span className="muted" lang="en">{w.nameLatin}</span>}
                    </div>
                    <div className="writer-row__usage">
                      {w.posts && w.posts.total > 0
                        ? <PSA.Link to={`/posts?writer=${w.id}`}>{usageText(w.posts)}</PSA.Link>
                        : <span>{usageText(w.posts)}</span>}
                    </div>
                  </div>
                  <div className="writer-row__actions">
                    <button type="button" className="btn btn--quiet btn--sm" aria-label={`Επεξεργασία: ${w.name}`} onClick={() => setEditing({ writer: w })}>
                      <Icon name="edit" size="sm" />Επεξεργασία
                    </button>
                    <button type="button" className="btn btn--quiet btn--sm" aria-label={`Διαγραφή: ${w.name}`} onClick={() => remove(w)}>
                      <Icon name="trash" size="sm" />Διαγραφή
                    </button>
                  </div>
                </li>
              ))}
            </ul>
          )}
        </div>
        {editing && (
          <PSA.WriterDialog key={editing.writer ? editing.writer.id : "new"} writer={editing.writer || undefined}
                            onClose={() => setEditing(null)} onSaved={() => setEditing(null)} />
        )}
        {reassigning && (
          <PSA.ReassignDialog writer={reassigning} writers={list}
                              onClose={() => setReassigning(null)} onDeleted={() => setReassigning(null)} />
        )}
      </div>
    );
  };
})();
