5c7a5bef1e
- 카테고리 DB를 type 기반(doc/img/vid/zip/code)으로 마이그레이션 - 사이드바·상단탭이 DB 카테고리 API를 공유해 항상 일치 - 기본 뷰를 그리드 → 리스트로 변경 - 로그인 버튼 제거, 관리자만 로그인 가능 - 관리자 패널에서 사용자 관리 메뉴 제거 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
857 lines
39 KiB
HTML
857 lines
39 KiB
HTML
<!doctype html>
|
|
<html lang="ko">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
|
<title>자료실 관리자</title>
|
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
|
<link rel="stylesheet" as="style" crossorigin href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable-dynamic-subset.min.css" />
|
|
<style>
|
|
:root{
|
|
--bg:#f8f8f6;--surface:#ffffff;--surface-2:#f1f1ee;
|
|
--border:rgba(20,20,20,.08);--border-strong:rgba(20,20,20,.14);
|
|
--ink:#1a1a1a;--ink-2:#3a3a37;--muted:#6b6b66;--faint:#9a9a93;
|
|
--accent:#3b5bdb;--accent-soft:#eef1ff;--warn:#d97757;--ok:#1f8a5b;
|
|
--radius:8px;--radius-lg:12px;
|
|
--shadow-sm:0 1px 0 rgba(20,20,20,.04),0 1px 2px rgba(20,20,20,.04);
|
|
--shadow-md:0 6px 20px rgba(20,20,20,.06),0 2px 4px rgba(20,20,20,.04);
|
|
--shadow-lg:0 24px 60px rgba(20,20,20,.16),0 6px 16px rgba(20,20,20,.08);
|
|
--font-sans:"Pretendard Variable",Pretendard,-apple-system,BlinkMacSystemFont,system-ui,sans-serif;
|
|
--font-mono:"JetBrains Mono",ui-monospace,SFMono-Regular,Menlo,monospace;
|
|
}
|
|
*{box-sizing:border-box}
|
|
html,body{margin:0;padding:0;height:100%;background:var(--bg);color:var(--ink);font-family:var(--font-sans);-webkit-font-smoothing:antialiased}
|
|
body{font-size:14px;letter-spacing:-.005em}
|
|
button{font-family:inherit;letter-spacing:inherit}
|
|
|
|
.app{display:grid;grid-template-columns:220px 1fr;height:100vh;overflow:hidden}
|
|
|
|
/* SIDEBAR */
|
|
.sb{display:flex;flex-direction:column;padding:14px 10px;border-right:1px solid var(--border);background:var(--bg);overflow-y:auto}
|
|
.sb-brand{display:flex;align-items:center;gap:10px;padding:6px 8px 16px;color:var(--ink)}
|
|
.sb-brand-mark{width:26px;height:26px;border-radius:7px;background:var(--ink);display:grid;place-items:center;color:#fff;font-weight:700;font-size:12px;flex-shrink:0}
|
|
.sb-brand-name{font-weight:600;font-size:14px}
|
|
.sb-brand-sub{font-size:11px;color:var(--muted);font-family:var(--font-mono);margin-top:1px}
|
|
.sb-item{display:flex;align-items:center;gap:9px;padding:7px 10px;border-radius:6px;color:var(--ink-2);cursor:default;font-size:13.5px;user-select:none}
|
|
.sb-item:hover{background:rgba(20,20,20,.04)}
|
|
.sb-item.active{background:rgba(20,20,20,.06);color:var(--ink);font-weight:500}
|
|
.sb-divider{height:1px;background:var(--border);margin:8px 4px}
|
|
.sb-back{display:flex;align-items:center;gap:8px;padding:7px 10px;border-radius:6px;font-size:12.5px;color:var(--muted);text-decoration:none;margin-top:auto}
|
|
.sb-back:hover{background:rgba(20,20,20,.05);color:var(--ink)}
|
|
|
|
/* MAIN */
|
|
.main{display:flex;flex-direction:column;overflow:hidden;min-width:0}
|
|
.page-hdr{display:flex;align-items:center;justify-content:space-between;padding:20px 28px 16px;border-bottom:1px solid var(--border);flex-shrink:0}
|
|
.page-hdr h2{font-size:18px;font-weight:600;margin:0}
|
|
.page-hdr p{font-size:13px;color:var(--muted);margin:2px 0 0}
|
|
.page-body{flex:1;overflow-y:auto;padding:24px 28px}
|
|
|
|
/* BUTTONS */
|
|
.btn{appearance:none;border:1px solid var(--border-strong);background:var(--surface);color:var(--ink);font-size:13px;font-weight:500;padding:7px 12px;border-radius:7px;cursor:default;display:inline-flex;align-items:center;gap:6px;line-height:1}
|
|
.btn:hover{background:var(--surface-2)}
|
|
.btn-accent{background:var(--accent);color:#fff;border-color:var(--accent)}
|
|
.btn-accent:hover{filter:brightness(1.08)}
|
|
.btn-danger{color:var(--warn);border-color:rgba(217,119,87,.3)}
|
|
.btn-danger:hover{background:rgba(217,119,87,.06)}
|
|
.btn-ghost{border-color:transparent;background:transparent;color:var(--ink-2)}
|
|
.btn-ghost:hover{background:rgba(20,20,20,.05)}
|
|
.btn-sm{padding:5px 9px;font-size:12px;border-radius:6px}
|
|
.btn-icon{padding:5px;width:28px;height:28px;justify-content:center;border-radius:6px}
|
|
|
|
/* TABLE */
|
|
.tbl-wrap{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-lg);overflow:hidden}
|
|
.tbl{width:100%;border-collapse:collapse}
|
|
.tbl th{font-size:11px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--faint);padding:10px 14px;border-bottom:1px solid var(--border);text-align:left;background:var(--surface-2)}
|
|
.tbl td{padding:11px 14px;border-bottom:1px solid var(--border);font-size:13px;color:var(--ink-2);vertical-align:middle}
|
|
.tbl tr:last-child td{border-bottom:0}
|
|
.tbl tr:hover td{background:rgba(20,20,20,.015)}
|
|
.tbl .actions{display:flex;gap:4px;justify-content:flex-end;opacity:0;transition:opacity .15s}
|
|
.tbl tr:hover .actions{opacity:1}
|
|
.tbl-empty{padding:40px;text-align:center;color:var(--faint);font-size:13px}
|
|
|
|
/* BADGE */
|
|
.badge{display:inline-flex;align-items:center;padding:2px 8px;border-radius:99px;font-size:11px;font-weight:500}
|
|
.badge-blue{background:var(--accent-soft);color:var(--accent)}
|
|
.badge-green{background:rgba(31,138,91,.1);color:var(--ok)}
|
|
.badge-gray{background:var(--surface-2);color:var(--muted)}
|
|
.badge-warn{background:rgba(217,119,87,.12);color:var(--warn)}
|
|
|
|
/* FORM / MODAL */
|
|
.modal-bg{position:fixed;inset:0;background:rgba(10,10,10,.45);backdrop-filter:blur(4px);display:grid;place-items:center;z-index:100}
|
|
.modal{background:var(--surface);border-radius:var(--radius-lg);width:460px;max-width:95vw;box-shadow:var(--shadow-lg)}
|
|
.modal-h{display:flex;align-items:center;justify-content:space-between;padding:16px 20px;border-bottom:1px solid var(--border)}
|
|
.modal-h h3{margin:0;font-size:15px;font-weight:600}
|
|
.modal-b{padding:20px}
|
|
.modal-f{display:flex;gap:10px;justify-content:flex-end;padding:14px 20px;border-top:1px solid var(--border)}
|
|
.field{display:flex;flex-direction:column;gap:5px;margin-bottom:14px}
|
|
.field:last-child{margin-bottom:0}
|
|
.field label{font-size:11px;font-weight:600;color:var(--muted);letter-spacing:.02em}
|
|
.field input,.field select,.field textarea{border:1px solid var(--border-strong);border-radius:6px;padding:8px 10px;font-size:13px;font-family:inherit;color:var(--ink);background:var(--surface);outline:none;transition:border-color .15s}
|
|
.field input:focus,.field select:focus,.field textarea:focus{border-color:var(--accent)}
|
|
.field .hint{font-size:11px;color:var(--faint)}
|
|
.field-row{display:grid;grid-template-columns:1fr 1fr;gap:12px}
|
|
|
|
/* TOOLBAR */
|
|
.toolbar{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:16px}
|
|
.toolbar-l{display:flex;align-items:center;gap:8px}
|
|
|
|
/* SEARCH */
|
|
.search-box{display:flex;align-items:center;gap:8px;padding:7px 10px;background:var(--surface);border:1px solid var(--border-strong);border-radius:7px}
|
|
.search-box input{border:0;outline:none;font-size:13px;color:var(--ink);background:transparent;min-width:220px}
|
|
.search-box input::placeholder{color:var(--faint)}
|
|
|
|
/* TOAST */
|
|
.toast{position:fixed;bottom:28px;left:50%;transform:translateX(-50%) translateY(10px);background:var(--ink);color:#fff;padding:10px 16px;border-radius:8px;font-size:13px;display:flex;align-items:center;gap:8px;opacity:0;transition:all .25s ease;pointer-events:none;z-index:200;white-space:nowrap}
|
|
.toast.on{opacity:1;transform:translateX(-50%) translateY(0)}
|
|
.toast .ck{width:18px;height:18px;border-radius:50%;background:var(--ok);display:grid;place-items:center;flex-shrink:0}
|
|
|
|
/* STATS BAR */
|
|
.stats-row{display:grid;grid-template-columns:repeat(3,1fr);gap:14px;margin-bottom:24px}
|
|
.stat-card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-lg);padding:16px 18px}
|
|
.stat-card .label{font-size:11px;color:var(--faint);font-weight:600;letter-spacing:.04em;text-transform:uppercase;margin-bottom:6px}
|
|
.stat-card .value{font-size:26px;font-weight:700;font-family:var(--font-mono);color:var(--ink)}
|
|
|
|
/* FILE SEARCH */
|
|
.file-filters{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px}
|
|
.filter-pill{appearance:none;border:1px solid var(--border);background:transparent;padding:4px 10px;border-radius:99px;font-size:12px;color:var(--ink-2);cursor:default}
|
|
.filter-pill.on{background:var(--ink);color:#fff;border-color:var(--ink)}
|
|
.filter-pill:hover:not(.on){background:rgba(20,20,20,.04)}
|
|
|
|
/* PAGINATION */
|
|
.pagination{display:flex;align-items:center;justify-content:center;gap:6px;padding:16px 0 4px}
|
|
.page-btn{appearance:none;border:1px solid var(--border);background:var(--surface);color:var(--ink-2);padding:5px 10px;border-radius:6px;font-size:12px;cursor:default;font-family:var(--font-mono)}
|
|
.page-btn:hover:not(:disabled){background:var(--surface-2)}
|
|
.page-btn.on{background:var(--ink);color:#fff;border-color:var(--ink)}
|
|
.page-btn:disabled{opacity:.4}
|
|
</style>
|
|
<script crossorigin src="https://unpkg.com/react@18.3.1/umd/react.production.min.js"></script>
|
|
<script crossorigin src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js"></script>
|
|
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
|
|
</head>
|
|
<body>
|
|
<div id="root"></div>
|
|
<script type="text/babel">
|
|
'use strict';
|
|
|
|
const { useState, useEffect, useRef, useCallback } = React;
|
|
|
|
// ── 인증 토큰 유틸 ────────────────────────────────────────────
|
|
|
|
const getToken = () => sessionStorage.getItem('admin_token');
|
|
const setToken = (t) => sessionStorage.setItem('admin_token', t);
|
|
const clearToken = () => sessionStorage.removeItem('admin_token');
|
|
|
|
// ── 유틸 ──────────────────────────────────────────────────────
|
|
|
|
async function apiFetch(path, options = {}) {
|
|
const token = getToken();
|
|
const headers = { ...(options.headers || {}) };
|
|
if (token) headers['Authorization'] = `Bearer ${token}`;
|
|
const res = await fetch(path, { ...options, headers });
|
|
if (res.status === 401) {
|
|
clearToken();
|
|
window.__onUnauthorized?.();
|
|
throw new Error('인증이 필요합니다');
|
|
}
|
|
const data = await res.json();
|
|
if (!res.ok) throw new Error(data.error || `API 오류 (${res.status})`);
|
|
return data;
|
|
}
|
|
|
|
const json = (body) => ({ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
|
const patchJson = (body) => ({ method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
|
|
|
// ── 아이콘 ────────────────────────────────────────────────────
|
|
|
|
const Icon = ({ name, size = 16, stroke = 1.75, ...rest }) => {
|
|
const paths = {
|
|
folder: <><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></>,
|
|
image: <><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-5-5-9 9"/></>,
|
|
code: <><path d="m16 6 6 6-6 6M8 18l-6-6 6-6"/></>,
|
|
video: <><rect x="3" y="6" width="13" height="12" rx="2"/><path d="m22 8-6 4 6 4V8Z"/></>,
|
|
star: <><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></>,
|
|
plus: <><path d="M12 5v14M5 12h14"/></>,
|
|
x: <><path d="m6 6 12 12M6 18 18 6"/></>,
|
|
check: <><path d="M5 12.5 10 17 19 7"/></>,
|
|
edit: <><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4Z"/></>,
|
|
trash: <><path d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M6 6v14a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V6"/></>,
|
|
user: <><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></>,
|
|
file: <><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/></>,
|
|
search: <><circle cx="11" cy="11" r="7"/><path d="m21 21-4.35-4.35"/></>,
|
|
"arrow-left": <><path d="M19 12H5M5 12l7 7M5 12l7-7"/></>,
|
|
"chevron-left": <><path d="m15 18-6-6 6-6"/></>,
|
|
"chevron-right": <><path d="m9 18 6-6-6-6"/></>,
|
|
settings: <><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></>,
|
|
lock: <><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></>,
|
|
"log-out": <><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9"/></>,
|
|
};
|
|
return (
|
|
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={stroke} strokeLinecap="round" strokeLinejoin="round" {...rest}>
|
|
{paths[name] || null}
|
|
</svg>
|
|
);
|
|
};
|
|
|
|
// ── 로그인 화면 ───────────────────────────────────────────────
|
|
|
|
function LoginScreen({ onLogin }) {
|
|
const [pw, setPw] = useState('');
|
|
const [error, setError] = useState('');
|
|
const [loading, setLoading] = useState(false);
|
|
const inputRef = useRef(null);
|
|
|
|
useEffect(() => { inputRef.current?.focus(); }, []);
|
|
|
|
const submit = async (e) => {
|
|
e?.preventDefault();
|
|
setError('');
|
|
setLoading(true);
|
|
try {
|
|
const res = await fetch('/api/admin/login', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ password: pw }),
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok) throw new Error(data.error || '로그인 실패');
|
|
onLogin(data.token);
|
|
} catch (e) {
|
|
setError(e.message);
|
|
setPw('');
|
|
inputRef.current?.focus();
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div style={{ display:'grid', placeItems:'center', height:'100vh', background:'var(--bg)' }}>
|
|
<div style={{ background:'var(--surface)', border:'1px solid var(--border)', borderRadius:'var(--radius-lg)', padding:'32px 28px', width:360, maxWidth:'92vw', boxShadow:'var(--shadow-lg)' }}>
|
|
<div style={{ display:'flex', alignItems:'center', gap:10, marginBottom:28 }}>
|
|
<div style={{ width:34, height:34, borderRadius:9, background:'var(--ink)', display:'grid', placeItems:'center', color:'#fff', fontWeight:700, fontSize:14, flexShrink:0 }}>자</div>
|
|
<div>
|
|
<div style={{ fontWeight:600, fontSize:15 }}>자료실 관리자</div>
|
|
<div style={{ fontSize:11, color:'var(--muted)', fontFamily:'var(--font-mono)' }}>Admin Console</div>
|
|
</div>
|
|
</div>
|
|
|
|
<form onSubmit={submit}>
|
|
<div className="field" style={{ marginBottom: error ? 8 : 16 }}>
|
|
<label style={{ display:'flex', alignItems:'center', gap:5 }}>
|
|
<Icon name="lock" size={11} />관리자 비밀번호
|
|
</label>
|
|
<input
|
|
ref={inputRef}
|
|
type="password"
|
|
value={pw}
|
|
onChange={e => { setPw(e.target.value); setError(''); }}
|
|
placeholder="비밀번호를 입력하세요"
|
|
disabled={loading}
|
|
/>
|
|
</div>
|
|
{error && (
|
|
<div style={{ fontSize:12, color:'var(--warn)', marginBottom:12, display:'flex', alignItems:'center', gap:5 }}>
|
|
<Icon name="x" size={12} />{error}
|
|
</div>
|
|
)}
|
|
<button
|
|
type="submit"
|
|
className="btn btn-accent"
|
|
style={{ width:'100%', justifyContent:'center', padding:'9px 16px', fontSize:14 }}
|
|
disabled={loading || !pw}
|
|
>
|
|
{loading ? '확인 중...' : '로그인'}
|
|
</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Toast ─────────────────────────────────────────────────────
|
|
|
|
const Toast = ({ msg, on }) => (
|
|
<div className={`toast ${on ? "on" : ""}`}>
|
|
<span className="ck"><Icon name="check" size={11} stroke={3} /></span>
|
|
{msg}
|
|
</div>
|
|
);
|
|
|
|
// ── 모달 공통 ─────────────────────────────────────────────────
|
|
|
|
const Modal = ({ title, onClose, onSave, saveLabel = "저장", children, danger = false }) => (
|
|
<div className="modal-bg" onClick={onClose}>
|
|
<div className="modal" onClick={e => e.stopPropagation()}>
|
|
<div className="modal-h">
|
|
<h3>{title}</h3>
|
|
<button className="btn btn-ghost btn-icon" onClick={onClose}><Icon name="x" size={15} /></button>
|
|
</div>
|
|
<div className="modal-b">{children}</div>
|
|
{onSave && (
|
|
<div className="modal-f">
|
|
<button className="btn btn-ghost" onClick={onClose}>취소</button>
|
|
<button className={`btn ${danger ? "btn-danger" : "btn-accent"}`} onClick={onSave}>{saveLabel}</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
// ── 카테고리 관리 ──────────────────────────────────────────────
|
|
|
|
const ICON_OPTIONS = ['folder', 'image', 'code', 'video', 'star', 'file', 'user', 'settings'];
|
|
|
|
const CategorySection = ({ showToast }) => {
|
|
const [cats, setCats] = useState([]);
|
|
const [modal, setModal] = useState(null); // null | { mode:'add'|'edit', data }
|
|
const [form, setForm] = useState({ id: '', label: '', icon: 'folder', sort_order: 0 });
|
|
const [loading, setLoading] = useState(true);
|
|
const [fileCounts, setFileCounts] = useState({});
|
|
|
|
const load = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const [catList, stats] = await Promise.all([
|
|
apiFetch('/api/categories'),
|
|
apiFetch('/api/stats'),
|
|
]);
|
|
setCats(catList);
|
|
setFileCounts(stats.categories || {});
|
|
} finally { setLoading(false); }
|
|
};
|
|
|
|
useEffect(() => { load(); }, []);
|
|
|
|
const openAdd = () => {
|
|
setForm({ id: '', label: '', icon: 'folder', sort_order: cats.length });
|
|
setModal({ mode: 'add' });
|
|
};
|
|
|
|
const openEdit = (c) => {
|
|
setForm({ id: c.id, label: c.label, icon: c.icon || 'folder', sort_order: c.sort_order ?? 0 });
|
|
setModal({ mode: 'edit', data: c });
|
|
};
|
|
|
|
const save = async () => {
|
|
try {
|
|
if (modal.mode === 'add') {
|
|
await apiFetch('/api/categories', json(form));
|
|
showToast('카테고리가 추가되었습니다');
|
|
} else {
|
|
await apiFetch(`/api/categories/${modal.data.id}`, patchJson({ label: form.label, icon: form.icon, sort_order: form.sort_order }));
|
|
showToast('카테고리가 수정되었습니다');
|
|
}
|
|
setModal(null);
|
|
load();
|
|
} catch (e) { showToast(`오류: ${e.message}`); }
|
|
};
|
|
|
|
const del = async (c) => {
|
|
if (!window.confirm(`"${c.label}" 카테고리를 삭제할까요?`)) return;
|
|
try {
|
|
await apiFetch(`/api/categories/${c.id}`, { method: 'DELETE' });
|
|
showToast('카테고리가 삭제되었습니다');
|
|
load();
|
|
} catch (e) { showToast(`삭제 실패: ${e.message}`); }
|
|
};
|
|
|
|
const f = (k) => (e) => setForm(p => ({ ...p, [k]: e.target.value }));
|
|
|
|
return (
|
|
<div>
|
|
<div className="toolbar">
|
|
<div>
|
|
<div style={{ fontSize:16, fontWeight:600, marginBottom:2 }}>카테고리 관리</div>
|
|
<div style={{ fontSize:12, color:'var(--muted)' }}>파일 분류 카테고리를 추가·수정·삭제합니다</div>
|
|
</div>
|
|
<button className="btn btn-accent" onClick={openAdd}><Icon name="plus" size={13} />새 카테고리</button>
|
|
</div>
|
|
|
|
<div className="tbl-wrap">
|
|
<table className="tbl">
|
|
<thead>
|
|
<tr>
|
|
<th>순서</th><th>아이콘</th><th>ID</th><th>이름</th><th>파일 수</th><th></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{loading ? (
|
|
<tr><td colSpan={6} className="tbl-empty">로딩 중...</td></tr>
|
|
) : cats.length === 0 ? (
|
|
<tr><td colSpan={6} className="tbl-empty">카테고리가 없습니다</td></tr>
|
|
) : cats.map(c => (
|
|
<tr key={c.id}>
|
|
<td style={{ fontFamily:'var(--font-mono)', color:'var(--faint)', width:60 }}>{c.sort_order}</td>
|
|
<td style={{ width:52 }}><Icon name={c.icon || 'folder'} size={16} /></td>
|
|
<td style={{ fontFamily:'var(--font-mono)', fontSize:12, color:'var(--muted)' }}>{c.id}</td>
|
|
<td style={{ fontWeight:500, color:'var(--ink)' }}>{c.label}</td>
|
|
<td>
|
|
<span className={`badge ${(fileCounts[c.id] || 0) > 0 ? 'badge-blue' : 'badge-gray'}`}>
|
|
{fileCounts[c.id] || 0}개
|
|
</span>
|
|
</td>
|
|
<td>
|
|
<div className="actions">
|
|
<button className="btn btn-sm btn-ghost" onClick={() => openEdit(c)}><Icon name="edit" size={13} />편집</button>
|
|
<button className="btn btn-sm btn-danger" onClick={() => del(c)}><Icon name="trash" size={13} />삭제</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{modal && (
|
|
<Modal
|
|
title={modal.mode === 'add' ? '새 카테고리 추가' : '카테고리 편집'}
|
|
onClose={() => setModal(null)}
|
|
onSave={save}
|
|
>
|
|
{modal.mode === 'add' && (
|
|
<div className="field">
|
|
<label>카테고리 ID <span style={{color:'var(--warn)'}}>*</span></label>
|
|
<input placeholder="예: design, marketing" value={form.id} onChange={f('id')} />
|
|
<span className="hint">영문 소문자·숫자·하이픈만 사용 · 생성 후 변경 불가</span>
|
|
</div>
|
|
)}
|
|
{modal.mode === 'edit' && (
|
|
<div className="field">
|
|
<label>카테고리 ID</label>
|
|
<input value={form.id} disabled style={{ opacity:.5 }} />
|
|
</div>
|
|
)}
|
|
<div className="field">
|
|
<label>이름 <span style={{color:'var(--warn)'}}>*</span></label>
|
|
<input placeholder="카테고리 이름" value={form.label} onChange={f('label')} />
|
|
</div>
|
|
<div className="field-row">
|
|
<div className="field">
|
|
<label>아이콘</label>
|
|
<select value={form.icon} onChange={f('icon')}>
|
|
{ICON_OPTIONS.map(i => <option key={i} value={i}>{i}</option>)}
|
|
</select>
|
|
</div>
|
|
<div className="field">
|
|
<label>정렬 순서</label>
|
|
<input type="number" value={form.sort_order} onChange={f('sort_order')} />
|
|
</div>
|
|
</div>
|
|
<div style={{ display:'flex', alignItems:'center', gap:8, padding:'10px 12px', background:'var(--surface-2)', borderRadius:7, fontSize:12.5, color:'var(--muted)' }}>
|
|
<Icon name={form.icon || 'folder'} size={15} />
|
|
<span>{form.label || '이름 없음'}</span>
|
|
</div>
|
|
</Modal>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ── 파일 관리 ──────────────────────────────────────────────────
|
|
|
|
const PAGE_SIZE = 20;
|
|
|
|
const FileSection = ({ showToast }) => {
|
|
const [files, setFiles] = useState([]);
|
|
const [cats, setCats] = useState([]);
|
|
const [users, setUsers] = useState([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [search, setSearch] = useState('');
|
|
const [catFilter, setCatFilter] = useState('');
|
|
const [page, setPage] = useState(1);
|
|
const [total, setTotal] = useState(0);
|
|
const [editModal, setEditModal] = useState(null);
|
|
const [form, setForm] = useState({});
|
|
|
|
const load = useCallback(async () => {
|
|
setLoading(true);
|
|
try {
|
|
const p = new URLSearchParams();
|
|
if (search) p.set('search', search);
|
|
if (catFilter) p.set('category', catFilter);
|
|
p.set('page', page); p.set('limit', PAGE_SIZE);
|
|
const [fileData, catList, userList] = await Promise.all([
|
|
apiFetch(`/api/admin/files?${p}`),
|
|
apiFetch('/api/categories'),
|
|
apiFetch('/api/users'),
|
|
]);
|
|
setFiles(fileData.items || fileData);
|
|
setTotal(fileData.total || (fileData.items ? fileData.total : fileData.length));
|
|
setCats(catList);
|
|
setUsers(userList);
|
|
} finally { setLoading(false); }
|
|
}, [search, catFilter, page]);
|
|
|
|
useEffect(() => { load(); }, [load]);
|
|
useEffect(() => { setPage(1); }, [search, catFilter]);
|
|
|
|
const openEdit = (f) => {
|
|
setForm({ name: f.name, category: f.category, author: f.author, visibility: f.visibility });
|
|
setEditModal(f);
|
|
};
|
|
|
|
const saveEdit = async () => {
|
|
try {
|
|
await apiFetch(`/api/files/${editModal.id}`, patchJson(form));
|
|
showToast('파일 정보가 수정되었습니다');
|
|
setEditModal(null);
|
|
load();
|
|
} catch (e) { showToast(`오류: ${e.message}`); }
|
|
};
|
|
|
|
const del = async (f) => {
|
|
if (!window.confirm(`"${f.name}"을(를) 삭제할까요?`)) return;
|
|
try {
|
|
await apiFetch('/api/files', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ids: [f.id] }) });
|
|
showToast('파일이 삭제되었습니다');
|
|
load();
|
|
} catch (e) { showToast(`오류: ${e.message}`); }
|
|
};
|
|
|
|
const ff = (k) => (e) => setForm(p => ({ ...p, [k]: e.target.value }));
|
|
|
|
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
|
const visMap = { public: '공개', team: '팀 공유', private: '비공개' };
|
|
const visClass = { public: 'badge-green', team: 'badge-blue', private: 'badge-gray' };
|
|
|
|
return (
|
|
<div>
|
|
<div className="toolbar">
|
|
<div>
|
|
<div style={{ fontSize:16, fontWeight:600, marginBottom:2 }}>파일 관리</div>
|
|
<div style={{ fontSize:12, color:'var(--muted)' }}>등록된 파일의 메타데이터를 편집하거나 삭제합니다</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div style={{ display:'flex', gap:10, marginBottom:14 }}>
|
|
<div className="search-box">
|
|
<Icon name="search" size={13} />
|
|
<input placeholder="파일명 검색..." value={search} onChange={e => setSearch(e.target.value)} />
|
|
</div>
|
|
<select
|
|
style={{ border:'1px solid var(--border-strong)', borderRadius:7, padding:'7px 10px', fontSize:13, background:'var(--surface)', color:'var(--ink)', outline:'none' }}
|
|
value={catFilter} onChange={e => setCatFilter(e.target.value)}>
|
|
<option value="">전체 카테고리</option>
|
|
{cats.map(c => <option key={c.id} value={c.id}>{c.label}</option>)}
|
|
</select>
|
|
</div>
|
|
|
|
<div className="tbl-wrap">
|
|
<table className="tbl">
|
|
<thead>
|
|
<tr>
|
|
<th>파일명</th><th>카테고리</th><th>업로더</th><th>공개 범위</th><th>크기</th><th>다운로드</th><th></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{loading ? (
|
|
<tr><td colSpan={7} className="tbl-empty">로딩 중...</td></tr>
|
|
) : files.length === 0 ? (
|
|
<tr><td colSpan={7} className="tbl-empty">파일이 없습니다</td></tr>
|
|
) : files.map(f => (
|
|
<tr key={f.id}>
|
|
<td style={{ maxWidth:280 }}>
|
|
<div style={{ fontWeight:500, color:'var(--ink)', overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>{f.name}</div>
|
|
<div style={{ fontSize:11, color:'var(--faint)', fontFamily:'var(--font-mono)' }}>{f.ext}</div>
|
|
</td>
|
|
<td><span className="badge badge-gray">{cats.find(c=>c.id===f.category)?.label || f.category}</span></td>
|
|
<td style={{ color:'var(--muted)' }}>{f.author}</td>
|
|
<td><span className={`badge ${visClass[f.visibility] || 'badge-gray'}`}>{visMap[f.visibility] || f.visibility}</span></td>
|
|
<td style={{ fontFamily:'var(--font-mono)', fontSize:12, color:'var(--muted)' }}>{f.size_label}</td>
|
|
<td style={{ fontFamily:'var(--font-mono)', fontSize:12, color:'var(--faint)' }}>↓ {f.downloads}</td>
|
|
<td>
|
|
<div className="actions">
|
|
<button className="btn btn-sm btn-ghost" onClick={() => openEdit(f)}><Icon name="edit" size={13} />편집</button>
|
|
<button className="btn btn-sm btn-danger" onClick={() => del(f)}><Icon name="trash" size={13} />삭제</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{totalPages > 1 && (
|
|
<div className="pagination">
|
|
<button className="page-btn" onClick={() => setPage(p => p-1)} disabled={page === 1}><Icon name="chevron-left" size={13} /></button>
|
|
{Array.from({ length: Math.min(7, totalPages) }, (_, i) => {
|
|
const p = page <= 4 ? i+1 : page > totalPages-4 ? totalPages-6+i : page-3+i;
|
|
if (p < 1 || p > totalPages) return null;
|
|
return <button key={p} className={`page-btn ${p === page ? 'on' : ''}`} onClick={() => setPage(p)}>{p}</button>;
|
|
})}
|
|
<button className="page-btn" onClick={() => setPage(p => p+1)} disabled={page === totalPages}><Icon name="chevron-right" size={13} /></button>
|
|
</div>
|
|
)}
|
|
|
|
{editModal && (
|
|
<Modal title="파일 정보 편집" onClose={() => setEditModal(null)} onSave={saveEdit}>
|
|
<div className="field">
|
|
<label>파일명</label>
|
|
<input value={form.name} onChange={ff('name')} />
|
|
</div>
|
|
<div className="field-row">
|
|
<div className="field">
|
|
<label>카테고리</label>
|
|
<select value={form.category} onChange={ff('category')}>
|
|
{cats.map(c => <option key={c.id} value={c.id}>{c.label}</option>)}
|
|
</select>
|
|
</div>
|
|
<div className="field">
|
|
<label>업로더</label>
|
|
<select value={form.author} onChange={ff('author')}>
|
|
{users.map(u => <option key={u.id} value={u.name}>{u.name}</option>)}
|
|
<option value={form.author}>{form.author}</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div className="field">
|
|
<label>공개 범위</label>
|
|
<select value={form.visibility} onChange={ff('visibility')}>
|
|
<option value="team">팀 공유</option>
|
|
<option value="public">공개</option>
|
|
<option value="private">비공개</option>
|
|
</select>
|
|
</div>
|
|
</Modal>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ── 사용자 관리 ────────────────────────────────────────────────
|
|
|
|
const UserSection = ({ showToast }) => {
|
|
const [users, setUsers] = useState([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [modal, setModal] = useState(null);
|
|
const [form, setForm] = useState({ name: '', email: '', role: 'member' });
|
|
|
|
const load = async () => {
|
|
setLoading(true);
|
|
try { setUsers(await apiFetch('/api/users')); }
|
|
finally { setLoading(false); }
|
|
};
|
|
useEffect(() => { load(); }, []);
|
|
|
|
const openAdd = () => {
|
|
setForm({ name: '', email: '', role: 'member' });
|
|
setModal({ mode: 'add' });
|
|
};
|
|
|
|
const openEdit = (u) => {
|
|
setForm({ name: u.name, email: u.email || '', role: u.role });
|
|
setModal({ mode: 'edit', data: u });
|
|
};
|
|
|
|
const save = async () => {
|
|
try {
|
|
if (modal.mode === 'add') {
|
|
await apiFetch('/api/users', json(form));
|
|
showToast('사용자가 추가되었습니다');
|
|
} else {
|
|
await apiFetch(`/api/users/${modal.data.id}`, patchJson(form));
|
|
showToast('사용자 정보가 수정되었습니다');
|
|
}
|
|
setModal(null);
|
|
load();
|
|
} catch (e) { showToast(`오류: ${e.message}`); }
|
|
};
|
|
|
|
const del = async (u) => {
|
|
const msg = u.file_count > 0
|
|
? `"${u.name}" 사용자를 삭제할까요?\n이 사용자가 업로드한 ${u.file_count}개 파일의 업로더 정보는 그대로 남습니다.`
|
|
: `"${u.name}" 사용자를 삭제할까요?`;
|
|
if (!window.confirm(msg)) return;
|
|
try {
|
|
await apiFetch(`/api/users/${u.id}`, { method: 'DELETE' });
|
|
showToast('사용자가 삭제되었습니다');
|
|
load();
|
|
} catch (e) { showToast(`오류: ${e.message}`); }
|
|
};
|
|
|
|
const f = (k) => (e) => setForm(p => ({ ...p, [k]: e.target.value }));
|
|
const roleClass = { admin: 'badge-warn', member: 'badge-blue' };
|
|
const roleLabel = { admin: '관리자', member: '멤버' };
|
|
|
|
return (
|
|
<div>
|
|
<div className="toolbar">
|
|
<div>
|
|
<div style={{ fontSize:16, fontWeight:600, marginBottom:2 }}>사용자 관리</div>
|
|
<div style={{ fontSize:12, color:'var(--muted)' }}>업로더 목록을 관리합니다 (업로드 시 드롭다운에 반영됩니다)</div>
|
|
</div>
|
|
<button className="btn btn-accent" onClick={openAdd}><Icon name="plus" size={13} />새 사용자</button>
|
|
</div>
|
|
|
|
<div className="tbl-wrap">
|
|
<table className="tbl">
|
|
<thead>
|
|
<tr>
|
|
<th>이름</th><th>이메일</th><th>역할</th><th>파일 수</th><th>가입일</th><th></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{loading ? (
|
|
<tr><td colSpan={6} className="tbl-empty">로딩 중...</td></tr>
|
|
) : users.length === 0 ? (
|
|
<tr><td colSpan={6} className="tbl-empty">사용자가 없습니다</td></tr>
|
|
) : users.map(u => (
|
|
<tr key={u.id}>
|
|
<td>
|
|
<div style={{ display:'flex', alignItems:'center', gap:9 }}>
|
|
<div style={{ width:28, height:28, borderRadius:'50%', background:'var(--surface-2)', border:'1px solid var(--border)', display:'grid', placeItems:'center', flexShrink:0 }}>
|
|
<Icon name="user" size={13} />
|
|
</div>
|
|
<span style={{ fontWeight:500, color:'var(--ink)' }}>{u.name}</span>
|
|
</div>
|
|
</td>
|
|
<td style={{ color:'var(--muted)', fontSize:12.5 }}>{u.email || <span style={{color:'var(--faint)'}}>—</span>}</td>
|
|
<td><span className={`badge ${roleClass[u.role] || 'badge-gray'}`}>{roleLabel[u.role] || u.role}</span></td>
|
|
<td><span className="badge badge-gray">{u.file_count}개</span></td>
|
|
<td style={{ fontSize:11.5, color:'var(--faint)', fontFamily:'var(--font-mono)' }}>{(u.created_at || '').slice(0, 10)}</td>
|
|
<td>
|
|
<div className="actions">
|
|
<button className="btn btn-sm btn-ghost" onClick={() => openEdit(u)}><Icon name="edit" size={13} />편집</button>
|
|
<button className="btn btn-sm btn-danger" onClick={() => del(u)}><Icon name="trash" size={13} />삭제</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{modal && (
|
|
<Modal
|
|
title={modal.mode === 'add' ? '새 사용자 추가' : '사용자 편집'}
|
|
onClose={() => setModal(null)}
|
|
onSave={save}
|
|
>
|
|
<div className="field">
|
|
<label>이름 <span style={{color:'var(--warn)'}}>*</span></label>
|
|
<input placeholder="홍길동" value={form.name} onChange={f('name')} />
|
|
</div>
|
|
<div className="field">
|
|
<label>이메일</label>
|
|
<input type="email" placeholder="user@example.com" value={form.email} onChange={f('email')} />
|
|
</div>
|
|
<div className="field">
|
|
<label>역할</label>
|
|
<select value={form.role} onChange={f('role')}>
|
|
<option value="member">멤버</option>
|
|
<option value="admin">관리자</option>
|
|
</select>
|
|
</div>
|
|
</Modal>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ── 메인 앱 ───────────────────────────────────────────────────
|
|
|
|
const NAV = [
|
|
{ id: 'categories', label: '카테고리 관리', icon: 'folder' },
|
|
{ id: 'files', label: '파일 관리', icon: 'file' },
|
|
];
|
|
|
|
function AdminContent({ onLogout }) {
|
|
const [active, setActive] = useState('categories');
|
|
const [toast, setToast] = useState({ on: false, msg: '' });
|
|
|
|
const showToast = (msg) => {
|
|
setToast({ on: true, msg });
|
|
setTimeout(() => setToast(s => ({ ...s, on: false })), 2200);
|
|
};
|
|
|
|
const handleLogout = async () => {
|
|
try {
|
|
await fetch('/api/admin/logout', {
|
|
method: 'POST',
|
|
headers: { Authorization: `Bearer ${getToken()}` },
|
|
});
|
|
} catch (_) {}
|
|
clearToken();
|
|
onLogout();
|
|
};
|
|
|
|
return (
|
|
<div className="app">
|
|
<aside className="sb">
|
|
<div className="sb-brand">
|
|
<div className="sb-brand-mark">자</div>
|
|
<div>
|
|
<div className="sb-brand-name">자료실 관리자</div>
|
|
<div className="sb-brand-sub">Admin Console</div>
|
|
</div>
|
|
</div>
|
|
|
|
{NAV.map(n => (
|
|
<div key={n.id}
|
|
className={`sb-item ${active === n.id ? 'active' : ''}`}
|
|
onClick={() => setActive(n.id)}>
|
|
<Icon name={n.icon} size={15} />
|
|
{n.label}
|
|
</div>
|
|
))}
|
|
|
|
<div className="sb-divider" style={{ marginTop: 'auto' }} />
|
|
<div className="sb-item" onClick={handleLogout} style={{ cursor:'default', color:'var(--warn)' }}>
|
|
<Icon name="log-out" size={14} />로그아웃
|
|
</div>
|
|
<a href="/index.html" className="sb-back">
|
|
<Icon name="arrow-left" size={13} />자료실로 돌아가기
|
|
</a>
|
|
</aside>
|
|
|
|
<div className="main">
|
|
<div className="page-body">
|
|
{active === 'categories' && <CategorySection showToast={showToast} />}
|
|
{active === 'files' && <FileSection showToast={showToast} />}
|
|
</div>
|
|
</div>
|
|
|
|
<Toast msg={toast.msg} on={toast.on} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function AdminApp() {
|
|
const [authed, setAuthed] = useState(null); // null=로딩중, true=인증됨, false=미인증
|
|
|
|
useEffect(() => {
|
|
const token = getToken();
|
|
if (!token) { setAuthed(false); return; }
|
|
fetch('/api/admin/verify', { headers: { Authorization: `Bearer ${token}` } })
|
|
.then(r => r.json())
|
|
.then(d => setAuthed(d.ok === true))
|
|
.catch(() => setAuthed(false));
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
window.__onUnauthorized = () => setAuthed(false);
|
|
return () => { window.__onUnauthorized = null; };
|
|
}, []);
|
|
|
|
if (authed === null) {
|
|
return (
|
|
<div style={{ display:'grid', placeItems:'center', height:'100vh', background:'var(--bg)' }}>
|
|
<span style={{ color:'var(--faint)', fontSize:13 }}>로딩 중...</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!authed) {
|
|
return <LoginScreen onLogin={token => { setToken(token); setAuthed(true); }} />;
|
|
}
|
|
|
|
return <AdminContent onLogout={() => setAuthed(false)} />;
|
|
}
|
|
|
|
ReactDOM.createRoot(document.getElementById('root')).render(<AdminApp />);
|
|
</script>
|
|
</body>
|
|
</html>
|