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:
@@ -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>
|
||||||
|
|
||||||
|
|||||||
+372
-64
@@ -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 && (
|
||||||
|
<>
|
||||||
|
<span style={{ fontSize:12.5, color:'var(--muted)', padding:'0 2px', display:'flex', alignItems:'center', gap:5 }}>
|
||||||
|
<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 }}>
|
<button className="btn btn-accent" onClick={onUpload} style={{ background:accent, borderColor:accent }}>
|
||||||
<Icon name="upload" size={14} />업로드
|
<Icon name="upload" size={14} />업로드
|
||||||
</button>
|
</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,16 +865,49 @@ 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 }) => {
|
||||||
|
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 (
|
||||||
<div className="list">
|
<div className="list">
|
||||||
<div className="list-head">
|
<div className="list-head" style={{ gridTemplateColumns: cols }}>
|
||||||
<div></div><div>이름</div><div>크기</div><div>수정일</div><div>업로더</div><div>다운로드</div><div></div>
|
{isLoggedIn && <div></div>}
|
||||||
|
<div>이름</div><div>크기</div><div>수정일</div><div>업로더</div><div>다운로드</div><div></div>
|
||||||
</div>
|
</div>
|
||||||
{files.map(f => (
|
{files.map(f => (
|
||||||
<div key={f.id} className={`list-row ${selected.has(f.id) ? "sel" : ""}`} onClick={() => onOpen(f)}>
|
<div key={f.id} className={`list-row ${selected.has(f.id) ? "sel" : ""}`}
|
||||||
<div className={`check ${selected.has(f.id) ? "on" : ""}`} onClick={e => { e.stopPropagation(); toggleSel(f.id); }}>
|
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} />
|
<Icon name="check" size={11} stroke={2.5} />
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
<div className="list-name">
|
<div className="list-name">
|
||||||
<FIcon type={f.type} ext={f.ext} />
|
<FIcon type={f.type} ext={f.ext} />
|
||||||
<div className="ftxt">
|
<div className="ftxt">
|
||||||
@@ -814,15 +920,38 @@ const ListView = ({ files, selected, toggleSel, toggleStar, onOpen, categories }
|
|||||||
<div className="list-meta" style={{ fontFamily:"var(--font-sans)", fontSize:13 }}>{f.author}</div>
|
<div className="list-meta" style={{ fontFamily:"var(--font-sans)", fontSize:13 }}>{f.author}</div>
|
||||||
<div className="list-meta">↓ {f.downloads}</div>
|
<div className="list-meta">↓ {f.downloads}</div>
|
||||||
<div style={{display:"flex",gap:2,justifyContent:"flex-end"}}>
|
<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)" }}>
|
<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} />
|
<Icon name="star" size={14} stroke={f.starred ? 2.5 : 1.75} />
|
||||||
</button>
|
</button>
|
||||||
<button className="iconbtn" onClick={e => e.stopPropagation()}><Icon name="more" size={15} /></button>
|
<button className="iconbtn" onClick={e => openMenu(e, f)}><Icon name="more" size={15} /></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
{menuFile && ReactDOM.createPortal(
|
||||||
|
<div ref={menuRef} className="ctx-menu" style={{ left: menuPos.x, top: menuPos.y }}>
|
||||||
|
<div className="ctx-mi" onClick={() => { onDownload(menuFile); setMenuFile(null); }}>
|
||||||
|
<Icon name="download" size={14} />다운로드
|
||||||
</div>
|
</div>
|
||||||
);
|
<div className="ctx-mi" onClick={() => { onCopyLink(menuFile); setMenuFile(null); }}>
|
||||||
|
<Icon name="link" size={14} />링크 복사
|
||||||
|
</div>
|
||||||
|
<div className="ctx-mi" onClick={() => { toggleStar(menuFile.id); setMenuFile(null); }}>
|
||||||
|
<Icon name="star" size={14} stroke={menuFile.starred ? 2.5 : 1.75}
|
||||||
|
style={{ color: menuFile.starred ? "#e8a317" : "currentColor" }} />
|
||||||
|
{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
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user