/* ============================================================
   app.jsx — shell, sidebar, routing
   ============================================================ */

const PROJECT_EDIT_PATH_PREFIX = '/edit/';
const PROJECT_EDITOR_VIEWS = ['ap550', 'appush', 'apH1', 'ap1e'];

function getProjectEditIdFromLocation() {
  try {
    const url = new URL(window.location.href);
    if (url.pathname.indexOf(PROJECT_EDIT_PATH_PREFIX) === 0) {
      const id = url.pathname.slice(PROJECT_EDIT_PATH_PREFIX.length).split('/')[0];
      return id ? decodeURIComponent(id) : null;
    }
    return url.searchParams.get('edit') || url.searchParams.get('banner') || null;
  } catch (e) {
    return null;
  }
}

function setProjectEditUrl(id, replace) {
  if (!id || !window.history) return;
  try {
    const url = new URL(window.location.href);
    url.pathname = PROJECT_EDIT_PATH_PREFIX + encodeURIComponent(id);
    url.search = '';
    url.hash = '';
    const method = replace ? 'replaceState' : 'pushState';
    window.history[method]({ bannerlyEditId: id }, '', url.toString());
  } catch (e) {}
}

function clearProjectEditUrl(replace) {
  try {
    if (!getProjectEditIdFromLocation() || !window.history) return;
    const url = new URL(window.location.href);
    url.pathname = '/';
    url.search = '';
    url.hash = '';
    const method = replace ? 'replaceState' : 'pushState';
    window.history[method]({ bannerlyView: 'home' }, '', url.toString());
  } catch (e) {}
}

/* 워크스페이스 메뉴(배너 어시스턴트 · 내 배너 · 즐겨찾기) — 글로벌 상단바에 삽입 */
function WorkspaceNav({ view, guideCount, role, onNav, favItems, onReorderFav, onOpenFavorite, onOpenFolder }) {
  const isGuide = ['dashboard', 'guideCreate', 'guideManage', 'urlFlow', 'imageFlow', 'url', 'analysis', 'urlReview', 'collected', 'guideDetail'].includes(view);
  const [favOpen, setFavOpen] = useState(false);
  const [favDrag, setFavDrag] = useState(null);
  const [favOver, setFavOver] = useState(null);
  const [favOverPos, setFavOverPos] = useState(null);
  const favRef = useRef(null);
  useEffect(() => {
    if (!favOpen) return;
    const onDoc = (e) => {if (favRef.current && !favRef.current.contains(e.target)) setFavOpen(false);};
    document.addEventListener('mousedown', onDoc);
    return () => document.removeEventListener('mousedown', onDoc);
  }, [favOpen]);
  const items = favItems || [];

  return (
    <nav className="topnav-links">
      {role === 'admin' ?
      /* 관리자 글로벌 메뉴: 가이드 만들기 · 가이드 관리 · 배너 관리 · 회원 관리 (모두 관리자 전용) */
      <React.Fragment>
        <button className={`nav-item ${view === 'guideCreate' ? 'active' : ''}`} onClick={() => onNav('guideCreate')}>
          가이드 만들기
        </button>
        <button className={`nav-item ${view === 'guideManage' ? 'active' : ''}`} onClick={() => onNav('guideManage')}>
          가이드 관리
        </button>
        <button className={`nav-item ${view === 'library' ? 'active' : ''}`} onClick={() => onNav('library')}>
          배너 관리
        </button>
        <button className={`nav-item ${view === 'ga4' ? 'active' : ''}`} onClick={() => onNav('ga4')}>
          분석
        </button>
        <button className={`nav-item ${view === 'members' ? 'active' : ''}`} onClick={() => onNav('members')}>
          회원 관리
        </button>
      </React.Fragment> :
      /* 사용자 글로벌 메뉴: 배너 어시스턴트 · 내 배너 · 각 배너 에디터 직접 진입 */
      <React.Fragment>
        <button className={`nav-item ${isGuide ? 'active' : ''}`} onClick={() => onNav('dashboard')}>
          배너 어시스턴트
        </button>
        {/* 배너 만들기 목록 화면의 복사본(merged/guide2.jsx · view 'dashboard2') — 원본과 독립 */}
        <button className={`nav-item ${view === 'dashboard2' ? 'active' : ''}`} onClick={() => onNav('dashboard2')}>
          배너 에디터
        </button>
        <button className={`nav-item ${view === 'library' ? 'active' : ''}`} onClick={() => onNav('library')}>
          내 배너
        </button>
        {/* [숨김] 에디터 직접진입(상품배너550·앱푸시·상품배너H1·1E)은 사용자 글로벌 메뉴에서만 숨김.
             라우트(view === 'ap550'|'appush'|'apH1'|'ap1e')는 그대로 살아 있음(app.jsx 하단 렌더 분기 999~1002행).
             복원 시 아래 주석 해제:
        <button className={`nav-item ${view === 'ap550' ? 'active' : ''}`} onClick={() => onNav('ap550')}>
          상품배너 550
        </button>
        <button className={`nav-item ${view === 'appush' ? 'active' : ''}`} onClick={() => onNav('appush')}>
          앱푸시
        </button>
        <button className={`nav-item ${view === 'apH1' ? 'active' : ''}`} onClick={() => onNav('apH1')}>
          상품배너 H1
        </button>
        <button className={`nav-item ${view === 'ap1e' ? 'active' : ''}`} onClick={() => onNav('ap1e')}>
          1E 기술서상단
        </button>
        */}
      </React.Fragment>
      }
      {/* [보류] 즐겨찾기 — 임시로 숨김(삭제 아님). 복원 시 이 블록 전체 주석을 해제할 것. */}
      {false &&
      <div className="fav-wrap" ref={favRef} style={{ position: 'relative' }}>
        <button className={`nav-item ${favOpen ? 'active' : ''}`} onClick={() => setFavOpen((o) => !o)}>
          <S2Icon name="star" size={15} /> 즐겨찾기
          <S2Icon name="chevronDown" size={13} style={{ marginLeft: 2, color: 'var(--text-faint)' }} />
        </button>
        {favOpen &&
        <div className="fav-menu">
            <div className="nav-fav-label">가이드 즐겨찾기</div>
            {items.length === 0 ?
            <div className="nav-fav-empty">즐겨찾기한 가이드나 폴더가 없어요.</div> :
            <div className="nav-fav-list">
              {items.map((it) =>
              <button key={it.key}
              className={`nav-fav-item${it.kind === 'guide' ? ' nav-fav-guide' : ''}${favDrag === it.key ? ' is-dragging' : ''}${favOver === it.key && favOverPos === 'above' ? ' drop-above' : ''}${favOver === it.key && favOverPos === 'below' ? ' drop-below' : ''}`}
              draggable
              onDragStart={(e) => {setFavDrag(it.key);e.dataTransfer.effectAllowed = 'move';try {e.dataTransfer.setData('text/plain', it.key);} catch (_) {}}}
              onDragOver={(e) => {
                if (favDrag && favDrag !== it.key) {
                  e.preventDefault();
                  const r = e.currentTarget.getBoundingClientRect();
                  setFavOver(it.key);
                  setFavOverPos(e.clientY < r.top + r.height / 2 ? 'above' : 'below');
                }
              }}
              onDragLeave={() => setFavOver((v) => v === it.key ? null : v)}
              onDrop={(e) => {e.preventDefault();if (favDrag && favDrag !== it.key) onReorderFav && onReorderFav(favDrag, it.key, favOverPos);setFavDrag(null);setFavOver(null);setFavOverPos(null);}}
              onDragEnd={() => {setFavDrag(null);setFavOver(null);setFavOverPos(null);}}
              onClick={() => {if (it.kind === 'folder') onOpenFolder && onOpenFolder(it.id);else onOpenFavorite && onOpenFavorite(it.guide);setFavOpen(false);}}
              title={it.kind === 'folder' ? it.name : `${it.guide.category || ''} · ${it.guide.name}`}>
                  {it.kind === 'folder' ?
                <React.Fragment>
                      <S2Icon name="folder" size={15} style={{ color: 'var(--text-subtle)', flexShrink: 0 }} />
                      <span className="nav-fav-name" style={{ fontSize: "12px" }}>{it.name}</span>
                    </React.Fragment> :

                <React.Fragment>
                      <S2Icon name="layout" size={15} style={{ color: 'var(--text-subtle)', flexShrink: 0 }} />
                      <span className="nav-fav-texts">
                        <span className="nav-fav-name" style={{ fontSize: "12px" }}>{it.guide.name}</span>
                        <span className="nav-fav-cat" style={{ color: "rgb(153, 153, 153)" }}>{it.guide.category || '기타'}</span>
                      </span>
                    </React.Fragment>}
                </button>
              )}
            </div>}
          </div>
        }
      </div>
      }
    </nav>);

}

/* 계정 칩 + 계정 메뉴 — 글로벌 상단바 우측에 삽입 */
function AccountMenu({ role, email, onLogout, onSettings, canSwitchView, viewAsUser, onToggleView }) {
  const [acctOpen, setAcctOpen] = useState(false);
  const acctButtonRef = useRef(null);
  // 로그인 계정의 실제 값 사용, 없으면 기존 표시값 유지
  const acctEmail = email || '';
  const acctName = (typeof window !== 'undefined' && window.__bannerlyName) || '';
  const acctDept = (typeof window !== 'undefined' && window.__bannerlyDept) || '';

  useEffect(() => {
    if (!acctOpen) return;
    const onKeyDown = (event) => {
      if (event.key !== 'Escape') return;
      event.preventDefault();
      setAcctOpen(false);
      window.requestAnimationFrame(() => acctButtonRef.current && acctButtonRef.current.focus());
    };
    document.addEventListener('keydown', onKeyDown);
    return () => document.removeEventListener('keydown', onKeyDown);
  }, [acctOpen]);

  return (
    <div className="topnav-acct" style={{ position: 'relative' }}>
      {/* backdrop: z-index 1 in root ctx → above editor (z=auto) but below iglobalbar (z=20) */}
      {acctOpen && <div onClick={() => setAcctOpen(false)} style={{ position: 'fixed', inset: 0, zIndex: 1 }} />}
      <button ref={acctButtonRef} className={`user-chip user-chip-icon${acctOpen ? ' open' : ''}`} onClick={() => setAcctOpen((o) => !o)} title={(acctName || acctEmail || '계정') + (acctDept ? ' / ' + acctDept : '')} aria-haspopup="menu" aria-expanded={acctOpen}>
        <div className={`avatar${role === 'user' ? ' avatar-user' : ''}`} style={{ backgroundColor: "#fff", color: "#6b6b78", border: "1.5px solid #d0d0d8" }}>{(acctName || acctEmail || '?').slice(0, 1).toUpperCase()}</div>
      </button>

      {acctOpen &&
      <div className="acct-menu acct-menu-top" role="menu">
          <div className="acct-menu-head">
            <div className="acct-name-row">
              <span className="acct-name">{acctName || '이름 없음'}</span>
              <span className="acct-role">{role === 'user' ? '사용자' : '관리자'}</span>
            </div>
            {acctDept && <div className="acct-team">{acctDept}</div>}
            {acctEmail && <div className="acct-email">{acctEmail}</div>}
            {canSwitchView && viewAsUser &&
              <div style={{ fontSize: 11, color: '#b45309', fontWeight: 700, marginTop: 6, background: '#fff7ed', border: '1px solid #fed7aa', borderRadius: 6, padding: '3px 8px', display: 'inline-block' }}>지금: 사용자 모드</div>}
          </div>
          <div className="acct-menu-sep" />
          <button className="acct-menu-item" onClick={() => {setAcctOpen(false);if (onSettings) onSettings();}} style={{ fontSize: "12px" }}>
            <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="3" /><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" /></svg>
            계정 설정
          </button>
          {canSwitchView &&
          <button className="acct-menu-item" onClick={() => {setAcctOpen(false);if (onToggleView) onToggleView();}} style={{ fontSize: "12px" }}>
            <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"><path d="M8 3H5a2 2 0 0 0-2 2v3" /><path d="M21 8V5a2 2 0 0 0-2-2h-3" /><path d="M3 16v3a2 2 0 0 0 2 2h3" /><path d="M16 21h3a2 2 0 0 0 2-2v-3" /></svg>
            {viewAsUser ? '관리자 모드로 전환' : '사용자 모드로 전환'}
          </button>
          }
          <button className="acct-menu-item" onClick={() => {setAcctOpen(false);if (onLogout) onLogout();}} style={{ fontSize: "12px" }}>
            <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" /><path d="m16 17 5-5-5-5" /><path d="M21 12H9" /></svg>
            로그아웃
          </button>
        </div>
      }
    </div>);

}

const S_CELL = { height: 40, padding: '0 12px', borderRadius: 8, border: '1px solid var(--border)', font: 'inherit', fontSize: 13, background: '#fff', width: '100%', boxSizing: 'border-box', color: 'var(--text-1)' };
const S_LB = { fontSize: 11.5, fontWeight: 700, color: '#43434e', margin: '0 0 6px' };
const ModalShell = ({ onClose, width, children }) =>
<div onClick={onClose} style={{ position: 'fixed', inset: 0, background: 'rgba(20,20,26,.45)', display: 'grid', placeItems: 'center', zIndex: 10000 }}>
    <div onClick={(e) => e.stopPropagation()} style={{ position: 'relative', width: width || 400, maxWidth: '92vw', background: '#fff', borderRadius: 16, padding: 24, boxShadow: '0 12px 40px rgba(0,0,0,.22)' }}>
      <button onClick={onClose} title="닫기" style={{ position: 'absolute', top: 14, right: 14, border: 'none', background: 'none', cursor: 'pointer', fontSize: 22, lineHeight: 1, color: 'var(--text-subtle)', padding: 4 }}>×</button>
      {children}
    </div>
  </div>;

