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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user