Files
jaryo/admin.html
T
vibsin9322 06730214ef feat: 사이드바 파일유형 필터링 수정, 아이콘 개선, 로고 변경
- 파일유형 클릭 시 activeCat 덮어쓰던 버그 수정
- 파일유형에 전체 항목 추가 (기본 선택)
- Icon 컴포넌트에 file/user/log-out/edit 추가, 미등록 아이콘 폴백 처리
- DH.png 로고로 사이드바 브랜드 마크 교체 (index/admin 공통)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-12 10:40:22 +09:00

858 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:32px;height:32px;border-radius:7px;background:#fff;display:grid;place-items:center;flex-shrink:0;overflow:hidden}
.sb-brand-mark img{width:100%;height:100%;object-fit:contain}
.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"><img src="/DH.png" alt="로고" /></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>