/* 계정 설정 — 한 팝업 안: 이름·부서 + 구분선 아래 비밀번호 변경(현재/새/새 확인) 인라인 */
function AccountSettingsModal({ email, onClose }) {
  const [name, setName] = useState((typeof window !== 'undefined' && window.__bannerlyName) || '');
  const [dept, setDept] = useState((typeof window !== 'undefined' && window.__bannerlyDept) || '');
  const [depts, setDepts] = useState([]);
  const [busy, setBusy] = useState(false);
  const [msg, setMsg] = useState('');
  const [cur, setCur] = useState('');
  const [np, setNp] = useState('');
  const [np2, setNp2] = useState('');
  const [show, setShow] = useState(false);
  useEffect(() => {(async () => {if (window.__admin) setDepts((await window.__admin.listDepartments()) || []);})();}, []);
  const deptNames = depts.map((d) => d.name);
  const opts = [{ value: '', label: '부서 선택' }].concat((dept && deptNames.indexOf(dept) < 0 ? [dept] : []).concat(deptNames).map((d) => ({ value: d, label: d })));
  const wantPw = !!(cur || np || np2); // 비번 칸에 뭔가 입력했으면 변경 시도
  const save = async () => {
    if (busy) return;
    if (wantPw) {
      if (!cur) {alert('현재 비밀번호를 입력하세요');return;}
      if (np.length < 6) {alert('새 비밀번호는 6자 이상이어야 해요');return;}
      if (np !== np2) {alert('새 비밀번호가 일치하지 않아요');return;}
    }
    setBusy(true); setMsg('');
    try {
      const r = await window.__account.updateInfo({ name: name.trim(), department: dept });
      let pwErr = '';
      if (wantPw) {const pr = await window.__auth.changePassword(cur, np);if (!(pr && pr.ok)) pwErr = (pr && pr.error) || '비밀번호 변경 실패';}
      if (r && r.ok && !pwErr) {setMsg(wantPw ? '저장되었습니다 (비밀번호 변경 완료)' : '저장되었습니다');setCur('');setNp('');setNp2('');}
      else alert([r && !r.ok ? (r.error || '정보 저장 실패') : '', pwErr].filter(Boolean).join('\n'));
    } finally {setBusy(false);}
  };
  const pwField = (val, set, ph, auto) =>
  <div style={{ position: 'relative' }}>
      <input style={{ ...S_CELL, paddingRight: 42 }} type={show ? 'text' : 'password'} value={val} placeholder={ph} autoComplete={auto} onChange={(e) => set(e.target.value)} />
      <button type="button" onClick={() => setShow((s) => !s)} title={show ? '숨기기' : '보기'} style={{ position: 'absolute', right: 10, top: '50%', transform: 'translateY(-50%)', border: 'none', background: 'none', cursor: 'pointer', color: '#8a8a96', padding: 2, lineHeight: 0 }}>
        {show ?
          <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24" /><path d="M1 1l22 22" /></svg> :
          <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" /><circle cx="12" cy="12" r="3" /></svg>}
      </button>
    </div>;
  return (
    <ModalShell onClose={onClose} width={400}>
      <div style={{ fontSize: 16, fontWeight: 800, marginBottom: 18 }}>계정 설정</div>
      <div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
        <div><div style={S_LB}>이메일</div><input style={{ ...S_CELL, background: '#f5f5f7', color: 'var(--text-subtle)' }} value={email} disabled /></div>
        <div><div style={S_LB}>이름</div><input style={S_CELL} value={name} onChange={(e) => setName(e.target.value)} placeholder="이름" /></div>
        <div><div style={S_LB}>부서</div><DDSelect value={dept} onChange={setDept} options={opts} placeholder="부서 선택" /></div>
        <div style={{ borderTop: '1px solid var(--border)', paddingTop: 16, display: 'flex', flexDirection: 'column', gap: 10 }}>
          <div style={{ fontSize: 13, fontWeight: 700 }}>비밀번호 변경 <span style={{ fontWeight: 400, color: 'var(--text-subtle)' }}>(변경 시에만 입력)</span></div>
          {pwField(cur, setCur, '현재 비밀번호', 'current-password')}
          {pwField(np, setNp, '새 비밀번호 (6자 이상)', 'new-password')}
          {pwField(np2, setNp2, '새 비밀번호 확인', 'new-password')}
        </div>
      </div>
      {msg && <div style={{ fontSize: 12.5, color: '#12833f', fontWeight: 700, marginTop: 12 }}>{msg}</div>}
      <button className="btn btn-primary" style={{ height: 40, width: '100%', marginTop: 18 }} disabled={busy} onClick={save}>{busy ? '저장 중…' : '저장'}</button>
    </ModalShell>);

}

function Topbar({ showCreate, onCreateUrl, onCreateImage }) {
  if (!showCreate) return null;
  return (
    <div className="topbar">
      <div className="topbar-actions">
        {showCreate &&
        <React.Fragment>
            <button className="btn btn-secondary" style={{ borderRadius: "6px", height: 30, fontSize: 12, padding: '0 12px' }} onClick={onCreateImage}>
              <S2Icon name="images" size={14} /> 이미지로 만들기
            </button>
            <button className="btn btn-secondary" style={{ borderRadius: "6px", height: 30, fontSize: 12, padding: '0 12px' }} onClick={onCreateUrl}>
              <S2Icon name="plus" size={14} /> 가이드 직접 추가
            </button>
            <button className="btn btn-primary" style={{ borderRadius: "6px", height: 30, fontSize: 12, padding: '0 12px' }} onClick={() => {try {window.postMessage({ type: 'bannerly-open-editor', name: '새 가이드', blank: true }, '*');} catch (e) {}}}>
              <S2Icon name="wand" size={14} /> 새 가이드 만들기
            </button>
          </React.Fragment>
        }
      </div>
    </div>);

}

function Placeholder({ title, text, icon }) {
  return (
    <div className="content"><div className="content-pad">
      <div className="empty-wrap">
        <div className="empty fade-up">
          <div style={{ width: 56, height: 56, borderRadius: 16, background: 'var(--bg-panel)', display: 'grid', placeItems: 'center', color: 'var(--text-subtle)', marginBottom: 20 }}>
            <S2Icon name={icon} size={26} />
          </div>
          <h2 className="empty-title">{title}</h2>
          <p className="empty-text">{text}</p>
        </div>
      </div>
    </div></div>);

}

function getPageWindow(activePage, pageCount, groupSize = 10) {
  const start = Math.floor((Math.max(1, activePage) - 1) / groupSize) * groupSize + 1;
  const end = Math.min(Math.max(1, pageCount), start + groupSize - 1);
  return Array.from({ length: end - start + 1 }, (_, index) => start + index);
}

function bannerDownloadExtension(blobType, url) {
  const type = String(blobType || '').toLowerCase();
  if (type.includes('png')) return 'png';
  if (type.includes('webp')) return 'webp';
  if (type.includes('jpeg') || type.includes('jpg')) return 'jpg';
  const match = String(url || '').match(/\.(png|webp|jpe?g)(?:\?|$)/i);
  return match ? (match[1].toLowerCase() === 'jpeg' ? 'jpg' : match[1].toLowerCase()) : 'jpg';
}

function bannerDownloadName(it, blobType, url) {
  const ext = bannerDownloadExtension(blobType, url);
  const stored = String(it && it.meta && it.meta.fileName || '').trim();
  if (stored) return /\.(png|webp|jpe?g)$/i.test(stored) ? stored.replace(/\.(png|webp|jpe?g)$/i, '.' + ext) : stored + '.' + ext;
  const base = ((it && it.meta && (it.meta.event || it.meta.category || 'banner')) + '_' + (it && it.meta && it.meta.type || ''))
    .replace(/\s+/g, '_').replace(/[\\/:*?"<>|]+/g, '_').replace(/_+$/g, '');
  return (base || 'banner') + '.' + ext;
}

function bannerDirectDownloadUrl(sourceUrl, fileName) {
  const source = String(sourceUrl || '');
  try {
    const parsed = new URL(source, window.location.href);
    if (!/\/storage\/v1\/object\/(?:sign|public|authenticated)\//.test(parsed.pathname)) return '';
    parsed.searchParams.set('download', fileName);
    return parsed.toString();
  } catch (e) {
    return '';
  }
}

/* 내 배너 — Supabase 에 저장된 배너 목록 (window.__myBanners.list) */
function MyBannersView({ role = 'user', onOpen, onToast }) {
  const PAGE_SIZE = 32;
  // 고객 배너 서비스의 공식 카테고리. 과거 1E 프로젝트는 DB에 보존하되 고객 목록·집계에는 노출하지 않는다.
  const CUSTOMER_BANNER_CATEGORIES = ['앱푸시', '상품배너 550', '상품배너 H1'];
  const [state, setState] = useState('loading'); // loading | ok | empty
  const [items, setItems] = useState([]);
  const [total, setTotal] = useState(0);
  const [page, setPage] = useState(1);
  const [reloadKey, setReloadKey] = useState(0);
  const previewRequestsRef = useRef(new Set());
  const [busy, setBusy] = useState(null); // 작업 중인 항목 id (삭제/복사)
  const [delTarget, setDelTarget] = useState(null); // 삭제 확인 대상
  const [sel, setSel] = useState(() => new Set()); // 선택된 배너 id (개별/전체 선택)
  const [bulkDelOpen, setBulkDelOpen] = useState(false); // 선택 삭제 확인
  const [bulkBusy, setBulkBusy] = useState(false);
  // ── 검색·필터 (관리자 회원관리와 동일 패턴) ──
  const [q, setQ] = useState('');                 // 검색어(유형·행사명·작성자)
  const [ownerFilter, setOwnerFilter] = useState(''); // 관리자: 고른 작성자
  const [ownerOpen, setOwnerOpen] = useState(false);
  const ownerBoxRef = useRef(null);
  const [typeFilter, setTypeFilter] = useState(''); // 배너 유형(카테고리) 필터
  const [statusFilter, setStatusFilter] = useState(''); // 상태 필터(완료/작업중)
  const [dateFrom, setDateFrom] = useState('');   // 날짜 시작(달력)
  const [dateTo, setDateTo] = useState('');       // 날짜 끝(달력)
  const isAdmin = role === 'admin';
  const hasFilter = !!(q || ownerFilter || typeFilter || statusFilter || dateFrom || dateTo);
  const requiresClientFilter = !!(q || ownerFilter || statusFilter === 'none');
  const serverPaged = !requiresClientFilter;
  const requestedPage = serverPaged ? page : 0;
  const serverFilterKey = [typeFilter, statusFilter, dateFrom, dateTo].join('|');
  const reload = React.useCallback(() => {
    setSel(new Set());
    setReloadKey((value) => value + 1);
  }, []);
  useEffect(() => {
    let cancelled = false;
    const load = async () => {
      setState('loading');
      setSel(new Set());
      if (!window.__myBanners) {
        if (!cancelled) {setItems([]);setTotal(0);setState(isAdmin ? 'ok' : 'empty');}
        return;
      }
      if (serverPaged && window.__myBanners.listPage) {
        const result = await window.__myBanners.listPage({
          page: page, pageSize: PAGE_SIZE, mineOnly: !isAdmin,
          allowedCategories: CUSTOMER_BANNER_CATEGORIES,
          typeFilter: typeFilter, statusFilter: statusFilter,
          dateFrom: dateFrom, dateTo: dateTo,
        });
        if (cancelled) return;
        const next = (result && result.items) || [];
        const scoped = next.filter((it) => CUSTOMER_BANNER_CATEGORIES.includes((it.meta && it.meta.category) || '') && (isAdmin || it.mine));
        setItems(scoped);
        setTotal((result && result.total) || 0);
        setState((scoped.length || (result && result.total) || isAdmin) ? 'ok' : 'empty');
        return;
      }
      const list = (await window.__myBanners.list()) || [];
      if (cancelled) return;
      const scoped = list.filter((it) => CUSTOMER_BANNER_CATEGORIES.includes((it.meta && it.meta.category) || '') && (isAdmin || it.mine));
      setItems(scoped);
      setTotal(scoped.length);
      setState((scoped.length || isAdmin) ? 'ok' : 'empty');
    };
    load().catch(() => {
      if (!cancelled) {setItems([]);setTotal(0);setState(isAdmin ? 'ok' : 'empty');}
    });
    return () => {cancelled = true;};
  }, [isAdmin, serverPaged, requestedPage, serverFilterKey, reloadKey]);
  // 유형(카테고리) 옵션 + 검색·필터 적용
  const catOf = (it) => (it.meta && it.meta.category) || '';
  const bannerTypes = CUSTOMER_BANNER_CATEGORIES;
  const _norm = (s) => (s || '').toString().toLowerCase();
  const _kw = _norm(q).trim();
  const owners = Array.from(new Set(items.map((it) => it.owner).filter(Boolean))).sort((a, b) => a.localeCompare(b, 'ko'));
  const ownerHits = (!_kw || !isAdmin) ? [] : owners.filter((n) => _norm(n).indexOf(_kw) >= 0);
  const filtered = items.filter((it) => {
    if (ownerFilter && (it.owner || '') !== ownerFilter) return false;
    if (_kw && !ownerFilter) {const hay = _norm([catOf(it), it.meta && it.meta.type, it.meta && it.meta.event, it.owner].filter(Boolean).join(' '));if (hay.indexOf(_kw) < 0) return false;}
    if (typeFilter && catOf(it) !== typeFilter) return false;
    if (statusFilter) {
      const st = it.status || '';
      if (statusFilter === 'none') { if (st !== '' && st) return false; }
      else if (st !== statusFilter) return false;
    }
    if (dateFrom || dateTo) {const d = it.createdAt ? String(it.createdAt).slice(0, 10) : '';if (dateFrom && d < dateFrom) return false;if (dateTo && d > dateTo) return false;}
    return true;
  });
  const displayTotal = serverPaged ? total : filtered.length;
  const pageCount = Math.max(1, Math.ceil(displayTotal / PAGE_SIZE));
  const activePage = Math.min(page, pageCount);
  const visiblePages = getPageWindow(activePage, pageCount);
  const shown = serverPaged ? items : filtered.slice((activePage - 1) * PAGE_SIZE, activePage * PAGE_SIZE);
  const clearFilters = () => {setQ('');setOwnerFilter('');setOwnerOpen(false);setTypeFilter('');setStatusFilter('');setDateFrom('');setDateTo('');setPage(1);};
  const pickOwner = (name) => {setOwnerFilter(name);setQ(name);setOwnerOpen(false);setPage(1);};
  const previewBatchKey = shown
    .filter((it) => it.previewState === 'pending' && !previewRequestsRef.current.has(it.projectId || it.id))
    .map((it) => it.projectId || it.id)
    .join('|');
  useEffect(() => {
    if (!previewBatchKey) return;
    const ids = previewBatchKey.split('|').filter(Boolean);
    ids.forEach((id) => previewRequestsRef.current.add(id));
    setItems((prev) => prev.map((it) => ids.includes(it.projectId || it.id) ? { ...it, previewState: 'loading' } : it));
    const load = window.__myBanners && window.__myBanners.loadPreviews;
    Promise.resolve(load ? load.call(window.__myBanners, ids) : null)
      .then((updates) => {
        const next = updates || {};
        setItems((prev) => prev.map((it) => {
          const id = it.projectId || it.id;
          if (!ids.includes(id)) return it;
          return { ...it, ...(next[id] || { previewState: 'error' }) };
        }));
      })
      .catch(() => {
        setItems((prev) => prev.map((it) => ids.includes(it.projectId || it.id) ? { ...it, previewState: 'error' } : it));
      })
      .finally(() => ids.forEach((id) => previewRequestsRef.current.delete(id)));
  }, [previewBatchKey]);
  useEffect(() => {
    if (page !== activePage) setPage(activePage);
  }, [page, activePage]);
  useEffect(() => { setSel(new Set()); }, [activePage, q, ownerFilter, typeFilter, statusFilter, dateFrom, dateTo]);
  const retryPreview = (it) => {
    const id = it.projectId || it.id;
    setItems((prev) => prev.map((row) => (row.projectId || row.id) === id ? { ...row, previewState: 'pending' } : row));
  };
  useEffect(() => {
    if (!ownerOpen) return;
    const onDoc = (e) => {if (ownerBoxRef.current && !ownerBoxRef.current.contains(e.target)) setOwnerOpen(false);};
    document.addEventListener('mousedown', onDoc);
    return () => document.removeEventListener('mousedown', onDoc);
  }, [ownerOpen]);
  const [dlBusy, setDlBusy] = useState(null); // 다운로드 중인 항목 id
  const download = async (it) => {
    const sourceUrl = it.downloadUrl || it.url;
    if (!sourceUrl) { toast('저장된 이미지가 없습니다. 배너를 먼저 저장해 주세요.'); return; }
    if (dlBusy === it.id) return;
    setDlBusy(it.id);
    const initialName = bannerDownloadName(it, '', sourceUrl);
    const directUrl = bannerDirectDownloadUrl(sourceUrl, initialName);
    try {
      if (directUrl) {
        const a = document.createElement('a');
        a.href = directUrl;
        a.download = initialName;
        document.body.appendChild(a); a.click(); document.body.removeChild(a);
        toast('다운로드했습니다');
        return;
      }
      const controller = typeof AbortController === 'function' ? new AbortController() : null;
      const timer = controller ? setTimeout(() => controller.abort(), 12000) : null;
      let res;
      try {
        res = await fetch(sourceUrl, { mode: 'cors', signal: controller ? controller.signal : undefined });
      } finally {
        if (timer) clearTimeout(timer);
      }
      if (!res.ok) throw new Error('HTTP ' + res.status);
      const blob = await res.blob();
      const blobUrl = URL.createObjectURL(blob);
      const a = document.createElement('a');
      a.href = blobUrl;
      a.download = bannerDownloadName(it, blob.type, sourceUrl);
      document.body.appendChild(a); a.click(); document.body.removeChild(a);
      setTimeout(() => URL.revokeObjectURL(blobUrl), 2000);
      toast('다운로드했습니다');
    } catch (e) {
      // fetch 실패 → 새 탭(브라우저 Save As로 대체)
      const a = document.createElement('a');
      a.href = sourceUrl; a.target = '_blank'; a.rel = 'noopener';
      document.body.appendChild(a); a.click(); document.body.removeChild(a);
      toast('이미지를 새 탭에서 열었습니다. 우클릭 → 다른 이름으로 저장하세요.');
    } finally {
      setDlBusy(null);
    }
  };
  const toast = (m) => {if (onToast) onToast(m);else alert(m);};
  // 복사 — selection(스냅샷)·이미지까지 그대로 새 항목 생성
  const copyItem = async (it) => {
    if (!window.__myBanners || busy) return;
    setBusy(it.id);
    try {
      const r = await window.__myBanners.duplicate(it.projectId || it.id);
      if (r && r.ok) {toast('배너를 복사했습니다');reload();}
      else toast('복사 실패: ' + ((r && r.error) || '알 수 없는 오류'));
    } catch (e) {
      toast('복사 중 오류가 발생했습니다');
    } finally {
      setBusy(null);
    }
  };
  // 삭제 — 확인 후 스토리지 이미지까지 함께 삭제
  const removeItem = async (it) => {
    if (!window.__myBanners || busy) return;
    setBusy(it.id);
    try {
      const r = it.assetId && window.__myBanners.removeAsset
        ? await window.__myBanners.removeAsset(it.assetId)
        : await window.__myBanners.remove(it.projectId || it.id);
      if (r && r.ok) {toast(it.assetId ? '저장 이미지를 삭제했습니다' : '배너를 삭제했습니다');reload();}
      else toast('삭제 실패: ' + ((r && r.error) || '알 수 없는 오류'));
    } catch (e) {
      toast('삭제 중 오류가 발생했습니다');
    } finally {
      setBusy(null);
    }
  };
  // ── 다중 선택 ── (mock/샘플은 선택 대상 아님)
  const selectable = shown.filter((it) => !it.mock && it.previewState === 'ready');
  const allSelected = selectable.length > 0 && selectable.every((it) => sel.has(it.id));
  const toggleOne = (id) => setSel((prev) => {const n = new Set(prev);n.has(id) ? n.delete(id) : n.add(id);return n;});
  const toggleAll = () => setSel(allSelected ? new Set() : new Set(selectable.map((it) => it.id)));
  const bulkRemove = async () => {
    if (!window.__myBanners || bulkBusy) return;
    const targets = selectable.filter((it) => sel.has(it.id));
    if (!targets.length) return;
    setBulkBusy(true);
    let ok = 0, fail = 0;
    for (const it of targets) {
      const r = it.assetId && window.__myBanners.removeAsset
        ? await window.__myBanners.removeAsset(it.assetId)
        : await window.__myBanners.remove(it.projectId || it.id);
      if (r && r.ok) ok++; else fail++;
    }
    setBulkBusy(false);
    toast(fail ? `${ok}개 삭제 · ${fail}개 실패` : `${ok}개 삭제했습니다`);
    reload();
  };

  return (
    <div className="content"><div className="content-pad">
      <div className="page-head" style={{ marginBottom: 20 }}>
        <h1 className="page-title">{isAdmin ? '배너 관리' : '내 배너'}</h1>
        <p className="page-sub">{isAdmin ? '사용자가 만든 배너를 확인·관리합니다.' : '저장한 배너가 이곳에 모입니다.'}</p>
      </div>
      {state === 'loading' &&
        <div className="empty-wrap"><div className="empty">
          <div className="spinner" style={{ width: 28, height: 28, marginBottom: 16 }} />
          <p className="empty-text">불러오는 중…</p>
        </div></div>}
      {state === 'empty' &&
        <div className="empty-wrap"><div className="empty">
          <h2 className="empty-title">아직 저장한 배너가 없어요</h2>
          <p className="empty-text">배너를 만든 뒤 “내 배너에 저장”을 누르면 여기에 모입니다.</p>
        </div></div>}
      {state === 'ok' && items.length > 0 &&
        <div style={{ marginBottom: 14, display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'center' }}>
          <div ref={ownerBoxRef} style={{ position: 'relative', flex: '1 1 220px', minWidth: 180 }}>
            <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#8a8a96" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ position: 'absolute', left: 12, top: '50%', transform: 'translateY(-50%)', pointerEvents: 'none' }}><circle cx="11" cy="11" r="8" /><path d="M21 21l-4.35-4.35" /></svg>
            <input type="text" placeholder={isAdmin ? '작성자·유형·행사명 검색' : '유형·행사명으로 검색'} value={q}
              onChange={(e) => {setQ(e.target.value);setOwnerFilter('');setOwnerOpen(true);setPage(1);}}
              onFocus={() => {if (isAdmin && q.trim()) setOwnerOpen(true);}}
              style={{ width: '100%', height: 40, borderRadius: 8, border: '1px solid var(--border)', padding: hasFilter ? '0 34px 0 36px' : '0 12px 0 36px', font: 'inherit', fontSize: 13, background: '#fff', boxSizing: 'border-box', color: 'var(--text-1)' }} />
            {hasFilter && <button type="button" title="필터 초기화" onClick={clearFilters}
              onMouseEnter={(e) => { e.currentTarget.style.background = '#e7e7ee'; }}
              onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; }}
              style={{ position: 'absolute', right: 8, top: '50%', transform: 'translateY(-50%)', border: 'none', background: 'transparent', color: '#6b6b78', width: 20, height: 20, borderRadius: 999, cursor: 'pointer', padding: 0, display: 'grid', placeItems: 'center' }}>
              <S2Icon name="refresh" size={13} strokeWidth={2.2} />
            </button>}
            {isAdmin && ownerOpen && ownerHits.length > 0 &&
              <div style={{ position: 'absolute', left: 0, right: 0, top: 44, background: '#fff', border: '1px solid var(--border)', borderRadius: 8, boxShadow: '0 8px 24px rgba(0,0,0,.14)', zIndex: 40, maxHeight: 240, overflowY: 'auto', padding: 4 }}>
                {ownerHits.map((n) =>
                  <div key={n} onMouseDown={(e) => {e.preventDefault();pickOwner(n);}}
                    style={{ padding: '9px 10px', borderRadius: 6, fontSize: 13, cursor: 'pointer', color: 'var(--text-1)', background: n === ownerFilter ? '#eef2ff' : 'transparent', fontWeight: n === ownerFilter ? 700 : 400 }}
                    onMouseEnter={(e) => {if (n !== ownerFilter) e.currentTarget.style.background = '#f5f5f7';}}
                    onMouseLeave={(e) => {e.currentTarget.style.background = n === ownerFilter ? '#eef2ff' : 'transparent';}}>
                    {n}
                  </div>)}
              </div>}
          </div>
          <DDSelect value={typeFilter} width={150} onChange={(v) => {setTypeFilter(v);setPage(1);}} placeholder="전체 유형"
            options={[{ value: '', label: '전체 유형' }].concat(bannerTypes.map((c) => ({ value: c, label: c })))} />
          <DDSelect value={statusFilter} width={120} onChange={(v) => {setStatusFilter(v);setPage(1);}} placeholder="전체 상태"
            options={[{ value: '', label: '전체 상태' }, { value: 'saved', label: '완료' }, { value: 'draft', label: '작업중' }, { value: 'none', label: '임시저장' }]} />
          <DateRangePicker from={dateFrom} to={dateTo} onChange={(f, t) => {setDateFrom(f);setDateTo(t);setPage(1);}} />
        </div>}
      {state === 'ok' && selectable.length > 0 &&
        <div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 14, flexWrap: 'wrap' }}>
          <label style={{ display: 'inline-flex', alignItems: 'center', gap: 7, fontSize: 13, fontWeight: 600, color: 'var(--text-1)', cursor: 'pointer', userSelect: 'none' }}>
            <input type="checkbox" checked={allSelected} onChange={toggleAll} style={{ width: 16, height: 16, cursor: 'pointer' }} />
            현재 페이지 전체 선택
          </label>
          <span style={{ fontSize: 12, color: 'var(--text-subtle)' }}>총 {displayTotal}개 · {activePage}/{pageCount} 페이지</span>
          {sel.size > 0 && <span style={{ fontSize: 13, color: 'var(--text-subtle)' }}>{sel.size}개 선택됨</span>}
          <div style={{ flex: 1 }} />
          <button className="btn btn-secondary" disabled={sel.size === 0 || bulkBusy}
            style={{ height: 34, fontSize: 13, color: sel.size ? '#D92D20' : undefined }}
            onClick={() => setBulkDelOpen(true)}>{bulkBusy ? '삭제 중…' : (sel.size ? `선택 삭제 (${sel.size})` : '선택 삭제')}</button>
        </div>}
      {state === 'ok' && shown.length === 0 && items.length > 0 &&
        <div className="empty-wrap"><div className="empty">
          <p className="empty-text">검색·필터 결과가 없어요</p>
          <button className="btn btn-secondary" style={{ marginTop: 12 }} onClick={clearFilters}>필터 초기화</button>
        </div></div>}
      {state === 'ok' && shown.length > 0 &&
        <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill,minmax(240px,1fr))', gap: 18 }}>
          {shown.map((it) =>
            <div key={it.id} style={{ border: it.mock ? '1px dashed var(--border)' : '1px solid var(--border)', boxShadow: sel.has(it.id) ? '0 0 0 2px #4E4CDB' : undefined, borderRadius: 12, overflow: 'hidden', background: '#fff' }}>
              <div style={{ position: 'relative', background: '#f3f3f5', display: 'grid', placeItems: 'center', height: 150, overflow: 'hidden' }}>
                {it.previewState === 'pending' || it.previewState === 'loading' ?
                  <span style={{ display: 'inline-flex', alignItems: 'center', gap: 7, fontSize: 12, color: '#7a7a86' }}><span className="spinner" style={{ width: 14, height: 14 }} />미리보기 불러오는 중</span> :
                  it.previewState === 'error' ?
                  <button type="button" className="btn btn-secondary" style={{ height: 32, fontSize: 12 }} onClick={() => retryPreview(it)}><S2Icon name="refresh" size={13} /> 다시 시도</button> :
                  it.url ?
                  <img src={it.url} alt={((it.meta && (it.meta.event || it.meta.category)) || '배너') + ' 미리보기'} loading="lazy" style={{ width: '100%', height: '100%', objectFit: 'contain', display: 'block' }} /> :
                  <span style={{ fontSize: 12, color: '#9a9aa6' }}>미리보기 없음</span>}
                {it.url && it.previewKind === 'source' &&
                  <span style={{ position: 'absolute', left: 8, bottom: 8, zIndex: 1, fontSize: 10.5, fontWeight: 700, color: '#fff', background: 'rgba(28,28,34,.72)', padding: '3px 7px', borderRadius: 4 }}>원본 이미지</span>}
                {!it.mock && it.previewState === 'ready' &&
                  <label style={{ position: 'absolute', top: 8, left: 8, zIndex: 2, display: 'inline-flex', cursor: 'pointer' }} onClick={(e) => e.stopPropagation()}>
                    <input type="checkbox" checked={sel.has(it.id)} onChange={() => toggleOne(it.id)} style={{ width: 18, height: 18, margin: 0, cursor: 'pointer', display: 'block', boxSizing: 'border-box', appearance: 'none', WebkitAppearance: 'none', border: '1.5px solid #1c1c22', borderRadius: 4, background: sel.has(it.id) ? `#1c1c22 url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='3.4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 6 9 17l-5-5'/%3E%3C/svg%3E") center/12px no-repeat` : '#fff' }} />
                  </label>}
                <span style={{
                  position: 'absolute', top: 8, right: 8, fontSize: 11, fontWeight: 700, padding: '3px 8px', borderRadius: 999,
                  color: '#fff',
                  background: it.status === 'saved' ? '#087A3E' : it.status === 'draft' ? '#B45309' : '#63636D',
                }}>{it.status === 'saved' ? '완료' : it.status === 'draft' ? '작업중' : '임시저장'}</span>
              </div>
              <div style={{ padding: '12px' }}>
                <div style={{ fontSize: 13, fontWeight: 700, color: 'var(--text-1)', marginBottom: 3 }}>
                  {(it.meta && it.meta.event) || '(행사명 없음)'}
                </div>
                <div style={{ fontSize: 11.5, color: 'var(--text-subtle)' }}>
                  {[it.meta && it.meta.category, it.meta && it.meta.type].filter(Boolean).join(' · ')}
                  {it.meta && it.meta.width ? ` · ${it.meta.width}×${it.meta.height}` : ''}
                </div>
                {it.createdAt &&
                  <div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 2 }}>
                    {new Date(it.createdAt).toLocaleDateString('ko-KR', { year: 'numeric', month: '2-digit', day: '2-digit' })}
                  </div>}
                {isAdmin &&
                  <div style={{ fontSize: 11, color: 'var(--text-subtle)', marginTop: 2 }}>작성자: {it.owner || '알 수 없음'}</div>}
                {it.mock ?
                  <div style={{ fontSize: 11.5, color: 'var(--text-subtle)', marginTop: 10 }}>표현 예시 · 실제 저장 시 이렇게 보여요</div> :
                  <div style={{ display: 'flex', gap: 6, marginTop: 10, alignItems: 'stretch' }}>
                    {/* 메인: 이어서 편집(가능하면) / 아니면 다운로드 */}
                    {it.previewState !== 'ready' ?
                      <button className="btn btn-secondary" style={{ flex: 1, height: 36 }} disabled>{it.previewState === 'error' ? '재시도 필요' : '불러오는 중…'}</button> : it.canResume ?
                      <button className="btn btn-primary" style={{ flex: 1, height: 36 }} onClick={(e) => {e.stopPropagation();setProjectEditUrl(it.projectId || it.id);onOpen && onOpen(it);}}>편집하기</button> :
                      <button className="btn btn-secondary" style={{ flex: 1, height: 36 }} disabled={!it.url || dlBusy === it.id} onClick={(e) => {e.stopPropagation();download(it);}}>
                        {dlBusy === it.id ? '저장 중…' : '다운로드'}
                      </button>}
                    {/* 복사 아이콘 */}
                    <button className="btn btn-secondary" title="복사" style={{ width: 36, height: 36, padding: 0, flex: '0 0 auto', display: 'grid', placeItems: 'center' }} disabled={busy === it.id || it.previewState !== 'ready'}
                      onClick={(e) => {e.stopPropagation();copyItem(it);}}>
                      {busy === it.id ? '…' : <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" /><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" /></svg>}
                    </button>
                    {/* 다운로드 아이콘 — 메인이 '이어서 편집'일 때만(다운로드가 메인이면 중복이라 생략) */}
                    {it.canResume && it.url &&
                      <button className="btn btn-secondary" title="다운로드" style={{ width: 36, height: 36, padding: 0, flex: '0 0 auto', display: 'grid', placeItems: 'center' }}
                        disabled={dlBusy === it.id}
                        onClick={(e) => {e.stopPropagation();download(it);}}>
                        {dlBusy === it.id ? '…' : <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><polyline points="7 10 12 15 17 10" /><line x1="12" y1="15" x2="12" y2="3" /></svg>}
                      </button>}
                    {/* 삭제 아이콘 */}
                    <button className="btn btn-secondary" title="삭제" style={{ width: 36, height: 36, padding: 0, flex: '0 0 auto', display: 'grid', placeItems: 'center', color: '#e5484d' }}
                      disabled={it.previewState !== 'ready'}
                      onClick={(e) => {e.stopPropagation();setDelTarget(it);}}>
                      <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18"/><path d="M8 6V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v2"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/></svg>
                    </button>
                  </div>}
              </div>
            </div>)}
        </div>}
      {state === 'ok' && displayTotal > PAGE_SIZE &&
        <nav aria-label="배너 목록 페이지" style={{ marginTop: 22, maxWidth: '100%', overflowX: 'auto', paddingBottom: 2 }}>
          <div style={{ display: 'flex', width: 'max-content', minWidth: '100%', justifyContent: 'center', alignItems: 'center', gap: 8 }}>
            <button type="button" className="btn btn-secondary" title="이전 페이지" aria-label="이전 페이지" disabled={activePage <= 1}
              style={{ width: 36, height: 36, padding: 0, flex: '0 0 36px', display: 'grid', placeItems: 'center' }} onClick={() => setPage((p) => Math.max(1, p - 1))}>
              <S2Icon name="arrowLeft" size={15} />
            </button>
            {visiblePages.map((pageNumber) =>
              <button key={pageNumber} type="button"
                className={'btn ' + (pageNumber === activePage ? 'btn-primary' : 'btn-secondary')}
                aria-label={`${pageNumber} 페이지`} aria-current={pageNumber === activePage ? 'page' : undefined}
                style={{ width: 36, height: 36, padding: 0, flex: '0 0 36px', display: 'grid', placeItems: 'center', fontVariantNumeric: 'tabular-nums' }}
                onClick={() => setPage(pageNumber)}>{pageNumber}</button>)}
            <button type="button" className="btn btn-secondary" title="다음 페이지" aria-label="다음 페이지" disabled={activePage >= pageCount}
              style={{ width: 36, height: 36, padding: 0, flex: '0 0 36px', display: 'grid', placeItems: 'center' }} onClick={() => setPage((p) => Math.min(pageCount, p + 1))}>
              <S2Icon name="arrowRight" size={15} />
            </button>
          </div>
        </nav>}
      <ConfirmDialog
        open={!!delTarget}
        title="배너를 삭제하시겠습니까?"
        message={delTarget ? (delTarget.assetId ? `“${(delTarget.meta && delTarget.meta.event) || '무제'}” 저장 이미지 1개가 삭제됩니다. 원본과 편집 레시피는 유지됩니다.` : `“${(delTarget.meta && delTarget.meta.event) || '무제'}” 배너와 저장된 이미지가 함께 삭제됩니다. 되돌릴 수 없습니다.`) : ''}
        confirmLabel="삭제하기"
        onCancel={() => setDelTarget(null)}
        onConfirm={() => {const t = delTarget;setDelTarget(null);removeItem(t);}} />
      <ConfirmDialog
        open={bulkDelOpen}
        title={`선택한 ${sel.size}개 배너를 삭제하시겠습니까?`}
        message="선택한 배너와 저장된 이미지가 함께 삭제됩니다. 되돌릴 수 없습니다."
        confirmLabel="삭제하기"
        onCancel={() => setBulkDelOpen(false)}
        onConfirm={() => {setBulkDelOpen(false);bulkRemove();}} />
    </div></div>);

}

