feat: 카테고리 통일, 리스트 기본뷰, 관리자 전용 로그인

- 카테고리 DB를 type 기반(doc/img/vid/zip/code)으로 마이그레이션
- 사이드바·상단탭이 DB 카테고리 API를 공유해 항상 일치
- 기본 뷰를 그리드 → 리스트로 변경
- 로그인 버튼 제거, 관리자만 로그인 가능
- 관리자 패널에서 사용자 관리 메뉴 제거

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 20:49:45 +09:00
parent 3c81d9a4a1
commit 5c7a5bef1e
3 changed files with 431 additions and 115 deletions
-2
View File
@@ -755,7 +755,6 @@ const UserSection = ({ showToast }) => {
const NAV = [ const NAV = [
{ id: 'categories', label: '카테고리 관리', icon: 'folder' }, { id: 'categories', label: '카테고리 관리', icon: 'folder' },
{ id: 'files', label: '파일 관리', icon: 'file' }, { id: 'files', label: '파일 관리', icon: 'file' },
{ id: 'users', label: '사용자 관리', icon: 'user' },
]; ];
function AdminContent({ onLogout }) { function AdminContent({ onLogout }) {
@@ -811,7 +810,6 @@ function AdminContent({ onLogout }) {
<div className="page-body"> <div className="page-body">
{active === 'categories' && <CategorySection showToast={showToast} />} {active === 'categories' && <CategorySection showToast={showToast} />}
{active === 'files' && <FileSection showToast={showToast} />} {active === 'files' && <FileSection showToast={showToast} />}
{active === 'users' && <UserSection showToast={showToast} />}
</div> </div>
</div> </div>
+396 -88
View File
@@ -247,6 +247,49 @@
.hidden{display:none !important} .hidden{display:none !important}
.scrollbar::-webkit-scrollbar{width:8px} .scrollbar::-webkit-scrollbar{width:8px}
.scrollbar::-webkit-scrollbar-thumb{background:var(--border-strong);border-radius:99px} .scrollbar::-webkit-scrollbar-thumb{background:var(--border-strong);border-radius:99px}
/* Hamburger */
.hamburger{display:none;appearance:none;border:0;background:transparent;color:var(--ink);width:32px;height:32px;border-radius:6px;cursor:default;place-items:center;flex-shrink:0;margin-right:2px}
.hamburger:hover{background:rgba(20,20,20,.06)}
/* Type nav tabs */
.hdr-tabs{display:flex;align-items:flex-end;gap:0;padding:0 28px;border-bottom:1px solid var(--border);background:var(--bg);flex-shrink:0;overflow-x:auto}
.hdr-tabs::-webkit-scrollbar{display:none}
.hdr-tab{padding:9px 14px;font-size:13.5px;font-family:inherit;color:var(--muted);border:none;background:transparent;border-bottom:2px solid transparent;cursor:default;white-space:nowrap;font-weight:500;line-height:1;transition:color .12s ease;margin-bottom:-1px}
.hdr-tab:hover{color:var(--ink-2)}
.hdr-tab.active{color:var(--ink);font-weight:600;border-bottom-color:var(--accent)}
/* Context menu */
.ctx-menu{position:fixed;background:var(--surface);border:1px solid var(--border-strong);border-radius:9px;box-shadow:var(--shadow-md);padding:4px;min-width:160px;z-index:500}
.ctx-mi{padding:7px 10px;border-radius:5px;font-size:13px;color:var(--ink-2);display:flex;align-items:center;gap:8px;cursor:default;user-select:none}
.ctx-mi:hover{background:var(--surface-2)}
.ctx-mi.danger{color:var(--warn)}
.ctx-mi.danger:hover{background:rgba(217,119,87,.06)}
/* Sidebar overlay */
.sb-overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,.4);z-index:299}
.sb-overlay.on{display:block;animation:fadeIn .15s ease}
/* Indeterminate checkbox */
.check.indeterminate{background:var(--accent);border-color:var(--accent);color:#fff}
/* Responsive */
@media(max-width:768px){
.app{grid-template-columns:1fr}
.sb{position:fixed;left:-270px;top:0;bottom:0;z-index:300;width:260px;border-right:1px solid var(--border);background:var(--surface);box-shadow:var(--shadow-lg);transition:left .25s cubic-bezier(.3,.7,.4,1)}
.sb.open{left:0}
.hamburger{display:grid}
.hdr{padding:10px 16px}
.hdr-tabs{padding:0 16px}
.toolbar{padding:10px 16px 6px}
.scroll{padding:6px 16px 28px}
.grid{grid-template-columns:repeat(auto-fill,minmax(150px,1fr))}
.list{overflow-x:auto}
.list-head,.list-row{min-width:580px}
}
@media(max-width:480px){
.grid{grid-template-columns:repeat(auto-fill,minmax(130px,1fr))}
}
</style> </style>
</head> </head>
<body> <body>
@@ -574,6 +617,7 @@ const CATEGORIES_DEF = [
{ id: "media", label: "사진·영상", icon: "video" }, { id: "media", label: "사진·영상", icon: "video" },
]; ];
const FOLDERS_DEF = [ const FOLDERS_DEF = [
{ id: "all", label: "모든 자료", icon: "folder" }, { id: "all", label: "모든 자료", icon: "folder" },
{ id: "starred", label: "즐겨찾기", icon: "star" }, { id: "starred", label: "즐겨찾기", icon: "star" },
@@ -584,8 +628,11 @@ const FOLDERS_DEF = [
// ── API 헬퍼 ────────────────────────────────────────────────── // ── API 헬퍼 ──────────────────────────────────────────────────
async function apiFetch(path, options) { async function apiFetch(path, options = {}) {
const res = await fetch(path, options); const token = sessionStorage.getItem('admin_token');
const headers = { ...(options.headers || {}) };
if (token) headers['Authorization'] = `Bearer ${token}`;
const res = await fetch(path, { ...options, headers });
if (!res.ok) throw new Error(`API ${path}${res.status}`); if (!res.ok) throw new Error(`API ${path}${res.status}`);
return res.json(); return res.json();
} }
@@ -637,12 +684,12 @@ const SkeletonGrid = () => (
</div> </div>
); );
const Sidebar = ({ activeFolder, setActiveFolder, activeCat, setActiveCat, accent, stats, categories }) => { const Sidebar = ({ activeFolder, setActiveFolder, activeCat, setActiveCat, categories, accent, stats, open, onClose }) => {
const { folders = {}, categories: catStats = {}, storage = { used: 0, total: 100, remaining: 100 } } = stats; const { folders = {}, categories: catStats = {}, storage = { used: 0, total: 100, remaining: 100 } } = stats;
const usedPct = Math.min(100, (storage.used / storage.total) * 100).toFixed(0); const usedPct = Math.min(100, (storage.used / storage.total) * 100).toFixed(0);
return ( return (
<aside className="sb scrollbar"> <aside className={`sb scrollbar${open ? ' open' : ''}`}>
<div className="sb-brand"> <div className="sb-brand">
<div className="sb-brand-mark" style={{ background: accent }}></div> <div className="sb-brand-mark" style={{ background: accent }}></div>
<div> <div>
@@ -661,24 +708,22 @@ const Sidebar = ({ activeFolder, setActiveFolder, activeCat, setActiveCat, accen
{FOLDERS_DEF.map(f => ( {FOLDERS_DEF.map(f => (
<div key={f.id} <div key={f.id}
className={`sb-item ${activeFolder === f.id && !activeCat ? "active" : ""}`} className={`sb-item ${activeFolder === f.id && !activeCat ? "active" : ""}`}
onClick={() => { setActiveFolder(f.id); setActiveCat(null); }}> onClick={() => { setActiveFolder(f.id); setActiveCat(null); onClose?.(); }}>
<Icon name={f.icon} size={15} className="ico" /> <Icon name={f.icon} size={15} className="ico" />
<span className="lbl">{f.label}</span> <span className="lbl">{f.label}</span>
<span className="cnt">{folders[f.id] ?? 0}</span> <span className="cnt">{folders[f.id] ?? 0}</span>
</div> </div>
))} ))}
</div> </div>
<div className="sb-section"> <div className="sb-section">
<span>카테고리</span> <span>파일 유형</span>
<button title="새 폴더"><Icon name="plus" size={12} /></button>
</div> </div>
<div style={{ display:"flex", flexDirection:"column", gap:1 }}> <div style={{ display:"flex", flexDirection:"column", gap:1 }}>
{categories.map(c => ( {categories.map(c => (
<div key={c.id} <div key={c.id}
className={`sb-item ${activeCat === c.id ? "active" : ""}`} className={`sb-item ${activeCat === c.id ? "active" : ""}`}
onClick={() => { setActiveCat(c.id); setActiveFolder(null); }}> onClick={() => { setActiveCat(c.id); setActiveFolder("all"); onClose?.(); }}>
<Icon name={c.icon} size={15} className="ico" /> <Icon name={c.icon} size={15} className="ico" />
<span className="lbl">{c.label}</span> <span className="lbl">{c.label}</span>
<span className="cnt">{catStats[c.id] ?? 0}</span> <span className="cnt">{catStats[c.id] ?? 0}</span>
@@ -701,12 +746,15 @@ const Sidebar = ({ activeFolder, setActiveFolder, activeCat, setActiveCat, accen
); );
}; };
const Header = ({ folder, category, onUpload, accent, categories }) => { const Header = ({ folder, category, onUpload, accent, categories, currentUser, onLogout, onMenuToggle }) => {
const folderObj = FOLDERS_DEF.find(f => f.id === folder); const folderObj = FOLDERS_DEF.find(f => f.id === folder);
const catObj = categories.find(c => c.id === category); const catObj = categories.find(c => c.id === category);
return ( return (
<div className="hdr"> <div className="hdr">
<div className="hdr-l"> <div className="hdr-l">
<button className="hamburger" onClick={onMenuToggle} title="메뉴">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M3 6h18M3 12h18M3 18h18"/></svg>
</button>
<div className="crumbs"> <div className="crumbs">
<a><Icon name="home" size={13} /></a> <a><Icon name="home" size={13} /></a>
<span className="sep">/</span> <span className="sep">/</span>
@@ -717,35 +765,52 @@ const Header = ({ folder, category, onUpload, accent, categories }) => {
</div> </div>
<div className="hdr-r"> <div className="hdr-r">
<button className="btn btn-icon btn-ghost" title="알림"><Icon name="bell" size={16} /></button> <button className="btn btn-icon btn-ghost" title="알림"><Icon name="bell" size={16} /></button>
<button className="btn btn-ghost"><Icon name="share" size={14} />공유</button> {currentUser && (
<button className="btn btn-accent" onClick={onUpload} style={{ background:accent, borderColor:accent }}> <>
<Icon name="upload" size={14} />업로드 <span style={{ fontSize:12.5, color:'var(--muted)', padding:'0 2px', display:'flex', alignItems:'center', gap:5 }}>
</button> <span style={{ width:20, height:20, borderRadius:'50%', background:'var(--surface-2)', border:'1px solid var(--border-strong)', display:'grid', placeItems:'center', fontSize:10, fontWeight:700, color:'var(--ink)', flexShrink:0 }}></span>
관리자
</span>
<button className="btn btn-ghost" style={{ fontSize:12.5 }} onClick={onLogout}>로그아웃</button>
<button className="btn btn-accent" onClick={onUpload} style={{ background:accent, borderColor:accent }}>
<Icon name="upload" size={14} />업로드
</button>
</>
)}
</div> </div>
</div> </div>
); );
}; };
const Toolbar = ({ view, setView, sort, setSort, filters, setFilters, count }) => { const TypeTabs = ({ categories, activeCat, onSelect }) => (
<div className="hdr-tabs">
<button className={`hdr-tab ${activeCat === null ? 'active' : ''}`} onClick={() => onSelect(null)}>전체</button>
{categories.map(c => (
<button key={c.id} className={`hdr-tab ${activeCat === c.id ? 'active' : ''}`}
onClick={() => onSelect(c.id)}>
{c.label}
</button>
))}
</div>
);
const Toolbar = ({ view, setView, sort, setSort, filters, setFilters, count, files, selected, onSelectAll, isLoggedIn }) => {
const [sortOpen, setSortOpen] = useState(false); const [sortOpen, setSortOpen] = useState(false);
const sortLabels = { recent:"최근 수정순", name:"이름순", size:"크기순", downloads:"다운로드순" }; const sortLabels = { recent:"최근 수정순", name:"이름순", size:"크기순", downloads:"다운로드순" };
const typeFilters = [ const allSelected = files.length > 0 && files.every(f => selected.has(f.id));
{ id:"doc", label:"문서" }, { id:"img", label:"이미지" }, const someSelected = !allSelected && files.some(f => selected.has(f.id));
{ id:"vid", label:"동영상" }, { id:"zip", label:"압축" }, { id:"code", label:"코드" },
];
const toggleType = id => {
const s = new Set(filters.types);
s.has(id) ? s.delete(id) : s.add(id);
setFilters({ ...filters, types: [...s] });
};
return ( return (
<div className="toolbar"> <div className="toolbar">
<div className="toolbar-l"> <div className="toolbar-l">
{typeFilters.map(t => ( {isLoggedIn && (
<button key={t.id} className={`pill ${filters.types.includes(t.id) ? "on" : ""}`} onClick={() => toggleType(t.id)}> <div className={`check ${allSelected ? 'on' : someSelected ? 'indeterminate' : ''}`}
{t.label}{filters.types.includes(t.id) && <span className="x">×</span>} onClick={onSelectAll}
</button> title={allSelected ? "전체 해제" : "전체 선택"}
))} style={{ cursor:'default', flexShrink:0 }}>
{allSelected && <Icon name="check" size={10} stroke={2.5} />}
{someSelected && <span style={{fontSize:12,lineHeight:1,fontWeight:700,color:'#fff'}}></span>}
</div>
)}
<button className={`pill ${filters.starredOnly ? "on" : ""}`} onClick={() => setFilters({...filters, starredOnly:!filters.starredOnly})}> <button className={`pill ${filters.starredOnly ? "on" : ""}`} onClick={() => setFilters({...filters, starredOnly:!filters.starredOnly})}>
<Icon name="star" size={11} />즐겨찾기만 <Icon name="star" size={11} />즐겨찾기만
</button> </button>
@@ -775,10 +840,18 @@ const Toolbar = ({ view, setView, sort, setSort, filters, setFilters, count }) =
); );
}; };
const GridView = ({ files, selected, toggleSel, toggleStar, onOpen }) => ( const GridView = ({ files, selected, toggleSel, toggleStar, onOpen, isLoggedIn }) => (
<div className="grid"> <div className="grid">
{files.map(f => ( {files.map(f => (
<div key={f.id} className={`card ${selected.has(f.id) ? "sel" : ""}`} onClick={() => onOpen(f)}> <div key={f.id} className={`card ${selected.has(f.id) ? "sel" : ""}`} onClick={() => onOpen(f)}>
{isLoggedIn && (
<div style={{ position:'absolute', top:8, left:8, zIndex:2 }}
onClick={e => { e.stopPropagation(); toggleSel(f.id); }}>
<div className={`check ${selected.has(f.id) ? 'on' : ''}`}>
<Icon name="check" size={10} stroke={2.5} />
</div>
</div>
)}
<Thumb file={f} /> <Thumb file={f} />
<button className={`star ${f.starred ? "on" : ""}`} onClick={e => { e.stopPropagation(); toggleStar(f.id); }} title="즐겨찾기"> <button className={`star ${f.starred ? "on" : ""}`} onClick={e => { e.stopPropagation(); toggleStar(f.id); }} title="즐겨찾기">
<Icon name="star" size={14} stroke={2} /> <Icon name="star" size={14} stroke={2} />
@@ -792,37 +865,93 @@ const GridView = ({ files, selected, toggleSel, toggleStar, onOpen }) => (
</div> </div>
); );
const ListView = ({ files, selected, toggleSel, toggleStar, onOpen, categories }) => ( const ListView = ({ files, selected, toggleSel, toggleStar, onOpen, categories, isLoggedIn, currentUser, onDownload, onCopyLink, onDelete }) => {
<div className="list"> const [menuFile, setMenuFile] = useState(null);
<div className="list-head"> const [menuPos, setMenuPos] = useState({ x: 0, y: 0 });
<div></div><div></div><div></div><div></div><div></div><div></div><div></div> const menuRef = useRef(null);
</div>
{files.map(f => ( useEffect(() => {
<div key={f.id} className={`list-row ${selected.has(f.id) ? "sel" : ""}`} onClick={() => onOpen(f)}> if (!menuFile) return;
<div className={`check ${selected.has(f.id) ? "on" : ""}`} onClick={e => { e.stopPropagation(); toggleSel(f.id); }}> const close = e => { if (menuRef.current && !menuRef.current.contains(e.target)) setMenuFile(null); };
<Icon name="check" size={11} stroke={2.5} /> const onKey = e => { if (e.key === 'Escape') setMenuFile(null); };
</div> document.addEventListener('mousedown', close);
<div className="list-name"> document.addEventListener('keydown', onKey);
<FIcon type={f.type} ext={f.ext} /> return () => { document.removeEventListener('mousedown', close); document.removeEventListener('keydown', onKey); };
<div className="ftxt"> }, [menuFile]);
<span className="nm">{f.name}</span>
<span className="pth">{categories.find(c => c.id === f.category)?.label} · {f.author}</span> const openMenu = (e, file) => {
e.stopPropagation();
const rect = e.currentTarget.getBoundingClientRect();
const x = Math.max(4, Math.min(rect.right - 164, window.innerWidth - 168));
const y = Math.min(rect.bottom + 4, window.innerHeight - 160);
setMenuPos({ x, y });
setMenuFile(f => f?.id === file.id ? null : file);
};
const cols = isLoggedIn
? "32px 1fr 100px 130px 130px 70px 52px"
: "1fr 100px 130px 130px 70px 52px";
return (
<div className="list">
<div className="list-head" style={{ gridTemplateColumns: cols }}>
{isLoggedIn && <div></div>}
<div>이름</div><div></div><div></div><div></div><div></div><div></div>
</div>
{files.map(f => (
<div key={f.id} className={`list-row ${selected.has(f.id) ? "sel" : ""}`}
style={{ gridTemplateColumns: cols }}
onClick={() => onOpen(f)}>
{isLoggedIn && (
<div className={`check ${selected.has(f.id) ? "on" : ""}`}
onClick={e => { e.stopPropagation(); toggleSel(f.id); }}>
<Icon name="check" size={11} stroke={2.5} />
</div>
)}
<div className="list-name">
<FIcon type={f.type} ext={f.ext} />
<div className="ftxt">
<span className="nm">{f.name}</span>
<span className="pth">{categories.find(c => c.id === f.category)?.label} · {f.author}</span>
</div>
</div>
<div className="list-meta">{f.size_label}</div>
<div className="list-meta">{f.modified_at}</div>
<div className="list-meta" style={{ fontFamily:"var(--font-sans)", fontSize:13 }}>{f.author}</div>
<div className="list-meta"> {f.downloads}</div>
<div style={{display:"flex",gap:2,justifyContent:"flex-end"}}>
<button className="iconbtn" onClick={e => { e.stopPropagation(); toggleStar(f.id); }}
style={{ color: f.starred ? "#e8a317" : "var(--faint)" }}>
<Icon name="star" size={14} stroke={f.starred ? 2.5 : 1.75} />
</button>
<button className="iconbtn" onClick={e => openMenu(e, f)}><Icon name="more" size={15} /></button>
</div> </div>
</div> </div>
<div className="list-meta">{f.size_label}</div> ))}
<div className="list-meta">{f.modified_at}</div> {menuFile && ReactDOM.createPortal(
<div className="list-meta" style={{ fontFamily:"var(--font-sans)", fontSize:13 }}>{f.author}</div> <div ref={menuRef} className="ctx-menu" style={{ left: menuPos.x, top: menuPos.y }}>
<div className="list-meta"> {f.downloads}</div> <div className="ctx-mi" onClick={() => { onDownload(menuFile); setMenuFile(null); }}>
<div style={{display:"flex",gap:2,justifyContent:"flex-end"}}> <Icon name="download" size={14} />다운로드
<button className="iconbtn" onClick={e => { e.stopPropagation(); toggleStar(f.id); }} style={{ color: f.starred ? "#e8a317" : "var(--faint)" }}> </div>
<Icon name="star" size={14} stroke={f.starred ? 2.5 : 1.75} /> <div className="ctx-mi" onClick={() => { onCopyLink(menuFile); setMenuFile(null); }}>
</button> <Icon name="link" size={14} />링크 복사
<button className="iconbtn" onClick={e => e.stopPropagation()}><Icon name="more" size={15} /></button> </div>
</div> <div className="ctx-mi" onClick={() => { toggleStar(menuFile.id); setMenuFile(null); }}>
</div> <Icon name="star" size={14} stroke={menuFile.starred ? 2.5 : 1.75}
))} style={{ color: menuFile.starred ? "#e8a317" : "currentColor" }} />
</div> {menuFile.starred ? '즐겨찾기 해제' : '즐겨찾기 추가'}
); </div>
{currentUser?.isAdmin && (
<div className="ctx-mi danger" onClick={() => { const f = menuFile; setMenuFile(null); onDelete([f.id]); }}>
<Icon name="trash" size={14} />삭제
</div>
)}
</div>,
document.body
)}
</div>
);
};
const UploadModal = ({ onClose, onUploaded, accent, categories, users }) => { const UploadModal = ({ onClose, onUploaded, accent, categories, users }) => {
const [dragOver, setDragOver] = useState(false); const [dragOver, setDragOver] = useState(false);
@@ -997,13 +1126,13 @@ const PreviewModal = ({ file, onClose, onDownload, onStar, onCopyLink, accent, c
); );
}; };
const SelectionBar = ({ count, onClear, onDownload, onDelete }) => ( const SelectionBar = ({ count, onClear, onDownload, onShare, onDelete, isAdmin }) => (
<div className="selbar"> <div className="selbar">
<span className="selcount">{count} 선택됨</span> <span className="selcount">{count} 선택됨</span>
<div className="selbtns"> <div className="selbtns">
<button title="다운로드" onClick={onDownload}><Icon name="download" size={15} /></button> <button title="다운로드" onClick={onDownload}><Icon name="download" size={15} /></button>
<button title="공유"><Icon name="share" size={15} /></button> <button title="링크 복사" onClick={onShare}><Icon name="share" size={15} /></button>
<button title="삭제" onClick={onDelete}><Icon name="trash" size={15} /></button> {isAdmin && <button title="삭제" onClick={onDelete}><Icon name="trash" size={15} /></button>}
<button className="x" title="선택 해제" onClick={onClear}><Icon name="x" size={15} /></button> <button className="x" title="선택 해제" onClick={onClear}><Icon name="x" size={15} /></button>
</div> </div>
</div> </div>
@@ -1016,6 +1145,91 @@ const Toast = ({ msg, on }) => (
</div> </div>
); );
// ── 로그인 모달 ───────────────────────────────────────────────
const UserLoginModal = ({ onLogin, onClose, users, accent }) => {
const [tab, setTab] = useState('user');
const [name, setName] = useState('');
const [pw, setPw] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const loginUser = () => {
if (!name.trim()) { setError('이름을 선택하세요'); return; }
onLogin({ name: name.trim(), isAdmin: false });
};
const loginAdmin = async () => {
if (!pw) { setError('비밀번호를 입력하세요'); return; }
setLoading(true); setError('');
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 || '로그인 실패');
sessionStorage.setItem('admin_token', data.token);
onLogin({ name: '관리자', isAdmin: true });
} catch (e) { setError(e.message); }
finally { setLoading(false); }
};
const sel = { width:'100%', border:'1px solid var(--border-strong)', borderRadius:6, padding:'8px 10px', fontSize:13, background:'var(--surface)', color:'var(--ink)', outline:'none', marginBottom:12 };
return (
<div className="modal-bg" onClick={onClose}>
<div className="modal" style={{ width:400, maxWidth:'92vw' }} onClick={e => e.stopPropagation()}>
<div className="modal-h">
<h3>로그인</h3>
<button className="iconbtn" onClick={onClose}><Icon name="x" size={16} /></button>
</div>
<div className="modal-b">
<div style={{ display:'flex', gap:3, marginBottom:20, background:'var(--surface-2)', padding:3, borderRadius:8, border:'1px solid var(--border)' }}>
{[['user','사용자'],['admin','관리자']].map(([t, l]) => (
<button key={t} onClick={() => { setTab(t); setError(''); setPw(''); setName(''); }}
style={{ flex:1, padding:'7px 0', border:'none', borderRadius:6, fontSize:13, fontWeight:500, cursor:'default', fontFamily:'inherit',
background: tab === t ? 'var(--surface)' : 'transparent',
color: tab === t ? 'var(--ink)' : 'var(--muted)',
boxShadow: tab === t ? 'var(--shadow-sm)' : 'none' }}>
{l}
</button>
))}
</div>
{tab === 'user' && (
<>
<div style={{ fontSize:11, fontWeight:600, color:'var(--muted)', marginBottom:5 }}>이름 선택</div>
<select style={sel} value={name} onChange={e => { setName(e.target.value); setError(''); }}>
<option value="">선택하세요</option>
{users.map(u => <option key={u.id} value={u.name}>{u.name}</option>)}
</select>
{error && <div style={{ fontSize:12, color:'var(--warn)', marginBottom:8 }}>{error}</div>}
<button className="btn btn-accent" style={{ width:'100%', justifyContent:'center', background:accent, borderColor:accent }} onClick={loginUser}>
로그인
</button>
</>
)}
{tab === 'admin' && (
<>
<div style={{ fontSize:11, fontWeight:600, color:'var(--muted)', marginBottom:5 }}>관리자 비밀번호</div>
<input type="password" value={pw} onChange={e => { setPw(e.target.value); setError(''); }}
placeholder="비밀번호 입력" autoFocus
style={{ ...sel, marginBottom: error ? 4 : 12 }}
onKeyDown={e => e.key === 'Enter' && loginAdmin()} />
{error && <div style={{ fontSize:12, color:'var(--warn)', marginBottom:8 }}>{error}</div>}
<button className="btn btn-accent" style={{ width:'100%', justifyContent:'center', background:accent, borderColor:accent }} onClick={loginAdmin} disabled={loading}>
{loading ? '확인 중...' : '관리자 로그인'}
</button>
</>
)}
</div>
</div>
</div>
);
};
// ── main app ────────────────────────────────────────────────── // ── main app ──────────────────────────────────────────────────
const TWEAK_DEFAULTS = { const TWEAK_DEFAULTS = {
@@ -1030,23 +1244,27 @@ function App() {
const [files, setFiles] = useState([]); const [files, setFiles] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [stats, setStats] = useState({ folders: {}, categories: {}, storage: { used: 0, total: 100, remaining: 100 } }); const [stats, setStats] = useState({ folders: {}, categories: {}, types: {}, storage: { used: 0, total: 100, remaining: 100 } });
const [categories, setCategories] = useState(CATEGORIES_DEF); const [categories, setCategories] = useState(CATEGORIES_DEF);
const [users, setUsers] = useState([]);
const [view, setView] = useState("grid"); const [view, setView] = useState("list");
const [sort, setSort] = useState("recent"); const [sort, setSort] = useState("recent");
const [filters, setFilters] = useState({ types: [], starredOnly: false }); const [filters, setFilters] = useState({ starredOnly: false });
const [activeFolder, setActiveFolder] = useState("all"); const [activeFolder, setActiveFolder] = useState("all");
const [activeCat, setActiveCat] = useState(null); const [activeCat, setActiveCat] = useState(null);
const [sidebarOpen, setSidebarOpen] = useState(false);
const [selected, setSelected] = useState(new Set()); const [selected, setSelected] = useState(new Set());
const [previewing, setPreviewing] = useState(null); const [previewing, setPreviewing] = useState(null);
const [uploadOpen, setUploadOpen] = useState(false); const [uploadOpen, setUploadOpen] = useState(false);
const [globalDrag, setGlobalDrag] = useState(false); const [globalDrag, setGlobalDrag] = useState(false);
const [toast, setToast] = useState({ on: false, msg: "" }); const [toast, setToast] = useState({ on: false, msg: "" });
const [currentUser, setCurrentUser] = useState(() => {
try { return JSON.parse(sessionStorage.getItem('jaryo_user') || 'null'); } catch { return null; }
});
const dragCounter = useRef(0); const dragCounter = useRef(0);
const currentUserRef = useRef(null);
// 액센트 컬러 적용 // 액센트 컬러 적용
useEffect(() => { useEffect(() => {
@@ -1058,6 +1276,8 @@ function App() {
document.documentElement.style.setProperty("--radius-lg", `${t.cornerRadius + 4}px`); document.documentElement.style.setProperty("--radius-lg", `${t.cornerRadius + 4}px`);
}, [t.accent, t.cornerRadius]); }, [t.accent, t.cornerRadius]);
useEffect(() => { currentUserRef.current = currentUser; }, [currentUser]);
const fetchStats = useCallback(() => { const fetchStats = useCallback(() => {
apiFetch('/api/stats').then(setStats).catch(() => {}); apiFetch('/api/stats').then(setStats).catch(() => {});
}, []); }, []);
@@ -1068,7 +1288,6 @@ function App() {
const p = new URLSearchParams(); const p = new URLSearchParams();
if (activeFolder) p.set('folder', activeFolder); if (activeFolder) p.set('folder', activeFolder);
if (activeCat) p.set('category', activeCat); if (activeCat) p.set('category', activeCat);
if (filters.types.length) p.set('types', filters.types.join(','));
if (filters.starredOnly) p.set('starred', '1'); if (filters.starredOnly) p.set('starred', '1');
p.set('sort', sort); p.set('sort', sort);
const data = await apiFetch(`/api/files?${p}`); const data = await apiFetch(`/api/files?${p}`);
@@ -1076,21 +1295,51 @@ function App() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [activeFolder, activeCat, filters, sort]); }, [activeFolder, activeCat, filters.starredOnly, sort]);
useEffect(() => { fetchFiles(); }, [fetchFiles]); useEffect(() => { fetchFiles(); }, [fetchFiles]);
useEffect(() => { fetchStats(); }, [fetchStats]); useEffect(() => { fetchStats(); }, [fetchStats]);
useEffect(() => { useEffect(() => {
apiFetch('/api/categories').then(setCategories).catch(() => {}); apiFetch('/api/categories').then(setCategories).catch(() => {});
apiFetch('/api/users').then(setUsers).catch(() => {});
}, []); }, []);
useEffect(() => {
const token = sessionStorage.getItem('admin_token');
if (!token) return;
fetch('/api/admin/verify', { headers: { Authorization: `Bearer ${token}` } })
.then(r => r.json())
.then(d => {
if (d.ok) {
const u = { name: '관리자', isAdmin: true };
setCurrentUser(u);
sessionStorage.setItem('jaryo_user', JSON.stringify(u));
} else {
sessionStorage.removeItem('admin_token');
sessionStorage.removeItem('jaryo_user');
setCurrentUser(null);
}
}).catch(() => {});
}, []);
const handleLogout = () => {
sessionStorage.removeItem('admin_token');
sessionStorage.removeItem('jaryo_user');
setCurrentUser(null);
setSelected(new Set());
};
const showToast = msg => { setToast({ on: true, msg }); setTimeout(() => setToast(s => ({...s, on: false})), 2200); };
// 전역 드래그 // 전역 드래그
useEffect(() => { useEffect(() => {
const onDragEnter = e => { if (e.dataTransfer?.types?.includes("Files")) { dragCounter.current++; setGlobalDrag(true); } }; const onDragEnter = e => { if (e.dataTransfer?.types?.includes("Files")) { dragCounter.current++; setGlobalDrag(true); } };
const onDragLeave = () => { dragCounter.current = Math.max(0, dragCounter.current - 1); if (!dragCounter.current) setGlobalDrag(false); }; const onDragLeave = () => { dragCounter.current = Math.max(0, dragCounter.current - 1); if (!dragCounter.current) setGlobalDrag(false); };
const onDragOver = e => e.preventDefault(); const onDragOver = e => e.preventDefault();
const onDrop = e => { e.preventDefault(); dragCounter.current = 0; setGlobalDrag(false); setUploadOpen(true); }; const onDrop = e => {
e.preventDefault(); dragCounter.current = 0; setGlobalDrag(false);
if (currentUserRef.current) setUploadOpen(true);
else setToast({ on: true, msg: '업로드는 관리자만 가능합니다' });
};
window.addEventListener("dragenter", onDragEnter); window.addEventListener("dragenter", onDragEnter);
window.addEventListener("dragleave", onDragLeave); window.addEventListener("dragleave", onDragLeave);
window.addEventListener("dragover", onDragOver); window.addEventListener("dragover", onDragOver);
@@ -1103,12 +1352,19 @@ function App() {
}; };
}, []); }, []);
const showToast = msg => { setToast({ on: true, msg }); setTimeout(() => setToast(s => ({...s, on: false})), 2200); };
const toggleSel = id => { const toggleSel = id => {
if (!currentUser) return;
setSelected(prev => { const next = new Set(prev); next.has(id) ? next.delete(id) : next.add(id); return next; }); setSelected(prev => { const next = new Set(prev); next.has(id) ? next.delete(id) : next.add(id); return next; });
}; };
const handleSelectAll = () => {
if (files.length > 0 && files.every(f => selected.has(f.id))) {
setSelected(new Set());
} else {
setSelected(new Set(files.map(f => f.id)));
}
};
const toggleStar = async id => { const toggleStar = async id => {
try { try {
const { starred } = await apiFetch(`/api/files/${id}/star`, { method: 'PATCH' }); const { starred } = await apiFetch(`/api/files/${id}/star`, { method: 'PATCH' });
@@ -1150,19 +1406,47 @@ function App() {
} }
}; };
const onBulkDownload = () => { showToast(`${selected.size}개 파일을 ZIP으로 다운로드합니다`); setSelected(new Set()); }; const onBulkDownload = () => {
const selectedFiles = files.filter(f => selected.has(f.id));
let count = 0;
selectedFiles.forEach(f => {
if (f.file_path) {
const a = document.createElement('a');
a.href = `/api/files/${f.id}/download`;
a.style.display = 'none';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
count++;
}
});
showToast(count > 0 ? `${count}개 파일 다운로드 시작` : '다운로드 가능한 파일이 없습니다');
setSelected(new Set());
};
const onBulkDelete = async () => { const onBulkShare = async () => {
const ids = [...selected]; const links = [...selected].map(id => `${window.location.origin}/api/files/${id}/download`);
try {
await navigator.clipboard.writeText(links.join('\n'));
showToast(`${selected.size}개 링크가 클립보드에 복사되었습니다`);
} catch { showToast('복사에 실패했습니다'); }
setSelected(new Set());
};
const deleteFiles = async (ids) => {
if (!currentUser?.isAdmin) { showToast('삭제는 관리자만 할 수 있습니다'); return; }
if (!window.confirm(`파일 ${ids.length}개를 삭제할까요?`)) return;
try { try {
await apiFetch('/api/files', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ids }) }); await apiFetch('/api/files', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ids }) });
showToast(`${ids.length}개 파일이 삭제되었습니다`); showToast(`${ids.length}개 파일이 삭제되었습니다`);
setSelected(new Set()); setSelected(prev => { const next = new Set(prev); ids.forEach(id => next.delete(id)); return next; });
fetchFiles(); fetchFiles();
fetchStats(); fetchStats();
} catch { showToast("삭제 중 오류가 발생했습니다"); } } catch { showToast('삭제 중 오류가 발생했습니다'); }
}; };
const onBulkDelete = () => deleteFiles([...selected]);
const onUploaded = newFile => { const onUploaded = newFile => {
setFiles(prev => [newFile, ...prev]); setFiles(prev => [newFile, ...prev]);
fetchStats(); fetchStats();
@@ -1171,18 +1455,39 @@ function App() {
return ( return (
<div className="app" style={{ fontSize: t.density === "compact" ? 13 : t.density === "comfy" ? 15 : 14 }}> <div className="app" style={{ fontSize: t.density === "compact" ? 13 : t.density === "comfy" ? 15 : 14 }}>
{sidebarOpen && <div className="sb-overlay on" onClick={() => setSidebarOpen(false)} />}
<Sidebar <Sidebar
activeFolder={activeFolder} setActiveFolder={setActiveFolder} activeFolder={activeFolder}
activeCat={activeCat} setActiveCat={setActiveCat} setActiveFolder={id => { setActiveFolder(id); setActiveCat(null); }}
accent={t.accent} stats={stats} categories={categories} activeCat={activeCat}
setActiveCat={id => { setActiveCat(id); setActiveFolder("all"); }}
categories={categories}
accent={t.accent} stats={stats}
open={sidebarOpen} onClose={() => setSidebarOpen(false)}
/> />
<div className="main"> <div className="main">
<Header folder={activeFolder} category={activeCat} onUpload={() => setUploadOpen(true)} accent={t.accent} categories={categories} /> <Header
<Toolbar view={view} setView={setView} sort={sort} setSort={setSort} folder={activeFolder} category={activeCat}
filters={filters} setFilters={setFilters} count={files.length} /> onUpload={() => setUploadOpen(true)} accent={t.accent} categories={categories}
currentUser={currentUser} onLogout={handleLogout}
onMenuToggle={() => setSidebarOpen(o => !o)}
/>
<TypeTabs
categories={categories}
activeCat={activeCat}
onSelect={id => { setActiveCat(id); setActiveFolder("all"); }}
/>
<Toolbar
view={view} setView={setView} sort={sort} setSort={setSort}
filters={filters} setFilters={setFilters} count={files.length}
files={files} selected={selected} onSelectAll={handleSelectAll} isLoggedIn={!!currentUser}
/>
<div className="scroll scrollbar" style={{ position:"relative" }}> <div className="scroll scrollbar" style={{ position:"relative" }}>
<div className="sec-h"> <div className="sec-h">
<h3>{activeCat ? categories.find(c=>c.id===activeCat)?.label : (FOLDERS_DEF.find(f=>f.id===activeFolder)?.label || "모든 자료")}</h3> <h3>
{activeCat ? categories.find(c=>c.id===activeCat)?.label :
(FOLDERS_DEF.find(f=>f.id===activeFolder)?.label || "모든 자료")}
</h3>
<span className="right">{files.length} 항목</span> <span className="right">{files.length} 항목</span>
</div> </div>
@@ -1195,9 +1500,11 @@ function App() {
<div className="s">필터를 조정하거나 파일을 업로드해보세요</div> <div className="s">필터를 조정하거나 파일을 업로드해보세요</div>
</div> </div>
) : view === "grid" ? ( ) : view === "grid" ? (
<GridView files={files} selected={selected} toggleSel={toggleSel} toggleStar={toggleStar} onOpen={setPreviewing} /> <GridView files={files} selected={selected} toggleSel={toggleSel} toggleStar={toggleStar} onOpen={setPreviewing} isLoggedIn={!!currentUser} />
) : ( ) : (
<ListView files={files} selected={selected} toggleSel={toggleSel} toggleStar={toggleStar} onOpen={setPreviewing} categories={categories} /> <ListView files={files} selected={selected} toggleSel={toggleSel} toggleStar={toggleStar} onOpen={setPreviewing} categories={categories}
isLoggedIn={!!currentUser} currentUser={currentUser}
onDownload={onDownload} onCopyLink={onCopyLink} onDelete={deleteFiles} />
)} )}
<div className={`dropzone ${globalDrag ? "on" : ""}`}> <div className={`dropzone ${globalDrag ? "on" : ""}`}>
@@ -1210,10 +1517,11 @@ function App() {
{selected.size > 0 && ( {selected.size > 0 && (
<SelectionBar count={selected.size} onClear={() => setSelected(new Set())} <SelectionBar count={selected.size} onClear={() => setSelected(new Set())}
onDownload={onBulkDownload} onDelete={onBulkDelete} /> onDownload={onBulkDownload} onShare={onBulkShare} onDelete={onBulkDelete}
isAdmin={currentUser?.isAdmin} />
)} )}
{uploadOpen && <UploadModal onClose={() => setUploadOpen(false)} onUploaded={onUploaded} accent={t.accent} categories={categories} users={users} />} {uploadOpen && <UploadModal onClose={() => setUploadOpen(false)} onUploaded={onUploaded} accent={t.accent} categories={categories} users={[]} />}
{previewing && ( {previewing && (
<PreviewModal <PreviewModal
+35 -25
View File
@@ -96,20 +96,20 @@ async function initDb() {
// ── 시드 데이터 ─────────────────────────────────────────────── // ── 시드 데이터 ───────────────────────────────────────────────
const SEED = [ 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:"f1", name:"2026년 1분기 마케팅 전략.pdf", type:"doc", ext:"PDF", size_bytes:4404019, size_label:"4.2 MB", modified_at:"2시간 전", author:"김민지", category:"doc", 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:"f2", name:"브랜드 가이드라인 v3.2.pdf", type:"doc", ext:"PDF", size_bytes:13421772, size_label:"12.8 MB", modified_at:"어제", author:"이서준", category:"doc", 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:"f3", name:"신제품 키비주얼.png", type:"img", ext:"PNG", size_bytes:8493466, size_label:"8.1 MB", modified_at:"3일 전", author:"박하늘", category:"img", 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:"f4", name:"온보딩 영상 (한국어).mp4", type:"vid", ext:"MP4", size_bytes:297795584, size_label:"284 MB", modified_at:"1주 전", author:"최유나", category:"vid", 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:"f5", name:"사용자 인터뷰 결과.docx", type:"doc", ext:"DOCX", size_bytes:1468006, size_label:"1.4 MB", modified_at:"5일 전", author:"정도현", category:"doc", 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:"f6", name:"디자인 시스템 컴포넌트.zip", type:"zip", ext:"ZIP", size_bytes:47840256, size_label:"45.6 MB", modified_at:"2주 전", author:"이서준", category:"zip", 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:"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:"f8", name:"Q4 실적 발표 자료.pptx", type:"doc", ext:"PPTX", size_bytes:29673267, size_label:"28.3 MB", modified_at:"4일 전", author:"한지원", category:"doc", 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:"f9", name:"회사소개 인포그래픽.png", type:"img", ext:"PNG", size_bytes:3355443, size_label:"3.2 MB", modified_at:"6일 전", author:"박하늘", category:"img", 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:"f10", name:"기술 면접 질문 모음.pdf", type:"doc", ext:"PDF", size_bytes:913408, size_label:"892 KB", modified_at:"3주 전", author:"정도현", category:"doc", 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:"f11", name:"CES 2026 발표 영상.mov", type:"vid", ext:"MOV", size_bytes:1288490188, size_label:"1.2 GB", modified_at:"2주 전", author:"최유나", category:"vid", 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:"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:"f13", name:"이메일 템플릿 모음.zip", type:"zip", ext:"ZIP", size_bytes:9123020, size_label:"8.7 MB", modified_at:"5일 전", author:"김민지", category:"zip", 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" }, { id:"f14", name:"워크샵 사진 모음.zip", type:"zip", ext:"ZIP", size_bytes:245366784, size_label:"234 MB", modified_at:"1주 전", author:"박하늘", category:"zip", visibility:"team", downloads:67, starred:0, thumb:"zip" },
]; ];
function seedData() { function seedData() {
@@ -131,24 +131,29 @@ function seedData() {
// ── 카테고리·사용자 시드 ────────────────────────────────────── // ── 카테고리·사용자 시드 ──────────────────────────────────────
function seedCategories() { function seedCategories() {
const count = queryGet('SELECT COUNT(*) as c FROM categories'); const newIds = ['doc', 'img', 'vid', 'zip', 'code'];
if (count && count.c > 0) return; const existing = queryAll('SELECT id FROM categories');
const existingIds = existing.map(r => r.id);
const needsMigration = existingIds.length === 0 || existingIds.some(id => !newIds.includes(id));
if (!needsMigration) return;
db.run('DELETE FROM categories');
const defaults = [ const defaults = [
{ id:'design', label:'디자인 리소스', icon:'image', sort_order:0 }, { id:'doc', label:'문서', icon:'folder', sort_order:0 },
{ id:'marketing', label:'마케팅 자료', icon:'folder', sort_order:1 }, { id:'img', label:'이미지', icon:'image', sort_order:1 },
{ id:'education', label:'교육·온보딩', icon:'folder', sort_order:2 }, { id:'vid', label:'동영상', icon:'video', sort_order:2 },
{ id:'templates', label:'템플릿', icon:'folder', sort_order:3 }, { id:'zip', label:'압축', icon:'folder', sort_order:3 },
{ id:'code', label:'코드 스니펫', icon:'code', sort_order:4 }, { id:'code', label:'코드', icon:'code', sort_order:4 },
{ id:'media', label:'사진·영상', icon:'video', sort_order:5 },
]; ];
for (const c of defaults) { for (const c of defaults) {
db.run( db.run(
`INSERT OR IGNORE INTO categories (id,label,icon,sort_order) VALUES (:id,:label,:icon,:sort_order)`, `INSERT 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 } { ':id':c.id, ':label':c.label, ':icon':c.icon, ':sort_order':c.sort_order }
); );
} }
db.run('UPDATE files SET category = type');
saveDb(); saveDb();
console.log(`카테고리 시드 ${defaults.length}개 삽입 완료`); console.log('카테고리 마이그레이션 완료: 파일 유형 기반으로 변경');
} }
function seedUsers() { function seedUsers() {
@@ -265,6 +270,10 @@ app.get('/api/stats', (req, res) => {
const categories = {}; const categories = {};
for (const r of catRows) categories[r.category] = r.c; for (const r of catRows) categories[r.category] = r.c;
const typeRows = queryAll('SELECT type, COUNT(*) as c FROM files GROUP BY type');
const types = {};
for (const r of typeRows) types[r.type] = r.c;
const usedBytes = queryGet('SELECT COALESCE(SUM(size_bytes),0) as s FROM files')?.s ?? 0; const usedBytes = queryGet('SELECT COALESCE(SUM(size_bytes),0) as s FROM files')?.s ?? 0;
const usedGB = parseFloat((usedBytes / 1073741824).toFixed(1)); const usedGB = parseFloat((usedBytes / 1073741824).toFixed(1));
const totalGB = 100; const totalGB = 100;
@@ -272,6 +281,7 @@ app.get('/api/stats', (req, res) => {
res.json({ res.json({
folders: { all: total, starred, recent, shared: 0, trash: 0 }, folders: { all: total, starred, recent, shared: 0, trash: 0 },
categories, categories,
types,
storage: { used: usedGB, total: totalGB, remaining: parseFloat((totalGB - usedGB).toFixed(1)) }, storage: { used: usedGB, total: totalGB, remaining: parseFloat((totalGB - usedGB).toFixed(1)) },
}); });
}); });
@@ -292,7 +302,7 @@ app.post('/api/files/upload', upload.single('file'), (req, res) => {
VALUES (:id,:name,:type,:ext,:size_bytes,:size_label,:modified_at,:author,:category,:visibility,0,0,: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, { ':id':id, ':name':name, ':type':type, ':ext':extLabel,
':size_bytes':f.size, ':size_label':formatSize(f.size), ':modified_at':'방금', ':size_bytes':f.size, ':size_label':formatSize(f.size), ':modified_at':'방금',
':author': req.body.author || '나', ':category': req.body.category || 'design', ':author': req.body.author || '나', ':category': req.body.category || type,
':visibility': req.body.visibility || 'team', ':thumb':type, ':file_path':f.filename } ':visibility': req.body.visibility || 'team', ':thumb':type, ':file_path':f.filename }
); );
saveDb(); saveDb();