feat: add ESP32 DIY platform Phase 1 (marketplace scaffold)
- 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>
This commit is contained in:
175
platform/frontend/src/pages/Dashboard/ProjectEdit.jsx
Normal file
175
platform/frontend/src/pages/Dashboard/ProjectEdit.jsx
Normal file
@@ -0,0 +1,175 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user