/* 커스텀 드롭다운 — 네이티브 select 대신 목록까지 디자인 통일. 목록은 fixed 위치라 팝업/테이블 overflow에 안 잘림. */
function DDSelect({ value, options, onChange, placeholder, disabled, width }) {
  const [open, setOpen] = useState(false);
  const [pos, setPos] = useState(null);
  const ref = useRef(null);
  const opts = (options || []).map((o) => typeof o === 'string' ? { value: o, label: o } : o);
  const cur = opts.find((o) => o.value === value);
  useEffect(() => {
    if (!open) return;
    const close = () => setOpen(false);
    const onDoc = (e) => {if (ref.current && !ref.current.contains(e.target)) setOpen(false);};
    document.addEventListener('mousedown', onDoc);
    window.addEventListener('scroll', close, true);
    window.addEventListener('resize', close);
    return () => {document.removeEventListener('mousedown', onDoc);window.removeEventListener('scroll', close, true);window.removeEventListener('resize', close);};
  }, [open]);
  const toggle = () => {
    if (disabled) return;
    if (!open && ref.current) {const r = ref.current.getBoundingClientRect();setPos({ left: r.left, top: r.bottom + 4, width: r.width });}
    setOpen((v) => !v);
  };
  const btn = { height: 40, width: '100%', padding: '0 34px 0 12px', borderRadius: 8, border: '1px solid var(--border)', background: '#fff', font: 'inherit', fontSize: 13, textAlign: 'left', cursor: disabled ? 'default' : 'pointer', color: cur && cur.value !== '' ? 'var(--text-1)' : 'var(--text-subtle)', position: 'relative', boxSizing: 'border-box', opacity: disabled ? 0.55 : 1 };
  return (
    <div ref={ref} style={{ position: 'relative', width: width || '100%' }}>
      <button type="button" disabled={disabled} onClick={toggle} style={btn}>
        {cur ? cur.label : (placeholder || '선택')}
        <span style={{ position: 'absolute', right: 12, top: '50%', transform: 'translateY(-50%) ' + (open ? 'rotate(180deg)' : ''), pointerEvents: 'none', color: '#8a8a96', fontSize: 11 }}>▾</span>
      </button>
      {open && pos &&
      <div style={{ position: 'fixed', left: pos.left, top: pos.top, width: pos.width, background: '#fff', border: '1px solid var(--border)', borderRadius: 8, boxShadow: '0 8px 24px rgba(0,0,0,.14)', zIndex: 10060, maxHeight: 240, overflowY: 'auto', padding: 4 }}>
        {opts.map((o) =>
          <div key={o.value} onClick={() => {onChange && onChange(o.value);setOpen(false);}}
            style={{ padding: '9px 10px', borderRadius: 6, fontSize: 13, cursor: 'pointer', color: 'var(--text-1)', background: o.value === value ? '#eef2ff' : 'transparent', fontWeight: o.value === value ? 700 : 400 }}
            onMouseEnter={(e) => {if (o.value !== value) e.currentTarget.style.background = '#f5f5f7';}}
            onMouseLeave={(e) => {if (o.value !== value) e.currentTarget.style.background = 'transparent';}}>
            {o.label}
          </div>)}
      </div>}
    </div>);

}

