Initial commit: jaryo project files
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules/
|
||||||
|
uploads/
|
||||||
|
jaryo.db
|
||||||
+720
@@ -0,0 +1,720 @@
|
|||||||
|
<!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;
|
||||||
|
|
||||||
|
// ── 유틸 ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function apiFetch(path, options) {
|
||||||
|
const res = await fetch(path, options);
|
||||||
|
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"/></>,
|
||||||
|
};
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── 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' },
|
||||||
|
{ id: 'users', label: '사용자 관리', icon: 'user' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function AdminApp() {
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
|
||||||
|
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' }} />
|
||||||
|
<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} />}
|
||||||
|
{active === 'users' && <UserSection showToast={showToast} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Toast msg={toast.msg} on={toast.on} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')).render(<AdminApp />);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+1236
File diff suppressed because it is too large
Load Diff
Generated
+1021
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "jaryo",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server.js",
|
||||||
|
"dev": "node --watch server.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"sql.js": "^1.12.0",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"express": "^4.19.2",
|
||||||
|
"multer": "^1.4.5-lts.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,491 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const initSqlJs = require('sql.js');
|
||||||
|
const multer = require('multer');
|
||||||
|
const cors = require('cors');
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
const { randomUUID } = require('crypto');
|
||||||
|
|
||||||
|
const PORT = 3000;
|
||||||
|
const DB_PATH = path.join(__dirname, 'jaryo.db');
|
||||||
|
const UPLOADS_DIR = path.join(__dirname, 'uploads');
|
||||||
|
|
||||||
|
if (!fs.existsSync(UPLOADS_DIR)) fs.mkdirSync(UPLOADS_DIR);
|
||||||
|
|
||||||
|
// ── DB 헬퍼 ───────────────────────────────────────────────────
|
||||||
|
let db;
|
||||||
|
|
||||||
|
function saveDb() {
|
||||||
|
const data = db.export();
|
||||||
|
fs.writeFileSync(DB_PATH, Buffer.from(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
function queryAll(sql, params = {}) {
|
||||||
|
const stmt = db.prepare(sql);
|
||||||
|
stmt.bind(params);
|
||||||
|
const rows = [];
|
||||||
|
while (stmt.step()) rows.push(stmt.getAsObject());
|
||||||
|
stmt.free();
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
function queryGet(sql, params = {}) {
|
||||||
|
return queryAll(sql, params)[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function run(sql, params = {}) {
|
||||||
|
db.run(sql, params);
|
||||||
|
saveDb();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── DB 초기화 ─────────────────────────────────────────────────
|
||||||
|
async function initDb() {
|
||||||
|
const SQL = await initSqlJs();
|
||||||
|
if (fs.existsSync(DB_PATH)) {
|
||||||
|
db = new SQL.Database(fs.readFileSync(DB_PATH));
|
||||||
|
} else {
|
||||||
|
db = new SQL.Database();
|
||||||
|
}
|
||||||
|
|
||||||
|
db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS files (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
ext TEXT NOT NULL,
|
||||||
|
size_bytes INTEGER DEFAULT 0,
|
||||||
|
size_label TEXT NOT NULL,
|
||||||
|
modified_at TEXT NOT NULL,
|
||||||
|
author TEXT NOT NULL,
|
||||||
|
category TEXT NOT NULL,
|
||||||
|
visibility TEXT DEFAULT 'team',
|
||||||
|
downloads INTEGER DEFAULT 0,
|
||||||
|
starred INTEGER DEFAULT 0,
|
||||||
|
thumb TEXT NOT NULL,
|
||||||
|
file_path TEXT,
|
||||||
|
created_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS categories (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
label TEXT NOT NULL,
|
||||||
|
icon TEXT DEFAULT 'folder',
|
||||||
|
sort_order INTEGER DEFAULT 0,
|
||||||
|
created_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
email TEXT,
|
||||||
|
role TEXT DEFAULT 'member',
|
||||||
|
created_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
saveDb();
|
||||||
|
seedData();
|
||||||
|
seedCategories();
|
||||||
|
seedUsers();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 시드 데이터 ───────────────────────────────────────────────
|
||||||
|
const SEED = [
|
||||||
|
{ id:"f1", name:"2026년 1분기 마케팅 전략.pdf", type:"doc", ext:"PDF", size_bytes:4404019, size_label:"4.2 MB", modified_at:"2시간 전", author:"김민지", category:"marketing", visibility:"team", downloads:142, starred:1, thumb:"doc" },
|
||||||
|
{ id:"f2", name:"브랜드 가이드라인 v3.2.pdf", type:"doc", ext:"PDF", size_bytes:13421772, size_label:"12.8 MB", modified_at:"어제", author:"이서준", category:"design", visibility:"public", downloads:891, starred:1, thumb:"doc" },
|
||||||
|
{ id:"f3", name:"신제품 키비주얼.png", type:"img", ext:"PNG", size_bytes:8493466, size_label:"8.1 MB", modified_at:"3일 전", author:"박하늘", category:"design", visibility:"team", downloads:56, starred:0, thumb:"img" },
|
||||||
|
{ id:"f4", name:"온보딩 영상 (한국어).mp4", type:"vid", ext:"MP4", size_bytes:297795584, size_label:"284 MB", modified_at:"1주 전", author:"최유나", category:"education", visibility:"public", downloads:312, starred:0, thumb:"vid" },
|
||||||
|
{ id:"f5", name:"사용자 인터뷰 결과.docx", type:"doc", ext:"DOCX", size_bytes:1468006, size_label:"1.4 MB", modified_at:"5일 전", author:"정도현", category:"marketing", visibility:"team", downloads:78, starred:1, thumb:"doc" },
|
||||||
|
{ id:"f6", name:"디자인 시스템 컴포넌트.zip", type:"zip", ext:"ZIP", size_bytes:47840256, size_label:"45.6 MB", modified_at:"2주 전", author:"이서준", category:"design", visibility:"team", downloads:234, starred:0, thumb:"zip" },
|
||||||
|
{ id:"f7", name:"react-utils.ts", type:"code", ext:"TS", size_bytes:12288, size_label:"12 KB", modified_at:"어제", author:"김민지", category:"code", visibility:"public", downloads:47, starred:0, thumb:"code" },
|
||||||
|
{ id:"f8", name:"Q4 실적 발표 자료.pptx", type:"doc", ext:"PPTX", size_bytes:29673267, size_label:"28.3 MB", modified_at:"4일 전", author:"한지원", category:"marketing", visibility:"team", downloads:198, starred:1, thumb:"doc" },
|
||||||
|
{ id:"f9", name:"회사소개 인포그래픽.png", type:"img", ext:"PNG", size_bytes:3355443, size_label:"3.2 MB", modified_at:"6일 전", author:"박하늘", category:"marketing", visibility:"public", downloads:421, starred:0, thumb:"img" },
|
||||||
|
{ id:"f10", name:"기술 면접 질문 모음.pdf", type:"doc", ext:"PDF", size_bytes:913408, size_label:"892 KB", modified_at:"3주 전", author:"정도현", category:"education", visibility:"team", downloads:612, starred:1, thumb:"doc" },
|
||||||
|
{ id:"f11", name:"CES 2026 발표 영상.mov", type:"vid", ext:"MOV", size_bytes:1288490188, size_label:"1.2 GB", modified_at:"2주 전", author:"최유나", category:"media", visibility:"public", downloads:89, starred:0, thumb:"vid" },
|
||||||
|
{ id:"f12", name:"고객 데이터 샘플.csv", type:"code", ext:"CSV", size_bytes:2202009, size_label:"2.1 MB", modified_at:"1시간 전", author:"한지원", category:"code", visibility:"team", downloads:23, starred:0, thumb:"code" },
|
||||||
|
{ id:"f13", name:"이메일 템플릿 모음.zip", type:"zip", ext:"ZIP", size_bytes:9123020, size_label:"8.7 MB", modified_at:"5일 전", author:"김민지", category:"templates", visibility:"team", downloads:156, starred:0, thumb:"zip" },
|
||||||
|
{ id:"f14", name:"워크샵 사진 모음.zip", type:"zip", ext:"ZIP", size_bytes:245366784, size_label:"234 MB", modified_at:"1주 전", author:"박하늘", category:"media", visibility:"team", downloads:67, starred:0, thumb:"zip" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function seedData() {
|
||||||
|
const count = queryGet('SELECT COUNT(*) as c FROM files');
|
||||||
|
if (count && count.c > 0) return;
|
||||||
|
for (const f of SEED) {
|
||||||
|
db.run(
|
||||||
|
`INSERT OR IGNORE INTO files (id,name,type,ext,size_bytes,size_label,modified_at,author,category,visibility,downloads,starred,thumb)
|
||||||
|
VALUES (:id,:name,:type,:ext,:size_bytes,:size_label,:modified_at,:author,:category,:visibility,:downloads,:starred,:thumb)`,
|
||||||
|
{ ':id':f.id, ':name':f.name, ':type':f.type, ':ext':f.ext, ':size_bytes':f.size_bytes,
|
||||||
|
':size_label':f.size_label, ':modified_at':f.modified_at, ':author':f.author,
|
||||||
|
':category':f.category, ':visibility':f.visibility, ':downloads':f.downloads,
|
||||||
|
':starred':f.starred, ':thumb':f.thumb }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
saveDb();
|
||||||
|
console.log(`시드 데이터 ${SEED.length}개 삽입 완료`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 카테고리·사용자 시드 ──────────────────────────────────────
|
||||||
|
function seedCategories() {
|
||||||
|
const count = queryGet('SELECT COUNT(*) as c FROM categories');
|
||||||
|
if (count && count.c > 0) return;
|
||||||
|
const defaults = [
|
||||||
|
{ id:'design', label:'디자인 리소스', icon:'image', sort_order:0 },
|
||||||
|
{ id:'marketing', label:'마케팅 자료', icon:'folder', sort_order:1 },
|
||||||
|
{ id:'education', label:'교육·온보딩', icon:'folder', sort_order:2 },
|
||||||
|
{ id:'templates', label:'템플릿', icon:'folder', sort_order:3 },
|
||||||
|
{ id:'code', label:'코드 스니펫', icon:'code', sort_order:4 },
|
||||||
|
{ id:'media', label:'사진·영상', icon:'video', sort_order:5 },
|
||||||
|
];
|
||||||
|
for (const c of defaults) {
|
||||||
|
db.run(
|
||||||
|
`INSERT OR IGNORE INTO categories (id,label,icon,sort_order) VALUES (:id,:label,:icon,:sort_order)`,
|
||||||
|
{ ':id':c.id, ':label':c.label, ':icon':c.icon, ':sort_order':c.sort_order }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
saveDb();
|
||||||
|
console.log(`카테고리 시드 ${defaults.length}개 삽입 완료`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedUsers() {
|
||||||
|
const count = queryGet('SELECT COUNT(*) as c FROM users');
|
||||||
|
if (count && count.c > 0) return;
|
||||||
|
const names = [...new Set(SEED.map(f => f.author))];
|
||||||
|
names.forEach((name, i) => {
|
||||||
|
db.run(
|
||||||
|
`INSERT OR IGNORE INTO users (id,name,role) VALUES (:id,:name,'member')`,
|
||||||
|
{ ':id': `u${i + 1}`, ':name': name }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
saveDb();
|
||||||
|
console.log(`사용자 시드 ${names.length}명 삽입 완료`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 헬퍼 ─────────────────────────────────────────────────────
|
||||||
|
function guessType(mimetype, ext) {
|
||||||
|
if (!mimetype) return 'doc';
|
||||||
|
if (mimetype.startsWith('image/')) return 'img';
|
||||||
|
if (mimetype.startsWith('video/')) return 'vid';
|
||||||
|
const e = (ext || '').toLowerCase();
|
||||||
|
if (['.zip','.tar','.gz','.7z','.rar'].includes(e)) return 'zip';
|
||||||
|
if (['.ts','.js','.py','.go','.rs','.java','.c','.cpp','.json','.csv','.sh'].includes(e)) return 'code';
|
||||||
|
return 'doc';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSize(bytes) {
|
||||||
|
if (bytes >= 1073741824) return `${(bytes / 1073741824).toFixed(1)} GB`;
|
||||||
|
if (bytes >= 1048576) return `${(bytes / 1048576).toFixed(1)} MB`;
|
||||||
|
if (bytes >= 1024) return `${Math.round(bytes / 1024)} KB`;
|
||||||
|
return `${bytes} B`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowToFile(row) {
|
||||||
|
if (!row) return null;
|
||||||
|
return { ...row, starred: row.starred === 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Express ───────────────────────────────────────────────────
|
||||||
|
const app = express();
|
||||||
|
app.use(cors());
|
||||||
|
app.use(express.json());
|
||||||
|
app.use('/uploads', express.static(UPLOADS_DIR));
|
||||||
|
app.use(express.static(__dirname));
|
||||||
|
|
||||||
|
const storage = multer.diskStorage({
|
||||||
|
destination: (req, file, cb) => cb(null, UPLOADS_DIR),
|
||||||
|
filename: (req, file, cb) => {
|
||||||
|
const ext = path.extname(file.originalname);
|
||||||
|
cb(null, `${Date.now()}-${randomUUID()}${ext}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const upload = multer({ storage, limits: { fileSize: 2 * 1024 * 1024 * 1024 } });
|
||||||
|
|
||||||
|
// ── API: 파일 목록 ────────────────────────────────────────────
|
||||||
|
app.get('/api/files', (req, res) => {
|
||||||
|
const { folder, category, types, starred, sort } = req.query;
|
||||||
|
|
||||||
|
let where = [];
|
||||||
|
const params = {};
|
||||||
|
|
||||||
|
if (folder === 'starred') {
|
||||||
|
where.push('starred = 1');
|
||||||
|
} else if (folder === 'trash') {
|
||||||
|
return res.json([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (category) {
|
||||||
|
where.push('category = :category');
|
||||||
|
params[':category'] = category;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (starred === '1') {
|
||||||
|
if (!where.includes('starred = 1')) where.push('starred = 1');
|
||||||
|
}
|
||||||
|
|
||||||
|
let typeList = [];
|
||||||
|
if (types) typeList = types.split(',').filter(Boolean);
|
||||||
|
if (typeList.length) {
|
||||||
|
const placeholders = typeList.map((_, i) => `:t${i}`).join(',');
|
||||||
|
where.push(`type IN (${placeholders})`);
|
||||||
|
typeList.forEach((t, i) => { params[`:t${i}`] = t; });
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause = where.length ? `WHERE ${where.join(' AND ')}` : '';
|
||||||
|
|
||||||
|
let orderClause = 'ORDER BY rowid DESC';
|
||||||
|
if (sort === 'name') orderClause = 'ORDER BY name ASC';
|
||||||
|
if (sort === 'downloads') orderClause = 'ORDER BY downloads DESC';
|
||||||
|
if (sort === 'size') orderClause = 'ORDER BY size_bytes DESC';
|
||||||
|
|
||||||
|
let limitClause = '';
|
||||||
|
if (folder === 'recent') { orderClause = 'ORDER BY rowid DESC'; limitClause = 'LIMIT 24'; }
|
||||||
|
|
||||||
|
const sql = `SELECT * FROM files ${whereClause} ${orderClause} ${limitClause}`;
|
||||||
|
const rows = queryAll(sql, params);
|
||||||
|
res.json(rows.map(rowToFile));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── API: 통계 ─────────────────────────────────────────────────
|
||||||
|
app.get('/api/stats', (req, res) => {
|
||||||
|
const total = queryGet('SELECT COUNT(*) as c FROM files')?.c ?? 0;
|
||||||
|
const starred = queryGet('SELECT COUNT(*) as c FROM files WHERE starred=1')?.c ?? 0;
|
||||||
|
const recent = queryGet("SELECT COUNT(*) as c FROM files WHERE created_at >= datetime('now','-7 days')")?.c ?? 0;
|
||||||
|
|
||||||
|
const catRows = queryAll('SELECT category, COUNT(*) as c FROM files GROUP BY category');
|
||||||
|
const categories = {};
|
||||||
|
for (const r of catRows) categories[r.category] = r.c;
|
||||||
|
|
||||||
|
const usedBytes = queryGet('SELECT COALESCE(SUM(size_bytes),0) as s FROM files')?.s ?? 0;
|
||||||
|
const usedGB = parseFloat((usedBytes / 1073741824).toFixed(1));
|
||||||
|
const totalGB = 100;
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
folders: { all: total, starred, recent, shared: 0, trash: 0 },
|
||||||
|
categories,
|
||||||
|
storage: { used: usedGB, total: totalGB, remaining: parseFloat((totalGB - usedGB).toFixed(1)) },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── API: 업로드 ───────────────────────────────────────────────
|
||||||
|
app.post('/api/files/upload', upload.single('file'), (req, res) => {
|
||||||
|
const f = req.file;
|
||||||
|
if (!f) return res.status(400).json({ error: 'no file' });
|
||||||
|
|
||||||
|
const name = Buffer.from(f.originalname, 'latin1').toString('utf8');
|
||||||
|
const ext = path.extname(name);
|
||||||
|
const type = guessType(f.mimetype, ext);
|
||||||
|
const extLabel = ext.replace('.', '').toUpperCase() || 'FILE';
|
||||||
|
const id = randomUUID();
|
||||||
|
|
||||||
|
db.run(
|
||||||
|
`INSERT INTO files (id,name,type,ext,size_bytes,size_label,modified_at,author,category,visibility,downloads,starred,thumb,file_path)
|
||||||
|
VALUES (:id,:name,:type,:ext,:size_bytes,:size_label,:modified_at,:author,:category,:visibility,0,0,:thumb,:file_path)`,
|
||||||
|
{ ':id':id, ':name':name, ':type':type, ':ext':extLabel,
|
||||||
|
':size_bytes':f.size, ':size_label':formatSize(f.size), ':modified_at':'방금',
|
||||||
|
':author': req.body.author || '나', ':category': req.body.category || 'design',
|
||||||
|
':visibility': req.body.visibility || 'team', ':thumb':type, ':file_path':f.filename }
|
||||||
|
);
|
||||||
|
saveDb();
|
||||||
|
|
||||||
|
const row = queryGet('SELECT * FROM files WHERE id = :id', { ':id': id });
|
||||||
|
res.json(rowToFile(row));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── API: 즐겨찾기 토글 ────────────────────────────────────────
|
||||||
|
app.patch('/api/files/:id/star', (req, res) => {
|
||||||
|
const row = queryGet('SELECT starred FROM files WHERE id = :id', { ':id': req.params.id });
|
||||||
|
if (!row) return res.status(404).json({ error: 'not found' });
|
||||||
|
const next = row.starred ? 0 : 1;
|
||||||
|
run('UPDATE files SET starred = :v WHERE id = :id', { ':v': next, ':id': req.params.id });
|
||||||
|
res.json({ starred: next === 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── API: 다운로드 수 증가 (카운트 전용) ──────────────────────
|
||||||
|
app.post('/api/files/:id/download', (req, res) => {
|
||||||
|
const row = queryGet('SELECT downloads FROM files WHERE id = :id', { ':id': req.params.id });
|
||||||
|
if (!row) return res.status(404).json({ error: 'not found' });
|
||||||
|
run('UPDATE files SET downloads = downloads + 1 WHERE id = :id', { ':id': req.params.id });
|
||||||
|
res.json({ downloads: row.downloads + 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── API: 실제 파일 다운로드 ───────────────────────────────────
|
||||||
|
app.get('/api/files/:id/download', (req, res) => {
|
||||||
|
const row = queryGet('SELECT * FROM files WHERE id = :id', { ':id': req.params.id });
|
||||||
|
if (!row) return res.status(404).json({ error: 'not found' });
|
||||||
|
if (!row.file_path) return res.status(404).json({ error: 'no file' });
|
||||||
|
const fp = path.join(UPLOADS_DIR, row.file_path);
|
||||||
|
if (!fs.existsSync(fp)) return res.status(404).json({ error: 'file missing' });
|
||||||
|
run('UPDATE files SET downloads = downloads + 1 WHERE id = :id', { ':id': req.params.id });
|
||||||
|
res.download(fp, row.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── API: 삭제 ─────────────────────────────────────────────────
|
||||||
|
app.delete('/api/files', (req, res) => {
|
||||||
|
const { ids } = req.body;
|
||||||
|
if (!Array.isArray(ids) || !ids.length) return res.status(400).json({ error: 'ids required' });
|
||||||
|
|
||||||
|
const placeholders = ids.map((_, i) => `:id${i}`).join(',');
|
||||||
|
const params = {};
|
||||||
|
ids.forEach((id, i) => { params[`:id${i}`] = id; });
|
||||||
|
|
||||||
|
const rows = queryAll(`SELECT file_path FROM files WHERE id IN (${placeholders})`, params);
|
||||||
|
db.run(`DELETE FROM files WHERE id IN (${placeholders})`, params);
|
||||||
|
saveDb();
|
||||||
|
|
||||||
|
for (const r of rows) {
|
||||||
|
if (r.file_path) {
|
||||||
|
const fp = path.join(UPLOADS_DIR, r.file_path);
|
||||||
|
if (fs.existsSync(fp)) fs.unlinkSync(fp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ deleted: ids.length });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── API: 관리자 파일 목록 (검색·페이지네이션) ─────────────────
|
||||||
|
app.get('/api/admin/files', (req, res) => {
|
||||||
|
const { search, category, page = 1, limit = 20 } = req.query;
|
||||||
|
const offset = (parseInt(page) - 1) * parseInt(limit);
|
||||||
|
const where = [];
|
||||||
|
const params = {};
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
where.push('name LIKE :search');
|
||||||
|
params[':search'] = `%${search}%`;
|
||||||
|
}
|
||||||
|
if (category) {
|
||||||
|
where.push('category = :category');
|
||||||
|
params[':category'] = category;
|
||||||
|
}
|
||||||
|
|
||||||
|
const w = where.length ? `WHERE ${where.join(' AND ')}` : '';
|
||||||
|
const total = queryGet(`SELECT COUNT(*) as c FROM files ${w}`, params)?.c ?? 0;
|
||||||
|
const items = queryAll(`SELECT * FROM files ${w} ORDER BY rowid DESC LIMIT :limit OFFSET :offset`,
|
||||||
|
{ ...params, ':limit': parseInt(limit), ':offset': offset }
|
||||||
|
).map(rowToFile);
|
||||||
|
|
||||||
|
res.json({ items, total, page: parseInt(page), limit: parseInt(limit) });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── API: 파일 메타데이터 수정 ─────────────────────────────────
|
||||||
|
app.patch('/api/files/:id', (req, res) => {
|
||||||
|
const row = queryGet('SELECT * FROM files WHERE id = :id', { ':id': req.params.id });
|
||||||
|
if (!row) return res.status(404).json({ error: 'not found' });
|
||||||
|
const { name, category, author, visibility, modified_at } = req.body;
|
||||||
|
run(
|
||||||
|
`UPDATE files SET
|
||||||
|
name = COALESCE(:name, name),
|
||||||
|
category = COALESCE(:category, category),
|
||||||
|
author = COALESCE(:author, author),
|
||||||
|
visibility = COALESCE(:visibility, visibility),
|
||||||
|
modified_at = COALESCE(:modified_at, modified_at)
|
||||||
|
WHERE id = :id`,
|
||||||
|
{
|
||||||
|
':name': name ?? null, ':category': category ?? null,
|
||||||
|
':author': author ?? null, ':visibility': visibility ?? null,
|
||||||
|
':modified_at': modified_at ?? null, ':id': req.params.id,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
res.json(rowToFile(queryGet('SELECT * FROM files WHERE id = :id', { ':id': req.params.id })));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── API: 카테고리 CRUD ────────────────────────────────────────
|
||||||
|
app.get('/api/categories', (req, res) => {
|
||||||
|
res.json(queryAll('SELECT * FROM categories ORDER BY sort_order ASC, id ASC'));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/categories', (req, res) => {
|
||||||
|
const { id, label, icon = 'folder', sort_order = 0 } = req.body;
|
||||||
|
if (!id || !label) return res.status(400).json({ error: 'id와 label은 필수입니다' });
|
||||||
|
const exists = queryGet('SELECT id FROM categories WHERE id = :id', { ':id': id });
|
||||||
|
if (exists) return res.status(409).json({ error: '이미 존재하는 카테고리 ID입니다' });
|
||||||
|
db.run(
|
||||||
|
`INSERT INTO categories (id,label,icon,sort_order) VALUES (:id,:label,:icon,:sort_order)`,
|
||||||
|
{ ':id': id, ':label': label, ':icon': icon, ':sort_order': sort_order }
|
||||||
|
);
|
||||||
|
saveDb();
|
||||||
|
res.status(201).json(queryGet('SELECT * FROM categories WHERE id = :id', { ':id': id }));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.patch('/api/categories/:id', (req, res) => {
|
||||||
|
const row = queryGet('SELECT * FROM categories WHERE id = :id', { ':id': req.params.id });
|
||||||
|
if (!row) return res.status(404).json({ error: 'not found' });
|
||||||
|
const { label, icon, sort_order } = req.body;
|
||||||
|
run(
|
||||||
|
`UPDATE categories SET
|
||||||
|
label = COALESCE(:label, label),
|
||||||
|
icon = COALESCE(:icon, icon),
|
||||||
|
sort_order = COALESCE(:sort_order, sort_order)
|
||||||
|
WHERE id = :id`,
|
||||||
|
{ ':label': label ?? null, ':icon': icon ?? null, ':sort_order': sort_order ?? null, ':id': req.params.id }
|
||||||
|
);
|
||||||
|
res.json(queryGet('SELECT * FROM categories WHERE id = :id', { ':id': req.params.id }));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/categories/:id', (req, res) => {
|
||||||
|
const row = queryGet('SELECT * FROM categories WHERE id = :id', { ':id': req.params.id });
|
||||||
|
if (!row) return res.status(404).json({ error: 'not found' });
|
||||||
|
const used = queryGet('SELECT COUNT(*) as c FROM files WHERE category = :id', { ':id': req.params.id });
|
||||||
|
if (used && used.c > 0) return res.status(409).json({ error: `사용 중인 카테고리입니다 (${used.c}개 파일)` });
|
||||||
|
run('DELETE FROM categories WHERE id = :id', { ':id': req.params.id });
|
||||||
|
res.json({ deleted: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── API: 사용자 CRUD ──────────────────────────────────────────
|
||||||
|
app.get('/api/users', (req, res) => {
|
||||||
|
const rows = queryAll('SELECT * FROM users ORDER BY name ASC');
|
||||||
|
const withCount = rows.map(u => {
|
||||||
|
const fc = queryGet('SELECT COUNT(*) as c FROM files WHERE author = :name', { ':name': u.name });
|
||||||
|
return { ...u, file_count: fc?.c ?? 0 };
|
||||||
|
});
|
||||||
|
res.json(withCount);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/users', (req, res) => {
|
||||||
|
const { name, email = null, role = 'member' } = req.body;
|
||||||
|
if (!name) return res.status(400).json({ error: 'name은 필수입니다' });
|
||||||
|
const exists = queryGet('SELECT id FROM users WHERE name = :name', { ':name': name });
|
||||||
|
if (exists) return res.status(409).json({ error: '이미 존재하는 사용자 이름입니다' });
|
||||||
|
const id = randomUUID();
|
||||||
|
db.run(
|
||||||
|
`INSERT INTO users (id,name,email,role) VALUES (:id,:name,:email,:role)`,
|
||||||
|
{ ':id': id, ':name': name, ':email': email, ':role': role }
|
||||||
|
);
|
||||||
|
saveDb();
|
||||||
|
res.status(201).json(queryGet('SELECT * FROM users WHERE id = :id', { ':id': id }));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.patch('/api/users/:id', (req, res) => {
|
||||||
|
const row = queryGet('SELECT * FROM users WHERE id = :id', { ':id': req.params.id });
|
||||||
|
if (!row) return res.status(404).json({ error: 'not found' });
|
||||||
|
const { name, email, role } = req.body;
|
||||||
|
if (name && name !== row.name) {
|
||||||
|
const dup = queryGet('SELECT id FROM users WHERE name = :name AND id != :id', { ':name': name, ':id': req.params.id });
|
||||||
|
if (dup) return res.status(409).json({ error: '이미 존재하는 사용자 이름입니다' });
|
||||||
|
run('UPDATE files SET author = :name WHERE author = :old', { ':name': name, ':old': row.name });
|
||||||
|
}
|
||||||
|
run(
|
||||||
|
`UPDATE users SET
|
||||||
|
name = COALESCE(:name, name),
|
||||||
|
email = COALESCE(:email, email),
|
||||||
|
role = COALESCE(:role, role)
|
||||||
|
WHERE id = :id`,
|
||||||
|
{ ':name': name ?? null, ':email': email ?? null, ':role': role ?? null, ':id': req.params.id }
|
||||||
|
);
|
||||||
|
res.json(queryGet('SELECT * FROM users WHERE id = :id', { ':id': req.params.id }));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/users/:id', (req, res) => {
|
||||||
|
const row = queryGet('SELECT * FROM users WHERE id = :id', { ':id': req.params.id });
|
||||||
|
if (!row) return res.status(404).json({ error: 'not found' });
|
||||||
|
const fc = queryGet('SELECT COUNT(*) as c FROM files WHERE author = :name', { ':name': row.name });
|
||||||
|
run('DELETE FROM users WHERE id = :id', { ':id': req.params.id });
|
||||||
|
res.json({ deleted: 1, file_count: fc?.c ?? 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 시작 ──────────────────────────────────────────────────────
|
||||||
|
initDb().then(() => {
|
||||||
|
app.listen(PORT, () => console.log(`자료실 서버 실행 중 → http://localhost:${PORT}`));
|
||||||
|
}).catch(err => { console.error('DB 초기화 실패:', err); process.exit(1); });
|
||||||
Reference in New Issue
Block a user