);
-const ListView = ({ files, selected, toggleSel, toggleStar, onOpen, categories }) => (
-
-
- {files.map(f => (
-
onOpen(f)}>
-
{ e.stopPropagation(); toggleSel(f.id); }}>
-
-
-
-
-
-
{f.name}
-
{categories.find(c => c.id === f.category)?.label} · {f.author}
+const ListView = ({ files, selected, toggleSel, toggleStar, onOpen, categories, isLoggedIn, currentUser, onDownload, onCopyLink, onDelete }) => {
+ const [menuFile, setMenuFile] = useState(null);
+ const [menuPos, setMenuPos] = useState({ x: 0, y: 0 });
+ const menuRef = useRef(null);
+
+ useEffect(() => {
+ if (!menuFile) return;
+ const close = e => { if (menuRef.current && !menuRef.current.contains(e.target)) setMenuFile(null); };
+ const onKey = e => { if (e.key === 'Escape') setMenuFile(null); };
+ document.addEventListener('mousedown', close);
+ document.addEventListener('keydown', onKey);
+ return () => { document.removeEventListener('mousedown', close); document.removeEventListener('keydown', onKey); };
+ }, [menuFile]);
+
+ 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 (
+
+
+ {isLoggedIn &&
}
+
이름
크기
수정일
업로더
다운로드
+
+ {files.map(f => (
+
onOpen(f)}>
+ {isLoggedIn && (
+
{ e.stopPropagation(); toggleSel(f.id); }}>
+
+
+ )}
+
+
+
+ {f.name}
+ {categories.find(c => c.id === f.category)?.label} · {f.author}
+
+
+
{f.size_label}
+
{f.modified_at}
+
{f.author}
+
↓ {f.downloads}
+
+
+
-
{f.size_label}
-
{f.modified_at}
-
{f.author}
-
↓ {f.downloads}
-
-
-
-
-
- ))}
-
-);
+ ))}
+ {menuFile && ReactDOM.createPortal(
+
+
{ onDownload(menuFile); setMenuFile(null); }}>
+ 다운로드
+
+
{ onCopyLink(menuFile); setMenuFile(null); }}>
+ 링크 복사
+
+
{ toggleStar(menuFile.id); setMenuFile(null); }}>
+
+ {menuFile.starred ? '즐겨찾기 해제' : '즐겨찾기 추가'}
+
+ {currentUser?.isAdmin && (
+
{ const f = menuFile; setMenuFile(null); onDelete([f.id]); }}>
+ 삭제
+
+ )}
+
,
+ document.body
+ )}
+
+ );
+};
const UploadModal = ({ onClose, onUploaded, accent, categories, users }) => {
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 }) => (
{count}개 선택됨
-
-
+
+ {isAdmin && }
@@ -1016,6 +1145,91 @@ const Toast = ({ msg, on }) => (
);
+// ── 로그인 모달 ───────────────────────────────────────────────
+
+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 (
+
+
e.stopPropagation()}>
+
+
로그인
+
+
+
+
+ {[['user','사용자'],['admin','관리자']].map(([t, l]) => (
+
+ ))}
+
+
+ {tab === 'user' && (
+ <>
+
이름 선택
+
+ {error &&
{error}
}
+
+ >
+ )}
+
+ {tab === 'admin' && (
+ <>
+
관리자 비밀번호
+
{ setPw(e.target.value); setError(''); }}
+ placeholder="비밀번호 입력" autoFocus
+ style={{ ...sel, marginBottom: error ? 4 : 12 }}
+ onKeyDown={e => e.key === 'Enter' && loginAdmin()} />
+ {error &&
{error}
}
+
+ >
+ )}
+
+
+
+ );
+};
+
// ── main app ──────────────────────────────────────────────────
const TWEAK_DEFAULTS = {
@@ -1030,23 +1244,27 @@ function App() {
const [files, setFiles] = useState([]);
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 [users, setUsers] = useState([]);
- const [view, setView] = useState("grid");
+ const [view, setView] = useState("list");
const [sort, setSort] = useState("recent");
- const [filters, setFilters] = useState({ types: [], starredOnly: false });
+ const [filters, setFilters] = useState({ starredOnly: false });
const [activeFolder, setActiveFolder] = useState("all");
const [activeCat, setActiveCat] = useState(null);
+ const [sidebarOpen, setSidebarOpen] = useState(false);
const [selected, setSelected] = useState(new Set());
const [previewing, setPreviewing] = useState(null);
const [uploadOpen, setUploadOpen] = useState(false);
const [globalDrag, setGlobalDrag] = useState(false);
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 currentUserRef = useRef(null);
// 액센트 컬러 적용
useEffect(() => {
@@ -1058,6 +1276,8 @@ function App() {
document.documentElement.style.setProperty("--radius-lg", `${t.cornerRadius + 4}px`);
}, [t.accent, t.cornerRadius]);
+ useEffect(() => { currentUserRef.current = currentUser; }, [currentUser]);
+
const fetchStats = useCallback(() => {
apiFetch('/api/stats').then(setStats).catch(() => {});
}, []);
@@ -1068,7 +1288,6 @@ function App() {
const p = new URLSearchParams();
if (activeFolder) p.set('folder', activeFolder);
if (activeCat) p.set('category', activeCat);
- if (filters.types.length) p.set('types', filters.types.join(','));
if (filters.starredOnly) p.set('starred', '1');
p.set('sort', sort);
const data = await apiFetch(`/api/files?${p}`);
@@ -1076,21 +1295,51 @@ function App() {
} finally {
setLoading(false);
}
- }, [activeFolder, activeCat, filters, sort]);
+ }, [activeFolder, activeCat, filters.starredOnly, sort]);
useEffect(() => { fetchFiles(); }, [fetchFiles]);
useEffect(() => { fetchStats(); }, [fetchStats]);
useEffect(() => {
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(() => {
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 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("dragleave", onDragLeave);
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 => {
+ if (!currentUser) return;
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 => {
try {
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 ids = [...selected];
+ const onBulkShare = async () => {
+ 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 {
await apiFetch('/api/files', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ids }) });
showToast(`${ids.length}개 파일이 삭제되었습니다`);
- setSelected(new Set());
+ setSelected(prev => { const next = new Set(prev); ids.forEach(id => next.delete(id)); return next; });
fetchFiles();
fetchStats();
- } catch { showToast("삭제 중 오류가 발생했습니다"); }
+ } catch { showToast('삭제 중 오류가 발생했습니다'); }
};
+ const onBulkDelete = () => deleteFiles([...selected]);
+
const onUploaded = newFile => {
setFiles(prev => [newFile, ...prev]);
fetchStats();
@@ -1171,18 +1455,39 @@ function App() {
return (
+ {sidebarOpen &&
setSidebarOpen(false)} />}
{ setActiveFolder(id); setActiveCat(null); }}
+ activeCat={activeCat}
+ setActiveCat={id => { setActiveCat(id); setActiveFolder("all"); }}
+ categories={categories}
+ accent={t.accent} stats={stats}
+ open={sidebarOpen} onClose={() => setSidebarOpen(false)}
/>
-
setUploadOpen(true)} accent={t.accent} categories={categories} />
-
+ setUploadOpen(true)} accent={t.accent} categories={categories}
+ currentUser={currentUser} onLogout={handleLogout}
+ onMenuToggle={() => setSidebarOpen(o => !o)}
+ />
+ { setActiveCat(id); setActiveFolder("all"); }}
+ />
+
-
{activeCat ? categories.find(c=>c.id===activeCat)?.label : (FOLDERS_DEF.find(f=>f.id===activeFolder)?.label || "모든 자료")}
+
+ {activeCat ? categories.find(c=>c.id===activeCat)?.label :
+ (FOLDERS_DEF.find(f=>f.id===activeFolder)?.label || "모든 자료")}
+
{files.length}개 항목
@@ -1195,9 +1500,11 @@ function App() {
필터를 조정하거나 새 파일을 업로드해보세요
) : view === "grid" ? (
-
+
) : (
-
+
)}
@@ -1210,10 +1517,11 @@ function App() {
{selected.size > 0 && (
setSelected(new Set())}
- onDownload={onBulkDownload} onDelete={onBulkDelete} />
+ onDownload={onBulkDownload} onShare={onBulkShare} onDelete={onBulkDelete}
+ isAdmin={currentUser?.isAdmin} />
)}
- {uploadOpen && setUploadOpen(false)} onUploaded={onUploaded} accent={t.accent} categories={categories} users={users} />}
+ {uploadOpen && setUploadOpen(false)} onUploaded={onUploaded} accent={t.accent} categories={categories} users={[]} />}
{previewing && (
0) return;
+ const newIds = ['doc', 'img', 'vid', 'zip', 'code'];
+ 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 = [
- { 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 },
+ { id:'doc', label:'문서', icon:'folder', sort_order:0 },
+ { id:'img', label:'이미지', icon:'image', sort_order:1 },
+ { id:'vid', label:'동영상', icon:'video', sort_order:2 },
+ { id:'zip', label:'압축', icon:'folder', sort_order:3 },
+ { id:'code', label:'코드', icon:'code', sort_order:4 },
];
for (const c of defaults) {
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 }
);
}
+ db.run('UPDATE files SET category = type');
saveDb();
- console.log(`카테고리 시드 ${defaults.length}개 삽입 완료`);
+ console.log('카테고리 마이그레이션 완료: 파일 유형 기반으로 변경');
}
function seedUsers() {
@@ -265,6 +270,10 @@ app.get('/api/stats', (req, res) => {
const categories = {};
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 usedGB = parseFloat((usedBytes / 1073741824).toFixed(1));
const totalGB = 100;
@@ -272,6 +281,7 @@ app.get('/api/stats', (req, res) => {
res.json({
folders: { all: total, starred, recent, shared: 0, trash: 0 },
categories,
+ types,
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)`,
{ ':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',
+ ':author': req.body.author || '나', ':category': req.body.category || type,
':visibility': req.body.visibility || 'team', ':thumb':type, ':file_path':f.filename }
);
saveDb();