From c6cb9eeaf609f77d65ed236e27f536ab42caedd5 Mon Sep 17 00:00:00 2001 From: vibsin9322 Date: Wed, 8 Jul 2026 18:10:51 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=B2=A8=EB=B6=80=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EB=8B=A4=EC=A4=91=20=EC=97=85=EB=A1=9C=EB=93=9C,=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=EC=9E=90=20=EC=A0=84=EC=9A=A9=20=EC=97=85=EB=A1=9C?= =?UTF-8?q?=EB=93=9C,=20=ED=8C=8C=EC=9D=BC=EC=9C=A0=ED=98=95=EB=B3=84=20?= =?UTF-8?q?=EB=8B=A4=EC=9A=B4=EB=A1=9C=EB=93=9C=20=EC=95=84=EC=9D=B4?= =?UTF-8?q?=EC=BD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 업로드시 제목 입력 + 첨부파일 최대 3개 등록 가능 (posts/attachments 구조 도입) - 개별 첨부파일 다운로드 API 및 UI 추가 (일반/관리자 페이지 모두) - 파일 업로드는 관리자만 가능하도록 서버 인증 적용, 일반 페이지 업로드 버튼 제거 - 다운로드 아이콘을 파일 유형(문서/이미지/동영상/압축/코드)별로 색상·아이콘 구분 - 관리자 페이지 강조 버튼 hover 시 배경색 사라지던 CSS 버그 수정 Co-Authored-By: Claude Sonnet 5 --- admin.html | 190 ++++++++++++++++++++++++++++++++++++++++-- index.html | 236 ++++++++++++++--------------------------------------- server.js | 130 ++++++++++++++--------------- 3 files changed, 308 insertions(+), 248 deletions(-) diff --git a/admin.html b/admin.html index f0be0cf..b1fcea6 100644 --- a/admin.html +++ b/admin.html @@ -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: <>, lock: <>, "log-out": <>, + upload: <>, + download: <>, }; return ( @@ -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 ( +
+
e.stopPropagation()}> +
+

파일 업로드

+ +
+
+
+ + { setTitle(e.target.value); setError(''); }} /> +
+
+
+ + +
+
+ + +
+
+
+ + +
+
{ 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()}> + +
파일을 여기로 드래그하거나 클릭하세요
+
첨부파일 최대 {MAX_ATTACHMENTS}개 ({pending.length}/{MAX_ATTACHMENTS})
+ { addFiles(e.target.files); e.target.value = ''; }} /> +
+ {pending.length > 0 && ( +
+ {pending.map(p => ( +
+
{p.file.name}
+ +
+ ))} +
+ )} + {error && ( +
+ {error} +
+ )} +
+
+ + +
+
+
+ ); +}; + // ── 파일 관리 ────────────────────────────────────────────────── 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 }) => {
파일 관리
등록된 파일의 메타데이터를 편집하거나 삭제합니다
+
- setSearch(e.target.value)} /> + setSearch(e.target.value)} />
@@ -617,6 +786,15 @@ const FileSection = ({ showToast }) => {
)} + + {uploadOpen && ( + { setUploadOpen(false); load(); }} + onUploaded={() => load()} + categories={cats} + users={users} + /> + )} ); }; diff --git a/index.html b/index.html index 5259ef1..3a82786 100644 --- a/index.html +++ b/index.html @@ -143,7 +143,7 @@ /* LIST */ .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-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} @@ -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 .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-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:hover{border-color:var(--ink-2)} .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: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 */ .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} @@ -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 h3{margin:0;font-size:15px;font-weight:600} .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{width:min(960px,94vw);max-height:90vh} @@ -217,6 +206,8 @@ .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 .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 .actions{display:flex;flex-direction:column;gap:6px;margin-top:auto} @@ -651,6 +642,8 @@ const FIcon = ({ type, ext, size = 28 }) => (
{ext}
); +const TYPE_ICON = { doc: 'file', img: 'image', vid: 'video', zip: 'folder', code: 'code' }; + const Thumb = ({ file }) => { const k = file.thumb; if (k === "doc") return
{file.ext}
; @@ -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 catObj = categories.find(c => c.id === category); return ( @@ -785,9 +778,6 @@ const Header = ({ folder, category, onUpload, accent, categories, currentUser, o 관리자 - )} @@ -878,7 +868,7 @@ const GridView = ({ files, selected, toggleSel, toggleStar, onOpen, isLoggedIn } ); -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 [menuPos, setMenuPos] = useState({ x: 0, y: 0 }); const menuRef = useRef(null); @@ -902,8 +892,8 @@ const ListView = ({ files, selected, toggleSel, toggleStar, onOpen, categories, }; const cols = isLoggedIn - ? "32px 1fr 100px 130px 130px 70px 52px" - : "1fr 100px 130px 130px 70px 52px"; + ? "32px 1fr 100px 130px 130px 118px 52px" + : "1fr 100px 130px 130px 118px 52px"; return (
@@ -931,7 +921,15 @@ const ListView = ({ files, selected, toggleSel, toggleStar, onOpen, categories,
{f.size_label}
{f.modified_at}
{f.author}
-
↓ {f.downloads}
+
e.stopPropagation()}> + {(f.attachments || []).map(a => ( + + ))} + {(!f.attachments || f.attachments.length === 0) && ↓ {f.downloads}} +
-
-
-
-
-
업로더
- -
-
-
카테고리
- -
-
-
공개 범위
- -
-
-
{ e.preventDefault(); setDragOver(true); }} - onDragLeave={() => setDragOver(false)} - onDrop={e => { e.preventDefault(); setDragOver(false); addFiles(e.dataTransfer.files); }}> - -
파일을 여기로 드래그하세요
-
또는 클릭하여 선택 · 최대 2GB · PDF, DOC, PPT, 이미지, 영상, ZIP
- addFiles(e.target.files)} /> - -
- {uploads.length > 0 && ( -
- {uploads.map(u => ( -
- -
-
{u.name}
-
-
-
-
-
{u.progress < 0 ? '오류' : u.progress >= 100 ? '✓' : `${Math.round(u.progress)}%`}
-
- ))} -
- )} - {uploads.length > 0 && uploads.every(u => u.progress === 100 || u.progress < 0) && ( -
- -
- )} -
-
- - ); -}; - -const PreviewModal = ({ file, onClose, onDownload, onStar, onCopyLink, accent, categories }) => { +const PreviewModal = ({ file, onClose, onDownload, onDownloadAttachment, onStar, onCopyLink, accent, categories }) => { if (!file) return null; return (
@@ -1122,6 +1018,22 @@ const PreviewModal = ({ file, onClose, onDownload, onStar, onCopyLink, accent, c
수정{file.modified_at}
분류{categories.find(c=>c.id===file.category)?.label || "—"}
+ {file.attachments && file.attachments.length > 0 && ( +
+ 첨부파일 ({file.attachments.length}) + {file.attachments.map(a => ( +
onDownloadAttachment(a)}> + + + {a.name} + + + {a.size_label} + +
+ ))} +
+ )}