feat: 첨부파일 다중 업로드, 관리자 전용 업로드, 파일유형별 다운로드 아이콘
- 업로드시 제목 입력 + 첨부파일 최대 3개 등록 가능 (posts/attachments 구조 도입) - 개별 첨부파일 다운로드 API 및 UI 추가 (일반/관리자 페이지 모두) - 파일 업로드는 관리자만 가능하도록 서버 인증 적용, 일반 페이지 업로드 버튼 제거 - 다운로드 아이콘을 파일 유형(문서/이미지/동영상/압축/코드)별로 색상·아이콘 구분 - 관리자 페이지 강조 버튼 hover 시 배경색 사라지던 CSS 버그 수정 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+184
-6
@@ -53,7 +53,7 @@
|
|||||||
.btn{appearance:none;border:1px solid var(--border-strong);background:var(--surface);color:var(--ink);font-size:13px;font-weight:500;padding:7px 12px;border-radius:7px;cursor:default;display:inline-flex;align-items:center;gap:6px;line-height:1}
|
.btn{appearance:none;border:1px solid var(--border-strong);background:var(--surface);color:var(--ink);font-size:13px;font-weight:500;padding:7px 12px;border-radius:7px;cursor:default;display:inline-flex;align-items:center;gap:6px;line-height:1}
|
||||||
.btn:hover{background:var(--surface-2)}
|
.btn:hover{background:var(--surface-2)}
|
||||||
.btn-accent{background:var(--accent);color:#fff;border-color:var(--accent)}
|
.btn-accent{background:var(--accent);color:#fff;border-color:var(--accent)}
|
||||||
.btn-accent:hover{filter:brightness(1.08)}
|
.btn-accent:hover{background:var(--accent);filter:brightness(1.08)}
|
||||||
.btn-danger{color:var(--warn);border-color:rgba(217,119,87,.3)}
|
.btn-danger{color:var(--warn);border-color:rgba(217,119,87,.3)}
|
||||||
.btn-danger:hover{background:rgba(217,119,87,.06)}
|
.btn-danger:hover{background:rgba(217,119,87,.06)}
|
||||||
.btn-ghost{border-color:transparent;background:transparent;color:var(--ink-2)}
|
.btn-ghost{border-color:transparent;background:transparent;color:var(--ink-2)}
|
||||||
@@ -120,6 +120,27 @@
|
|||||||
.filter-pill.on{background:var(--ink);color:#fff;border-color:var(--ink)}
|
.filter-pill.on{background:var(--ink);color:#fff;border-color:var(--ink)}
|
||||||
.filter-pill:hover:not(.on){background:rgba(20,20,20,.04)}
|
.filter-pill:hover:not(.on){background:rgba(20,20,20,.04)}
|
||||||
|
|
||||||
|
/* UPLOAD */
|
||||||
|
.dropzone{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;padding:32px 20px;border:1.5px dashed var(--border-strong);border-radius:var(--radius-lg);color:var(--muted);cursor:default;transition:border-color .15s,background .15s}
|
||||||
|
.dropzone:hover,.dropzone.on{border-color:var(--accent);background:var(--accent-soft)}
|
||||||
|
.dropzone .dz-t{font-size:13px;color:var(--ink-2)}
|
||||||
|
.dropzone .dz-s{font-size:11.5px;color:var(--faint)}
|
||||||
|
.upload-list{display:flex;flex-direction:column;gap:8px;margin-top:14px;max-height:220px;overflow-y:auto}
|
||||||
|
.upload-row{display:flex;align-items:center;gap:10px;padding:8px 10px;background:var(--surface-2);border-radius:7px}
|
||||||
|
.upload-row-name{flex:1;font-size:12.5px;color:var(--ink-2);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||||
|
.upload-row .progbar{width:80px;height:5px;border-radius:99px;background:var(--border);overflow:hidden;flex-shrink:0}
|
||||||
|
.upload-row .progfill{height:100%;border-radius:99px;transition:width .2s}
|
||||||
|
.upload-row-pct{width:38px;text-align:right;font-size:11px;font-family:var(--font-mono);color:var(--faint);flex-shrink:0}
|
||||||
|
|
||||||
|
/* DOWNLOAD BUTTONS */
|
||||||
|
.dlbtn{width:24px;height:24px;border-radius:6px;color:var(--muted);background:var(--surface-2);border:1px solid transparent;display:inline-flex;align-items:center;justify-content:center;flex-shrink:0}
|
||||||
|
.dlbtn:hover{filter:brightness(0.96)}
|
||||||
|
.dlbtn.dlbtn-doc{background:#fef4e6;color:#b56b18}
|
||||||
|
.dlbtn.dlbtn-img{background:#e8f4ee;color:#1f7a52}
|
||||||
|
.dlbtn.dlbtn-vid{background:#1a1a1a;color:#fff}
|
||||||
|
.dlbtn.dlbtn-zip{background:#f3eed9;color:#7a6418}
|
||||||
|
.dlbtn.dlbtn-code{background:#0f1115;color:#a3b8ff}
|
||||||
|
|
||||||
/* PAGINATION */
|
/* PAGINATION */
|
||||||
.pagination{display:flex;align-items:center;justify-content:center;gap:6px;padding:16px 0 4px}
|
.pagination{display:flex;align-items:center;justify-content:center;gap:6px;padding:16px 0 4px}
|
||||||
.page-btn{appearance:none;border:1px solid var(--border);background:var(--surface);color:var(--ink-2);padding:5px 10px;border-radius:6px;font-size:12px;cursor:default;font-family:var(--font-mono)}
|
.page-btn{appearance:none;border:1px solid var(--border);background:var(--surface);color:var(--ink-2);padding:5px 10px;border-radius:6px;font-size:12px;cursor:default;font-family:var(--font-mono)}
|
||||||
@@ -187,6 +208,8 @@ const Icon = ({ name, size = 16, stroke = 1.75, ...rest }) => {
|
|||||||
settings: <><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></>,
|
settings: <><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></>,
|
||||||
lock: <><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></>,
|
lock: <><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></>,
|
||||||
"log-out": <><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9"/></>,
|
"log-out": <><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9"/></>,
|
||||||
|
upload: <><path d="M12 3v13"/><path d="m7 8 5-5 5 5"/><path d="M5 21h14"/></>,
|
||||||
|
download: <><path d="M12 4v12M6 12l6 6 6-6M4 20h16"/></>,
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={stroke} strokeLinecap="round" strokeLinejoin="round" {...rest}>
|
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={stroke} strokeLinecap="round" strokeLinejoin="round" {...rest}>
|
||||||
@@ -303,6 +326,7 @@ const Modal = ({ title, onClose, onSave, saveLabel = "저장", children, danger
|
|||||||
// ── 카테고리 관리 ──────────────────────────────────────────────
|
// ── 카테고리 관리 ──────────────────────────────────────────────
|
||||||
|
|
||||||
const ICON_OPTIONS = ['folder', 'image', 'code', 'video', 'star', 'file', 'user', 'settings'];
|
const ICON_OPTIONS = ['folder', 'image', 'code', 'video', 'star', 'file', 'user', 'settings'];
|
||||||
|
const TYPE_ICON = { doc: 'file', img: 'image', vid: 'video', zip: 'folder', code: 'code' };
|
||||||
|
|
||||||
const CategorySection = ({ showToast }) => {
|
const CategorySection = ({ showToast }) => {
|
||||||
const [cats, setCats] = useState([]);
|
const [cats, setCats] = useState([]);
|
||||||
@@ -450,6 +474,138 @@ const CategorySection = ({ showToast }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── 파일 업로드 ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const MAX_ATTACHMENTS = 3;
|
||||||
|
|
||||||
|
const UploadModal = ({ onClose, onUploaded, categories, users }) => {
|
||||||
|
const [dragOver, setDragOver] = useState(false);
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [pending, setPending] = useState([]);
|
||||||
|
const [meta, setMeta] = useState({ author: '', category: '', visibility: 'team' });
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const inputRef = useRef(null);
|
||||||
|
|
||||||
|
const addFiles = (fileList) => {
|
||||||
|
const incoming = Array.from(fileList || []);
|
||||||
|
if (!incoming.length) return;
|
||||||
|
setPending(p => {
|
||||||
|
const room = MAX_ATTACHMENTS - p.length;
|
||||||
|
if (room <= 0) { setError(`첨부파일은 최대 ${MAX_ATTACHMENTS}개까지 등록할 수 있습니다`); return p; }
|
||||||
|
if (incoming.length > room) setError(`첨부파일은 최대 ${MAX_ATTACHMENTS}개까지 등록할 수 있습니다`);
|
||||||
|
else setError('');
|
||||||
|
const mapped = incoming.slice(0, room).map(file => ({
|
||||||
|
key: `${file.name}-${file.size}-${Date.now()}-${Math.random()}`,
|
||||||
|
file,
|
||||||
|
}));
|
||||||
|
return [...p, ...mapped];
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeFile = (key) => setPending(p => p.filter(x => x.key !== key));
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
if (!title.trim()) { setError('제목을 입력해주세요'); return; }
|
||||||
|
if (!pending.length) { setError('첨부파일을 1개 이상 등록해주세요'); return; }
|
||||||
|
setSubmitting(true);
|
||||||
|
setError('');
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('title', title.trim());
|
||||||
|
if (meta.author) fd.append('author', meta.author);
|
||||||
|
if (meta.category) fd.append('category', meta.category);
|
||||||
|
fd.append('visibility', meta.visibility);
|
||||||
|
pending.forEach(p => fd.append('files', p.file));
|
||||||
|
try {
|
||||||
|
const saved = await apiFetch('/api/files/upload', { method: 'POST', body: fd });
|
||||||
|
onUploaded(saved);
|
||||||
|
onClose();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message || '업로드 중 오류가 발생했습니다');
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const full = pending.length >= MAX_ATTACHMENTS;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-bg" onClick={submitting ? undefined : onClose}>
|
||||||
|
<div className="modal" onClick={e => e.stopPropagation()}>
|
||||||
|
<div className="modal-h">
|
||||||
|
<h3>파일 업로드</h3>
|
||||||
|
<button className="btn btn-ghost btn-icon" onClick={onClose} disabled={submitting}><Icon name="x" size={15} /></button>
|
||||||
|
</div>
|
||||||
|
<div className="modal-b">
|
||||||
|
<div className="field">
|
||||||
|
<label>제목 <span style={{ color:'var(--warn)' }}>*</span></label>
|
||||||
|
<input value={title} placeholder="제목을 입력하세요" disabled={submitting}
|
||||||
|
onChange={e => { setTitle(e.target.value); setError(''); }} />
|
||||||
|
</div>
|
||||||
|
<div className="field-row">
|
||||||
|
<div className="field">
|
||||||
|
<label>업로더</label>
|
||||||
|
<select value={meta.author} disabled={submitting} onChange={e => setMeta(m => ({ ...m, author: e.target.value }))}>
|
||||||
|
<option value="">자동 (나)</option>
|
||||||
|
{users.map(u => <option key={u.id} value={u.name}>{u.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label>카테고리</label>
|
||||||
|
<select value={meta.category} disabled={submitting} onChange={e => setMeta(m => ({ ...m, category: e.target.value }))}>
|
||||||
|
<option value="">자동 (파일 형식 기준)</option>
|
||||||
|
{categories.map(c => <option key={c.id} value={c.id}>{c.label}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label>공개 범위</label>
|
||||||
|
<select value={meta.visibility} disabled={submitting} onChange={e => setMeta(m => ({ ...m, visibility: e.target.value }))}>
|
||||||
|
<option value="team">팀 공유</option>
|
||||||
|
<option value="public">공개</option>
|
||||||
|
<option value="private">비공개</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className={`dropzone ${dragOver ? "on" : ""}`}
|
||||||
|
onDragOver={e => { e.preventDefault(); if (!full) setDragOver(true); }}
|
||||||
|
onDragLeave={() => setDragOver(false)}
|
||||||
|
onDrop={e => { e.preventDefault(); setDragOver(false); addFiles(e.dataTransfer.files); }}
|
||||||
|
onClick={() => !full && !submitting && inputRef.current?.click()}>
|
||||||
|
<Icon name="upload" size={26} stroke={1.5} style={{ color: dragOver ? 'var(--accent)' : 'var(--muted)' }} />
|
||||||
|
<div className="dz-t">파일을 여기로 드래그하거나 클릭하세요</div>
|
||||||
|
<div className="dz-s">첨부파일 최대 {MAX_ATTACHMENTS}개 ({pending.length}/{MAX_ATTACHMENTS})</div>
|
||||||
|
<input ref={inputRef} type="file" multiple style={{ display: 'none' }}
|
||||||
|
onChange={e => { addFiles(e.target.files); e.target.value = ''; }} />
|
||||||
|
</div>
|
||||||
|
{pending.length > 0 && (
|
||||||
|
<div className="upload-list">
|
||||||
|
{pending.map(p => (
|
||||||
|
<div key={p.key} className="upload-row">
|
||||||
|
<div className="upload-row-name">{p.file.name}</div>
|
||||||
|
<button className="btn btn-ghost btn-icon" style={{ width:22, height:22 }} disabled={submitting}
|
||||||
|
onClick={() => removeFile(p.key)}>
|
||||||
|
<Icon name="x" size={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<div style={{ fontSize:12, color:'var(--warn)', marginTop:10, display:'flex', alignItems:'center', gap:5 }}>
|
||||||
|
<Icon name="x" size={12} />{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="modal-f">
|
||||||
|
<button className="btn btn-ghost" onClick={onClose} disabled={submitting}>취소</button>
|
||||||
|
<button className="btn btn-accent" onClick={submit} disabled={submitting}>
|
||||||
|
{submitting ? '업로드 중...' : '업로드'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// ── 파일 관리 ──────────────────────────────────────────────────
|
// ── 파일 관리 ──────────────────────────────────────────────────
|
||||||
|
|
||||||
const PAGE_SIZE = 20;
|
const PAGE_SIZE = 20;
|
||||||
@@ -465,6 +621,7 @@ const FileSection = ({ showToast }) => {
|
|||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
const [editModal, setEditModal] = useState(null);
|
const [editModal, setEditModal] = useState(null);
|
||||||
const [form, setForm] = useState({});
|
const [form, setForm] = useState({});
|
||||||
|
const [uploadOpen, setUploadOpen] = useState(false);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -524,12 +681,13 @@ const FileSection = ({ showToast }) => {
|
|||||||
<div style={{ fontSize:16, fontWeight:600, marginBottom:2 }}>파일 관리</div>
|
<div style={{ fontSize:16, fontWeight:600, marginBottom:2 }}>파일 관리</div>
|
||||||
<div style={{ fontSize:12, color:'var(--muted)' }}>등록된 파일의 메타데이터를 편집하거나 삭제합니다</div>
|
<div style={{ fontSize:12, color:'var(--muted)' }}>등록된 파일의 메타데이터를 편집하거나 삭제합니다</div>
|
||||||
</div>
|
</div>
|
||||||
|
<button className="btn btn-accent" onClick={() => setUploadOpen(true)}><Icon name="upload" size={13} />파일 업로드</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display:'flex', gap:10, marginBottom:14 }}>
|
<div style={{ display:'flex', gap:10, marginBottom:14 }}>
|
||||||
<div className="search-box">
|
<div className="search-box">
|
||||||
<Icon name="search" size={13} />
|
<Icon name="search" size={13} />
|
||||||
<input placeholder="파일명 검색..." value={search} onChange={e => setSearch(e.target.value)} />
|
<input placeholder="제목 검색..." value={search} onChange={e => setSearch(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
<select
|
<select
|
||||||
style={{ border:'1px solid var(--border-strong)', borderRadius:7, padding:'7px 10px', fontSize:13, background:'var(--surface)', color:'var(--ink)', outline:'none' }}
|
style={{ border:'1px solid var(--border-strong)', borderRadius:7, padding:'7px 10px', fontSize:13, background:'var(--surface)', color:'var(--ink)', outline:'none' }}
|
||||||
@@ -543,7 +701,7 @@ const FileSection = ({ showToast }) => {
|
|||||||
<table className="tbl">
|
<table className="tbl">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>파일명</th><th>카테고리</th><th>업로더</th><th>공개 범위</th><th>크기</th><th>다운로드</th><th></th>
|
<th>제목</th><th>카테고리</th><th>업로더</th><th>공개 범위</th><th>크기</th><th>다운로드</th><th></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -555,13 +713,24 @@ const FileSection = ({ showToast }) => {
|
|||||||
<tr key={f.id}>
|
<tr key={f.id}>
|
||||||
<td style={{ maxWidth:280 }}>
|
<td style={{ maxWidth:280 }}>
|
||||||
<div style={{ fontWeight:500, color:'var(--ink)', overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>{f.name}</div>
|
<div style={{ fontWeight:500, color:'var(--ink)', overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>{f.name}</div>
|
||||||
<div style={{ fontSize:11, color:'var(--faint)', fontFamily:'var(--font-mono)' }}>{f.ext}</div>
|
<div style={{ fontSize:11, color:'var(--faint)', fontFamily:'var(--font-mono)' }}>첨부 {(f.attachments || []).length}개</div>
|
||||||
</td>
|
</td>
|
||||||
<td><span className="badge badge-gray">{cats.find(c=>c.id===f.category)?.label || f.category}</span></td>
|
<td><span className="badge badge-gray">{cats.find(c=>c.id===f.category)?.label || f.category}</span></td>
|
||||||
<td style={{ color:'var(--muted)' }}>{f.author}</td>
|
<td style={{ color:'var(--muted)' }}>{f.author}</td>
|
||||||
<td><span className={`badge ${visClass[f.visibility] || 'badge-gray'}`}>{visMap[f.visibility] || f.visibility}</span></td>
|
<td><span className={`badge ${visClass[f.visibility] || 'badge-gray'}`}>{visMap[f.visibility] || f.visibility}</span></td>
|
||||||
<td style={{ fontFamily:'var(--font-mono)', fontSize:12, color:'var(--muted)' }}>{f.size_label}</td>
|
<td style={{ fontFamily:'var(--font-mono)', fontSize:12, color:'var(--muted)' }}>{f.size_label}</td>
|
||||||
<td style={{ fontFamily:'var(--font-mono)', fontSize:12, color:'var(--faint)' }}>↓ {f.downloads}</td>
|
<td>
|
||||||
|
<div style={{ display:'flex', gap:4 }}>
|
||||||
|
{(f.attachments || []).map(a => (
|
||||||
|
<a key={a.id} href={`/api/attachments/${a.id}/download`} title={`${a.name} 다운로드`}
|
||||||
|
className={`dlbtn dlbtn-${a.type}`}
|
||||||
|
onClick={e => e.stopPropagation()}>
|
||||||
|
<Icon name={TYPE_ICON[a.type] || 'file'} size={12} />
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
{(!f.attachments || f.attachments.length === 0) && <span style={{ fontFamily:'var(--font-mono)', fontSize:12, color:'var(--faint)' }}>—</span>}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div className="actions">
|
<div className="actions">
|
||||||
<button className="btn btn-sm btn-ghost" onClick={() => openEdit(f)}><Icon name="edit" size={13} />편집</button>
|
<button className="btn btn-sm btn-ghost" onClick={() => openEdit(f)}><Icon name="edit" size={13} />편집</button>
|
||||||
@@ -589,7 +758,7 @@ const FileSection = ({ showToast }) => {
|
|||||||
{editModal && (
|
{editModal && (
|
||||||
<Modal title="파일 정보 편집" onClose={() => setEditModal(null)} onSave={saveEdit}>
|
<Modal title="파일 정보 편집" onClose={() => setEditModal(null)} onSave={saveEdit}>
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label>파일명</label>
|
<label>제목</label>
|
||||||
<input value={form.name} onChange={ff('name')} />
|
<input value={form.name} onChange={ff('name')} />
|
||||||
</div>
|
</div>
|
||||||
<div className="field-row">
|
<div className="field-row">
|
||||||
@@ -617,6 +786,15 @@ const FileSection = ({ showToast }) => {
|
|||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{uploadOpen && (
|
||||||
|
<UploadModal
|
||||||
|
onClose={() => { setUploadOpen(false); load(); }}
|
||||||
|
onUploaded={() => load()}
|
||||||
|
categories={cats}
|
||||||
|
users={users}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
+59
-177
@@ -143,7 +143,7 @@
|
|||||||
|
|
||||||
/* LIST */
|
/* LIST */
|
||||||
.list{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-lg);overflow:hidden}
|
.list{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-lg);overflow:hidden}
|
||||||
.list-head,.list-row{display:grid;grid-template-columns:32px 1fr 100px 130px 130px 70px 32px;gap:14px;align-items:center;padding:10px 16px}
|
.list-head,.list-row{display:grid;grid-template-columns:32px 1fr 100px 130px 130px 118px 32px;gap:14px;align-items:center;padding:10px 16px}
|
||||||
.list-head{font-size:11px;font-weight:600;letter-spacing:.05em;text-transform:uppercase;color:var(--faint);border-bottom:1px solid var(--border);background:var(--surface-2)}
|
.list-head{font-size:11px;font-weight:600;letter-spacing:.05em;text-transform:uppercase;color:var(--faint);border-bottom:1px solid var(--border);background:var(--surface-2)}
|
||||||
.list-row{border-bottom:1px solid var(--border);font-size:13.5px;color:var(--ink-2);cursor:default;transition:background .12s ease}
|
.list-row{border-bottom:1px solid var(--border);font-size:13.5px;color:var(--ink-2);cursor:default;transition:background .12s ease}
|
||||||
.list-row:last-child{border-bottom:0}
|
.list-row:last-child{border-bottom:0}
|
||||||
@@ -160,18 +160,20 @@
|
|||||||
.list-name .ftxt .nm{font-weight:500;color:var(--ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
.list-name .ftxt .nm{font-weight:500;color:var(--ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||||
.list-name .ftxt .pth{font-size:11px;color:var(--faint);font-family:var(--font-mono);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
.list-name .ftxt .pth{font-size:11px;color:var(--faint);font-family:var(--font-mono);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||||
.list-meta{font-family:var(--font-mono);font-size:12px;color:var(--muted);font-variant-numeric:tabular-nums}
|
.list-meta{font-family:var(--font-mono);font-size:12px;color:var(--muted);font-variant-numeric:tabular-nums}
|
||||||
|
.list-dl{display:flex;align-items:center;gap:4px}
|
||||||
|
.dlbtn{width:22px;height:22px;border:1px solid var(--border-strong);border-radius:5px;color:var(--muted);background:var(--surface);display:inline-flex;align-items:center;justify-content:center;flex-shrink:0}
|
||||||
|
.dlbtn:hover{filter:brightness(0.96)}
|
||||||
|
.dlbtn.dlbtn-doc{background:#fef4e6;color:#b56b18;border-color:transparent}
|
||||||
|
.dlbtn.dlbtn-img{background:#e8f4ee;color:#1f7a52;border-color:transparent}
|
||||||
|
.dlbtn.dlbtn-vid{background:#1a1a1a;color:#fff;border-color:transparent}
|
||||||
|
.dlbtn.dlbtn-zip{background:#f3eed9;color:#7a6418;border-color:transparent}
|
||||||
|
.dlbtn.dlbtn-code{background:#0f1115;color:#a3b8ff;border-color:transparent}
|
||||||
.check{width:16px;height:16px;border-radius:4px;border:1.5px solid var(--border-strong);display:grid;place-items:center;cursor:default;color:transparent;background:var(--surface);transition:all .12s ease}
|
.check{width:16px;height:16px;border-radius:4px;border:1.5px solid var(--border-strong);display:grid;place-items:center;cursor:default;color:transparent;background:var(--surface);transition:all .12s ease}
|
||||||
.check:hover{border-color:var(--ink-2)}
|
.check:hover{border-color:var(--ink-2)}
|
||||||
.check.on{background:var(--accent);border-color:var(--accent);color:#fff}
|
.check.on{background:var(--accent);border-color:var(--accent);color:#fff}
|
||||||
.iconbtn{appearance:none;border:0;background:transparent;color:var(--faint);width:28px;height:28px;border-radius:5px;cursor:default;display:grid;place-items:center}
|
.iconbtn{appearance:none;border:0;background:transparent;color:var(--faint);width:28px;height:28px;border-radius:5px;cursor:default;display:grid;place-items:center}
|
||||||
.iconbtn:hover{background:rgba(20,20,20,.06);color:var(--ink)}
|
.iconbtn:hover{background:rgba(20,20,20,.06);color:var(--ink)}
|
||||||
|
|
||||||
/* Drop zone */
|
|
||||||
.dropzone{position:absolute;inset:0;background:rgba(59,91,219,.06);backdrop-filter:blur(2px);border:2px dashed var(--accent);border-radius:18px;margin:12px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:14px;pointer-events:none;z-index:200;opacity:0;transition:opacity .15s ease}
|
|
||||||
.dropzone.on{opacity:1}
|
|
||||||
.dropzone .t{font-size:18px;font-weight:600;color:var(--accent)}
|
|
||||||
.dropzone .s{font-size:13px;color:var(--ink-2);font-family:var(--font-mono)}
|
|
||||||
|
|
||||||
/* Selection bar */
|
/* Selection bar */
|
||||||
.selbar{position:fixed;left:50%;bottom:24px;transform:translateX(-50%);background:var(--ink);color:#fff;padding:8px 8px 8px 18px;border-radius:99px;display:flex;align-items:center;gap:14px;box-shadow:var(--shadow-lg);z-index:100;font-size:13.5px}
|
.selbar{position:fixed;left:50%;bottom:24px;transform:translateX(-50%);background:var(--ink);color:#fff;padding:8px 8px 8px 18px;border-radius:99px;display:flex;align-items:center;gap:14px;box-shadow:var(--shadow-lg);z-index:100;font-size:13.5px}
|
||||||
.selbar .selcount{font-weight:500}
|
.selbar .selcount{font-weight:500}
|
||||||
@@ -188,19 +190,6 @@
|
|||||||
.modal-h{display:flex;align-items:center;justify-content:space-between;padding:16px 20px;border-bottom:1px solid var(--border)}
|
.modal-h{display:flex;align-items:center;justify-content:space-between;padding:16px 20px;border-bottom:1px solid var(--border)}
|
||||||
.modal-h h3{margin:0;font-size:15px;font-weight:600}
|
.modal-h h3{margin:0;font-size:15px;font-weight:600}
|
||||||
.modal-b{padding:20px;overflow-y:auto}
|
.modal-b{padding:20px;overflow-y:auto}
|
||||||
.updz{border:2px dashed var(--border-strong);border-radius:12px;padding:36px 20px;text-align:center;color:var(--muted);transition:all .15s ease;background:var(--surface-2)}
|
|
||||||
.updz.on{border-color:var(--accent);background:var(--accent-soft);color:var(--accent)}
|
|
||||||
.updz .t{font-size:15px;font-weight:600;color:var(--ink);margin-bottom:4px}
|
|
||||||
.updz .s{font-size:12.5px;color:var(--muted);font-family:var(--font-mono)}
|
|
||||||
.updz button{margin-top:14px}
|
|
||||||
.uplist{margin-top:14px;display:flex;flex-direction:column;gap:8px}
|
|
||||||
.uprow{display:flex;align-items:center;gap:12px;padding:10px 12px;background:var(--surface-2);border-radius:8px}
|
|
||||||
.uprow .ficon{width:32px;height:32px;border-radius:6px;background:var(--surface);display:grid;place-items:center;font-family:var(--font-mono);font-size:9px;font-weight:600;color:var(--muted);flex-shrink:0}
|
|
||||||
.uprow .info{flex:1;min-width:0}
|
|
||||||
.uprow .nm{font-size:13px;font-weight:500;color:var(--ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
||||||
.uprow .progbar{height:3px;background:rgba(20,20,20,.08);border-radius:99px;margin-top:5px;overflow:hidden}
|
|
||||||
.uprow .progfill{height:100%;background:var(--accent);transition:width .3s ease}
|
|
||||||
.uprow .pct{font-family:var(--font-mono);font-size:11px;color:var(--muted);min-width:36px;text-align:right}
|
|
||||||
|
|
||||||
/* Preview modal */
|
/* Preview modal */
|
||||||
.preview-modal{width:min(960px,94vw);max-height:90vh}
|
.preview-modal{width:min(960px,94vw);max-height:90vh}
|
||||||
@@ -217,6 +206,8 @@
|
|||||||
.preview-info .infogrp{display:flex;flex-direction:column;gap:7px}
|
.preview-info .infogrp{display:flex;flex-direction:column;gap:7px}
|
||||||
.preview-info .infogrp .lbl{font-size:11px;font-weight:600;letter-spacing:.05em;text-transform:uppercase;color:var(--faint)}
|
.preview-info .infogrp .lbl{font-size:11px;font-weight:600;letter-spacing:.05em;text-transform:uppercase;color:var(--faint)}
|
||||||
.preview-info .row{display:flex;justify-content:space-between;font-size:13px;color:var(--ink-2);font-family:var(--font-mono)}
|
.preview-info .row{display:flex;justify-content:space-between;font-size:13px;color:var(--ink-2);font-family:var(--font-mono)}
|
||||||
|
.preview-info .infogrp .row.att{padding:6px 8px;margin:0 -8px;border-radius:6px;gap:10px}
|
||||||
|
.preview-info .infogrp .row.att:hover{background:var(--surface-2);color:var(--accent)}
|
||||||
.preview-info .row span:last-child{color:var(--ink);font-variant-numeric:tabular-nums}
|
.preview-info .row span:last-child{color:var(--ink);font-variant-numeric:tabular-nums}
|
||||||
.preview-info .actions{display:flex;flex-direction:column;gap:6px;margin-top:auto}
|
.preview-info .actions{display:flex;flex-direction:column;gap:6px;margin-top:auto}
|
||||||
|
|
||||||
@@ -651,6 +642,8 @@ const FIcon = ({ type, ext, size = 28 }) => (
|
|||||||
<div className={`ficon ${type}`} style={{ width: size, height: size, borderRadius: size * 0.18 }}>{ext}</div>
|
<div className={`ficon ${type}`} style={{ width: size, height: size, borderRadius: size * 0.18 }}>{ext}</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const TYPE_ICON = { doc: 'file', img: 'image', vid: 'video', zip: 'folder', code: 'code' };
|
||||||
|
|
||||||
const Thumb = ({ file }) => {
|
const Thumb = ({ file }) => {
|
||||||
const k = file.thumb;
|
const k = file.thumb;
|
||||||
if (k === "doc") return <div className="thumb tn-doc"><span className="ext">{file.ext}</span><div className="doc-page"><i/><i/><i/><i/><i/><i/></div></div>;
|
if (k === "doc") return <div className="thumb tn-doc"><span className="ext">{file.ext}</span><div className="doc-page"><i/><i/><i/><i/><i/><i/></div></div>;
|
||||||
@@ -759,7 +752,7 @@ const Sidebar = ({ activeFolder, setActiveFolder, activeCat, setActiveCat, categ
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const Header = ({ folder, category, onUpload, accent, categories, currentUser, onLogout, onMenuToggle }) => {
|
const Header = ({ folder, category, 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 (
|
||||||
@@ -785,9 +778,6 @@ const Header = ({ folder, category, onUpload, accent, categories, currentUser, o
|
|||||||
관리자
|
관리자
|
||||||
</span>
|
</span>
|
||||||
<button className="btn btn-ghost" style={{ fontSize:12.5 }} onClick={onLogout}>로그아웃</button>
|
<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>
|
||||||
@@ -878,7 +868,7 @@ const GridView = ({ files, selected, toggleSel, toggleStar, onOpen, isLoggedIn }
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const ListView = ({ files, selected, toggleSel, toggleStar, onOpen, categories, isLoggedIn, currentUser, onDownload, onCopyLink, onDelete }) => {
|
const ListView = ({ files, selected, toggleSel, toggleStar, onOpen, categories, isLoggedIn, currentUser, onDownload, onDownloadAttachment, onCopyLink, onDelete }) => {
|
||||||
const [menuFile, setMenuFile] = useState(null);
|
const [menuFile, setMenuFile] = useState(null);
|
||||||
const [menuPos, setMenuPos] = useState({ x: 0, y: 0 });
|
const [menuPos, setMenuPos] = useState({ x: 0, y: 0 });
|
||||||
const menuRef = useRef(null);
|
const menuRef = useRef(null);
|
||||||
@@ -902,8 +892,8 @@ const ListView = ({ files, selected, toggleSel, toggleStar, onOpen, categories,
|
|||||||
};
|
};
|
||||||
|
|
||||||
const cols = isLoggedIn
|
const cols = isLoggedIn
|
||||||
? "32px 1fr 100px 130px 130px 70px 52px"
|
? "32px 1fr 100px 130px 130px 118px 52px"
|
||||||
: "1fr 100px 130px 130px 70px 52px";
|
: "1fr 100px 130px 130px 118px 52px";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="list">
|
<div className="list">
|
||||||
@@ -931,7 +921,15 @@ const ListView = ({ files, selected, toggleSel, toggleStar, onOpen, categories,
|
|||||||
<div className="list-meta">{f.size_label}</div>
|
<div className="list-meta">{f.size_label}</div>
|
||||||
<div className="list-meta">{f.modified_at}</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" style={{ fontFamily:"var(--font-sans)", fontSize:13 }}>{f.author}</div>
|
||||||
<div className="list-meta">↓ {f.downloads}</div>
|
<div className="list-dl" onClick={e => e.stopPropagation()}>
|
||||||
|
{(f.attachments || []).map(a => (
|
||||||
|
<button key={a.id} className={`dlbtn dlbtn-${a.type}`} title={`${a.name} 다운로드`}
|
||||||
|
onClick={() => onDownloadAttachment(a)}>
|
||||||
|
<Icon name={TYPE_ICON[a.type] || 'file'} size={11} />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{(!f.attachments || f.attachments.length === 0) && <span className="pth">↓ {f.downloads}</span>}
|
||||||
|
</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); }}
|
<button className="iconbtn" onClick={e => { e.stopPropagation(); toggleStar(f.id); }}
|
||||||
style={{ color: f.starred ? "#e8a317" : "var(--faint)" }}>
|
style={{ color: f.starred ? "#e8a317" : "var(--faint)" }}>
|
||||||
@@ -966,109 +964,7 @@ const ListView = ({ files, selected, toggleSel, toggleStar, onOpen, categories,
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const UploadModal = ({ onClose, onUploaded, accent, categories, users }) => {
|
const PreviewModal = ({ file, onClose, onDownload, onDownloadAttachment, onStar, onCopyLink, accent, categories }) => {
|
||||||
const [dragOver, setDragOver] = useState(false);
|
|
||||||
const [uploads, setUploads] = useState([]);
|
|
||||||
const [meta, setMeta] = useState({ author: '', category: '', visibility: 'team' });
|
|
||||||
const inputRef = useRef(null);
|
|
||||||
|
|
||||||
const uploadFile = async file => {
|
|
||||||
const id = `up-${Date.now()}-${Math.random()}`;
|
|
||||||
const ext = (file.name.split('.').pop() || 'FILE').toUpperCase();
|
|
||||||
const type = file.type.startsWith('image/') ? 'img' : file.type.startsWith('video/') ? 'vid' : 'doc';
|
|
||||||
setUploads(u => [...u, { id, name: file.name, ext, type, progress: 10 }]);
|
|
||||||
const fd = new FormData();
|
|
||||||
fd.append('file', file);
|
|
||||||
if (meta.author) fd.append('author', meta.author);
|
|
||||||
if (meta.category) fd.append('category', meta.category);
|
|
||||||
fd.append('visibility', meta.visibility);
|
|
||||||
try {
|
|
||||||
setUploads(u => u.map(x => x.id === id ? { ...x, progress: 50 } : x));
|
|
||||||
const saved = await apiFetch('/api/files/upload', { method: 'POST', body: fd });
|
|
||||||
setUploads(u => u.map(x => x.id === id ? { ...x, progress: 100 } : x));
|
|
||||||
onUploaded(saved);
|
|
||||||
} catch {
|
|
||||||
setUploads(u => u.map(x => x.id === id ? { ...x, progress: -1 } : x));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const addFiles = fileList => Array.from(fileList || []).forEach(uploadFile);
|
|
||||||
const sel = { border:"1px solid var(--border-strong)", borderRadius:6, padding:"6px 8px", fontSize:13, background:"var(--surface)", color:"var(--ink)", outline:"none", width:"100%" };
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="modal-bg" onClick={onClose}>
|
|
||||||
<div className="modal" 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:"grid", gridTemplateColumns:"1fr 1fr 1fr", gap:10, marginBottom:12 }}>
|
|
||||||
<div>
|
|
||||||
<div style={{ fontSize:11, fontWeight:600, color:"var(--muted)", marginBottom:4 }}>업로더</div>
|
|
||||||
<select style={sel} value={meta.author} onChange={e => setMeta(m => ({...m, author: e.target.value}))}>
|
|
||||||
<option value="">자동</option>
|
|
||||||
{users.map(u => <option key={u.id} value={u.name}>{u.name}</option>)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div style={{ fontSize:11, fontWeight:600, color:"var(--muted)", marginBottom:4 }}>카테고리</div>
|
|
||||||
<select style={sel} value={meta.category} onChange={e => setMeta(m => ({...m, category: e.target.value}))}>
|
|
||||||
<option value="">자동</option>
|
|
||||||
{categories.map(c => <option key={c.id} value={c.id}>{c.label}</option>)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div style={{ fontSize:11, fontWeight:600, color:"var(--muted)", marginBottom:4 }}>공개 범위</div>
|
|
||||||
<select style={sel} value={meta.visibility} onChange={e => setMeta(m => ({...m, visibility: e.target.value}))}>
|
|
||||||
<option value="team">팀 공유</option>
|
|
||||||
<option value="public">공개</option>
|
|
||||||
<option value="private">비공개</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className={`updz ${dragOver ? "on" : ""}`}
|
|
||||||
onDragOver={e => { e.preventDefault(); setDragOver(true); }}
|
|
||||||
onDragLeave={() => setDragOver(false)}
|
|
||||||
onDrop={e => { e.preventDefault(); setDragOver(false); addFiles(e.dataTransfer.files); }}>
|
|
||||||
<Icon name="cloud" size={38} stroke={1.5} style={{color: dragOver ? accent : "var(--muted)"}}/>
|
|
||||||
<div className="t">파일을 여기로 드래그하세요</div>
|
|
||||||
<div className="s">또는 클릭하여 선택 · 최대 2GB · PDF, DOC, PPT, 이미지, 영상, ZIP</div>
|
|
||||||
<input ref={inputRef} type="file" multiple style={{display:"none"}} onChange={e => addFiles(e.target.files)} />
|
|
||||||
<button className="btn btn-primary" onClick={() => inputRef.current?.click()}>
|
|
||||||
<Icon name="upload" size={13} />파일 선택
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{uploads.length > 0 && (
|
|
||||||
<div className="uplist">
|
|
||||||
{uploads.map(u => (
|
|
||||||
<div key={u.id} className="uprow">
|
|
||||||
<FIcon type={u.type} ext={u.ext} size={32} />
|
|
||||||
<div className="info">
|
|
||||||
<div className="nm">{u.name}</div>
|
|
||||||
<div className="progbar">
|
|
||||||
<div className="progfill" style={{ width:`${Math.max(0, u.progress)}%`, background: u.progress < 0 ? 'var(--warn)' : accent }}></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="pct">{u.progress < 0 ? '오류' : u.progress >= 100 ? '✓' : `${Math.round(u.progress)}%`}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{uploads.length > 0 && uploads.every(u => u.progress === 100 || u.progress < 0) && (
|
|
||||||
<div style={{ display:'flex', justifyContent:'flex-end', marginTop:16 }}>
|
|
||||||
<button className="btn btn-accent" onClick={onClose} style={{ background:accent, borderColor:accent }}>
|
|
||||||
<Icon name="check" size={14} />확인
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const PreviewModal = ({ file, onClose, onDownload, onStar, onCopyLink, accent, categories }) => {
|
|
||||||
if (!file) return null;
|
if (!file) return null;
|
||||||
return (
|
return (
|
||||||
<div className="modal-bg" onClick={onClose}>
|
<div className="modal-bg" onClick={onClose}>
|
||||||
@@ -1122,6 +1018,22 @@ const PreviewModal = ({ file, onClose, onDownload, onStar, onCopyLink, accent, c
|
|||||||
<div className="row"><span>수정</span><span>{file.modified_at}</span></div>
|
<div className="row"><span>수정</span><span>{file.modified_at}</span></div>
|
||||||
<div className="row"><span>분류</span><span>{categories.find(c=>c.id===file.category)?.label || "—"}</span></div>
|
<div className="row"><span>분류</span><span>{categories.find(c=>c.id===file.category)?.label || "—"}</span></div>
|
||||||
</div>
|
</div>
|
||||||
|
{file.attachments && file.attachments.length > 0 && (
|
||||||
|
<div className="infogrp">
|
||||||
|
<span className="lbl">첨부파일 ({file.attachments.length})</span>
|
||||||
|
{file.attachments.map(a => (
|
||||||
|
<div key={a.id} className="row att" onClick={() => onDownloadAttachment(a)}>
|
||||||
|
<span style={{ display:"flex", alignItems:"center", gap:8, minWidth:0 }}>
|
||||||
|
<span className={`dlbtn dlbtn-${a.type}`} style={{ flexShrink:0 }}><Icon name={TYPE_ICON[a.type] || 'file'} size={11} /></span>
|
||||||
|
<span style={{ overflow:"hidden", textOverflow:"ellipsis", whiteSpace:"nowrap" }}>{a.name}</span>
|
||||||
|
</span>
|
||||||
|
<span style={{ display:"flex", alignItems:"center", gap:6, flexShrink:0 }}>
|
||||||
|
{a.size_label}<Icon name="download" size={12} />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="actions">
|
<div className="actions">
|
||||||
<button className="btn btn-accent" onClick={() => onDownload(file)} style={{ background:accent, borderColor:accent, justifyContent:"center" }}>
|
<button className="btn btn-accent" onClick={() => onDownload(file)} style={{ background:accent, borderColor:accent, justifyContent:"center" }}>
|
||||||
<Icon name="download" size={14} />다운로드
|
<Icon name="download" size={14} />다운로드
|
||||||
@@ -1269,16 +1181,11 @@ function App() {
|
|||||||
|
|
||||||
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 [globalDrag, setGlobalDrag] = useState(false);
|
|
||||||
const [toast, setToast] = useState({ on: false, msg: "" });
|
const [toast, setToast] = useState({ on: false, msg: "" });
|
||||||
const [currentUser, setCurrentUser] = useState(() => {
|
const [currentUser, setCurrentUser] = useState(() => {
|
||||||
try { return JSON.parse(sessionStorage.getItem('jaryo_user') || 'null'); } catch { return null; }
|
try { return JSON.parse(sessionStorage.getItem('jaryo_user') || 'null'); } catch { return null; }
|
||||||
});
|
});
|
||||||
|
|
||||||
const dragCounter = useRef(0);
|
|
||||||
const currentUserRef = useRef(null);
|
|
||||||
|
|
||||||
// 액센트 컬러 적용
|
// 액센트 컬러 적용
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.documentElement.style.setProperty("--accent", t.accent);
|
document.documentElement.style.setProperty("--accent", t.accent);
|
||||||
@@ -1289,8 +1196,6 @@ 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(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
@@ -1343,28 +1248,6 @@ function App() {
|
|||||||
|
|
||||||
const showToast = msg => { setToast({ on: true, msg }); setTimeout(() => setToast(s => ({...s, on: false})), 2200); };
|
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);
|
|
||||||
if (currentUserRef.current) setUploadOpen(true);
|
|
||||||
else setToast({ on: true, msg: '업로드는 관리자만 가능합니다' });
|
|
||||||
};
|
|
||||||
window.addEventListener("dragenter", onDragEnter);
|
|
||||||
window.addEventListener("dragleave", onDragLeave);
|
|
||||||
window.addEventListener("dragover", onDragOver);
|
|
||||||
window.addEventListener("drop", onDrop);
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener("dragenter", onDragEnter);
|
|
||||||
window.removeEventListener("dragleave", onDragLeave);
|
|
||||||
window.removeEventListener("dragover", onDragOver);
|
|
||||||
window.removeEventListener("drop", onDrop);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const toggleSel = id => {
|
const toggleSel = id => {
|
||||||
if (!currentUser) return;
|
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; });
|
||||||
@@ -1409,6 +1292,18 @@ function App() {
|
|||||||
} catch { showToast("오류가 발생했습니다"); }
|
} catch { showToast("오류가 발생했습니다"); }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onDownloadAttachment = a => {
|
||||||
|
const el = document.createElement('a');
|
||||||
|
el.href = `/api/attachments/${a.id}/download`;
|
||||||
|
el.style.display = 'none';
|
||||||
|
document.body.appendChild(el);
|
||||||
|
el.click();
|
||||||
|
document.body.removeChild(el);
|
||||||
|
setFiles(prev => prev.map(x => x.id === a.file_id ? { ...x, downloads: x.downloads + 1 } : x));
|
||||||
|
if (previewing?.id === a.file_id) setPreviewing(p => ({ ...p, downloads: (p.downloads || 0) + 1 }));
|
||||||
|
showToast(`${a.name} 다운로드를 시작합니다`);
|
||||||
|
};
|
||||||
|
|
||||||
const onCopyLink = async f => {
|
const onCopyLink = async f => {
|
||||||
const url = `${window.location.origin}/api/files/${f.id}/download`;
|
const url = `${window.location.origin}/api/files/${f.id}/download`;
|
||||||
try {
|
try {
|
||||||
@@ -1460,12 +1355,6 @@ function App() {
|
|||||||
|
|
||||||
const onBulkDelete = () => deleteFiles([...selected]);
|
const onBulkDelete = () => deleteFiles([...selected]);
|
||||||
|
|
||||||
const onUploaded = newFile => {
|
|
||||||
setFiles(prev => [newFile, ...prev]);
|
|
||||||
fetchStats();
|
|
||||||
showToast(`${newFile.name} 업로드 완료`);
|
|
||||||
};
|
|
||||||
|
|
||||||
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)} />}
|
{sidebarOpen && <div className="sb-overlay on" onClick={() => setSidebarOpen(false)} />}
|
||||||
@@ -1481,7 +1370,7 @@ function App() {
|
|||||||
<div className="main">
|
<div className="main">
|
||||||
<Header
|
<Header
|
||||||
folder={activeFolder} category={activeCat}
|
folder={activeFolder} category={activeCat}
|
||||||
onUpload={() => setUploadOpen(true)} accent={t.accent} categories={categories}
|
categories={categories}
|
||||||
currentUser={currentUser} onLogout={handleLogout}
|
currentUser={currentUser} onLogout={handleLogout}
|
||||||
onMenuToggle={() => setSidebarOpen(o => !o)}
|
onMenuToggle={() => setSidebarOpen(o => !o)}
|
||||||
/>
|
/>
|
||||||
@@ -1510,21 +1399,15 @@ function App() {
|
|||||||
<div className="empty">
|
<div className="empty">
|
||||||
<Icon name="folder-open" size={42} stroke={1.4} />
|
<Icon name="folder-open" size={42} stroke={1.4} />
|
||||||
<div className="t">표시할 자료가 없습니다</div>
|
<div className="t">표시할 자료가 없습니다</div>
|
||||||
<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} isLoggedIn={!!currentUser} />
|
<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}
|
isLoggedIn={!!currentUser} currentUser={currentUser}
|
||||||
onDownload={onDownload} onCopyLink={onCopyLink} onDelete={deleteFiles} />
|
onDownload={onDownload} onDownloadAttachment={onDownloadAttachment} onCopyLink={onCopyLink} onDelete={deleteFiles} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={`dropzone ${globalDrag ? "on" : ""}`}>
|
|
||||||
<Icon name="cloud" size={48} stroke={1.4} />
|
|
||||||
<div className="t">파일을 드롭하여 업로드</div>
|
|
||||||
<div className="s">최대 2GB · 다중 파일 지원</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1534,13 +1417,12 @@ function App() {
|
|||||||
isAdmin={currentUser?.isAdmin} />
|
isAdmin={currentUser?.isAdmin} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{uploadOpen && <UploadModal onClose={() => setUploadOpen(false)} onUploaded={onUploaded} accent={t.accent} categories={categories} users={[]} />}
|
|
||||||
|
|
||||||
{previewing && (
|
{previewing && (
|
||||||
<PreviewModal
|
<PreviewModal
|
||||||
file={previewing}
|
file={previewing}
|
||||||
onClose={() => setPreviewing(null)}
|
onClose={() => setPreviewing(null)}
|
||||||
onDownload={f => { onDownload(f); setPreviewing(null); }}
|
onDownload={f => { onDownload(f); setPreviewing(null); }}
|
||||||
|
onDownloadAttachment={onDownloadAttachment}
|
||||||
onStar={id => toggleStar(id)}
|
onStar={id => toggleStar(id)}
|
||||||
onCopyLink={onCopyLink}
|
onCopyLink={onCopyLink}
|
||||||
accent={t.accent}
|
accent={t.accent}
|
||||||
|
|||||||
@@ -88,48 +88,26 @@ async function initDb() {
|
|||||||
created_at TEXT DEFAULT (datetime('now'))
|
created_at TEXT DEFAULT (datetime('now'))
|
||||||
);
|
);
|
||||||
`);
|
`);
|
||||||
saveDb();
|
db.run(`
|
||||||
seedData();
|
CREATE TABLE IF NOT EXISTS attachments (
|
||||||
seedCategories();
|
id TEXT PRIMARY KEY,
|
||||||
seedUsers();
|
file_id TEXT NOT NULL,
|
||||||
}
|
name TEXT NOT NULL,
|
||||||
|
type TEXT NOT NULL,
|
||||||
// ── 시드 데이터 ───────────────────────────────────────────────
|
ext TEXT NOT NULL,
|
||||||
const SEED = [
|
size_bytes INTEGER DEFAULT 0,
|
||||||
{ 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" },
|
size_label TEXT NOT NULL,
|
||||||
{ 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" },
|
file_path TEXT NOT NULL,
|
||||||
{ 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" },
|
downloads INTEGER DEFAULT 0,
|
||||||
{ 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" },
|
sort_order INTEGER DEFAULT 0,
|
||||||
{ 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" },
|
created_at TEXT DEFAULT (datetime('now'))
|
||||||
{ 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:"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:"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:"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:"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:"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:"zip", visibility:"team", downloads:67, starred:0, thumb:"zip" },
|
|
||||||
];
|
|
||||||
|
|
||||||
function seedData() {
|
|
||||||
const count = queryGet('SELECT COUNT(*) as c FROM files');
|
|
||||||
if (count && count.c > 0) return;
|
|
||||||
for (const f of SEED) {
|
|
||||||
db.run(
|
|
||||||
`INSERT OR IGNORE INTO files (id,name,type,ext,size_bytes,size_label,modified_at,author,category,visibility,downloads,starred,thumb)
|
|
||||||
VALUES (:id,:name,:type,:ext,:size_bytes,:size_label,:modified_at,:author,:category,:visibility,:downloads,:starred,:thumb)`,
|
|
||||||
{ ':id':f.id, ':name':f.name, ':type':f.type, ':ext':f.ext, ':size_bytes':f.size_bytes,
|
|
||||||
':size_label':f.size_label, ':modified_at':f.modified_at, ':author':f.author,
|
|
||||||
':category':f.category, ':visibility':f.visibility, ':downloads':f.downloads,
|
|
||||||
':starred':f.starred, ':thumb':f.thumb }
|
|
||||||
);
|
);
|
||||||
}
|
`);
|
||||||
saveDb();
|
saveDb();
|
||||||
console.log(`시드 데이터 ${SEED.length}개 삽입 완료`);
|
seedCategories();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 카테고리·사용자 시드 ──────────────────────────────────────
|
// ── 카테고리 시드 ─────────────────────────────────────────────
|
||||||
function seedCategories() {
|
function seedCategories() {
|
||||||
const newIds = ['doc', 'img', 'vid', 'zip', 'code'];
|
const newIds = ['doc', 'img', 'vid', 'zip', 'code'];
|
||||||
const existing = queryAll('SELECT id FROM categories');
|
const existing = queryAll('SELECT id FROM categories');
|
||||||
@@ -156,20 +134,6 @@ function seedCategories() {
|
|||||||
console.log('카테고리 마이그레이션 완료: 파일 유형 기반으로 변경');
|
console.log('카테고리 마이그레이션 완료: 파일 유형 기반으로 변경');
|
||||||
}
|
}
|
||||||
|
|
||||||
function seedUsers() {
|
|
||||||
const count = queryGet('SELECT COUNT(*) as c FROM users');
|
|
||||||
if (count && count.c > 0) return;
|
|
||||||
const names = [...new Set(SEED.map(f => f.author))];
|
|
||||||
names.forEach((name, i) => {
|
|
||||||
db.run(
|
|
||||||
`INSERT OR IGNORE INTO users (id,name,role) VALUES (:id,:name,'member')`,
|
|
||||||
{ ':id': `u${i + 1}`, ':name': name }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
saveDb();
|
|
||||||
console.log(`사용자 시드 ${names.length}명 삽입 완료`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 헬퍼 ─────────────────────────────────────────────────────
|
// ── 헬퍼 ─────────────────────────────────────────────────────
|
||||||
function guessType(mimetype, ext) {
|
function guessType(mimetype, ext) {
|
||||||
if (!mimetype) return 'doc';
|
if (!mimetype) return 'doc';
|
||||||
@@ -190,7 +154,8 @@ function formatSize(bytes) {
|
|||||||
|
|
||||||
function rowToFile(row) {
|
function rowToFile(row) {
|
||||||
if (!row) return null;
|
if (!row) return null;
|
||||||
return { ...row, starred: row.starred === 1 };
|
const attachments = queryAll('SELECT * FROM attachments WHERE file_id = :id ORDER BY sort_order ASC', { ':id': row.id });
|
||||||
|
return { ...row, starred: row.starred === 1, attachments };
|
||||||
}
|
}
|
||||||
|
|
||||||
function authMiddleware(req, res, next) {
|
function authMiddleware(req, res, next) {
|
||||||
@@ -287,24 +252,45 @@ app.get('/api/stats', (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ── API: 업로드 ───────────────────────────────────────────────
|
// ── API: 업로드 ───────────────────────────────────────────────
|
||||||
app.post('/api/files/upload', upload.single('file'), (req, res) => {
|
app.post('/api/files/upload', authMiddleware, upload.array('files', 3), (req, res) => {
|
||||||
const f = req.file;
|
const uploadedFiles = req.files;
|
||||||
if (!f) return res.status(400).json({ error: 'no file' });
|
if (!uploadedFiles || !uploadedFiles.length) return res.status(400).json({ error: '첨부파일이 필요합니다 (최대 3개)' });
|
||||||
|
|
||||||
|
const title = (req.body.title || '').trim();
|
||||||
|
if (!title) return res.status(400).json({ error: '제목을 입력해주세요' });
|
||||||
|
|
||||||
|
const attachmentRows = uploadedFiles.map((f, i) => {
|
||||||
|
const name = Buffer.from(f.originalname, 'latin1').toString('utf8');
|
||||||
|
const ext = path.extname(name);
|
||||||
|
return {
|
||||||
|
id: randomUUID(), name, type: guessType(f.mimetype, ext),
|
||||||
|
ext: ext.replace('.', '').toUpperCase() || 'FILE',
|
||||||
|
size_bytes: f.size, size_label: formatSize(f.size),
|
||||||
|
file_path: f.filename, sort_order: i,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
const name = Buffer.from(f.originalname, 'latin1').toString('utf8');
|
|
||||||
const ext = path.extname(name);
|
|
||||||
const type = guessType(f.mimetype, ext);
|
|
||||||
const extLabel = ext.replace('.', '').toUpperCase() || 'FILE';
|
|
||||||
const id = randomUUID();
|
const id = randomUUID();
|
||||||
|
const primary = attachmentRows[0];
|
||||||
|
const totalBytes = attachmentRows.reduce((s, a) => s + a.size_bytes, 0);
|
||||||
|
|
||||||
db.run(
|
db.run(
|
||||||
`INSERT INTO files (id,name,type,ext,size_bytes,size_label,modified_at,author,category,visibility,downloads,starred,thumb,file_path)
|
`INSERT INTO files (id,name,type,ext,size_bytes,size_label,modified_at,author,category,visibility,downloads,starred,thumb,file_path)
|
||||||
VALUES (:id,:name,:type,:ext,:size_bytes,:size_label,:modified_at,:author,:category,:visibility,0,0,:thumb,:file_path)`,
|
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':title, ':type':primary.type, ':ext':primary.ext,
|
||||||
':size_bytes':f.size, ':size_label':formatSize(f.size), ':modified_at':'방금',
|
':size_bytes':totalBytes, ':size_label':formatSize(totalBytes), ':modified_at':'방금',
|
||||||
':author': req.body.author || '나', ':category': req.body.category || type,
|
':author': req.body.author || '나', ':category': req.body.category || primary.type,
|
||||||
':visibility': req.body.visibility || 'team', ':thumb':type, ':file_path':f.filename }
|
':visibility': req.body.visibility || 'team', ':thumb':primary.type, ':file_path':primary.file_path }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
for (const a of attachmentRows) {
|
||||||
|
db.run(
|
||||||
|
`INSERT INTO attachments (id,file_id,name,type,ext,size_bytes,size_label,file_path,downloads,sort_order)
|
||||||
|
VALUES (:id,:file_id,:name,:type,:ext,:size_bytes,:size_label,:file_path,0,:sort_order)`,
|
||||||
|
{ ':id':a.id, ':file_id':id, ':name':a.name, ':type':a.type, ':ext':a.ext,
|
||||||
|
':size_bytes':a.size_bytes, ':size_label':a.size_label, ':file_path':a.file_path, ':sort_order':a.sort_order }
|
||||||
|
);
|
||||||
|
}
|
||||||
saveDb();
|
saveDb();
|
||||||
|
|
||||||
const row = queryGet('SELECT * FROM files WHERE id = :id', { ':id': id });
|
const row = queryGet('SELECT * FROM files WHERE id = :id', { ':id': id });
|
||||||
@@ -335,7 +321,20 @@ app.get('/api/files/:id/download', (req, res) => {
|
|||||||
if (!row.file_path) return res.status(404).json({ error: 'no file' });
|
if (!row.file_path) return res.status(404).json({ error: 'no file' });
|
||||||
const fp = path.join(UPLOADS_DIR, row.file_path);
|
const fp = path.join(UPLOADS_DIR, row.file_path);
|
||||||
if (!fs.existsSync(fp)) return res.status(404).json({ error: 'file missing' });
|
if (!fs.existsSync(fp)) return res.status(404).json({ error: 'file missing' });
|
||||||
|
const primary = queryGet('SELECT name FROM attachments WHERE file_id = :id ORDER BY sort_order ASC LIMIT 1', { ':id': row.id });
|
||||||
run('UPDATE files SET downloads = downloads + 1 WHERE id = :id', { ':id': req.params.id });
|
run('UPDATE files SET downloads = downloads + 1 WHERE id = :id', { ':id': req.params.id });
|
||||||
|
res.download(fp, primary?.name || row.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── API: 개별 첨부파일 다운로드 ───────────────────────────────
|
||||||
|
app.get('/api/attachments/:id/download', (req, res) => {
|
||||||
|
const row = queryGet('SELECT * FROM attachments WHERE id = :id', { ':id': req.params.id });
|
||||||
|
if (!row) return res.status(404).json({ error: 'not found' });
|
||||||
|
const fp = path.join(UPLOADS_DIR, row.file_path);
|
||||||
|
if (!fs.existsSync(fp)) return res.status(404).json({ error: 'file missing' });
|
||||||
|
db.run('UPDATE attachments SET downloads = downloads + 1 WHERE id = :id', { ':id': row.id });
|
||||||
|
db.run('UPDATE files SET downloads = downloads + 1 WHERE id = :id', { ':id': row.file_id });
|
||||||
|
saveDb();
|
||||||
res.download(fp, row.name);
|
res.download(fp, row.name);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -348,7 +347,8 @@ app.delete('/api/files', authMiddleware, (req, res) => {
|
|||||||
const params = {};
|
const params = {};
|
||||||
ids.forEach((id, i) => { params[`:id${i}`] = id; });
|
ids.forEach((id, i) => { params[`:id${i}`] = id; });
|
||||||
|
|
||||||
const rows = queryAll(`SELECT file_path FROM files WHERE id IN (${placeholders})`, params);
|
const rows = queryAll(`SELECT file_path FROM attachments WHERE file_id IN (${placeholders})`, params);
|
||||||
|
db.run(`DELETE FROM attachments WHERE file_id IN (${placeholders})`, params);
|
||||||
db.run(`DELETE FROM files WHERE id IN (${placeholders})`, params);
|
db.run(`DELETE FROM files WHERE id IN (${placeholders})`, params);
|
||||||
saveDb();
|
saveDb();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user