feat: 작업실 앱에 프로젝트별 파일 브라우저 + K2 CAD 편집기 연동
- routes-workshop.ts: 파일 목록/업로드/폴더생성/삭제 API 추가 (case-storage.ts 재사용, appType='workshop', caseId=projectId) - workshop-app.html: 파일 탭 추가 — 드래그앤드롭 업로드, 폴더 그룹핑, 사진 썸네일, STL 클릭 시 Three.js CAD 편집기 팝업으로 연동 - cad-editor-view.js: ?path= 쿼리로 특정 STL 자동 로드 지원 - server.ts: registerWorkshopRoutes에 isPathInsideDir 전달 - stl-cad-core.ts: 평면뷰 렌더 카메라가 원점이 아닌 바운딩박스 중심을 look-at하도록 수정 — 원점에서 떨어진 모델 위쪽 잘림 해결 Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -4,14 +4,38 @@
|
||||
* routes-robot.ts/robot-app.html이었던 것을 여러 프로젝트를 오갈 수 있게 일반화했다,
|
||||
* 2026-09-20). 저장 로직은 workshop-storage.ts에 있음 — 전용 채팅도구
|
||||
* (src/tools/workshop-project.ts)와 공유한다.
|
||||
*
|
||||
* 09-22: 프로젝트별 첨부파일(사진/CAD/STL 등) 폴더 기능 추가 — 저장소 레이아웃/업로드
|
||||
* 파싱은 doctor·lawyer 앱이 쓰는 case-storage.ts를 그대로 재사용(appType='workshop',
|
||||
* caseId=projectId). 파일은 <workspace>/workshop/<projectId>/... 에 저장되고 기존
|
||||
* 범용 /api/files/{*filePath} 라우트로 서빙된다.
|
||||
*/
|
||||
import express from 'express';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { getUserWorkspace } from '../../config/config';
|
||||
import { loadWorkshop, saveWorkshop } from './workshop-storage';
|
||||
import { caseFilesDir, resolveUploadPath, sanitizePathSegment, walkCaseFiles, fileCategory } from './case-storage';
|
||||
|
||||
const APP_TYPE = 'workshop';
|
||||
|
||||
// walkCaseFiles만으로는 파일이 하나도 없는 빈 폴더가 안 보이므로, 디렉토리 구조를 따로 순회.
|
||||
function listCaseFolders(dir: string, relBase = ''): string[] {
|
||||
if (!fs.existsSync(dir)) return [];
|
||||
const out: string[] = [];
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const rel = relBase ? `${relBase}/${entry.name}` : entry.name;
|
||||
out.push(rel);
|
||||
out.push(...listCaseFolders(path.join(dir, entry.name), rel));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function registerWorkshopRoutes(
|
||||
app: express.Application,
|
||||
getSessionUser: (req: express.Request) => any,
|
||||
isPathInsideDir: (base: string, target: string) => boolean,
|
||||
): void {
|
||||
app.get('/api/workshop/data', (req, res) => {
|
||||
const session = getSessionUser(req);
|
||||
@@ -29,4 +53,136 @@ export function registerWorkshopRoutes(
|
||||
saveWorkshop(getUserWorkspace(session.username), { projects, activeProjectId: String(activeProjectId || '') });
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
app.get('/api/workshop/files', (req, res) => {
|
||||
const session = getSessionUser(req);
|
||||
if (!session) return res.status(401).json({ error: 'Unauthorized' });
|
||||
const projectId = String(req.query.projectId || '');
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(projectId)) return res.status(400).json({ error: 'invalid projectId' });
|
||||
const dir = caseFilesDir(session.username, APP_TYPE, projectId);
|
||||
const files = walkCaseFiles(dir).map(e => {
|
||||
const segs = e.relPath.split('/');
|
||||
return {
|
||||
name: e.name,
|
||||
relPath: e.relPath,
|
||||
folder: segs.length > 1 ? segs.slice(0, -1).join('/') : '',
|
||||
size: e.size,
|
||||
mtime: e.mtime,
|
||||
url: `/api/files/${APP_TYPE}/${projectId}/${segs.map(encodeURIComponent).join('/')}`,
|
||||
category: fileCategory(e.name),
|
||||
};
|
||||
});
|
||||
res.json({ files, folders: listCaseFolders(dir) });
|
||||
});
|
||||
|
||||
app.post('/api/workshop/files/folder', (req, res) => {
|
||||
const session = getSessionUser(req);
|
||||
if (!session) return res.status(401).json({ error: 'Unauthorized' });
|
||||
const projectId = String(req.body?.projectId || '');
|
||||
const folder = String(req.body?.folder || '');
|
||||
const segments = folder.split('/').filter(Boolean).map(sanitizePathSegment);
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(projectId) || !segments.length) {
|
||||
return res.status(400).json({ error: 'invalid' });
|
||||
}
|
||||
const dir = caseFilesDir(session.username, APP_TYPE, projectId);
|
||||
const targetPath = path.join(dir, ...segments);
|
||||
if (!isPathInsideDir(dir, targetPath)) return res.status(403).json({ error: 'Forbidden' });
|
||||
fs.mkdirSync(targetPath, { recursive: true });
|
||||
res.json({ success: true, folder: segments.join('/') });
|
||||
});
|
||||
|
||||
app.post('/api/workshop/upload', (req, res) => {
|
||||
const session = getSessionUser(req);
|
||||
if (!session) { res.status(401).json({ success: false, error: 'Unauthorized' }); return; }
|
||||
const projectId = String(req.query.projectId || '');
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(projectId)) { res.status(400).json({ success: false, error: 'invalid projectId' }); return; }
|
||||
const folder = String(req.query.folder || '').split('/').filter(Boolean).map(sanitizePathSegment).join('/');
|
||||
const contentType = String(req.headers['content-type'] || '');
|
||||
if (!contentType.includes('multipart/form-data')) {
|
||||
res.status(400).json({ success: false, error: 'Content-Type must be multipart/form-data' }); return;
|
||||
}
|
||||
const boundary = contentType.split('boundary=')[1];
|
||||
if (!boundary) { res.status(400).json({ success: false, error: 'Missing boundary' }); return; }
|
||||
|
||||
const chunks: Buffer[] = [];
|
||||
req.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||||
req.on('end', () => {
|
||||
const raw = Buffer.concat(chunks).toString('binary');
|
||||
const boundaryDelim = '--' + boundary;
|
||||
|
||||
let filename = 'upload.bin';
|
||||
let filetype = 'application/octet-stream';
|
||||
let fileData: Buffer | null = null;
|
||||
|
||||
const parts = raw.split(boundaryDelim);
|
||||
for (const part of parts) {
|
||||
if (!part || part.trim() === '--' || part.trim() === '') continue;
|
||||
const headerEnd = part.indexOf('\r\n\r\n');
|
||||
if (headerEnd === -1) continue;
|
||||
const header = part.substring(0, headerEnd);
|
||||
if (!header.includes('name="file"')) continue;
|
||||
|
||||
const fnMatch = header.match(/filename\*=UTF-8''([^\r\n]+)/i)
|
||||
?? header.match(/filename="([^"]+)"/);
|
||||
if (fnMatch) {
|
||||
const raw8 = decodeURIComponent(fnMatch[1]) === fnMatch[1]
|
||||
? Buffer.from(fnMatch[1], 'binary').toString('utf-8')
|
||||
: decodeURIComponent(fnMatch[1]);
|
||||
filename = raw8;
|
||||
}
|
||||
const ctMatch = header.match(/Content-Type:\s*([^\r\n]+)/i);
|
||||
if (ctMatch) filetype = ctMatch[1].trim();
|
||||
|
||||
const bodyStart = headerEnd + 4;
|
||||
const bodyEnd = part.lastIndexOf('\r\n');
|
||||
if (bodyEnd <= bodyStart) continue;
|
||||
|
||||
fileData = Buffer.from(part.substring(bodyStart, bodyEnd), 'binary');
|
||||
break;
|
||||
}
|
||||
|
||||
if (!fileData) { res.status(400).json({ success: false, error: 'No file found in upload' }); return; }
|
||||
if (fileData.length > 100 * 1024 * 1024) { res.status(400).json({ success: false, error: 'File too large (max 100MB)' }); return; }
|
||||
|
||||
const dir = caseFilesDir(session.username, APP_TYPE, projectId);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const targetName = folder ? `${folder}/${filename}` : filename;
|
||||
const { relPath, absPath } = resolveUploadPath(dir, targetName);
|
||||
fs.writeFileSync(absPath, fileData);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
name: path.basename(relPath),
|
||||
relPath,
|
||||
size: fileData.length,
|
||||
type: filetype,
|
||||
url: `/api/files/${APP_TYPE}/${projectId}/${relPath.split('/').map(encodeURIComponent).join('/')}`,
|
||||
category: fileCategory(relPath),
|
||||
});
|
||||
});
|
||||
req.on('error', (err: any) => { res.status(500).json({ success: false, error: String(err?.message || err) }); });
|
||||
});
|
||||
|
||||
app.delete('/api/workshop/files', (req, res) => {
|
||||
const session = getSessionUser(req);
|
||||
if (!session) return res.status(401).json({ error: 'Unauthorized' });
|
||||
const projectId = String(req.body?.projectId || '');
|
||||
const relPath = String(req.body?.relPath || '');
|
||||
const segments = relPath.split('/').filter(Boolean);
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(projectId) || !segments.length || segments.some(s => s === '..' || s === '.')) {
|
||||
return res.status(400).json({ error: 'invalid' });
|
||||
}
|
||||
const dir = caseFilesDir(session.username, APP_TYPE, projectId);
|
||||
const targetPath = path.join(dir, ...segments);
|
||||
if (!isPathInsideDir(dir, targetPath)) return res.status(403).json({ error: 'Forbidden' });
|
||||
if (!fs.existsSync(targetPath)) { res.json({ success: true, alreadyGone: true }); return; }
|
||||
try {
|
||||
const stat = fs.statSync(targetPath);
|
||||
if (stat.isDirectory()) fs.rmSync(targetPath, { recursive: true, force: true });
|
||||
else fs.unlinkSync(targetPath);
|
||||
} catch (e: any) {
|
||||
res.status(500).json({ success: false, error: String(e?.message || e) }); return;
|
||||
}
|
||||
res.json({ success: true });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4613,7 +4613,7 @@ registerLawyerCaseRoutes(app, getSessionUser, isPathInsideDir);
|
||||
registerWopiRoutes(app, '/api/lawyer');
|
||||
registerDoctorCaseRoutes(app, getSessionUser, isPathInsideDir);
|
||||
registerWopiRoutes(app, '/api/doctor');
|
||||
registerWorkshopRoutes(app, getSessionUser);
|
||||
registerWorkshopRoutes(app, getSessionUser, isPathInsideDir);
|
||||
registerCadRoutes(app);
|
||||
|
||||
|
||||
|
||||
@@ -70,6 +70,13 @@ export async function renderStlPng(stlPath: string, view: string, outPngPath: st
|
||||
try { bbox = readStlBBox(stlPath); } catch (e: any) { return { ok: false, detail: String(e?.message || e) }; }
|
||||
const maxExtent = Math.max(bbox.max[0] - bbox.min[0], bbox.max[1] - bbox.min[1], bbox.max[2] - bbox.min[2]);
|
||||
const dist = Math.max(50, maxExtent * 3);
|
||||
// 카메라 시선 중심(look-at) — 원점이 아니라 실제 바운딩박스 중심으로 잡아야 원점에서
|
||||
// 떨어진 모델(예: 바닥이 Z=0이고 위로 솟은 조립체)도 위쪽이 잘리지 않는다.
|
||||
const center = [
|
||||
(bbox.min[0] + bbox.max[0]) / 2,
|
||||
(bbox.min[1] + bbox.max[1]) / 2,
|
||||
(bbox.min[2] + bbox.max[2]) / 2,
|
||||
];
|
||||
|
||||
const scadPath = outPngPath.replace(/\.png$/i, '.scad');
|
||||
fs.writeFileSync(scadPath, `import(${JSON.stringify(stlPath)});\n`);
|
||||
@@ -80,7 +87,7 @@ export async function renderStlPng(stlPath: string, view: string, outPngPath: st
|
||||
} else {
|
||||
const rot = VIEW_ROTATIONS[view];
|
||||
if (!rot) { try { fs.unlinkSync(scadPath); } catch { /* noop */ } return { ok: false, detail: `알 수 없는 view: ${view}` }; }
|
||||
scadArgs.push(`--camera=0,0,0,${rot[0]},${rot[1]},${rot[2]},${dist}`, '--projection=ortho');
|
||||
scadArgs.push(`--camera=${center[0]},${center[1]},${center[2]},${rot[0]},${rot[1]},${rot[2]},${dist}`, '--projection=ortho');
|
||||
}
|
||||
scadArgs.push(scadPath);
|
||||
|
||||
|
||||
@@ -157,6 +157,33 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
|
||||
/* ── 메모 ── */
|
||||
.rb-notes{width:100%;height:calc(100dvh - 220px);box-sizing:border-box;background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:12px 14px;font-size:12px;color:var(--text);font-family:inherit;resize:none;outline:none;line-height:1.7;}
|
||||
.rb-notes:focus{border-color:#f59e0b;}
|
||||
|
||||
/* ── 파일 ── */
|
||||
.rb-files-toolbar{display:flex;gap:8px;align-items:center;margin-bottom:10px;flex-wrap:wrap;}
|
||||
.rb-files-toolbar select{background:var(--panel-2);border:1px solid var(--line);border-radius:6px;padding:5px 8px;font-size:12px;color:var(--text);font-family:inherit;outline:none;}
|
||||
.rb-upload-btn{background:rgba(245,158,11,.1);border:1px solid rgba(245,158,11,.3);border-radius:6px;padding:5px 12px;font-size:11px;color:#f59e0b;cursor:pointer;font-weight:700;display:inline-flex;align-items:center;gap:4px;}
|
||||
.rb-upload-btn:hover{background:rgba(245,158,11,.25);}
|
||||
.rb-files-dropzone{border:2px dashed var(--line);border-radius:10px;padding:18px;text-align:center;font-size:12px;color:var(--muted);margin-bottom:14px;transition:.15s;}
|
||||
.rb-files-dropzone.dragover{border-color:#f59e0b;color:#f59e0b;background:rgba(245,158,11,.05);}
|
||||
.rb-files-empty{font-size:12px;color:var(--muted);padding:20px 0;text-align:center;}
|
||||
.rb-files-group{margin-bottom:18px;}
|
||||
.rb-files-group-hdr{display:flex;align-items:center;gap:8px;margin-bottom:8px;}
|
||||
.rb-files-group-name{font-size:12px;font-weight:700;color:var(--text);}
|
||||
.rb-files-group-count{font-size:10px;color:var(--muted);}
|
||||
.rb-files-group-del{margin-left:auto;background:none;border:none;cursor:pointer;font-size:11px;color:var(--muted);}
|
||||
.rb-files-group-del:hover{color:#ef4444;}
|
||||
.rb-files-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(120px,1fr));gap:10px;}
|
||||
.rb-file-card{border:1px solid var(--line);border-radius:8px;overflow:hidden;background:var(--panel);position:relative;}
|
||||
.rb-file-thumb{width:100%;aspect-ratio:1/1;background:var(--panel-2);display:flex;align-items:center;justify-content:center;overflow:hidden;}
|
||||
.rb-file-thumb img{width:100%;height:100%;object-fit:cover;}
|
||||
.rb-file-thumb .rb-file-icon{font-size:28px;opacity:.6;}
|
||||
.rb-file-info{padding:6px 8px;}
|
||||
.rb-file-name{font-size:11px;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
||||
.rb-file-size{font-size:10px;color:var(--muted);}
|
||||
.rb-file-del{position:absolute;top:4px;right:4px;background:rgba(0,0,0,.55);border:none;border-radius:5px;color:#fff;font-size:10px;padding:2px 5px;cursor:pointer;opacity:0;transition:.15s;}
|
||||
.rb-file-card:hover .rb-file-del{opacity:1;}
|
||||
.rb-file-del:hover{background:#ef4444;}
|
||||
.rb-file-card a{display:block;color:inherit;text-decoration:none;}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -185,6 +212,7 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
|
||||
<button class="rb-tab-btn active" data-tab="parts" onclick="switchTab('parts')">부품</button>
|
||||
<button class="rb-tab-btn" data-tab="tasks" onclick="switchTab('tasks')">작업</button>
|
||||
<button class="rb-tab-btn" data-tab="equip" onclick="switchTab('equip')">장비</button>
|
||||
<button class="rb-tab-btn" data-tab="files" onclick="switchTab('files')">파일</button>
|
||||
<button class="rb-tab-btn" data-tab="notes" onclick="switchTab('notes')">메모</button>
|
||||
</div>
|
||||
|
||||
@@ -222,6 +250,16 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rb-pane" id="pane-files">
|
||||
<div class="rb-files-toolbar">
|
||||
<select id="upload-folder-select"></select>
|
||||
<button class="hdr-btn" onclick="promptNewFolder()">+ 새 폴더</button>
|
||||
<label class="rb-upload-btn">📎 파일 업로드<input type="file" id="file-input" multiple style="display:none" onchange="handleFileInputChange(event)"></label>
|
||||
</div>
|
||||
<div class="rb-files-dropzone" id="files-dropzone">여기로 파일을 끌어다 놓거나 위 버튼으로 업로드 (사진/CAD/STL 등, 최대 100MB)</div>
|
||||
<div id="files-list-container"></div>
|
||||
</div>
|
||||
|
||||
<div class="rb-pane" id="pane-notes">
|
||||
<textarea class="rb-notes" id="notes-textarea" placeholder="자유 메모..." oninput="onNotesChange()"></textarea>
|
||||
</div>
|
||||
@@ -320,6 +358,7 @@ function switchProject(id){
|
||||
loadLocalFromProject();
|
||||
refreshAllPanes();
|
||||
scheduleSave();
|
||||
if(document.getElementById('pane-files').classList.contains('active'))loadFiles();
|
||||
}
|
||||
|
||||
function createProjectPrompt(){
|
||||
@@ -390,6 +429,7 @@ function switchTab(tab){
|
||||
document.querySelectorAll('.rb-pane').forEach(p=>p.classList.remove('active'));
|
||||
document.getElementById('pane-'+tab).classList.add('active');
|
||||
if(tab==='equip')startEquipTimers();else stopEquipTimers();
|
||||
if(tab==='files')loadFiles();
|
||||
}
|
||||
|
||||
// ── 장비 위젯(K2/밀링) ────────────────────────────────────────────────────────
|
||||
@@ -731,6 +771,182 @@ function deleteTask(phaseId,taskId){
|
||||
scheduleSave();
|
||||
}
|
||||
|
||||
// ── 파일 ──────────────────────────────────────────────────────────────────────
|
||||
// 부품/작업/메모와 달리 workshopData(JSON)에 안 담고, 서버 디스크에 실제 파일로
|
||||
// 저장된다(case-storage.ts, doctor/lawyer 앱과 동일 패턴) — /api/workshop/files 로
|
||||
// 목록을 받아 그때그때 다시 그린다.
|
||||
const FILE_ICONS={photo:'🖼️',video:'🎬',document:'📄',other:'📦'};
|
||||
let filesState={files:[],folders:[]};
|
||||
|
||||
async function loadFiles(){
|
||||
const proj=currentProject();
|
||||
if(!proj)return;
|
||||
const container=document.getElementById('files-list-container');
|
||||
try{
|
||||
const r=await fetch('/api/workshop/files?projectId='+encodeURIComponent(proj.id),{headers:authH()});
|
||||
if(!r.ok)throw new Error('불러오기 실패');
|
||||
const d=await r.json();
|
||||
filesState.files=Array.isArray(d.files)?d.files:[];
|
||||
filesState.folders=Array.isArray(d.folders)?d.folders:[];
|
||||
}catch{
|
||||
container.innerHTML='<div class="rb-files-empty">파일 목록을 불러오지 못했습니다.</div>';
|
||||
return;
|
||||
}
|
||||
renderFiles();
|
||||
}
|
||||
|
||||
function renderFolderSelect(){
|
||||
const sel=document.getElementById('upload-folder-select');
|
||||
const prev=sel.value;
|
||||
sel.innerHTML='<option value="">(폴더 없음)</option>'+filesState.folders.map(f=>`<option value="${escAttr(f)}">${escAttr(f)}</option>`).join('');
|
||||
if([...sel.options].some(o=>o.value===prev))sel.value=prev;
|
||||
}
|
||||
|
||||
function renderFiles(){
|
||||
renderFolderSelect();
|
||||
const container=document.getElementById('files-list-container');
|
||||
if(!filesState.files.length&&!filesState.folders.length){
|
||||
container.innerHTML='<div class="rb-files-empty">아직 첨부된 파일이 없습니다. 사진/CAD/STL 등을 업로드해 보세요.</div>';
|
||||
return;
|
||||
}
|
||||
// 폴더별로 묶기 — 최상위(폴더 없음) 파일은 "미분류"로.
|
||||
const groups=new Map();
|
||||
for(const f of filesState.folders)groups.set(f,[]);
|
||||
for(const file of filesState.files){
|
||||
const key=file.folder||'';
|
||||
if(!groups.has(key))groups.set(key,[]);
|
||||
groups.get(key).push(file);
|
||||
}
|
||||
const keys=[...groups.keys()].sort((a,b)=>{
|
||||
if(a==='')return 1; if(b==='')return -1;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
container.innerHTML=keys.map(key=>{
|
||||
const files=groups.get(key)||[];
|
||||
const label=key||'미분류';
|
||||
const delBtn=key?`<button class="rb-files-group-del" onclick="deleteFolder('${escAttr(key)}')">🗑 폴더 삭제</button>`:'';
|
||||
const grid=files.length?`<div class="rb-files-grid">${files.map(renderFileCard).join('')}</div>`
|
||||
:'<div class="rb-files-empty" style="padding:8px 0">(빈 폴더)</div>';
|
||||
return `<div class="rb-files-group">
|
||||
<div class="rb-files-group-hdr">
|
||||
<span class="rb-files-group-name">📁 ${escAttr(label)}</span>
|
||||
<span class="rb-files-group-count">${files.length}개</span>
|
||||
${delBtn}
|
||||
</div>
|
||||
${grid}
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
const STL_RE=/\.stl$/i;
|
||||
function renderFileCard(file){
|
||||
const isStl=STL_RE.test(file.name);
|
||||
const icon=isStl?'🔧':(FILE_ICONS[file.category]||FILE_ICONS.other);
|
||||
const thumb=file.category==='photo'
|
||||
? `<img src="${escAttr(file.url)}" alt="${escAttr(file.name)}" loading="lazy">`
|
||||
: `<span class="rb-file-icon">${icon}</span>`;
|
||||
// STL은 새탭 다운로드 대신 Three.js CAD 편집기 팝업으로 연다(작업실↔K2 CAD 플러그인 연동).
|
||||
const openAttrs=isStl?`href="javascript:void(0)" onclick="openCadEditorForFile('${escAttr(file.relPath)}')"`:`href="${escAttr(file.url)}" target="_blank" rel="noopener"`;
|
||||
return `<div class="rb-file-card" data-rel="${escAttr(file.relPath)}">
|
||||
<button class="rb-file-del" onclick="deleteFile('${escAttr(file.relPath)}')" title="삭제">✕</button>
|
||||
<a ${openAttrs}>
|
||||
<div class="rb-file-thumb">${thumb}</div>
|
||||
<div class="rb-file-info">
|
||||
<div class="rb-file-name" title="${escAttr(file.name)}">${escAttr(file.name)}</div>
|
||||
<div class="rb-file-size">${fmtFileSize(file.size)}${isStl?' · CAD 편집기에서 열기':''}</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// K2 프린터 앱의 "🔧 STL 편집기"(Three.js, cad-editor-app.html)를 그대로 재사용 — STL 목록은
|
||||
// 워크스페이스 전체를 훑으므로(findStlFiles) 작업실 파일도 이미 그 목록에 잡힌다. ?path= 로
|
||||
// 어느 파일을 열지 지정하면 편집기가 자동으로 로드한다(cad-editor-view.js 09-22 추가).
|
||||
function openCadEditorForFile(relPath){
|
||||
const proj=currentProject();
|
||||
if(!proj)return;
|
||||
const workspacePath='workshop/'+proj.id+'/'+relPath;
|
||||
const w=1320,h=860;
|
||||
const left=Math.max(0,(screen.width-w)/2), top=Math.max(0,(screen.height-h)/2);
|
||||
window.open('/html/cad-editor-app.html?path='+encodeURIComponent(workspacePath),'cad-editor',`width=${w},height=${h},left=${left},top=${top},menubar=no,toolbar=no,location=no,status=no,resizable=yes`);
|
||||
}
|
||||
|
||||
function fmtFileSize(n){
|
||||
if(!n&&n!==0)return '';
|
||||
if(n<1024)return n+'B';
|
||||
if(n<1024*1024)return (n/1024).toFixed(1)+'KB';
|
||||
return (n/1024/1024).toFixed(1)+'MB';
|
||||
}
|
||||
|
||||
async function promptNewFolder(){
|
||||
const name=(prompt('새 폴더 이름을 입력하세요 (예: 사진, CAD, STL):')||'').trim();
|
||||
if(!name)return;
|
||||
const proj=currentProject();
|
||||
if(!proj)return;
|
||||
try{
|
||||
const r=await fetch('/api/workshop/files/folder',{method:'POST',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({projectId:proj.id,folder:name})});
|
||||
if(!r.ok)throw new Error();
|
||||
await loadFiles();
|
||||
}catch{alert('폴더 생성 실패');}
|
||||
}
|
||||
|
||||
async function deleteFolder(folder){
|
||||
if(!confirm(`"${folder}" 폴더와 그 안의 파일이 모두 삭제됩니다. 계속할까요?`))return;
|
||||
const proj=currentProject();
|
||||
if(!proj)return;
|
||||
try{
|
||||
await fetch('/api/workshop/files',{method:'DELETE',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({projectId:proj.id,relPath:folder})});
|
||||
}catch{}
|
||||
await loadFiles();
|
||||
}
|
||||
|
||||
async function deleteFile(relPath){
|
||||
if(!confirm('이 파일을 삭제할까요?'))return;
|
||||
const proj=currentProject();
|
||||
if(!proj)return;
|
||||
try{
|
||||
await fetch('/api/workshop/files',{method:'DELETE',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({projectId:proj.id,relPath})});
|
||||
}catch{}
|
||||
await loadFiles();
|
||||
}
|
||||
|
||||
async function uploadFiles(fileList){
|
||||
const proj=currentProject();
|
||||
if(!proj||!fileList||!fileList.length)return;
|
||||
const folder=document.getElementById('upload-folder-select').value;
|
||||
const zone=document.getElementById('files-dropzone');
|
||||
const origText=zone.textContent;
|
||||
for(const file of fileList){
|
||||
zone.textContent=`업로드 중… ${file.name}`;
|
||||
const fd=new FormData();
|
||||
fd.append('file',file);
|
||||
try{
|
||||
const qs='projectId='+encodeURIComponent(proj.id)+(folder?'&folder='+encodeURIComponent(folder):'');
|
||||
const r=await fetch('/api/workshop/upload?'+qs,{method:'POST',headers:authH(),body:fd});
|
||||
if(!r.ok){const d=await r.json().catch(()=>({}));throw new Error(d.error||'업로드 실패');}
|
||||
}catch(e){
|
||||
alert(`"${file.name}" 업로드 실패: ${e.message}`);
|
||||
}
|
||||
}
|
||||
zone.textContent=origText;
|
||||
await loadFiles();
|
||||
}
|
||||
|
||||
function handleFileInputChange(ev){
|
||||
uploadFiles(ev.target.files);
|
||||
ev.target.value='';
|
||||
}
|
||||
|
||||
(function initDropzone(){
|
||||
const zone=document.getElementById('files-dropzone');
|
||||
['dragenter','dragover'].forEach(evt=>zone.addEventListener(evt,e=>{e.preventDefault();zone.classList.add('dragover');}));
|
||||
['dragleave','drop'].forEach(evt=>zone.addEventListener(evt,e=>{e.preventDefault();zone.classList.remove('dragover');}));
|
||||
zone.addEventListener('drop',e=>{
|
||||
const files=e.dataTransfer?.files;
|
||||
if(files&&files.length)uploadFiles(files);
|
||||
});
|
||||
})();
|
||||
|
||||
// ── 메모 ──────────────────────────────────────────────────────────────────────
|
||||
function onNotesChange(){
|
||||
notes=document.getElementById('notes-textarea').value;
|
||||
|
||||
@@ -612,6 +612,12 @@
|
||||
const ro = new ResizeObserver(() => resize());
|
||||
ro.observe(vpEl);
|
||||
requestAnimationFrame(() => { resize(); requestAnimationFrame(resize); });
|
||||
// 다른 앱(작업실 파일 탭 등)에서 특정 STL을 바로 열도록 ?path= 로 넘겨줄 수 있다.
|
||||
const preselect = new URLSearchParams(location.search).get('path');
|
||||
if (preselect && files.includes(preselect)) {
|
||||
panelEl.querySelector('#ce-file').value = preselect;
|
||||
loadSelected();
|
||||
}
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
|
||||
Reference in New Issue
Block a user