/* 날짜 범위 달력 팝업 — 날짜 1번 클릭=단일, 2번=기간. '전체(해제)'로 초기화. */
function DateRangePicker({ from, to, onChange, width }) {
  const [open, setOpen] = useState(false);
  const [pos, setPos] = useState(null);
  const ref = useRef(null);
  const pad = (n) => String(n).padStart(2, '0');
  const fmt = (d) => d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate());
  const [anchor, setAnchor] = useState('');
  const [pf, setPf] = useState(from || '');
  const [pt, setPt] = useState(to || '');
  const [ym, setYm] = useState(() => (from || fmt(new Date())).slice(0, 7));
  useEffect(() => {
    if (!open) return;
    setPf(from || ''); setPt(to || ''); setAnchor(''); setYm((from || fmt(new Date())).slice(0, 7));
    const onDoc = (e) => {if (ref.current && !ref.current.contains(e.target)) setOpen(false);};
    const onEsc = (e) => {if (e.key === 'Escape') setOpen(false);};
    document.addEventListener('mousedown', onDoc);
    document.addEventListener('keydown', onEsc);
    return () => {document.removeEventListener('mousedown', onDoc);document.removeEventListener('keydown', onEsc);};
  }, [open]);
  const toggle = () => {
    const r = ref.current && ref.current.getBoundingClientRect();
    if (!open) setPos(r ? { left: Math.min(r.left, window.innerWidth - 288), top: r.bottom + 4 } : { left: 0, top: 80 });
    setOpen((v) => !v);
  };
  const label = (!from && !to) ? '전체 기간' : (from && to && from === to ? from : ((from || '…') + ' ~ ' + (to || '…')));
  const YM = ym.split('-'); const Y = +YM[0], M = +YM[1];
  const startDow = new Date(Y, M - 1, 1).getDay();
  const dim = new Date(Y, M, 0).getDate();
  const days = [];
  for (let i = 0; i < startDow; i++) days.push(null);
  for (let d = 1; d <= dim; d++) days.push(Y + '-' + pad(M) + '-' + pad(d));
  const inRange = (ds) => pf && pt && ds > pf && ds < pt;
  const isEdge = (ds) => ds && (ds === pf || ds === pt);
  const onDay = (ds) => {
    if (!anchor) {setAnchor(ds);setPf(ds);setPt(ds);}
    else {const f = anchor <= ds ? anchor : ds, t = anchor <= ds ? ds : anchor;setPf(f);setPt(t);setAnchor('');}
  };
  const shiftMonth = (delta) => {const d = new Date(Y, M - 1 + delta, 1);setYm(d.getFullYear() + '-' + pad(d.getMonth() + 1));};
  const btn = { height: 40, minWidth: 150, width: width || 'auto', padding: '0 30px 0 12px', borderRadius: 8, border: '1px solid var(--border)', background: '#fff', font: 'inherit', fontSize: 13, textAlign: 'left', cursor: 'pointer', color: (from || to) ? 'var(--text-1)' : 'var(--text-subtle)', position: 'relative', boxSizing: 'border-box', whiteSpace: 'nowrap' };
  const WD = ['일', '월', '화', '수', '목', '금', '토'];
  return (
    <div ref={ref} style={{ position: 'relative' }}>
      <button type="button" onClick={toggle} style={btn}>
        {label}
        <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="#8a8a96" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ position: 'absolute', right: 9, top: '50%', transform: 'translateY(-50%)', pointerEvents: 'none' }}><rect x="3" y="4" width="18" height="18" rx="2" /><line x1="16" y1="2" x2="16" y2="6" /><line x1="8" y1="2" x2="8" y2="6" /><line x1="3" y1="10" x2="21" y2="10" /></svg>
      </button>
      {open && pos &&
      <div style={{ position: 'fixed', left: pos.left, top: pos.top, width: 272, background: '#fff', border: '1px solid var(--border)', borderRadius: 10, boxShadow: '0 8px 28px rgba(0,0,0,.16)', zIndex: 10060, padding: 12 }}>
        <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8 }}>
          <button type="button" onClick={() => shiftMonth(-1)} style={{ border: 'none', background: 'none', cursor: 'pointer', fontSize: 17, color: '#6b6b78', padding: '2px 8px', lineHeight: 1 }}>‹</button>
          <span style={{ fontSize: 13.5, fontWeight: 700 }}>{Y}년 {M}월</span>
          <button type="button" onClick={() => shiftMonth(1)} style={{ border: 'none', background: 'none', cursor: 'pointer', fontSize: 17, color: '#6b6b78', padding: '2px 8px', lineHeight: 1 }}>›</button>
        </div>
        <div style={{ display: 'grid', gridTemplateColumns: 'repeat(7,1fr)', gap: 2, textAlign: 'center' }}>
          {WD.map((w, i) => <div key={'w' + i} style={{ fontSize: 11, fontWeight: 700, color: i === 0 ? '#e5484d' : (i === 6 ? '#4E4CDB' : '#9a9aa6'), padding: '3px 0' }}>{w}</div>)}
          {days.map((ds, i) => ds ?
            <button key={ds} type="button" onClick={() => onDay(ds)}
              style={{ border: 'none', cursor: 'pointer', height: 30, borderRadius: 7, fontSize: 12.5, fontFamily: 'inherit', background: isEdge(ds) ? '#4E4CDB' : (inRange(ds) ? '#eef2ff' : 'transparent'), color: isEdge(ds) ? '#fff' : 'var(--text-1)', fontWeight: isEdge(ds) ? 700 : 400 }}>
              {+ds.slice(8)}
            </button> :
            <div key={'e' + i} />)}
        </div>
        <div style={{ display: 'flex', gap: 8, marginTop: 10 }}>
          <button type="button" className="btn btn-secondary" style={{ flex: 1, height: 34, fontSize: 12.5 }} onClick={() => {onChange('', '');setOpen(false);}}>전체(해제)</button>
          <button type="button" className="btn btn-primary" style={{ flex: 1, height: 34, fontSize: 12.5 }} onClick={() => {onChange(pf, pt);setOpen(false);}}>적용</button>
        </div>
        <div style={{ fontSize: 11, color: 'var(--text-subtle)', marginTop: 8, textAlign: 'center' }}>날짜 1번=단일 · 2번=기간</div>
      </div>}
    </div>);

}

