Skip to content

Commit fce7010

Browse files
authored
Isolate the admin archive file workflow (#306)
Admin.jsx still owned archive file listing, upload, delete, and file-size formatting. Moving that workflow into AdminFiles keeps the admin shell smaller while preserving the current archive API behavior. Constraint: Preserve the existing /api/files multipart upload contract Rejected: Extract only the list rendering | upload, delete, and file-size display share the same file workflow boundary Confidence: high Scope-risk: narrow Directive: Keep future admin archive file mutations inside AdminFiles or a dedicated file hook Tested: npm run lint; npx playwright test tests/e2e/app-smoke.spec.js -g "admin files"; npm test; npm run build; npm run smoke; git diff --check Not-tested: Live large-file upload against production storage
1 parent c5ca526 commit fce7010

3 files changed

Lines changed: 174 additions & 102 deletions

File tree

src/pages/Admin.jsx

Lines changed: 2 additions & 102 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import { useSearchParams } from 'react-router-dom'
33
import { Download, Eye, RefreshCw, RotateCcw, X } from 'lucide-react'
44
import { apiUrl } from '../services/apiClient.js'
55
import { listCommunityReports, listDeletedCommunityPosts, getDeletedCommunityPost, restoreDeletedCommunityPost, resolveCommunityReport, listRecruitApplications, updateRecruitApplicationStatus } from '../services/adminApi.js'
6-
import { listFiles, createPost, deleteFile } from '../services/archiveApi.js'
76
import {
87
createClubActivity,
98
deleteClubActivity,
@@ -23,6 +22,7 @@ import { sanitizeHtml } from '../utils/sanitizeHtml.js'
2322
import AdminAuditLogs from './admin/AdminAuditLogs.jsx'
2423
import AdminAppCatalog from './admin/AdminAppCatalog.jsx'
2524
import AdminBan from './admin/AdminBan.jsx'
25+
import AdminFiles from './admin/AdminFiles.jsx'
2626
import AdminFonts from './admin/AdminFonts.jsx'
2727
import AdminMembers from './admin/AdminMembers.jsx'
2828
import AdminRoster from './admin/AdminRoster.jsx'
@@ -199,7 +199,7 @@ export default function Admin({ onBack }) {
199199
{activeTab === 'roster' && <AdminRoster />}
200200
{activeTab === 'activities' && <ActivitiesAdminTab />}
201201
{activeTab === 'projects' && <AdminAppCatalog />}
202-
{activeTab === 'files' && <FilesTab />}
202+
{activeTab === 'files' && <AdminFiles />}
203203
{activeTab === 'fonts' && <AdminFonts />}
204204
{activeTab === 'community' && <CommunityReportsTab />}
205205
{activeTab === 'deleted-posts' && <DeletedCommunityPostsTab />}
@@ -1142,99 +1142,6 @@ function ClubActivityCategoriesAdmin({ categories, onChanged }) {
11421142

11431143
// ─── Club projects (동아리 부원들이 만든 프로젝트 모음) admin ────────────────────────
11441144

1145-
function FilesTab() {
1146-
const [files, setFiles] = useState([])
1147-
const [loading, setLoading] = useState(true)
1148-
const [uploading, setUploading] = useState(false)
1149-
const fileInputRef = useRef(null)
1150-
1151-
const loadFiles = () => {
1152-
listFiles()
1153-
.then(setFiles)
1154-
.catch(() => {})
1155-
.finally(() => setLoading(false))
1156-
}
1157-
1158-
useEffect(() => {
1159-
let mounted = true
1160-
listFiles()
1161-
.then(d => { if (mounted) setFiles(d) })
1162-
.catch(() => {})
1163-
.finally(() => { if (mounted) setLoading(false) })
1164-
return () => { mounted = false }
1165-
}, [])
1166-
1167-
const handleUpload = async (e) => {
1168-
const file = e.target.files?.[0]
1169-
if (!file) return
1170-
setUploading(true)
1171-
try {
1172-
await createPost({ title: file.name, file })
1173-
loadFiles()
1174-
} catch (err) {
1175-
alert(err.message || '업로드 중 오류가 발생했습니다.')
1176-
} finally {
1177-
setUploading(false)
1178-
if (fileInputRef.current) fileInputRef.current.value = ''
1179-
}
1180-
}
1181-
1182-
const handleDelete = async (id) => {
1183-
if (!window.confirm('파일을 삭제하시겠습니까?')) return
1184-
try {
1185-
await deleteFile(id)
1186-
setFiles((prev) => prev.filter((f) => f.id !== id))
1187-
} catch (err) {
1188-
alert(err.message || '삭제 중 오류가 발생했습니다.')
1189-
}
1190-
}
1191-
1192-
return (
1193-
<div className="space-y-4">
1194-
<div>
1195-
<input ref={fileInputRef} type="file" className="hidden" onChange={handleUpload} />
1196-
<button
1197-
type="button"
1198-
onClick={() => fileInputRef.current?.click()}
1199-
disabled={uploading}
1200-
className="shape-cut-sm border border-[var(--app-hairline)] bg-white/60 px-4 py-2 text-sm font-semibold text-[var(--theme-body-dark)] transition hover:bg-white/80 disabled:opacity-50"
1201-
>
1202-
{uploading ? '업로드 중...' : '파일 업로드'}
1203-
</button>
1204-
</div>
1205-
1206-
{loading ? (
1207-
<p className="text-sm text-[var(--theme-body-muted)]">불러오는 중...</p>
1208-
) : files.length === 0 ? (
1209-
<p className="text-sm text-[var(--theme-body-muted)]">등록된 파일이 없습니다.</p>
1210-
) : (
1211-
<div className="space-y-2">
1212-
{files.map((file) => (
1213-
<div
1214-
key={file.id}
1215-
className="shape-cut-sm flex items-center justify-between gap-3 border border-[var(--app-hairline)] bg-black/5 px-4 py-3"
1216-
>
1217-
<div>
1218-
<p className="font-semibold text-[var(--theme-body-dark)]">{file.originalName}</p>
1219-
<p className="text-xs text-[var(--theme-body-muted)]">
1220-
{file.uploadedBy} · {formatFileSize(file.fileSize)}
1221-
</p>
1222-
</div>
1223-
<button
1224-
type="button"
1225-
onClick={() => handleDelete(file.id)}
1226-
className="text-xs font-semibold text-red-500 transition hover:underline"
1227-
>
1228-
삭제
1229-
</button>
1230-
</div>
1231-
))}
1232-
</div>
1233-
)}
1234-
</div>
1235-
)
1236-
}
1237-
12381145
function CommunityReportsTab() {
12391146
const [reports, setReports] = useState([])
12401147
const [notes, setNotes] = useState({})
@@ -2203,13 +2110,6 @@ function isSafeHttpsUrl(value) {
22032110
}
22042111
}
22052112

2206-
function formatFileSize(size) {
2207-
if (!Number.isFinite(size)) return '알 수 없음'
2208-
if (size < 1024) return `${size} B`
2209-
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`
2210-
return `${(size / 1024 / 1024).toFixed(1)} MB`
2211-
}
2212-
22132113
function formatDateTime(value) {
22142114
if (!value) return '영구'
22152115
const date = new Date(value)

src/pages/admin/AdminFiles.jsx

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import { useEffect, useRef, useState } from 'react'
2+
import { listFiles, createPost, deleteFile } from '../../services/archiveApi.js'
3+
4+
export default function AdminFiles() {
5+
const [files, setFiles] = useState([])
6+
const [loading, setLoading] = useState(true)
7+
const [uploading, setUploading] = useState(false)
8+
const fileInputRef = useRef(null)
9+
10+
const loadFiles = () => {
11+
listFiles()
12+
.then(setFiles)
13+
.catch(() => {})
14+
.finally(() => setLoading(false))
15+
}
16+
17+
useEffect(() => {
18+
let mounted = true
19+
listFiles()
20+
.then(d => { if (mounted) setFiles(d) })
21+
.catch(() => {})
22+
.finally(() => { if (mounted) setLoading(false) })
23+
return () => { mounted = false }
24+
}, [])
25+
26+
const handleUpload = async (e) => {
27+
const file = e.target.files?.[0]
28+
if (!file) return
29+
setUploading(true)
30+
try {
31+
await createPost({ title: file.name, file })
32+
loadFiles()
33+
} catch (err) {
34+
alert(err.message || '업로드 중 오류가 발생했습니다.')
35+
} finally {
36+
setUploading(false)
37+
if (fileInputRef.current) fileInputRef.current.value = ''
38+
}
39+
}
40+
41+
const handleDelete = async (id) => {
42+
if (!window.confirm('파일을 삭제하시겠습니까?')) return
43+
try {
44+
await deleteFile(id)
45+
setFiles((prev) => prev.filter((f) => f.id !== id))
46+
} catch (err) {
47+
alert(err.message || '삭제 중 오류가 발생했습니다.')
48+
}
49+
}
50+
51+
return (
52+
<div className="space-y-4">
53+
<div>
54+
<input ref={fileInputRef} type="file" className="hidden" onChange={handleUpload} />
55+
<button
56+
type="button"
57+
onClick={() => fileInputRef.current?.click()}
58+
disabled={uploading}
59+
className="shape-cut-sm border border-[var(--app-hairline)] bg-white/60 px-4 py-2 text-sm font-semibold text-[var(--theme-body-dark)] transition hover:bg-white/80 disabled:opacity-50"
60+
>
61+
{uploading ? '업로드 중...' : '파일 업로드'}
62+
</button>
63+
</div>
64+
65+
{loading ? (
66+
<p className="text-sm text-[var(--theme-body-muted)]">불러오는 중...</p>
67+
) : files.length === 0 ? (
68+
<p className="text-sm text-[var(--theme-body-muted)]">등록된 파일이 없습니다.</p>
69+
) : (
70+
<div className="space-y-2">
71+
{files.map((file) => (
72+
<div
73+
key={file.id}
74+
className="shape-cut-sm flex items-center justify-between gap-3 border border-[var(--app-hairline)] bg-black/5 px-4 py-3"
75+
>
76+
<div>
77+
<p className="font-semibold text-[var(--theme-body-dark)]">{file.originalName}</p>
78+
<p className="text-xs text-[var(--theme-body-muted)]">
79+
{file.uploadedBy} · {formatFileSize(file.fileSize)}
80+
</p>
81+
</div>
82+
<button
83+
type="button"
84+
onClick={() => handleDelete(file.id)}
85+
className="text-xs font-semibold text-red-500 transition hover:underline"
86+
>
87+
삭제
88+
</button>
89+
</div>
90+
))}
91+
</div>
92+
)}
93+
</div>
94+
)
95+
}
96+
97+
function formatFileSize(size) {
98+
if (!Number.isFinite(size)) return '알 수 없음'
99+
if (size < 1024) return `${size} B`
100+
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`
101+
return `${(size / 1024 / 1024).toFixed(1)} MB`
102+
}

tests/e2e/app-smoke.spec.js

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -417,6 +417,76 @@ test('admin ban tab can ban and unban student ids', async ({ page }) => {
417417
await expect(page.getByRole('row').filter({ hasText: '2026123456' })).toHaveCount(0)
418418
})
419419

420+
test('admin files tab can upload and delete archive files', async ({ page }) => {
421+
await mockAdminApis(page)
422+
let files = [
423+
{
424+
id: 1,
425+
originalName: '기존자료.pdf',
426+
uploadedBy: '관리자',
427+
fileSize: 2048,
428+
},
429+
]
430+
let uploadPayload = null
431+
let deletedFileId = null
432+
433+
await page.addInitScript(() => {
434+
window.confirm = () => true
435+
window.alert = () => {}
436+
})
437+
await page.route('**/api/files', async (route) => {
438+
if (route.request().method() === 'GET') {
439+
return route.fulfill({ status: 200, json: files })
440+
}
441+
if (route.request().method() === 'POST') {
442+
const body = route.request().postData() || ''
443+
uploadPayload = {
444+
title: multipartField(body, 'title'),
445+
category: multipartField(body, 'category'),
446+
}
447+
files = [
448+
...files,
449+
{
450+
id: 2,
451+
originalName: uploadPayload.title,
452+
uploadedBy: '관리자',
453+
fileSize: 7,
454+
},
455+
]
456+
return route.fulfill({ status: 200, json: files.at(-1) })
457+
}
458+
return route.fallback()
459+
})
460+
await page.route('**/api/files/*', async (route) => {
461+
if (route.request().method() !== 'DELETE') return route.fallback()
462+
deletedFileId = Number(new URL(route.request().url()).pathname.split('/').at(-1))
463+
files = files.filter((file) => file.id !== deletedFileId)
464+
return route.fulfill({ status: 204 })
465+
})
466+
467+
await page.goto('/admin')
468+
await page.getByRole('button', { name: '파일 관리' }).click()
469+
470+
await expect(page.getByText('기존자료.pdf')).toBeVisible()
471+
await expect(page.getByText('관리자 · 2.0 KB')).toBeVisible()
472+
await page.locator('input[type="file"]').setInputFiles({
473+
name: '회의록.pdf',
474+
mimeType: 'application/pdf',
475+
buffer: Buffer.from('pdfbody'),
476+
})
477+
478+
await expect.poll(() => uploadPayload).toEqual({
479+
title: '회의록.pdf',
480+
category: 'GENERAL',
481+
})
482+
await expect(page.getByText('회의록.pdf')).toBeVisible()
483+
484+
await page.locator('.space-y-2 > .shape-cut-sm').filter({ hasText: '회의록.pdf' }).getByRole('button', { name: '삭제' }).click()
485+
486+
await expect.poll(() => deletedFileId).toBe(2)
487+
await expect(page.getByText('회의록.pdf')).toHaveCount(0)
488+
})
489+
420490
test('admin font management explains availability and uses action labels', async ({ page }) => {
421491
await mockAdminApis(page)
422492
await page.route('**/api/fonts/*/file', (route) => route.fulfill({

0 commit comments

Comments
 (0)