- Docker Compose with Postgres, Redis, MinIO, backend, frontend (port 3200/3201) - Prisma schema: User, Project, ProjectFile, Product, Order, FlashToken, Review, AuditLog - Backend: JWT auth, project CRUD + file upload (MinIO + sharp WebP), admin approval flow - Frontend: React + Vite SPA with auth, project/shop browse, seller dashboard, admin panel - Admin: pending approval queue, user management, audit log viewer, stats dashboard - Audit logging middleware for legal compliance - Admin init script: createAdmin.js - Full design document in PLATFORM_DESIGN.md Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
176 lines
6.7 KiB
JavaScript
176 lines
6.7 KiB
JavaScript
import { useEffect, useState, useRef } from 'react';
|
|
import { useParams, useNavigate } from 'react-router-dom';
|
|
import api from '../../api/client';
|
|
|
|
const STATUS_LABEL = {
|
|
draft: '임시저장', pending: '검토 대기', approved: '승인됨',
|
|
rejected: '반려됨', suspended: '정지됨',
|
|
};
|
|
|
|
export default function ProjectEdit() {
|
|
const { id } = useParams();
|
|
const navigate = useNavigate();
|
|
const [project, setProject] = useState(null);
|
|
const [form, setForm] = useState({});
|
|
const [loading, setLoading] = useState(true);
|
|
const [saving, setSaving] = useState(false);
|
|
const [error, setError] = useState('');
|
|
const [msg, setMsg] = useState('');
|
|
const [files, setFiles] = useState([]);
|
|
const [fileType, setFileType] = useState('image');
|
|
const fileRef = useRef();
|
|
|
|
useEffect(() => {
|
|
api.get(`/projects/${id}`)
|
|
.then(r => {
|
|
setProject(r.data);
|
|
setForm({ title: r.data.title, description: r.data.description, difficultyLevel: r.data.difficultyLevel });
|
|
})
|
|
.catch(() => navigate('/dashboard'))
|
|
.finally(() => setLoading(false));
|
|
}, [id]);
|
|
|
|
async function handleSave(e) {
|
|
e.preventDefault();
|
|
setSaving(true);
|
|
try {
|
|
await api.put(`/projects/${id}`, form);
|
|
setMsg('저장되었습니다');
|
|
} catch (err) {
|
|
setError(err.response?.data?.error || '저장 실패');
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}
|
|
|
|
async function handleUpload() {
|
|
if (!files.length) return;
|
|
setSaving(true);
|
|
try {
|
|
const fd = new FormData();
|
|
fd.append('fileType', fileType);
|
|
files.forEach(f => fd.append('files', f));
|
|
const { data } = await api.post(`/projects/${id}/files`, fd, {
|
|
headers: { 'Content-Type': 'multipart/form-data' },
|
|
});
|
|
setProject(p => ({ ...p, files: [...(p.files || []), ...data] }));
|
|
setFiles([]);
|
|
setMsg('파일이 업로드되었습니다');
|
|
} catch (err) {
|
|
setError(err.response?.data?.error || '업로드 실패');
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}
|
|
|
|
async function handleDeleteFile(fileId) {
|
|
if (!confirm('파일을 삭제하시겠습니까?')) return;
|
|
await api.delete(`/projects/${id}/files/${fileId}`);
|
|
setProject(p => ({ ...p, files: p.files.filter(f => f.id !== fileId) }));
|
|
}
|
|
|
|
async function handleSubmit() {
|
|
setSaving(true);
|
|
try {
|
|
await api.post(`/projects/${id}/submit`);
|
|
navigate('/dashboard');
|
|
} catch (err) {
|
|
setError(err.response?.data?.error || '제출 실패');
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}
|
|
|
|
if (loading) return <div className="spinner" />;
|
|
const canEdit = ['draft', 'rejected'].includes(project?.status);
|
|
|
|
return (
|
|
<div className="container page" style={{ maxWidth: 700 }}>
|
|
<div className="flex items-center justify-between" style={{ marginBottom: 24 }}>
|
|
<h2>프로젝트 관리</h2>
|
|
<span className={`badge badge-${project.status}`}>{STATUS_LABEL[project.status]}</span>
|
|
</div>
|
|
|
|
{project.adminNote && (
|
|
<div className="alert alert-warn">
|
|
<strong>관리자 메모:</strong> {project.adminNote}
|
|
</div>
|
|
)}
|
|
{error && <div className="alert alert-error">{error}</div>}
|
|
{msg && <div className="alert alert-success">{msg}</div>}
|
|
|
|
{/* 기본 정보 수정 */}
|
|
<form className="card" onSubmit={handleSave} style={{ marginBottom: 20 }}>
|
|
<h3 style={{ marginBottom: 16 }}>기본 정보</h3>
|
|
<div className="form-group">
|
|
<label>제목</label>
|
|
<input required disabled={!canEdit} value={form.title || ''}
|
|
onChange={e => setForm(f => ({ ...f, title: e.target.value }))} />
|
|
</div>
|
|
<div className="form-group">
|
|
<label>설명</label>
|
|
<textarea disabled={!canEdit} value={form.description || ''}
|
|
onChange={e => setForm(f => ({ ...f, description: e.target.value }))} rows={6} />
|
|
</div>
|
|
<div style={{ display: 'flex', gap: 8 }}>
|
|
<button className="btn btn-primary" disabled={!canEdit || saving}>
|
|
{saving ? '저장 중...' : '저장'}
|
|
</button>
|
|
{['draft', 'rejected'].includes(project.status) && (
|
|
<button type="button" className="btn btn-success" onClick={handleSubmit} disabled={saving}>
|
|
검토 요청
|
|
</button>
|
|
)}
|
|
</div>
|
|
</form>
|
|
|
|
{/* 파일 관리 */}
|
|
<div className="card">
|
|
<h3 style={{ marginBottom: 16 }}>파일 관리</h3>
|
|
{project.files?.length > 0 && (
|
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(100px, 1fr))', gap: 8, marginBottom: 20 }}>
|
|
{project.files.map(f => (
|
|
<div key={f.id} style={{ position: 'relative', borderRadius: 4, overflow: 'hidden', background: 'var(--bg3)' }}>
|
|
{f.fileType === 'image'
|
|
? <img src={f.thumbnailUrl || f.url} alt="" style={{ width: '100%', height: 80, objectFit: 'cover' }} />
|
|
: <div style={{ height: 80, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 24 }}>
|
|
{f.fileType === 'stl' ? '🖨️' : f.fileType === 'firmware' ? '💾' : '📄'}
|
|
</div>
|
|
}
|
|
<button onClick={() => handleDeleteFile(f.id)}
|
|
style={{ position: 'absolute', top: 2, right: 2, background: 'var(--danger)', border: 'none',
|
|
borderRadius: 4, color: '#fff', cursor: 'pointer', fontSize: 11, padding: '2px 5px' }}>
|
|
✕
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<div style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
|
|
<select value={fileType} onChange={e => setFileType(e.target.value)} style={{ width: 160 }}>
|
|
<option value="image">이미지</option>
|
|
<option value="video">영상</option>
|
|
<option value="wiring">배선도</option>
|
|
<option value="stl">STL</option>
|
|
<option value="firmware">펌웨어</option>
|
|
</select>
|
|
<button className="btn btn-outline" onClick={() => fileRef.current.click()}>파일 선택</button>
|
|
<input ref={fileRef} type="file" multiple hidden
|
|
onChange={e => setFiles(Array.from(e.target.files))} />
|
|
{files.length > 0 && (
|
|
<button className="btn btn-primary" onClick={handleUpload} disabled={saving}>
|
|
{saving ? '업로드 중...' : `${files.length}개 업로드`}
|
|
</button>
|
|
)}
|
|
</div>
|
|
{files.length > 0 && (
|
|
<div className="text-muted" style={{ fontSize: 12 }}>
|
|
{files.map(f => f.name).join(', ')}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|