/* 회원 관리 — 관리자 전용 (window.__admin). 회원 목록·정보·권한·삭제·추가 */
function MembersView() {
  const [state, setState] = useState('loading'); // loading | ok
  const [rows, setRows] = useState([]);
  const [busy, setBusy] = useState(null);
  const [showAdd, setShowAdd] = useState(false);
  const [adding, setAdding] = useState(false);
  const [form, setForm] = useState({ email: '', password: '', name: '', department: '', role: 'user' });
  const [q, setQ] = useState('');            // 검색어(이메일/이름/부서)
  const [deptFilter, setDeptFilter] = useState(''); // 부서 필터
  const [depts, setDepts] = useState([]);    // 부서 마스터 목록
  const [showDept, setShowDept] = useState(false); // 부서 관리 패널
  const [newDept, setNewDept] = useState('');
  const [editDeptId, setEditDeptId] = useState(null); // 인라인 수정 중인 부서 id
  const [editDeptName, setEditDeptName] = useState('');
  const [deptDelId, setDeptDelId] = useState(null);   // 인라인 삭제 확인 중인 부서 id
  const [confirm, setConfirm] = useState(null); // 삭제 확인 팝업 {title,message,confirmLabel,run}
  const [page, setPage] = useState(1);
  const [pageSize, setPageSize] = useState(20);
  const meId = window.__bannerlyUserId || null;
  const reload = React.useCallback(async () => {
    setState('loading');
    if (!window.__admin) {setRows([]);setDepts([]);setState('ok');return;}
    const list = (await window.__admin.listMembers()) || [];
    const dl = (await window.__admin.listDepartments()) || [];
    // 관리자 최상단 정렬, 그다음 가입일순
    list.sort((a, b) => ((a.role === 'admin' ? 0 : 1) - (b.role === 'admin' ? 0 : 1)) || String(a.created_at || '').localeCompare(String(b.created_at || '')));
    setRows(list);
    setDepts(dl);
    setState('ok');
  }, []);
  const deptNames = depts.map((d) => d.name);
  const optionsFor = (cur) => deptNames.concat(cur && deptNames.indexOf(cur) < 0 ? [cur] : []);

  const addDept = async () => {
    const r = await window.__admin.addDepartment(newDept);
    if (r && r.ok) {setNewDept('');reload();}
    else alert('부서 추가 실패: ' + ((r && r.error) || '오류'));
  };
  const startEditDept = (d) => {setEditDeptId(d.id);setEditDeptName(d.name);};
  const commitEditDept = async (d) => {
    const nn = (editDeptName || '').trim();
    if (!nn || nn === d.name) {setEditDeptId(null);return;}
    const r = await window.__admin.renameDepartment(d.id, d.name, nn);
    if (r && r.ok) {setEditDeptId(null);reload();}
    else alert('이름 변경 실패: ' + ((r && r.error) || '오류'));
  };
  const removeDept = (d) => setDeptDelId(d.id); // 인라인 삭제 확인
  const doDeleteDept = async (d) => {
    const r = await window.__admin.deleteDepartment(d.id);
    if (r && r.ok) {setDeptDelId(null);reload();}
    else alert('삭제 실패: ' + ((r && r.error) || '오류'));
  };
  useEffect(() => {reload();}, [reload]);

  const changeRole = async (m, role) => {
    if (busy) return; setBusy(m.id);
    try {
      const r = await window.__admin.setRole(m.id, role);
      if (r && r.ok) setRows((rs) => rs.map((x) => x.id === m.id ? { ...x, role: r.role } : x));
      else alert('권한 변경 실패: ' + ((r && r.error) || '오류'));
    } finally {setBusy(null);}
  };
  const saveField = async (m, field, value) => {
    if ((m[field] || '') === (value || '')) return; // 변화 없으면 무시
    const r = await window.__admin.updateMember(m.id, { [field]: value });
    if (r && r.ok) setRows((rs) => rs.map((x) => x.id === m.id ? { ...x, [field]: value } : x));
    else alert('저장 실패: ' + ((r && r.error) || '오류'));
  };
  const removeMember = (m) => {
    if (busy) return;
    if (m.id === meId) {alert('본인 계정은 삭제할 수 없습니다');return;}
    setConfirm({
      title: '회원 삭제', confirmLabel: '삭제',
      message: `'${m.email}' 회원을 삭제할까요?\n이 회원이 만든 배너·이미지도 함께 삭제됩니다.`,
      run: async () => {
        setBusy(m.id);
        try {
          const r = await window.__admin.deleteMember(m.id);
          if (r && r.ok) setRows((rs) => rs.filter((x) => x.id !== m.id));
          else alert('삭제 실패: ' + ((r && r.error) || '오류'));
        } finally {setBusy(null);}
      },
    });
  };
  const addUser = async () => {
    if (adding) return;
    if (!form.email.trim()) {alert('이메일을 입력하세요');return;}
    setAdding(true);
    try {
      const r = await window.__admin.createMember({
        email: form.email.trim(),
        name: form.name.trim(), department: form.department, role: form.role,
      });
      if (r && r.ok) {
        setForm({ email: '', password: '', name: '', department: '', role: 'user' });
        setShowAdd(false); reload();
        alert('초대 메일을 보냈어요. 받은 사람이 비밀번호를 설정하면 로그인할 수 있어요.');
      } else { alert('사용자 초대 실패: ' + ((r && r.error) || '오류')); }
    } finally {setAdding(false);}
  };
  const fmtDate = (s) => {try {return (s || '').slice(0, 10);} catch (e) {return '';}};
  const GRID = { display: 'grid', gridTemplateColumns: '1.7fr 1fr 1fr 0.9fr 0.85fr auto', gap: 10, alignItems: 'center' };
  // 입력/드롭다운 공통 — 버튼과 높이(40) 맞춤, 일반적인 간격
  const cell = { height: 40, padding: '0 12px', borderRadius: 8, border: '1px solid var(--border)', font: 'inherit', fontSize: 13, background: '#fff', width: '100%', boxSizing: 'border-box', color: 'var(--text-1)' };
  // 드롭다운(select) — 기본 화살표 제거 + 커스텀 chevron 으로 디자인 통일
  const CHEV = "data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%238a8a96' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='M6 9l6 6 6-6'/></svg>";
  const selectCell = Object.assign({}, cell, { appearance: 'none', WebkitAppearance: 'none', MozAppearance: 'none', backgroundImage: 'url("' + CHEV + '")', backgroundRepeat: 'no-repeat', backgroundPosition: 'right 12px center', paddingRight: 34, cursor: 'pointer' });
  const btn40 = { height: 40 };
  // 부서 목록(중복 제거) + 검색·부서 필터 적용
  const departments = Array.from(new Set(rows.map((r) => (r.department || '').trim()).filter(Boolean))).sort();
  const kw = q.trim().toLowerCase();
  const norm = (s) => (s || '').toLowerCase();
  const filtered = rows.filter((m) =>
    (!deptFilter || (m.department || '') === deptFilter) &&
    (!kw || norm(m.email).indexOf(kw) >= 0 || norm(m.name).indexOf(kw) >= 0 || norm(m.department).indexOf(kw) >= 0));
  // 페이지네이션
  const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize));
  const curPage = Math.min(page, totalPages);
  const pageRows = filtered.slice((curPage - 1) * pageSize, curPage * pageSize);
  const pageList = (() => {
    if (totalPages <= 7) return Array.from({ length: totalPages }, (_, i) => i + 1);
    const arr = [1];
    const s = Math.max(2, curPage - 1), e = Math.min(totalPages - 1, curPage + 1);
    if (s > 2) arr.push('…');
    for (let p = s; p <= e; p++) arr.push(p);
    if (e < totalPages - 1) arr.push('…');
    arr.push(totalPages);
    return arr;
  })();
  const th = { textAlign: 'left', padding: '11px 16px', fontSize: 12.5, fontWeight: 700, color: 'var(--text-subtle)', whiteSpace: 'nowrap' };
  const td = { padding: '9px 16px', fontSize: 13, verticalAlign: 'middle', borderTop: '1px solid var(--border)', color: 'var(--text-1)' };
  const pgBtn = (on) => ({ minWidth: 30, height: 30, padding: '0 8px', borderRadius: 7, border: '1px solid ' + (on ? 'transparent' : 'var(--border)'), background: on ? 'var(--accent, #4E4CDB)' : '#fff', color: on ? '#fff' : 'var(--text-1)', fontSize: 12.5, fontWeight: on ? 700 : 500, cursor: 'pointer' });

  return (
    <div className="content"><div className="content-pad">
      <div className="page-head" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 20 }}>
        <h1 className="page-title">회원 관리</h1>
        <div style={{ display: 'flex', gap: 8 }}>
          <button className="btn btn-secondary" style={{ minWidth: 112 }} onClick={() => setShowDept((v) => !v)}>부서 관리</button>
          <button className="btn btn-primary" style={{ minWidth: 112 }} onClick={() => setShowAdd((v) => !v)}>사용자 추가</button>
        </div>
      </div>

      {showDept &&
        <div onClick={() => setShowDept(false)} style={{ position: 'fixed', inset: 0, background: 'rgba(20,20,26,.45)', display: 'grid', placeItems: 'center', zIndex: 9990 }}>
        <div onClick={(e) => e.stopPropagation()} style={{ position: 'relative', width: 460, maxWidth: '92vw', maxHeight: '85vh', overflowY: 'auto', background: '#fff', borderRadius: 16, padding: 24, boxShadow: '0 12px 40px rgba(0,0,0,.22)' }}>
          <button onClick={() => setShowDept(false)} title="닫기" style={{ position: 'absolute', top: 14, right: 14, border: 'none', background: 'none', cursor: 'pointer', fontSize: 22, lineHeight: 1, color: 'var(--text-subtle)', padding: 4 }}>×</button>
          <div style={{ fontSize: 16, fontWeight: 800, marginBottom: 18 }}>부서 관리</div>
          <div style={{ display: 'flex', gap: 10, marginBottom: 16 }}>
            <input style={{ ...cell, flex: 1 }} type="text" placeholder="새 부서명" value={newDept}
              onChange={(e) => setNewDept(e.target.value)} onKeyDown={(e) => {if (e.key === 'Enter') addDept();}} />
            <button className="btn btn-primary" style={{ ...btn40, flexShrink: 0 }} onClick={addDept}>추가</button>
          </div>
          {!depts.length && <div style={{ fontSize: 13, color: 'var(--text-subtle)', padding: '14px 0' }}>등록된 부서가 없습니다. 위에서 추가하세요.</div>}
          {!!depts.length &&
          <div style={{ border: '1px solid var(--border)', borderRadius: 10, overflow: 'hidden' }}>
            {depts.map((d, i) =>
              <div key={d.id} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10, padding: '10px 14px', borderTop: i ? '1px solid var(--border)' : 'none', fontSize: 13 }}>
                {editDeptId === d.id ?
                  <input autoFocus style={{ ...cell, flex: 1, height: 34 }} value={editDeptName}
                    onChange={(e) => setEditDeptName(e.target.value)}
                    onKeyDown={(e) => {if (e.key === 'Enter') commitEditDept(d);if (e.key === 'Escape') setEditDeptId(null);}} /> :
                  <span style={{ fontWeight: 600 }}>{d.name}</span>}
                <div style={{ display: 'flex', gap: 6, flexShrink: 0, alignItems: 'center' }}>
                  {editDeptId === d.id ?
                    <React.Fragment>
                      <button className="btn btn-primary btn-sm" onClick={() => commitEditDept(d)}>저장</button>
                      <button className="btn btn-ghost btn-sm" onClick={() => setEditDeptId(null)}>취소</button>
                    </React.Fragment> :
                    deptDelId === d.id ?
                    <React.Fragment>
                      <span style={{ fontSize: 12, color: 'var(--text-subtle)' }}>삭제할까요?</span>
                      <button className="btn btn-sm" style={{ background: '#e5484d', color: '#fff', fontWeight: 700 }} onClick={() => doDeleteDept(d)}>삭제</button>
                      <button className="btn btn-ghost btn-sm" onClick={() => setDeptDelId(null)}>취소</button>
                    </React.Fragment> :
                    <React.Fragment>
                      <button className="btn btn-ghost btn-sm" onClick={() => startEditDept(d)}>수정</button>
                      <button className="btn btn-ghost btn-sm" onClick={() => removeDept(d)} style={{ color: '#e5484d' }}>삭제</button>
                    </React.Fragment>}
                </div>
              </div>)}
          </div>}
        </div></div>}

      {showAdd &&
        <div onClick={() => setShowAdd(false)} style={{ position: 'fixed', inset: 0, background: 'rgba(20,20,26,.45)', display: 'grid', placeItems: 'center', zIndex: 9990 }}>
        <div onClick={(e) => e.stopPropagation()} style={{ position: 'relative', width: 460, maxWidth: '92vw', maxHeight: '85vh', overflowY: 'auto', background: '#fff', borderRadius: 16, padding: 24, boxShadow: '0 12px 40px rgba(0,0,0,.22)' }}>
          <button onClick={() => setShowAdd(false)} title="닫기" style={{ position: 'absolute', top: 14, right: 14, border: 'none', background: 'none', cursor: 'pointer', fontSize: 22, lineHeight: 1, color: 'var(--text-subtle)', padding: 4 }}>×</button>
          <div style={{ fontSize: 16, fontWeight: 800, marginBottom: 6 }}>사용자 초대</div>
          <div style={{ fontSize: 12.5, color: 'var(--text-subtle)', marginBottom: 16, lineHeight: 1.5 }}>입력한 이메일로 초대 메일이 발송됩니다. 받은 사람이 직접 비밀번호를 설정하면 로그인할 수 있어요.</div>
          <div style={{ display: 'flex', flexDirection: 'column', gap: 12, marginBottom: 18 }}>
            <input style={cell} type="email" placeholder="이메일 *" value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} />
            <input style={cell} type="text" placeholder="이름" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
            <DDSelect value={form.department} placeholder="부서 선택" onChange={(v) => setForm({ ...form, department: v })}
              options={[{ value: '', label: '부서 선택' }].concat(deptNames.map((d) => ({ value: d, label: d })))} />
            <DDSelect value={form.role} onChange={(v) => setForm({ ...form, role: v })}
              options={[{ value: 'user', label: '사용자' }, { value: 'admin', label: '관리자' }]} />
          </div>
          <button className="btn btn-primary" style={{ ...btn40, width: '100%' }} disabled={adding} onClick={addUser}>{adding ? '초대 중…' : '초대 메일 보내기'}</button>
        </div></div>}

      {state === 'loading' &&
        <div className="empty-wrap"><div className="empty">
          <div className="spinner" style={{ width: 28, height: 28, marginBottom: 16 }} />
          <p className="empty-text">불러오는 중…</p>
        </div></div>}
      {state === 'ok' && !rows.length &&
        <div className="empty-wrap"><div className="empty"><h2 className="empty-title">회원이 없습니다</h2></div></div>}
      {state === 'ok' && !!rows.length &&
        <React.Fragment>
        {/* 검색 영역 (박스 없음) — 입력 즉시 필터, 아이콘만(주 액션은 사용자 추가 하나) */}
        <div style={{ marginBottom: 16, display: 'flex', gap: 10, flexWrap: 'wrap' }}>
          <div style={{ position: 'relative', flex: '1 1 260px' }}>
            <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#8a8a96" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ position: 'absolute', left: 12, top: '50%', transform: 'translateY(-50%)', pointerEvents: 'none' }}><circle cx="11" cy="11" r="8" /><path d="M21 21l-4.35-4.35" /></svg>
            <input style={{ ...cell, width: '100%', paddingLeft: 36, paddingRight: q ? 34 : 12 }} type="text" placeholder="이메일·이름·부서로 검색" value={q} onChange={(e) => {setQ(e.target.value);setPage(1);}} />
            {q &&
              <button type="button" title="검색어 지우기" onClick={() => {setQ('');setPage(1);}}
                style={{ position: 'absolute', right: 8, top: '50%', transform: 'translateY(-50%)', border: 'none', background: '#e7e7ee', color: '#6b6b78', width: 20, height: 20, borderRadius: 999, cursor: 'pointer', fontSize: 13, lineHeight: '20px', padding: 0 }}>×</button>}
          </div>
          <DDSelect value={deptFilter} width={170} onChange={(v) => {setDeptFilter(v);setPage(1);}}
            options={[{ value: '', label: '전체 부서' }].concat(departments.map((d) => ({ value: d, label: d })))} />
        </div>

        {/* 테이블 카드 */}
        <div style={{ border: '1px solid var(--border)', borderRadius: 12, overflow: 'hidden', background: '#fff' }}>
          <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '14px 16px' }}>
            <div style={{ fontSize: 13, fontWeight: 700 }}>총 {filtered.length}명</div>
            <div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 12.5, color: 'var(--text-subtle)' }}>
              페이지당
              <DDSelect value={String(pageSize)} width={92} onChange={(v) => {setPageSize(Number(v));setPage(1);}}
                options={[{ value: '10', label: '10개' }, { value: '20', label: '20개' }, { value: '50', label: '50개' }]} />
            </div>
          </div>
          <table style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
            <colgroup>
              <col style={{ width: '24%' }} /><col style={{ width: '16%' }} /><col style={{ width: '16%' }} />
              <col style={{ width: '16%' }} /><col style={{ width: '16%' }} /><col style={{ width: '12%' }} />
            </colgroup>
            <thead>
              <tr style={{ background: '#fafafa', borderTop: '1px solid var(--border)', borderBottom: '1px solid var(--border)' }}>
                <th style={th}>이메일</th><th style={th}>이름</th><th style={th}>부서</th>
                <th style={th}>권한</th><th style={th}>가입일</th><th style={th}>관리</th>
              </tr>
            </thead>
            <tbody>
              {!filtered.length &&
                <tr><td colSpan={6} style={{ ...td, textAlign: 'center', color: 'var(--text-subtle)', padding: '28px 16px' }}>검색 결과가 없습니다</td></tr>}
              {pageRows.map((m) =>
                <tr key={m.id}>
                  <td style={{ ...td, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
                    {m.email || '(이메일 없음)'} {m.id === meId && <span style={{ fontSize: 11, color: 'var(--text-subtle)' }}>(나)</span>}
                  </td>
                  <td style={td}>
                    <input style={{ ...cell, height: 34 }} type="text" placeholder="이름" defaultValue={m.name || ''}
                      onBlur={(e) => saveField(m, 'name', e.target.value.trim())} />
                  </td>
                  <td style={td}>
                    <DDSelect value={m.department || ''} onChange={(v) => saveField(m, 'department', v)}
                      options={[{ value: '', label: '(미지정)' }].concat(optionsFor(m.department).map((d) => ({ value: d, label: d })))} />
                  </td>
                  <td style={td}>
                    <DDSelect value={m.role} disabled={busy === m.id || m.id === meId} onChange={(v) => changeRole(m, v)}
                      options={[{ value: 'user', label: '사용자' }, { value: 'admin', label: '관리자' }]} />
                  </td>
                  <td style={{ ...td, color: 'var(--text-subtle)' }}>{fmtDate(m.created_at)}</td>
                  <td style={td}>
                    <button disabled={busy === m.id || m.id === meId} onClick={() => removeMember(m)}
                      style={{ border: 'none', background: 'none', padding: 0, fontSize: 13, fontWeight: 600, color: m.id === meId ? 'var(--text-subtle)' : '#e5484d', cursor: m.id === meId ? 'default' : 'pointer' }}>삭제</button>
                  </td>
                </tr>)}
            </tbody>
          </table>
          {/* 페이지네이션 */}
          {totalPages > 1 &&
          <div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', gap: 6, padding: '16px' }}>
            <button style={pgBtn(false)} disabled={curPage <= 1} onClick={() => setPage(curPage - 1)}>‹</button>
            {pageList.map((p, i) => p === '…' ?
              <span key={'e' + i} style={{ color: 'var(--text-subtle)', padding: '0 4px' }}>…</span> :
              <button key={p} style={pgBtn(p === curPage)} onClick={() => setPage(p)}>{p}</button>)}
            <button style={pgBtn(false)} disabled={curPage >= totalPages} onClick={() => setPage(curPage + 1)}>›</button>
          </div>}
        </div>
        </React.Fragment>}

      {confirm &&
        <div onClick={() => setConfirm(null)} style={{ position: 'fixed', inset: 0, background: 'rgba(20,20,26,.45)', display: 'grid', placeItems: 'center', zIndex: 10000 }}>
        <div onClick={(e) => e.stopPropagation()} style={{ position: 'relative', width: 420, maxWidth: '92vw', background: '#fff', borderRadius: 16, padding: 24, boxShadow: '0 12px 40px rgba(0,0,0,.22)' }}>
          <button onClick={() => setConfirm(null)} title="닫기" style={{ position: 'absolute', top: 14, right: 14, border: 'none', background: 'none', cursor: 'pointer', fontSize: 22, lineHeight: 1, color: 'var(--text-subtle)', padding: 4 }}>×</button>
          <div style={{ fontSize: 16, fontWeight: 800, marginBottom: 12, paddingRight: 28 }}>{confirm.title}</div>
          <div style={{ fontSize: 13.5, color: 'var(--text-1)', lineHeight: 1.65, whiteSpace: 'pre-line', marginBottom: 22 }}>{confirm.message}</div>
          <div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
            <button className="btn btn-secondary" onClick={() => setConfirm(null)}>취소</button>
            <button className="btn" style={{ background: '#e5484d', color: '#fff', fontWeight: 700 }} onClick={() => {const fn = confirm.run;setConfirm(null);if (fn) fn();}}>{confirm.confirmLabel || '삭제'}</button>
          </div>
        </div></div>}
    </div></div>);

}

