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:hover{background:var(--surface-2)}
|
||||
.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:hover{background:rgba(217,119,87,.06)}
|
||||
.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: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{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)}
|
||||
@@ -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"/></>,
|
||||
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"/></>,
|
||||
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 (
|
||||
<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 TYPE_ICON = { doc: 'file', img: 'image', vid: 'video', zip: 'folder', code: 'code' };
|
||||
|
||||
const CategorySection = ({ showToast }) => {
|
||||
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;
|
||||
@@ -465,6 +621,7 @@ const FileSection = ({ showToast }) => {
|
||||
const [total, setTotal] = useState(0);
|
||||
const [editModal, setEditModal] = useState(null);
|
||||
const [form, setForm] = useState({});
|
||||
const [uploadOpen, setUploadOpen] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -524,12 +681,13 @@ const FileSection = ({ showToast }) => {
|
||||
<div style={{ fontSize:16, fontWeight:600, marginBottom:2 }}>파일 관리</div>
|
||||
<div style={{ fontSize:12, color:'var(--muted)' }}>등록된 파일의 메타데이터를 편집하거나 삭제합니다</div>
|
||||
</div>
|
||||
<button className="btn btn-accent" onClick={() => setUploadOpen(true)}><Icon name="upload" size={13} />파일 업로드</button>
|
||||
</div>
|
||||
|
||||
<div style={{ display:'flex', gap:10, marginBottom:14 }}>
|
||||
<div className="search-box">
|
||||
<Icon name="search" size={13} />
|
||||
<input placeholder="파일명 검색..." value={search} onChange={e => setSearch(e.target.value)} />
|
||||
<input placeholder="제목 검색..." value={search} onChange={e => setSearch(e.target.value)} />
|
||||
</div>
|
||||
<select
|
||||
style={{ border:'1px solid var(--border-strong)', borderRadius:7, padding:'7px 10px', fontSize:13, background:'var(--surface)', color:'var(--ink)', outline:'none' }}
|
||||
@@ -543,7 +701,7 @@ const FileSection = ({ showToast }) => {
|
||||
<table className="tbl">
|
||||
<thead>
|
||||
<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>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -555,13 +713,24 @@ const FileSection = ({ showToast }) => {
|
||||
<tr key={f.id}>
|
||||
<td style={{ maxWidth:280 }}>
|
||||
<div style={{ fontWeight:500, color:'var(--ink)', overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>{f.name}</div>
|
||||
<div style={{ fontSize:11, color:'var(--faint)', fontFamily:'var(--font-mono)' }}>{f.ext}</div>
|
||||
<div style={{ fontSize:11, color:'var(--faint)', fontFamily:'var(--font-mono)' }}>첨부 {(f.attachments || []).length}개</div>
|
||||
</td>
|
||||
<td><span className="badge badge-gray">{cats.find(c=>c.id===f.category)?.label || f.category}</span></td>
|
||||
<td style={{ color:'var(--muted)' }}>{f.author}</td>
|
||||
<td><span className={`badge ${visClass[f.visibility] || 'badge-gray'}`}>{visMap[f.visibility] || f.visibility}</span></td>
|
||||
<td style={{ fontFamily:'var(--font-mono)', fontSize:12, color:'var(--muted)' }}>{f.size_label}</td>
|
||||
<td style={{ fontFamily:'var(--font-mono)', fontSize:12, color:'var(--faint)' }}>↓ {f.downloads}</td>
|
||||
<td>
|
||||
<div 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>
|
||||
<div className="actions">
|
||||
<button className="btn btn-sm btn-ghost" onClick={() => openEdit(f)}><Icon name="edit" size={13} />편집</button>
|
||||
@@ -589,7 +758,7 @@ const FileSection = ({ showToast }) => {
|
||||
{editModal && (
|
||||
<Modal title="파일 정보 편집" onClose={() => setEditModal(null)} onSave={saveEdit}>
|
||||
<div className="field">
|
||||
<label>파일명</label>
|
||||
<label>제목</label>
|
||||
<input value={form.name} onChange={ff('name')} />
|
||||
</div>
|
||||
<div className="field-row">
|
||||
@@ -617,6 +786,15 @@ const FileSection = ({ showToast }) => {
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{uploadOpen && (
|
||||
<UploadModal
|
||||
onClose={() => { setUploadOpen(false); load(); }}
|
||||
onUploaded={() => load()}
|
||||
categories={cats}
|
||||
users={users}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user