/* ── 가이드 직접 추가 — URL 분석 없이 메타데이터만 입력해 가이드 라이브러리에 추가 ── */
function ManualGuideCreate({ onSave, onCancel }) {
  const CATS = ['상품배너 550', '앱푸시', '상품배너 H1', '1E 기술서상단'];
  const LAYOUT_BY_CAT = {
    '상품배너 550': [{ id: 'nukki', label: 'A 누끼형' }, { id: 'split-nukki', label: 'B 분할+누끼형' }, { id: 'split', label: 'C 분할형' }, { id: 'full', label: 'D 풀이미지형' }, { id: 'insurance', label: 'E 보험형' }],
    '앱푸시': [{ id: 'basic', label: '기본형' }, { id: 'airtime', label: '방송기재' }, { id: 'logo', label: '로고형' }, { id: 'leading', label: '리딩콘텐츠형' }],
    '상품배너 H1': [{ id: 'nukki', label: 'A 누끼형' }, { id: 'split-nukki', label: 'B 분할+누끼형' }, { id: 'split', label: 'C 분할형' }, { id: 'full', label: 'D 풀이미지형' }, { id: 'insurance', label: 'E 보험형' }],
    '1E 기술서상단': [{ id: 'editorial', label: '화보형' }, { id: 'nukki', label: '누끼형' }],
  };
  const [cat, setCat] = useState(CATS[0]);
  const [layout, setLayout] = useState('');
  const [name, setName] = useState('');
  const [thumb, setThumb] = useState('');
  const [err, setErr] = useState('');
  const layouts = LAYOUT_BY_CAT[cat] || [];
  const submit = () => {
    const n = name.trim() || (layouts.find((l) => l.id === layout) || {}).label || cat;
    if (!n) { setErr('이름을 입력하세요'); return; }
    const now = new Date().toISOString();
    const g = {
      id: 'd-' + Date.now() + '-' + Math.random().toString(36).slice(2),
      category: cat, name: n, layoutId: layout || null,
      pushType: cat === '앱푸시' ? layout || 'basic' : null,
      thumb: thumb.trim() || null,
      width: cat === '상품배너 550' ? 550 : cat === '앱푸시' ? 720 : cat === '1E 기술서상단' ? 360 : 776,
      height: cat === '상품배너 550' ? 550 : cat === '앱푸시' ? 380 : cat === '1E 기술서상단' ? 100 : 388,
      published: true, createdAt: now, updatedAt: now,
      author: window.__bannerlyName || null,
    };
    onSave && onSave(g);
  };
  const S = { marginBottom: 16 };
  const LB = { fontSize: 11.5, fontWeight: 700, color: '#43434e', marginBottom: 5, display: 'block' };
  const IN = { width: '100%', height: 40, padding: '0 11px', borderRadius: 8, border: '1px solid var(--border-input)', font: 'inherit', fontSize: 13, boxSizing: 'border-box' };
  return (
    <div className="content"><div className="content-pad" style={{ maxWidth: 560 }}>
      <div className="page-head" style={{ marginBottom: 24 }}>
        <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
          <button type="button" className="btn btn-ghost" style={{ width: 32, height: 32, padding: 0 }} onClick={onCancel}><S2Icon name="arrowLeft" size={17} /></button>
          <h1 className="page-title">가이드 직접 추가</h1>
        </div>
        <p className="page-sub">배너 유형과 이름을 입력해 가이드 라이브러리에 추가합니다.</p>
      </div>
      <div style={S}>
        <span style={LB}>배너 유형 <span style={{ color: '#4E4CDB' }}>*</span></span>
        <select value={cat} onChange={(e) => { setCat(e.target.value); setLayout(''); }} style={{ ...IN }}>
          {CATS.map((c) => <option key={c} value={c}>{c}</option>)}
        </select>
      </div>
      <div style={S}>
        <span style={LB}>레이아웃 (선택)</span>
        <select value={layout} onChange={(e) => setLayout(e.target.value)} style={{ ...IN }}>
          <option value="">— 선택 안 함 —</option>
          {layouts.map((l) => <option key={l.id} value={l.id}>{l.label}</option>)}
        </select>
      </div>
      <div style={S}>
        <span style={LB}>가이드 이름 <span style={{ color: '#4E4CDB' }}>*</span></span>
        <input type="text" value={name} onChange={(e) => { setName(e.target.value); setErr(''); }}
          placeholder={`예: ${(layouts[0] || {}).label || cat}`} style={IN} />
      </div>
      <div style={S}>
        <span style={LB}>썸네일 URL (선택)</span>
        <input type="url" value={thumb} onChange={(e) => setThumb(e.target.value)} placeholder="https://…" style={IN} />
      </div>
      {err && <div style={{ fontSize: 12.5, color: '#c0392b', marginBottom: 12 }}>{err}</div>}
      <div style={{ display: 'flex', gap: 10 }}>
        <button type="button" className="btn btn-secondary" onClick={onCancel}>취소</button>
        <button type="button" className="btn btn-primary" onClick={submit}>가이드 추가하기</button>
      </div>
    </div></div>
  );
}

/* ── 에디터 레지스트리: 유형(카테고리) → 에디터. "에디터 페이지 하나에서 유형별로 로드" 단일 출처.
   새 유형 추가 = 여기 한 줄. 앱푸시·1E=공유 팩토리 / 550=기존 550 에디터 / H1=CreateBanner(SCHEMA_H1) 재사용. ── */
const _apShell = (el) => React.createElement('div', { style: { flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' } }, el);
// 에디터 마운트 key = restoreKey(openProject마다 증가). 복원 소비(restore=null)엔 안 바뀜 → 복원 직후 재마운트로 작업 날아가는 것 방지.
const _rk = (ctx) => 'ed' + ((ctx && ctx.restoreKey) || 0);
const _oneEInitialType = (guide) => {
  if (!guide) return undefined;
  if (guide.oneEType) return guide.oneEType;
  if (guide.name === '화보형') return 'editorial';
  if (guide.name === '누끼형') return 'A';
  return undefined;
};
const EDITOR_REGISTRY = {
  '상품배너 550': (ctx) => _apShell(window.AutoPlace550 ? React.createElement(window.AutoPlace550, { key: _rk(ctx), guide: ctx && ctx.guide, initialImages: ctx && ctx.initialImages, initialRecords: ctx && ctx.initialRecords, initialCategory: ctx && ctx.initialCategory, restore: ctx && ctx.restore, onRestoreConsumed: ctx && ctx.onRestoreConsumed, ga4Source: (ctx && ctx.ga4Source) || 'edit' }) : null),
  '앱푸시': (ctx) => _apShell(window.AutoPlacePush ? React.createElement(window.AutoPlacePush, { key: _rk(ctx), initialType: ctx && ctx.guide && ctx.guide.pushType, initialImages: ctx && ctx.initialImages, initialRecords: ctx && ctx.initialRecords, initialCategory: ctx && ctx.initialCategory, restore: ctx && ctx.restore, onRestoreConsumed: ctx && ctx.onRestoreConsumed, ga4Source: (ctx && ctx.ga4Source) || 'edit' }) : null),
  '1E 기술서상단': (ctx) => _apShell(window.AutoPlace1E ? React.createElement(window.AutoPlace1E, { key: _rk(ctx), initialType: _oneEInitialType(ctx && ctx.guide), initialImages: ctx && ctx.initialImages, initialRecords: ctx && ctx.initialRecords, initialCategory: ctx && ctx.initialCategory, restore: ctx && ctx.restore, onRestoreConsumed: ctx && ctx.onRestoreConsumed, ga4Source: (ctx && ctx.ga4Source) || 'edit' }) : null),
  '상품배너 H1': (ctx) => _apShell(window.AutoPlaceH1 ? React.createElement(window.AutoPlaceH1, { key: _rk(ctx), guide: ctx && ctx.guide, initialImages: ctx && ctx.initialImages, initialRecords: ctx && ctx.initialRecords, initialCategory: ctx && ctx.initialCategory, restore: ctx && ctx.restore, onRestoreConsumed: ctx && ctx.onRestoreConsumed, ga4Source: (ctx && ctx.ga4Source) || 'edit' }) : (window.CreateBanner ? React.createElement(window.CreateBanner, { guide: ctx && ctx.guide, onBack: ctx && ctx.onBack, onGenerate: ctx && ctx.onGenerate }) : null)),
};
// 카테고리 → 에디터 뷰 라우트 단일 출처(SSOT). 이어서 편집/메뉴 진입이 공유. 미등록 카테고리는 550 폴백.
const CAT_VIEW = { '상품배너 550': 'ap550', '앱푸시': 'appush', '상품배너 H1': 'apH1', '1E 기술서상단': 'ap1e' };
const catToView = (cat) => CAT_VIEW[cat] || 'ap550';

function Studio2App({ role = "admin", publishNav }) {
  const canCreate = role === "admin";
  const [navh, setNavh] = useState({ stack: ['dashboard'], idx: 0 });
  const view = navh.stack[navh.idx];
  const [dashFolder, setDashFolder] = useState(null);
  const setView = (v) => setNavh((n) => {
    if (n.stack[n.idx] === v) return n;
    const base = n.stack.slice(0, n.idx + 1);
    return { stack: [...base, v], idx: base.length };
  });
  const goBack = () => {
    if (view === 'dashboard' && dashFolder) {setDashFolder(null);return;}
    setNavh((n) => n.idx > 0 ? { ...n, idx: n.idx - 1 } : n);
  };
  const goFwd = () => setNavh((n) => n.idx < n.stack.length - 1 ? { ...n, idx: n.idx + 1 } : n);
  const canBack = navh.idx > 0 || view === 'dashboard' && dashFolder;
  const canFwd = navh.idx < navh.stack.length - 1;
  // 가이드 목록 — 단일 출처: __guideStore (localStorage + Supabase).
  // 관리자: listAll() (미공개 포함), 사용자: list() (published=true 만).
  // 초기값은 DUMMY_GUIDES 로 시드, 이후 __guideStore 에서 불러온다.
  const [guides, setGuides] = useState(() => {
    // DUMMY_GUIDES 로 스토어를 시드(localStorage 가 비어 있을 때만 1회).
    if (window.__guideStore && window.DUMMY_GUIDES) window.__guideStore.seed(window.DUMMY_GUIDES);
    // 동기 초기값: DUMMY_GUIDES (비동기 로드 전 깜빡임 방지)
    return (window.DUMMY_GUIDES || []).map((g) => ({ ...g, sizes: [{ w: g.width || (g.spec && g.spec.w), h: g.height || (g.spec && g.spec.h) }] }));
  });
  useEffect(() => {
    if (!window.__guideStore) return;
    (window.__guideStore.listAll || window.__guideStore.list)().then((list) => {
      if (list && list.length) setGuides(list.map((g) => ({ ...g, sizes: [{ w: g.width, h: g.height }] })));
    }).catch(() => {});
  }, []);
  const [favFolders, setFavFolders] = useState([]);
  const [favSeq, setFavSeq] = useState([]);
  const [activeGuide, setActiveGuide] = useState(null);
  const [analyzeMode, setAnalyzeMode] = useState('url');
  const [analyzeUrl, setAnalyzeUrl] = useState('');
  const [analyzeImages, setAnalyzeImages] = useState([]);
  const [analyzeGroups, setAnalyzeGroups] = useState([]);
  const [analyzeMerged, setAnalyzeMerged] = useState([]);
  const [bannerData, setBannerData] = useState(null);
  const [catalogInput, setCatalogInput] = useState(null);
  const [restorePayload, setRestorePayload] = useState(null); // 이어서 편집 복원 페이로드 {id,snapshot,images,selection}
  const [restoreKey, setRestoreKey] = useState(0); // openProject마다 증가 → 에디터 fresh 마운트 key (복원 소비엔 불변)
  const routeEditIdRef = useRef(null); // /edit/{id} 직진입 중복 소비 방지
  const [toast, setToast] = useState(null);
  const [ga4Source, setGa4Source] = useState('edit');
  useEffect(() => {
    if (window.__ga4 && window.__ga4.trackUserPage) window.__ga4.trackUserPage(view, role, activeGuide && activeGuide.category, ga4Source);
  }, [view, role, activeGuide, ga4Source]);

  const showToast = (msg) => {setToast(msg);setTimeout(() => setToast(null), 2600);};

  const nav = (v) => {
    if (!PROJECT_EDITOR_VIEWS.includes(v)) clearProjectEditUrl();
    if (v === 'create' && guides.length === 0) {
      setView('dashboard');
      showToast('먼저 가이드를 만들어 주세요');
      return;
    }
    if (v === 'dashboard' || v === 'guideCreate' || v === 'guideManage') setDashFolder(null);
    setView(v);
  };

  const startUrlAnalysis = (url) => {setAnalyzeMode('url');setAnalyzeUrl(url);setView('analysis');};
  const startImageAnalysis = (imgs) => {setAnalyzeMode('image');setAnalyzeImages(imgs);setView('analysis');};
  const finishAnalysis = (payload) => {
    if (analyzeMode === 'image') {
      // payload from the real pixel analysis; fall back to heuristic grouping if unavailable
      const groups = payload && payload.groups && payload.groups.length ?
      payload.groups :
      window.autoGroup ? window.autoGroup(analyzeImages) : [];
      setAnalyzeGroups(groups);
      setAnalyzeMerged(payload && payload.mergedPalette || groups[0] && groups[0].palette || []);
      setView('grouping');
      return;
    }
    // URL 모드: 추출 데이터 + 후보 선택 화면으로 이동 (바로 생성하지 않음)
    setView('urlReview');
  };
  const generateFromCandidates = (selected) => {
    const now = new Date().toISOString();
    const chosen = selected && selected.length ? selected : [GUIDE_A];
    setGuides((prev) => {
      const merged = [...prev];
      chosen.forEach((g0) => {
        const g = { ...g0, thumb: g0.thumb || g0._prev, createdAt: g0.createdAt || now, updatedAt: now, author: window.__bannerlyName || null, published: true };
        const idx = merged.findIndex((x) => x.id === g.id);
        if (idx >= 0) merged[idx] = g;else merged.push(g);
        if (window.__guideStore) window.__guideStore.upsert(g).catch(() => {});
      });
      return merged;
    });
    setActiveGuide(chosen[0]);
    setView('dashboard');
    showToast(chosen.length === 1 ? `${chosen[0].name}가 생성되었습니다` : `가이드 ${chosen.length}개가 생성되었습니다`);
  };
  const generateFromCollectedGroups = (groups) => {
    const now = new Date().toISOString();
    const byId = { 'guide-a': GUIDE_A, 'guide-b': GUIDE_B, 'guide-c': GUIDE_C };
    const made = groups.map((grp, i) => {
      const base = byId[grp.ref] || GUIDE_A;
      return {
        ...base,
        id: base.id + '-url-' + Date.now() + '-' + i,
        via: 'url',
        source: analyzeUrl,
        createdAt: now, updatedAt: now,
        author: window.__bannerlyName || null, published: true,
        banners: grp.banners.length,
        confidence: grp.confidence || base.confidence,
        previewBanner: grp.banners[0]
      };
    });
    setGuides((prev) => {
      const next = [...prev, ...made];
      if (window.__guideStore) made.forEach((g) => window.__guideStore.upsert(g).catch(() => {}));
      return next;
    });
    setActiveGuide(made[0]);
    setView('dashboard');
    showToast(made.length === 1 ? `${made[0].name} 가이드가 생성되었습니다` : `가이드 ${made.length}개가 생성되었습니다`);
  };
  const generateFromGroups = (groups) => {
    const now = new Date().toISOString();
    const roleList = ['Primary', 'Accent', 'Sub', 'Text', 'Surface', 'Muted'];
    const made = groups.map((grp, i) => {
      const paletteFull = grp.paletteFull && grp.paletteFull.length ?
      grp.paletteFull :
      grp.palette && grp.palette.length ?
      grp.palette.map((hex, k) => ({ role: roleList[k] || 'Color', hex, name: hex })) :
      GUIDE_IMG.palette;
      return {
        ...GUIDE_IMG,
        id: 'guide-img-' + Date.now() + '-' + i,
        name: grp.name,
        via: 'image',
        source: `업로드 이미지 ${grp.imgs.length}장`,
        banners: grp.imgs.length,
        confidence: grp.confidence || 88,
        thumb: grp.imgs[0] && grp.imgs[0].src,
        palette: paletteFull,
        sizes: grp.sizes && grp.sizes.length ? grp.sizes : GUIDE_IMG.sizes,
        createdAt: now, updatedAt: now,
        author: window.__bannerlyName || null, published: true,
      };
    });
    setGuides((prev) => {
      const next = [...prev, ...made];
      if (window.__guideStore) made.forEach((g) => window.__guideStore.upsert(g).catch(() => {}));
      return next;
    });
    setActiveGuide(made[0]);
    setView('dashboard');
    showToast(made.length === 1 ? '이미지에서 가이드가 생성되었습니다' : `가이드 ${made.length}개가 생성되었습니다`);
  };
  const openGuide = (g) => {setActiveGuide(g);setView('guideDetail');};
  const useGuideFrom = (src) => (g) => {
    setGa4Source(src);
    setCatalogInput(null);
    setActiveGuide(g);
    setView('create');
    if (src === 'asst' && window.__ga4 && window.__ga4.trackAssistantStart) window.__ga4.trackAssistantStart(g && g.category);
  };
  const useGuide = (g) => useGuideFrom(ga4Source)(g);
  const useCategorySamples = async (g, images, forcedCategory) => {
    if (!g || !images || !images.length) return;
    setGa4Source('asst');
    // CDN URL → local blob URL. 에디터·누끼는 항상 로컬 사본으로 작업해야 한다.
    // blob:/data: 는 스킵(이미 로컬). 실패한 장은 toast 표시 후 원본 CDN URL 유지(160px 썸네일 폴백은 절대 금지).
    const P = window.BannerEditorPersist;
    const hasRemote = images.some((im) => im.path && !im.path.startsWith('blob:') && !im.path.startsWith('data:'));
    if (hasRemote) showToast('이미지 다운로드 중…');
    const settled = await Promise.all(images.map(async (image) => {
      let blobUrl = null;
      try { blobUrl = P ? await P.downloadOriginal(image.path) : null; } catch (e) { blobUrl = null; }
      const remote = image.path && !image.path.startsWith('blob:') && !image.path.startsWith('data:');
      return {
        image: blobUrl && blobUrl !== image.path ? { ...image, path: blobUrl } : image,
        failed: remote && (!blobUrl || blobUrl === image.path)
      };
    }));
    const dlImages = settled.map((entry) => entry.image);
    const failCount = settled.filter((entry) => entry.failed).length;
    // 상품 DB 대분류 → 편집기 커머스 카테고리. 카테고리가 섞여 사용자가 고른 값이 있으면 그것을 우선한다.
    // 못 찾으면 null → 편집기 자동 감지 유지.
    const cc = window.__commerceCategory;
    setCatalogInput({
      images: dlImages.map((image) => image.path),
      records: dlImages,
      category: forcedCategory || (cc ? cc.fromRecords(dlImages) : null) });
    setActiveGuide(g);
    setView('create');
    if (window.__ga4 && window.__ga4.trackAssistantStart) window.__ga4.trackAssistantStart(g && g.category);
    if (failCount) {
      showToast(`등록 이미지 ${dlImages.length}장 불러옴 (${failCount}장 다운로드 실패)`);
    } else {
      showToast(`등록 이미지 ${dlImages.length}장을 불러왔습니다`);
    }
  };
  const duplicateGuide = (g) => {
    const now = new Date().toISOString();
    const copy = { ...g, id: g.id + '-copy-' + Date.now(), name: g.name + ' 복사본', createdAt: now, updatedAt: now, author: window.__bannerlyName || null };
    setGuides((prev) => {
      const idx = prev.findIndex((x) => x.id === g.id);
      if (idx < 0) return [...prev, copy];
      const next = [...prev];
      next.splice(idx + 1, 0, copy);
      return next;
    });
    if (window.__guideStore) window.__guideStore.upsert(copy).catch(() => {});
    showToast('가이드가 복제되었습니다');
  };
  const updateGuide = (next) => {
    if (!next || !next.id) return;
    setGuides((prev) => prev.map((g) => g.id === next.id ? next : g));
    if (window.__guideStore) window.__guideStore.upsert(next).catch(() => {});
    showToast('가이드 변경사항을 저장했습니다');
  };
  const deleteGuide = (g) => {
    setGuides((prev) => prev.filter((x) => x.id !== g.id));
    if (window.__guideStore) window.__guideStore.remove(g.id).catch(() => {});
    showToast('가이드가 삭제되었습니다');
  };
  const toggleFav = (g) => {
    setGuides((prev) => prev.map((x) => x.id === g.id ? { ...x, fav: !x.fav } : x));
    setFavSeq((prev) => prev.some((e) => e.kind === 'guide' && e.id === g.id) ?
    prev.filter((e) => !(e.kind === 'guide' && e.id === g.id)) :
    [...prev, { kind: 'guide', id: g.id }]);
  };
  const toggleFavFolder = (cat) => {
    setFavFolders((prev) => prev.includes(cat) ? prev.filter((c) => c !== cat) : [...prev, cat]);
    setFavSeq((prev) => prev.some((e) => e.kind === 'folder' && e.id === cat) ?
    prev.filter((e) => !(e.kind === 'folder' && e.id === cat)) :
    [...prev, { kind: 'folder', id: cat }]);
  };
  const openFolderFromSidebar = (cat) => {setDashFolder(cat);setView('dashboard');};

  // 권한(역할) 전환 시 항상 해당 영역의 초입(대시보드)으로 리셋 — 사용자가 보던 화면이 관리자 영역에 그대로 노출되는 문제 방지
  useEffect(() => {
    setNavh({ stack: ['dashboard'], idx: 0 });
    setDashFolder(null);
    setActiveGuide(null);
  }, [role]);

  const _createGuide = activeGuide || (role === 'user' ? (window.DUMMY_GUIDES || [])[0] : guides[0]);
  const crumbsMap = {
    dashboard: dashFolder ? [{ label: role === 'user' ? '배너 어시스턴트' : '가이드', view: 'dashboard' }, { label: dashFolder }] : [{ label: role === 'user' ? '배너 어시스턴트' : '가이드', view: 'dashboard' }],
    dashboard2: [{ label: role === 'user' ? '배너 에디터' : '가이드 2', view: 'dashboard2' }],
    urlFlow: [{ label: '가이드', view: 'dashboard' }, { label: 'URL로 가이드 만들기' }],
    imageFlow: [{ label: '가이드', view: 'dashboard' }, { label: '이미지로 가이드 만들기' }],
    url: [{ label: '가이드', view: 'dashboard' }, { label: 'URL로 만들기' }],
    imageUpload: [{ label: '가이드', view: 'dashboard' }, { label: '이미지로 만들기' }],
    analysis: [{ label: '가이드', view: 'dashboard' }, { label: '분석 중' }],
    urlReview: [{ label: '가이드', view: 'dashboard' }, { label: 'URL로 만들기', view: 'url' }, { label: '분석 결과' }],
    collected: [{ label: '가이드', view: 'dashboard' }, { label: 'URL로 만들기', view: 'url' }, { label: '분석 결과', view: 'urlReview' }, { label: '수집된 배너' }],
    grouping: [{ label: '가이드', view: 'dashboard' }, { label: '이미지로 만들기', view: 'imageUpload' }, { label: '그룹 확인' }],
    guideDetail: [{ label: '가이드', view: 'dashboard' }, { label: activeGuide ? activeGuide.name : '' }],
    create: _createGuide ? [{ label: '배너 만들기', view: 'create' }, { label: _createGuide.category, folder: _createGuide.category }, { label: _createGuide.name }] : [{ label: '배너 만들기', view: 'create' }],
    result: _createGuide ? [{ label: '배너 만들기', view: 'create' }, { label: _createGuide.category, folder: _createGuide.category }, { label: _createGuide.name, view: 'create' }, { label: '생성 결과' }] : [{ label: '배너 만들기', view: 'create' }, { label: '생성 결과' }],
    library: [{ label: role === 'admin' ? '배너 관리' : '내 배너', view: 'library' }],
    ga4: [{ label: '분석', view: 'ga4' }]
  };

  const favItems = favSeq.map((e) => e.kind === 'folder' ?
  { kind: 'folder', key: 'f-' + e.id, id: e.id, name: e.id } :
  (() => {const g = guides.find((x) => x.id === e.id);return g ? { kind: 'guide', key: 'g-' + g.id, guide: g } : null;})()).
  filter(Boolean);
  const reorderFav = (fromKey, toKey, pos) => setFavSeq((prev) => {
    const keyOf = (e) => e.kind === 'folder' ? 'f-' + e.id : 'g-' + e.id;
    const arr = [...prev];
    const fi = arr.findIndex((e) => keyOf(e) === fromKey);
    if (fi < 0) return prev;
    const [m] = arr.splice(fi, 1);
    let ti = arr.findIndex((e) => keyOf(e) === toKey);
    if (ti < 0) return prev;
    if (pos === 'below') ti += 1;
    arr.splice(ti, 0, m);
    return arr;
  });

  let screen;
  // 사용자도 guide-store를 읽되, 편집기 전용 1E가 저장소에 없을 때만 정식 시드 가이드를 복원한다.
  // 어시스턴트는 공식 고객 카테고리 3종만, 배너 에디터는 별도 제작 규격인 1E까지 노출한다.
  const CUSTOMER_GUIDE_CATEGORIES = ['상품배너 550', '앱푸시', '상품배너 H1'];
  const EDITOR_GUIDE_CATEGORIES = [...CUSTOMER_GUIDE_CATEGORIES, '1E 기술서상단'];
  const displayGuides = guides.filter((g) => CUSTOMER_GUIDE_CATEGORIES.includes(g.category));
  const storedEditorGuides = guides.filter((g) => EDITOR_GUIDE_CATEGORIES.includes(g.category));
  const editorGuides = storedEditorGuides.some((g) => g.category === '1E 기술서상단') ?
    storedEditorGuides :
    storedEditorGuides.concat((window.DUMMY_GUIDES || []).filter((g) => g.category === '1E 기술서상단'));

  // 내 배너에서 항목 클릭 → 이어서 편집(복원). 저장된 원본 이미지 + 설정으로 결과화면 재진입(자동 재생성).
  const openProject = async (item, options) => {
    if (!window.__myBanners || !item) return;
    const projectId = item.projectId || item.id;
    if (projectId) routeEditIdRef.current = projectId;
    if (projectId && (!options || options.updateUrl !== false)) setProjectEditUrl(projectId);
    setGa4Source('edit');
    const proj = await window.__myBanners.get(projectId);
    if (!proj) {showToast('불러오지 못했습니다');return;}
    const sel = proj.selection || {};
    const cat = sel.category || (item.meta && item.meta.category) || '';
    // ── 스냅샷이 있으면(신규 저장분) → 만든 리치 에디터로 완전 복원 ──
    if (window.BannerSnapshot && proj.snapshot && window.BannerSnapshot.isValid(proj.snapshot)) {
      showToast('복원 모드로 엽니다 (저장 레이아웃 불러오는 중)'); // [진단] 나중에 제거
      setRestorePayload({ id: projectId, snapshot: proj.snapshot, images: proj.images, selection: sel });
      setRestoreKey((k) => k + 1); // 새 복원 → 에디터 fresh 마운트
      setView(catToView(cat));
      return;
    }
    // ── 폴백: 구버전(스냅샷 없음) — 원본만 재생성(기존 동작) ──
    showToast('이 배너엔 저장 레시피(스냅샷)가 없어요 — 자동배치로 엽니다'); // [진단] 나중에 제거
    const srcs = proj.sources || [];
    if (!srcs.length) {showToast('이 배너는 원본 이미지가 없어 이어서 편집할 수 없어요');return;}
    const guide = displayGuides.find((g) => g.category === cat) || activeGuide || displayGuides[0];
    if (cat === '앱푸시') {
      const SCH = window.SCHEMA_PUSH;
      const ty = (SCH && SCH.types && (SCH.types.find((t) => t.label === sel.type) || SCH.types.find((t) => t.id === sel.type) || SCH.types[0])) || {};
      setBannerData({ content: { head: sel.event || '' }, image: srcs[0] || null, images: srcs, sty: window.STYLE_A, push: { type: ty.id, fields: {}, logo: null, copy: sel.event || '', finalImg: srcs[0] || null } });
    } else {
      setBannerData({ content: {}, image: srcs[0] || null, images: srcs, sty: window.STYLE_A, schema550: { layout: 'nukki', event: sel.event || '' } });
    }
    setActiveGuide(guide);
    setView('result');
  };

  useEffect(() => {
    const openFromRoute = () => {
      const id = getProjectEditIdFromLocation();
      if (!id) {
        routeEditIdRef.current = null;
        if (PROJECT_EDITOR_VIEWS.includes(view)) setView('library');
        return;
      }
      if (routeEditIdRef.current === id) return;
      openProject({ id: id, meta: {} }, { updateUrl: false });
    };
    openFromRoute();
    window.addEventListener('popstate', openFromRoute);
    return () => window.removeEventListener('popstate', openFromRoute);
  }, [view, displayGuides.length]);

  // 글로벌 상단바(shell)로 네비게이션 상태 발행 — 메뉴/계정을 최상단 바에 렌더
  const _favKey = favItems.map((f) => f.key).join('|');
  const _navApi = useRef({});
  _navApi.current = {
    nav, reorderFav, openFolder: openFolderFromSidebar,
    openFav: (g) => {try {window.postMessage({ type: 'bannerly-open-editor', name: g.name, category: g.category }, '*');} catch (e) {}}
  };
  const _dNav = useCallback((v) => _navApi.current.nav(v), []);
  const _dReorder = useCallback((a, b, c) => _navApi.current.reorderFav(a, b, c), []);
  const _dFolder = useCallback((id) => _navApi.current.openFolder(id), []);
  const _dFav = useCallback((g) => _navApi.current.openFav(g), []);
  useEffect(() => {
    if (!publishNav) return;
    publishNav({ view, guideCount: displayGuides.length, role, favItems, onNav: _dNav, onReorderFav: _dReorder, onOpenFolder: _dFolder, onOpenFavorite: _dFav });
  }, [view, displayGuides.length, role, _favKey]);
  if (view === 'dashboard' || view === 'guideCreate' || view === 'guideManage') screen = <Dashboard guides={displayGuides} role={role} canCreate={canCreate} section={view === 'guideCreate' ? 'create' : view === 'guideManage' ? 'manage' : 'all'} openFolder={dashFolder} setOpenFolder={setDashFolder} favFolders={favFolders} onToggleFavFolder={toggleFavFolder} onCreateUrl={() => setView('urlFlow')} onCreateImage={() => setView('imageFlow')} onOpenGuide={(g) => {setGa4Source('asst');openGuide(g);}} onUseGuide={useGuideFrom('asst')} onUseSample={useCategorySamples} onDuplicate={duplicateGuide} onDelete={deleteGuide} onToggleFav={toggleFav} />;else
  /* 예전 폴더 카드 화면(guide2.jsx) — 폴더 열림 상태는 Dashboard 지역 상태라 원본 대시보드와 간섭하지 않음 */
  if (view === 'dashboard2') screen = window.Dashboard2 ?
  <Dashboard2 guides={editorGuides} role={role} canCreate={canCreate} section="manage" favFolders={favFolders} onToggleFavFolder={toggleFavFolder} onCreateUrl={() => setView('urlFlow')} onCreateImage={() => setView('imageFlow')} onOpenGuide={(g) => {setGa4Source('edit');openGuide(g);}} onUseGuide={useGuideFrom('edit')} onDuplicate={duplicateGuide} onDelete={deleteGuide} onToggleFav={toggleFav} /> :
  <Placeholder icon="grid" title="배너 에디터" text="merged/guide2.jsx 가 로드되지 않았습니다." />;else
  if (view === 'urlFlow') screen = <ManualGuideCreate onSave={(g) => { generateFromCandidates([g]); }} onCancel={() => setView('dashboard')} />;else
  if (view === 'imageFlow') screen = window.CreateFlow ?
  <CreateFlow mode="image" onGenerate={generateFromGroups} onCancel={() => setView('dashboard')} /> :
  <Placeholder icon="images" title="이미지로 가이드 만들기" text="이 화면은 다음 단계에서 추가됩니다." />;else
  if (view === 'grouping') screen = window.GroupReview ?
  <GroupReview images={analyzeImages} groups={analyzeGroups} mergedPalette={analyzeMerged} onBack={() => setView('imageUpload')} onGenerate={generateFromGroups} /> :
  <Placeholder icon="grid" title="그룹 확인" text="이 화면은 다음 단계에서 추가됩니다." />;else
  if (view === 'guideDetail') screen = <GuideDetail guide={activeGuide} onBack={() => setView('dashboard')} onUse={useGuide} onDuplicate={duplicateGuide} onUpdate={updateGuide} />;else
  if (view === 'create') screen = (() => {
    // 유형(카테고리)별 에디터 = EDITOR_REGISTRY 단일 출처. 미등록 유형은 범용 CreateBanner 폴백.
    const g = activeGuide || displayGuides[0];
    const ctx = { guide: g, initialImages: catalogInput && catalogInput.images, initialRecords: catalogInput && catalogInput.records, initialCategory: catalogInput && catalogInput.category, onBack: () => setView('dashboard'), onGenerate: (data) => { setBannerData(data); setView('result'); }, ga4Source: ga4Source };
    const r = g && EDITOR_REGISTRY[g.category];
    if (r) return r(ctx);
    return window.CreateBanner
      ? <CreateBanner guide={g} onBack={ctx.onBack} onGenerate={ctx.onGenerate} />
      : <Placeholder icon="wand" title="배너 만들기" text="이 화면은 다음 단계에서 추가됩니다." />;
  })();else
  if (view === 'result') screen = window.BannerResult ?
  <BannerResult guide={activeGuide || displayGuides[0]} data={bannerData} onBack={() => setView('create')} onHome={() => setView('dashboard')} ga4Source={ga4Source} /> :
  <Placeholder icon="sparkles" title="생성 결과" text="이 화면은 다음 단계에서 추가됩니다." />;else
  if (view === 'ap550') screen = EDITOR_REGISTRY['상품배너 550']({ restore: restorePayload, restoreKey: restoreKey, onRestoreConsumed: () => setRestorePayload(null) });else
  if (view === 'ap1e') screen = EDITOR_REGISTRY['1E 기술서상단']({ restore: restorePayload, restoreKey: restoreKey, onRestoreConsumed: () => setRestorePayload(null) });else
  if (view === 'appush') screen = EDITOR_REGISTRY['앱푸시']({ restore: restorePayload, restoreKey: restoreKey, onRestoreConsumed: () => setRestorePayload(null) });else
  if (view === 'apH1') screen = EDITOR_REGISTRY['상품배너 H1']({ guide: displayGuides.find((g) => g.category === '상품배너 H1') || activeGuide || displayGuides[0], onBack: () => setView('dashboard'), onGenerate: (data) => { setBannerData(data); setView('result'); }, restore: restorePayload, restoreKey: restoreKey, onRestoreConsumed: () => setRestorePayload(null) });else
  if (view === 'library') screen = <MyBannersView role={role} onOpen={openProject} onToast={showToast} />;
  if (view === 'members') screen = role === 'admin' ? <MembersView /> : <Placeholder icon="lock" title="접근 권한 없음" text="관리자만 이용할 수 있습니다." />;
  if (view === 'ga4') screen = role === 'admin'
    ? (window.Ga4AnalyticsView ? React.createElement(window.Ga4AnalyticsView) : <Placeholder icon="grid" title="분석" text="분석 화면이 로드되지 않았습니다." />)
    : <Placeholder icon="lock" title="접근 권한 없음" text="관리자만 이용할 수 있습니다." />;

  return (
    <div className="app">
      <div className="main">
        {screen}
      </div>
      {toast &&
      <div className="toast-wrap" role="status" aria-live="polite">
          <div className="toast">
            <span className="tcheck"><S2Icon name="check" size={13} style={{ color: '#fff' }} strokeWidth={3} /></span>
            {toast}
          </div>
        </div>
      }
    </div>);

}

Object.assign(window, { Studio2App, WorkspaceNav, AccountMenu, AccountSettingsModal, Topbar, Placeholder });
