- ▶ 라이브 버튼 → 카드 안에서 상시 WebRTC 스트리밍(k2-app.html의 검증된 연결 로직 이식: STUN 필수·오퍼 null-candidate/1.5초 중 빠른 쪽 퀴크 포함) - 라이브 중엔 20초 스냅샷 그랩(WebRTC 풀협상)을 건너뛴다 — 비디오가 이미 실시간이고 짧은 연결이 간섭하므로. 상태 조회(Moonraker)는 계속, 상태줄에 "● 실시간" 표시 - 장비 탭을 벗어나면 stopEquipTimers가 라이브 연결을 닫는다(안 보는 동안 자원 안 쓰기), ICE failed면 자동 종료 후 안내문 Co-Authored-By: Claude Code <noreply@anthropic.com>
1468 lines
78 KiB
HTML
1468 lines
78 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="ko">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>🛠️ 작업실</title>
|
|
<link rel="icon" type="image/png" sizes="64x64" href="/homeclaw_logo.png">
|
|
<link rel="stylesheet" href="../css/styles.css">
|
|
<script>try{var _t=localStorage.getItem('homeclaw_theme')||'dark';document.documentElement.setAttribute('data-theme',_t);}catch(e){}</script>
|
|
<style>
|
|
html,body{height:100%;margin:0;padding:0;}
|
|
body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;display:flex;flex-direction:column;overflow:hidden;height:100dvh;}
|
|
|
|
.rb-hdr{display:flex;align-items:center;gap:8px;padding:6px 12px;background:var(--panel);border-bottom:1px solid var(--line);flex-shrink:0;}
|
|
.rb-hdr h1{font-size:13px;font-weight:700;margin:0;}
|
|
.rb-hdr-sp{flex:1;}
|
|
.hdr-btn{background:none;border:1.5px solid var(--line);border-radius:6px;padding:3px 9px;font-size:11px;color:var(--muted);cursor:pointer;font-family:inherit;transition:.15s;}
|
|
.hdr-btn:hover{border-color:#f59e0b;color:#f59e0b;}
|
|
.rb-save-indicator{font-size:10px;color:var(--muted);transition:.3s;flex-shrink:0;}
|
|
.ws-project-select{background:var(--panel-2);border:1px solid var(--line);border-radius:6px;padding:4px 8px;font-size:12px;font-weight:700;color:var(--text);font-family:inherit;outline:none;max-width:220px;}
|
|
.ws-project-select:focus{border-color:#f59e0b;}
|
|
|
|
.rb-budget{display:flex;gap:0;border-bottom:1px solid var(--line);background:var(--panel);flex-shrink:0;flex-wrap:wrap;}
|
|
.rb-budget-item{flex:1;min-width:120px;padding:8px 14px;border-right:1px solid var(--line);}
|
|
.rb-budget-item:last-child{border-right:none;background:rgba(245,158,11,.06);}
|
|
.rb-budget-label{font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;}
|
|
.rb-budget-val{font-size:15px;font-weight:700;margin-top:2px;}
|
|
|
|
.rb-layout{flex:1;display:flex;min-height:0;overflow:hidden;}
|
|
.rb-main{flex:1;display:flex;flex-direction:column;min-width:0;overflow:hidden;}
|
|
|
|
.rb-tabs{display:flex;border-bottom:1px solid var(--line);background:var(--panel);flex-shrink:0;padding:0 14px;}
|
|
.rb-tab-btn{background:none;border:none;border-bottom:2px solid transparent;padding:8px 14px;font-size:12px;font-weight:600;color:var(--muted);cursor:pointer;font-family:inherit;transition:.15s;margin-bottom:-1px;}
|
|
.rb-tab-btn.active{color:#f59e0b;border-bottom-color:#f59e0b;}
|
|
.rb-tab-btn:hover:not(.active){color:var(--text);}
|
|
.rb-body{flex:1;overflow-y:auto;padding:14px;min-height:0;}
|
|
.rb-pane{display:none;}
|
|
.rb-pane.active{display:block;}
|
|
|
|
/* ── 채팅 패널 ── */
|
|
.rb-resize{width:4px;flex-shrink:0;background:var(--line);cursor:col-resize;transition:.15s;}
|
|
.rb-resize:hover,.rb-resize.dragging{background:#f59e0b;}
|
|
.rb-chat{width:340px;flex-shrink:0;display:flex;flex-direction:column;border-left:1px solid var(--line);background:var(--panel);min-height:0;}
|
|
.rb-chat-hdr{display:flex;align-items:center;padding:6px 10px;border-bottom:1px solid var(--line);flex-shrink:0;gap:6px;}
|
|
.rb-chat-label{font-size:10px;font-weight:700;color:#f59e0b;text-transform:uppercase;letter-spacing:.07em;flex-shrink:0;}
|
|
.rb-chat-clear{background:none;border:none;cursor:pointer;font-size:14px;color:var(--muted);padding:2px 4px;transition:.12s;flex-shrink:0;}
|
|
.rb-chat-clear:hover{color:#ef4444;}
|
|
.rb-msgs{flex:1;overflow-y:auto;padding:10px;display:flex;flex-direction:column;gap:8px;}
|
|
.rb-msgs::-webkit-scrollbar{width:3px;}
|
|
.rb-msgs::-webkit-scrollbar-thumb{background:var(--line);border-radius:3px;}
|
|
.rb-msg{display:flex;flex-direction:column;}
|
|
.rb-msg.user{align-items:flex-end;}
|
|
.rb-msg.assistant{align-items:flex-start;}
|
|
.rb-msg.system{align-items:center;}
|
|
.rb-bubble{padding:7px 11px;border-radius:10px;font-size:12px;line-height:1.55;max-width:93%;word-break:break-word;}
|
|
.rb-msg.user .rb-bubble{background:#3d2c05;color:#fde9b8;border-radius:10px 10px 3px 10px;}
|
|
.rb-msg.assistant .rb-bubble{background:var(--panel-2);border:1px solid var(--line);border-radius:10px 10px 10px 3px;}
|
|
.rb-msg.system .rb-bubble{background:transparent;color:var(--muted);font-size:11px;}
|
|
.rb-bubble p{margin:3px 0;}
|
|
.rb-bubble h3{font-size:13px;margin:6px 0 3px;}
|
|
.rb-bubble ul,.rb-bubble ol{padding-left:16px;margin:3px 0;}
|
|
.rb-bubble pre{background:var(--bg);border:1px solid var(--line);border-radius:6px;padding:8px;overflow-x:auto;font-size:11px;margin:6px 0;}
|
|
.rb-bubble strong{font-weight:700;}
|
|
.rb-typing{display:flex;gap:3px;align-items:center;padding:6px 10px;}
|
|
.rb-dot{width:5px;height:5px;background:var(--muted);border-radius:50%;animation:rbDot 1.2s ease-in-out infinite;}
|
|
.rb-dot:nth-child(2){animation-delay:.2s;}.rb-dot:nth-child(3){animation-delay:.4s;}
|
|
@keyframes rbDot{0%,80%,100%{transform:scale(.5);opacity:.4;}40%{transform:scale(1);opacity:1;}}
|
|
.rb-input-bar{flex-shrink:0;padding:8px;border-top:1px solid var(--line);display:flex;gap:6px;align-items:flex-end;}
|
|
.rb-textarea{flex:1;background:var(--panel-2);border:1px solid var(--line);border-radius:8px;padding:7px 10px;font-size:12px;color:var(--text);font-family:inherit;resize:none;min-height:34px;max-height:120px;outline:none;transition:.15s;}
|
|
.rb-textarea:focus{border-color:#f59e0b;}
|
|
.rb-send{background:#7c4a03;border:none;border-radius:8px;padding:7px 13px;font-size:12px;color:#fff;cursor:pointer;font-family:inherit;font-weight:700;transition:.15s;flex-shrink:0;}
|
|
.rb-send:hover:not(:disabled){background:#9a5c03;}
|
|
.rb-send:disabled{opacity:.5;cursor:not-allowed;}
|
|
|
|
.rb-model-pill{position:relative;flex-shrink:0;}
|
|
.rb-model-pill-btn{background:none;border:1px solid var(--line);border-radius:6px;padding:2px 8px;font-size:10px;font-weight:700;color:var(--muted);cursor:pointer;font-family:inherit;max-width:120px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;transition:.15s;}
|
|
.rb-model-pill-btn:hover{border-color:#f59e0b;color:#f59e0b;}
|
|
.rb-model-dropdown{display:none;position:absolute;top:100%;right:0;margin-top:4px;background:var(--panel-2);border:1px solid var(--line);border-radius:8px;box-shadow:0 4px 16px rgba(0,0,0,.35);min-width:200px;max-height:280px;overflow-y:auto;z-index:50;padding:4px;}
|
|
.rb-model-dropdown.open{display:block;}
|
|
.rb-model-opt{padding:6px 10px;font-size:12px;border-radius:5px;cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:var(--text);}
|
|
.rb-model-opt:hover{background:var(--panel);}
|
|
.rb-model-opt.current{color:#f59e0b;font-weight:700;}
|
|
.rb-model-dropdown-msg{padding:6px 10px;font-size:11px;color:var(--muted);}
|
|
|
|
@media (max-width:900px){
|
|
.rb-layout{flex-direction:column;}
|
|
.rb-resize{display:none;}
|
|
.rb-chat{width:auto;height:38vh;border-left:none;border-top:1px solid var(--line);}
|
|
}
|
|
|
|
/* ── 부품 카드 ── */
|
|
.rb-part-card{border:1px solid var(--line);border-radius:10px;margin-bottom:8px;overflow:hidden;background:var(--panel);}
|
|
.rb-part-hdr{display:flex;align-items:center;gap:8px;padding:9px 12px;cursor:pointer;user-select:none;}
|
|
.rb-part-hdr:hover{background:rgba(245,158,11,.05);}
|
|
.rb-part-chevron{font-size:10px;color:var(--muted);transition:transform .15s;flex-shrink:0;}
|
|
.rb-part-card.expanded .rb-part-chevron{transform:rotate(90deg);}
|
|
.rb-part-name{flex:1;font-size:13px;font-weight:600;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
|
.rb-part-badge{font-size:10px;padding:2px 8px;border-radius:10px;flex-shrink:0;font-weight:700;}
|
|
.rb-part-badge.st-검토중{background:rgba(107,114,128,.15);color:#9ca3af;}
|
|
.rb-part-badge.st-주문완료{background:rgba(59,130,246,.15);color:#60a5fa;}
|
|
.rb-part-badge.st-보유{background:rgba(34,197,94,.15);color:#4ade80;}
|
|
.rb-part-sub{font-size:12px;color:var(--muted);flex-shrink:0;min-width:80px;text-align:right;}
|
|
.rb-part-body{display:none;padding:4px 12px 12px;border-top:1px solid var(--line);}
|
|
.rb-part-card.expanded .rb-part-body{display:block;}
|
|
.rb-part-row{display:flex;gap:8px;margin-top:8px;flex-wrap:wrap;}
|
|
.rb-part-field{display:flex;flex-direction:column;gap:2px;}
|
|
.rb-part-field label{font-size:9px;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;}
|
|
.rb-part-field input[type=text],.rb-part-field input[type=number],.rb-part-field 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-part-field input:focus,.rb-part-field select:focus{border-color:#f59e0b;}
|
|
.rb-part-field.grow{flex:1;min-width:120px;}
|
|
.rb-part-field.qty{width:64px;}
|
|
.rb-part-field.price{width:110px;}
|
|
.rb-part-field.status{width:100px;}
|
|
.rb-part-links{margin-top:10px;}
|
|
.rb-part-links label{font-size:9px;color:var(--muted);text-transform:uppercase;letter-spacing:.04em;}
|
|
.rb-link-row{display:flex;gap:6px;margin-top:6px;align-items:center;}
|
|
.rb-link-row input[type=text]{flex:1;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-link-row input:focus{border-color:#f59e0b;}
|
|
.rb-link-row input.rb-link-label{flex:0 0 110px;}
|
|
.rb-link-open{background:none;border:1px solid var(--line);border-radius:6px;padding:4px 8px;font-size:11px;color:var(--muted);text-decoration:none;flex-shrink:0;}
|
|
.rb-link-open:hover{border-color:#f59e0b;color:#f59e0b;}
|
|
.rb-link-add{margin-top:6px;background:none;border:1px dashed var(--line);border-radius:6px;padding:4px 10px;font-size:11px;color:var(--muted);cursor:pointer;font-family:inherit;}
|
|
.rb-link-add:hover{border-color:#f59e0b;color:#f59e0b;}
|
|
.rb-part-actions{display:flex;justify-content:flex-end;margin-top:8px;}
|
|
.rb-del-btn{background:none;border:1px solid var(--line);border-radius:6px;cursor:pointer;font-size:11px;color:var(--muted);padding:4px 10px;}
|
|
.rb-del-btn:hover{color:#ef4444;border-color:#ef4444;}
|
|
.rb-add-btn{margin-top:10px;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-family:inherit;font-weight:700;}
|
|
.rb-add-btn:hover{background:rgba(245,158,11,.25);}
|
|
|
|
/* ── 작업(단계) ── */
|
|
.rb-phase{border:1px solid var(--line);border-radius:8px;margin-bottom:12px;overflow:hidden;}
|
|
.rb-phase-hdr{display:flex;align-items:center;gap:6px;padding:8px 10px;background:var(--panel);}
|
|
.rb-phase-name{flex:1;background:transparent;border:none;font-size:13px;font-weight:700;color:var(--text);font-family:inherit;outline:none;}
|
|
.rb-phase-tasks{padding:6px 10px 10px;}
|
|
.rb-task-row{display:flex;align-items:center;gap:6px;padding:3px 0;}
|
|
.rb-task-row input[type=text]{flex:1;background:var(--panel-2);border:1px solid var(--line);border-radius:5px;padding:4px 8px;font-size:12px;color:var(--text);font-family:inherit;outline:none;}
|
|
.rb-task-row input[type=text]:focus{border-color:#f59e0b;}
|
|
.rb-task-row input[type=text].done{text-decoration:line-through;color:var(--muted);}
|
|
.rb-task-add,.rb-phase-add{background:none;border:1px dashed var(--line);border-radius:6px;padding:5px 10px;font-size:11px;color:var(--muted);cursor:pointer;font-family:inherit;margin-top:4px;}
|
|
.rb-task-add:hover,.rb-phase-add:hover{border-color:#f59e0b;color:#f59e0b;}
|
|
|
|
/* ── 장비 위젯 ── */
|
|
.rb-equip-grid{display:flex;gap:14px;flex-wrap:wrap;}
|
|
.rb-equip-card{flex:1;min-width:300px;max-width:480px;border:1px solid var(--line);border-radius:10px;overflow:hidden;background:var(--panel);}
|
|
.rb-equip-hdr{display:flex;align-items:center;gap:6px;padding:8px 12px;border-bottom:1px solid var(--line);font-size:12px;font-weight:700;}
|
|
.rb-equip-dot{width:7px;height:7px;border-radius:50%;background:#6b7280;flex-shrink:0;}
|
|
.rb-equip-dot.on{background:#22c55e;}
|
|
.rb-equip-dot.off{background:#ef4444;}
|
|
.rb-equip-refresh{margin-left:auto;background:none;border:none;cursor:pointer;font-size:12px;color:var(--muted);}
|
|
.rb-equip-refresh:hover{color:#f59e0b;}
|
|
.rb-equip-frame-wrap{width:100%;aspect-ratio:16/9;background:#000;display:flex;align-items:center;justify-content:center;overflow:hidden;position:relative;}
|
|
.rb-equip-frame-wrap img{width:100%;height:100%;object-fit:contain;}
|
|
.rb-equip-frame-wrap .rb-equip-placeholder{color:#666;font-size:11px;}
|
|
.rb-equip-live-btn{position:absolute;right:8px;bottom:8px;background:rgba(0,0,0,.55);color:#fff;border:1px solid rgba(255,255,255,.3);border-radius:6px;padding:4px 10px;font-size:11px;cursor:pointer;z-index:2;}
|
|
.rb-equip-live-btn:hover{background:rgba(245,158,11,.85);border-color:transparent;}
|
|
.rb-equip-live-btn.on{background:rgba(239,68,68,.8);border-color:transparent;}
|
|
.rb-equip-status{padding:8px 12px;font-size:11px;color:var(--muted);line-height:1.7;}
|
|
.rb-equip-status b{color:var(--text);}
|
|
|
|
/* ── 메모 ── */
|
|
.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;}
|
|
/* STL 카드 전용 — 슬라이서로 보낼 파일 다중선택 체크박스(좌상단). 평소엔 델버튼처럼
|
|
숨어있다 호버 시 보이되, 체크된 상태면 계속 보임(선택 중임을 표시). */
|
|
.rb-file-stl-sel{position:absolute;top:4px;left:4px;z-index:2;width:16px;height:16px;cursor:pointer;opacity:0;transition:.15s;accent-color:#f59e0b;}
|
|
.rb-file-card:hover .rb-file-stl-sel,.rb-file-stl-sel:checked{opacity:1;}
|
|
.rb-file-card.stl-selected{outline:2px solid #f59e0b;outline-offset:-1px;}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<div class="rb-hdr">
|
|
<a href="/" style="text-decoration:none;font-size:13px;color:var(--muted)">← 홈</a>
|
|
<h1>🛠️ 작업실</h1>
|
|
<select class="ws-project-select" id="project-select" onchange="switchProject(this.value)"></select>
|
|
<button class="hdr-btn" onclick="createProjectPrompt()">+ 새 프로젝트</button>
|
|
<button class="hdr-btn" onclick="deleteProjectPrompt()">🗑 삭제</button>
|
|
<span class="rb-hdr-sp"></span>
|
|
<span class="rb-save-indicator" id="save-indicator"></span>
|
|
<button class="hdr-btn" id="theme-btn" onclick="toggleTheme()">🌙</button>
|
|
</div>
|
|
|
|
<div class="rb-budget">
|
|
<div class="rb-budget-item"><div class="rb-budget-label">검토중</div><div class="rb-budget-val" id="sum-review">0원</div></div>
|
|
<div class="rb-budget-item"><div class="rb-budget-label">주문완료</div><div class="rb-budget-val" id="sum-ordered">0원</div></div>
|
|
<div class="rb-budget-item"><div class="rb-budget-label">보유</div><div class="rb-budget-val" id="sum-owned">0원</div></div>
|
|
<div class="rb-budget-item"><div class="rb-budget-label">총 예상비용</div><div class="rb-budget-val" id="sum-total">0원</div></div>
|
|
</div>
|
|
|
|
<div class="rb-layout">
|
|
<div class="rb-main">
|
|
<div class="rb-tabs">
|
|
<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="overview" onclick="switchTab('overview')">개요</button>
|
|
<button class="rb-tab-btn" data-tab="notes" onclick="switchTab('notes')">메모</button>
|
|
</div>
|
|
|
|
<div class="rb-body">
|
|
<div class="rb-pane active" id="pane-parts">
|
|
<div id="parts-container"></div>
|
|
<button class="rb-add-btn" onclick="addPart()">+ 부품 추가</button>
|
|
</div>
|
|
|
|
<div class="rb-pane" id="pane-tasks">
|
|
<div id="phases-container"></div>
|
|
<button class="rb-phase-add" onclick="addPhase()">+ 단계 추가</button>
|
|
</div>
|
|
|
|
<div class="rb-pane" id="pane-equip">
|
|
<div class="rb-equip-grid">
|
|
<div class="rb-equip-card">
|
|
<div class="rb-equip-hdr">
|
|
<span class="rb-equip-dot" id="k2-dot"></span>
|
|
<span>🖨️ K2 콤보</span>
|
|
<button class="rb-equip-refresh" onclick="refreshK2Widget()">↺</button>
|
|
</div>
|
|
<div class="rb-equip-frame-wrap">
|
|
<video id="k2-live" autoplay muted playsinline style="display:none;width:100%;height:100%;object-fit:contain"></video>
|
|
<img id="k2-frame" alt="" style="display:none">
|
|
<span class="rb-equip-placeholder" id="k2-placeholder">불러오는 중...</span>
|
|
<button class="rb-equip-live-btn" id="k2-live-btn" onclick="toggleK2Live()">▶ 라이브</button>
|
|
</div>
|
|
<div class="rb-equip-status" id="k2-status-text">-</div>
|
|
</div>
|
|
<div class="rb-equip-card">
|
|
<div class="rb-equip-hdr">
|
|
<span class="rb-equip-dot" id="mill-dot"></span>
|
|
<span>⚙️ CNC 밀링(프린터실)</span>
|
|
<button class="rb-equip-refresh" onclick="refreshMillWidget()">↺</button>
|
|
</div>
|
|
<div class="rb-equip-frame-wrap"><img id="mill-frame" alt="" style="display:none" onerror="millFrameError()" onload="millFrameLoaded()"><span class="rb-equip-placeholder" id="mill-placeholder">불러오는 중...</span></div>
|
|
<div class="rb-equip-status">20초마다 자동 새로고침(스냅샷) — <a href="/html/milling-app.html" target="_blank" style="color:#f59e0b">밀링 앱에서 라이브로 보기 ↗</a></div>
|
|
</div>
|
|
</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>
|
|
<button class="hdr-btn" id="stl-slice-btn" onclick="openSlicerForSelected()" disabled>🔪 선택 STL 슬라이서로 (<span id="stl-sel-count">0</span>)</button>
|
|
</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-overview">
|
|
<textarea class="rb-notes" id="overview-textarea" placeholder="프로젝트 개요 — 무엇을 만드는지/목표/구성. 채팅에서 '개요 써줘'로도 쓸 수 있다." oninput="onOverviewChange()"></textarea>
|
|
</div>
|
|
|
|
<div class="rb-pane" id="pane-notes">
|
|
<textarea class="rb-notes" id="notes-textarea" placeholder="자유 메모..." oninput="onNotesChange()"></textarea>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="rb-resize" id="resize-handle"></div>
|
|
|
|
<div class="rb-chat" id="chat-panel">
|
|
<div class="rb-chat-hdr">
|
|
<span class="rb-chat-label" id="chat-label">🛠️ 작업실 채팅</span>
|
|
<span class="rb-hdr-sp"></span>
|
|
<div class="rb-model-pill">
|
|
<button class="rb-model-pill-btn" id="model-pill-btn" onclick="toggleModelDropdown(event)" title="모델 변경">-</button>
|
|
<div class="rb-model-dropdown" id="model-dropdown"></div>
|
|
</div>
|
|
<button class="rb-chat-clear" onclick="clearChat()" title="대화 지우기">🗑️</button>
|
|
</div>
|
|
<div class="rb-msgs" id="chat-msgs"></div>
|
|
<div class="rb-input-bar">
|
|
<textarea class="rb-textarea" id="chat-input" rows="1" placeholder="부품/작업/예산에 대해 물어보세요..." onkeydown="handleKey(event)" oninput="autoResize(this)"></textarea>
|
|
<button class="rb-send" id="send-btn" onclick="sendMessage()">전송</button>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<script>
|
|
// ── Auth ──────────────────────────────────────────────────────────────────────
|
|
const TOKEN_KEY='smallclaw_token';
|
|
function getToken(){try{return sessionStorage.getItem(TOKEN_KEY)||localStorage.getItem(TOKEN_KEY)||'';}catch{return '';}}
|
|
function authH(extra){const t=getToken();const h={...(extra||{})};if(t)h['Authorization']='Bearer '+t;return h;}
|
|
async function checkAuth(){
|
|
if(!getToken()){location.href='/login.html?redirect='+encodeURIComponent(location.pathname);return false;}
|
|
try{const r=await fetch('/api/auth/status',{headers:authH()});if(!r.ok){location.href='/login.html?redirect='+encodeURIComponent(location.pathname);return false;}const d=await r.json();if(!d.authenticated){location.href='/login.html?redirect='+encodeURIComponent(location.pathname);return false;}return true;}
|
|
catch{location.href='/login.html?redirect='+encodeURIComponent(location.pathname);return false;}
|
|
}
|
|
|
|
// ── Theme ─────────────────────────────────────────────────────────────────────
|
|
function toggleTheme(){const d=document.documentElement;const n=d.getAttribute('data-theme')==='dark'?'light':'dark';d.setAttribute('data-theme',n);try{localStorage.setItem('homeclaw_theme',n);}catch{}const tb=document.getElementById('theme-btn');if(tb)tb.textContent=n==='light'?'☀️':'🌙';}
|
|
|
|
function escAttr(s){return String(s||'').replace(/'/g,''').replace(/"/g,'"');}
|
|
// onclick="fn('...')" 안의 JS 문자열 컨텍스트용 — escAttr의 HTML 엔티티(')는
|
|
// HTML 파서가 속성값을 JS로 넘기기 전에 다시 ' 로 디코딩해버려 따옴표 주입을 못 막으므로
|
|
// JS 수준에서 이스케이프한다. 폴더/파일 이름(사용자 입력)을 싣는 데만 쓴다.
|
|
function escJs(s){return String(s??'').replace(/\\/g,'\\\\').replace(/'/g,"\\'").replace(/"/g,'"').replace(/</g,'<').replace(/>/g,'>').replace(/\r?\n/g,'\\n');}
|
|
function genId(prefix){return prefix+'_'+Math.random().toString(36).slice(2,10);}
|
|
|
|
// ── 상태 ──────────────────────────────────────────────────────────────────────
|
|
// "작업실"은 여러 프로젝트를 담는다(workshopData.projects) — 화면엔 그중 하나
|
|
// (activeProjectId)만 표시하고, parts/phases/notes는 그 프로젝트의 내용을 가리키는
|
|
// 지역 변수다(기존 로봇전용 시절 코드를 최대한 그대로 재사용하기 위한 구조 — 함수 대부분은
|
|
// parts/phases/notes를 그대로 조작하고, 저장 직전에만 현재 프로젝트 객체로 동기화한다).
|
|
let workshopData={projects:[],activeProjectId:''};
|
|
let parts=[];
|
|
let phases=[];
|
|
let notes='';
|
|
let overviewDesc='';
|
|
let saveTimer=null;
|
|
let saveDebounce=null;
|
|
// 저장 개편(2026-09-24): 전체문서 PUT 대신 바뀐 프로젝트만 개별 PUT한다 — 서버가
|
|
// 디스크의 현재 문서에 해당 프로젝트만 upsert하므로, 채팅 도구가 다른 프로젝트를
|
|
// 동시에 저장해도 한쪽 편집이 통째로 사라지는 일이 없다. 어디가 더러운지는
|
|
// dirtyProjects로 추적하고 saveProject()가 그 대상만 순차 전송한다.
|
|
const dirtyProjects=new Set();
|
|
|
|
function currentProject(){
|
|
return workshopData.projects.find(p=>p.id===workshopData.activeProjectId)||workshopData.projects[0];
|
|
}
|
|
|
|
// STL 선택은 프로젝트 기준 relPath를 담는다 — 프로젝트가 바뀌면(수동 전환/생성/삭제,
|
|
// 채팅 도구의 set_active_project 포함) 반드시 비운다. 안 그러면 옛 프로젝트 파일의
|
|
// relPath가 workshopFilePath()에서 새 프로젝트 경로로 재해석되어, 같은 이름의 다른
|
|
// 파일이 슬라이서로 잘못 전달될 수 있다.
|
|
function clearStlSelection(){
|
|
stlSelection.clear();
|
|
const btn=document.getElementById('stl-slice-btn');
|
|
const countEl=document.getElementById('stl-sel-count');
|
|
if(countEl)countEl.textContent='0';
|
|
if(btn)btn.disabled=true;
|
|
}
|
|
|
|
function loadLocalFromProject(){
|
|
const proj=currentProject();
|
|
parts=proj?(proj.parts||[]):[];
|
|
phases=proj?(proj.phases||[]):[];
|
|
notes=proj?(proj.notes||''):'';
|
|
overviewDesc=proj?(proj.description||''):'';
|
|
}
|
|
|
|
function syncLocalToProject(){
|
|
const proj=currentProject();
|
|
if(!proj)return;
|
|
proj.parts=parts;
|
|
proj.phases=phases;
|
|
proj.notes=notes;
|
|
proj.description=overviewDesc;
|
|
dirtyProjects.add(proj.id);
|
|
}
|
|
|
|
function renderProjectSelector(){
|
|
const sel=document.getElementById('project-select');
|
|
sel.innerHTML=workshopData.projects.map(p=>`<option value="${escAttr(p.id)}" ${p.id===workshopData.activeProjectId?'selected':''}>${escAttr(p.name)}</option>`).join('');
|
|
const label=document.getElementById('chat-label');
|
|
const proj=currentProject();
|
|
if(label&&proj)label.textContent='🛠️ '+proj.name+' 채팅';
|
|
}
|
|
|
|
function refreshAllPanes(){
|
|
expandedParts.clear();
|
|
renderProjectSelector();
|
|
renderParts();
|
|
renderPhases();
|
|
document.getElementById('notes-textarea').value=notes;
|
|
document.getElementById('overview-textarea').value=overviewDesc;
|
|
updateBudget();
|
|
}
|
|
|
|
function switchProject(id){
|
|
syncLocalToProject();
|
|
workshopData.activeProjectId=id;
|
|
loadLocalFromProject();
|
|
clearStlSelection();
|
|
refreshAllPanes();
|
|
scheduleSave();
|
|
putActive();
|
|
refreshChatForProject();
|
|
if(document.getElementById('pane-files').classList.contains('active'))loadFiles();
|
|
}
|
|
|
|
function createProjectPrompt(){
|
|
// 새 프로젝트로 넘어가기 전 지금 편집 중인 내용을 직전 프로젝트에 반영 —
|
|
// 안 그러면 loadLocalFromProject()가 지역변수를 새 빈 프로젝트로 덮어써서
|
|
// 600ms debounce 안 끝난 편집이 유실된다(switchProject와 동일 순서).
|
|
syncLocalToProject();
|
|
const name=(prompt('새 프로젝트 이름을 입력하세요:')||'').trim();
|
|
if(!name)return;
|
|
const id=genId('proj');
|
|
workshopData.projects.push({id,name,parts:[],phases:[],notes:'',description:''});
|
|
workshopData.activeProjectId=id;
|
|
dirtyProjects.add(id); // 새 프로젝트도 서버에 upsert돼야 함 — 직전 프로젝트는 sync가 dirty 마킹
|
|
loadLocalFromProject();
|
|
clearStlSelection();
|
|
refreshAllPanes();
|
|
scheduleSave();
|
|
putActive();
|
|
refreshChatForProject();
|
|
if(document.getElementById('pane-files').classList.contains('active'))loadFiles();
|
|
}
|
|
|
|
function deleteProjectPrompt(){
|
|
const proj=currentProject();
|
|
if(!proj)return;
|
|
if(workshopData.projects.length<=1){alert('마지막 남은 프로젝트는 삭제할 수 없습니다.');return;}
|
|
if(!confirm(`"${proj.name}" 프로젝트를 삭제할까요? 되돌릴 수 없습니다.`))return;
|
|
dirtyProjects.delete(proj.id);
|
|
workshopData.projects=workshopData.projects.filter(p=>p.id!==proj.id);
|
|
workshopData.activeProjectId=workshopData.projects[0].id;
|
|
// 삭제는 프로젝트 단위 저장 API로 직접 반영 — 저장(debounce)이 못 건드리는
|
|
// 이미 사라진 프로젝트를 PUT하면 404 소음만 생긴다.
|
|
fetch('/api/workshop/project/'+encodeURIComponent(proj.id),{method:'DELETE',headers:authH()}).catch(()=>{});
|
|
loadLocalFromProject();
|
|
clearStlSelection();
|
|
refreshAllPanes();
|
|
scheduleSave();
|
|
putActive();
|
|
refreshChatForProject();
|
|
if(document.getElementById('pane-files').classList.contains('active'))loadFiles();
|
|
}
|
|
|
|
async function loadWorkshopData(){
|
|
try{
|
|
const r=await fetch('/api/workshop/data',{headers:authH()});
|
|
if(r.ok){
|
|
const d=await r.json();
|
|
workshopData.projects=Array.isArray(d.projects)?d.projects:[];
|
|
workshopData.activeProjectId=d.activeProjectId||(workshopData.projects[0]?.id||'');
|
|
}
|
|
}catch{}
|
|
if(!workshopData.projects.length){
|
|
const id=genId('proj');
|
|
workshopData.projects=[{id,name:'새 프로젝트',parts:[],phases:[],notes:'',description:''}];
|
|
workshopData.activeProjectId=id;
|
|
}
|
|
loadLocalFromProject();
|
|
refreshAllPanes();
|
|
}
|
|
|
|
function scheduleSave(){
|
|
clearTimeout(saveDebounce);
|
|
saveDebounce=setTimeout(saveProject,600);
|
|
}
|
|
|
|
// 탭 닫기/새로고침 시 대기 중인 저장을 즉시 flush — 600ms debounce가 끝나기 전에
|
|
// 닫아버리면 마지막 편집이 그대로 사라진다. unload 중엔 일반 fetch가 버려질 수 있어
|
|
// keepalive:true로 브라우저에 전송을 보장받는다. beforeunload(닫기 직전)와
|
|
// pagehide(실제 내비게이션 확정) 둘 다에 걸고, saveDebounce 널 처리로 이중 실행을 막는다.
|
|
// 저장 개편에 맞춰 더러운 프로젝트마다 개별 upsert로 보낸다(응답을 기다리지 않음).
|
|
function flushPendingSave(){
|
|
if(!saveDebounce)return;
|
|
clearTimeout(saveDebounce);saveDebounce=null;
|
|
syncLocalToProject();
|
|
for(const id of dirtyProjects){
|
|
const p=workshopData.projects.find(x=>x.id===id);
|
|
if(!p)continue;
|
|
fetch('/api/workshop/project/'+encodeURIComponent(id),{method:'PUT',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify(p),keepalive:true});
|
|
}
|
|
dirtyProjects.clear();
|
|
}
|
|
window.addEventListener('beforeunload',flushPendingSave);
|
|
window.addEventListener('pagehide',flushPendingSave);
|
|
|
|
// 채팅 응답 후 호출: 서버 데이터를 다시 당겨오되, 내용이 안 바뀌었으면 화면을 건드리지
|
|
// 않는다(편집 중 포커스/펼침 카드 보존). 바뀌었으면 살아있는 부품에 한해 펼침 상태를
|
|
// 유지한 채 다시 그린다. loadWorkshopData(초기 로딩용, 무조건 전체 새로고침)와 다른 용도.
|
|
async function softRefreshWorkshop(){
|
|
// 대기 중인 저장(debounce)이 있으면 먼저 flush — 안 그러면 방금 편집한 내용이 아직
|
|
// 서버에 안 간 상태에서 서버 데이터를 당겨오면 그 편집이 옛 데이터에 덮어씌워 사라진다.
|
|
if(saveDebounce){clearTimeout(saveDebounce);saveDebounce=null;await saveProject();}
|
|
try{
|
|
const r=await fetch('/api/workshop/data',{headers:authH()});
|
|
if(!r.ok)return;
|
|
const d=await r.json();
|
|
const newProjects=Array.isArray(d.projects)?d.projects:[];
|
|
const newActive=d.activeProjectId||(newProjects[0]?.id||'');
|
|
if(JSON.stringify(newProjects)===JSON.stringify(workshopData.projects)&&newActive===workshopData.activeProjectId)return;
|
|
const activeChanged=newActive!==workshopData.activeProjectId;
|
|
workshopData.projects=newProjects;
|
|
workshopData.activeProjectId=newActive;
|
|
loadLocalFromProject();
|
|
const liveIds=new Set(parts.map(p=>p.id));
|
|
for(const id of [...expandedParts])if(!liveIds.has(id))expandedParts.delete(id);
|
|
renderProjectSelector();
|
|
renderParts();
|
|
renderPhases();
|
|
const ta=document.getElementById('notes-textarea');
|
|
if(ta&&ta.value!==notes)ta.value=notes;
|
|
const ota=document.getElementById('overview-textarea');
|
|
if(ota&&ota.value!==overviewDesc)ota.value=overviewDesc;
|
|
updateBudget();
|
|
if(activeChanged){
|
|
// 채팅에서 set_active_project/create_project로 프로젝트가 바뀐 경우 —
|
|
// switchProject와 같은 정리를 해줘야 한다. 안 그러면 옛 프로젝트에서
|
|
// 선택해둔 STL이 그대로 남아 슬라이서에 잘못 전송되고, 채팅창도 옛
|
|
// 프로젝트의 세션 기록을 보여준다. 매 응답마다가 아니라 실제 전환 시에만.
|
|
clearStlSelection();
|
|
refreshChatForProject();
|
|
if(document.getElementById('pane-files').classList.contains('active'))loadFiles();
|
|
}
|
|
}catch{}
|
|
}
|
|
|
|
async function saveProject(){
|
|
syncLocalToProject();
|
|
const ids=[...dirtyProjects];
|
|
dirtyProjects.clear();
|
|
if(!ids.length)return;
|
|
try{
|
|
// 바뀐 프로젝트만 개별 upsert — 다른 프로젝트는 서버 디스크 값 그대로 보존.
|
|
for(const id of ids){
|
|
const p=workshopData.projects.find(x=>x.id===id);
|
|
if(!p)continue; // 이번 턴에 삭제된 프로젝트 — deleteProjectPrompt가 서버 DELETE로 처리
|
|
const r=await fetch('/api/workshop/project/'+encodeURIComponent(id),{method:'PUT',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify(p)});
|
|
if(!r.ok)throw new Error('HTTP '+r.status);
|
|
}
|
|
showSaveIndicator('저장됨');
|
|
}catch{showSaveIndicator('저장 실패');}
|
|
}
|
|
|
|
// 활성 프로젝트 포인터는 값 하나라 별도 즉시 저장(debounce 없음) — switch/create/delete에서 호출.
|
|
async function putActive(){
|
|
try{
|
|
await fetch('/api/workshop/active',{method:'PUT',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({activeProjectId:workshopData.activeProjectId})});
|
|
}catch{}
|
|
}
|
|
|
|
function showSaveIndicator(msg){
|
|
const el=document.getElementById('save-indicator');
|
|
if(!el)return;
|
|
el.textContent=msg;
|
|
clearTimeout(saveTimer);
|
|
saveTimer=setTimeout(()=>{el.textContent='';},2000);
|
|
}
|
|
|
|
// ── Tabs ──────────────────────────────────────────────────────────────────────
|
|
function switchTab(tab){
|
|
document.querySelectorAll('.rb-tab-btn').forEach(b=>b.classList.toggle('active',b.dataset.tab===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/밀링) ────────────────────────────────────────────────────────
|
|
// "장비" 탭을 보고 있을 때만 폴링한다 — 계속 켜두면 K2는 매번 짧은 WebRTC 연결을,
|
|
// 밀링은 go2rtc 프레임 요청을 반복하니 안 보는 동안은 자원을 안 쓰는 게 맞다.
|
|
const MILL_CHANNEL_ID=1000;
|
|
let equipTimer=null;
|
|
function startEquipTimers(){
|
|
if(equipTimer)return;
|
|
refreshK2Widget();
|
|
refreshMillWidget();
|
|
equipTimer=setInterval(()=>{refreshK2Widget();refreshMillWidget();},20000);
|
|
}
|
|
function stopEquipTimers(){
|
|
clearInterval(equipTimer);
|
|
equipTimer=null;
|
|
stopK2Live(); // 장비 탭을 벗어나면 라이브 WebRTC 연결도 닫는다(안 보는 동안 자원 안 쓰기)
|
|
}
|
|
|
|
function refreshMillWidget(){
|
|
const img=document.getElementById('mill-frame');
|
|
img.src='/api/nvr/frame/'+MILL_CHANNEL_ID+'?t='+Date.now();
|
|
}
|
|
function millFrameLoaded(){
|
|
document.getElementById('mill-frame').style.display='block';
|
|
document.getElementById('mill-placeholder').style.display='none';
|
|
document.getElementById('mill-dot').className='rb-equip-dot on';
|
|
}
|
|
function millFrameError(){
|
|
document.getElementById('mill-frame').style.display='none';
|
|
document.getElementById('mill-placeholder').style.display='block';
|
|
document.getElementById('mill-placeholder').textContent='카메라를 불러올 수 없습니다';
|
|
document.getElementById('mill-dot').className='rb-equip-dot off';
|
|
}
|
|
|
|
// K2 카메라는 go2rtc 없이 프린터 자체 WebRTC(:8000)로만 나오므로 정지 프레임을 얻으려면
|
|
// 짧게 연결→ontrack에서 캔버스로 한 프레임 캡처→즉시 연결 종료한다(k2-app.html의 상시
|
|
// 라이브 연결과 달리 "장비" 탭을 볼 때만, 20초 간격으로만 짧게 붙었다 끊는 방식).
|
|
function grabK2Frame(){
|
|
return new Promise((resolve)=>{
|
|
let settled=false;
|
|
const pc=new RTCPeerConnection({iceServers:[{urls:'stun:stun.l.google.com:19302'}]});
|
|
const video=document.createElement('video');
|
|
video.autoplay=true;video.muted=true;video.playsInline=true;
|
|
const finish=(dataUrl)=>{
|
|
if(settled)return;
|
|
settled=true;
|
|
try{pc.close();}catch{}
|
|
try{video.remove();}catch{}
|
|
resolve(dataUrl);
|
|
};
|
|
// 비디오 엘리먼트를 DOM에 붙이지 않으면 일부 브라우저에서 프레임 디코딩이 실제로
|
|
// 시작되지 않아(재생 안 함) videoWidth/Height가 계속 0으로 남고 캔버스가 새까맣게
|
|
// 찍힌다 — 화면 밖(offscreen)에 실제로 붙이고 명시적으로 play()까지 호출해야 한다.
|
|
video.style.cssText='position:fixed;left:-9999px;top:-9999px;width:1px;height:1px;';
|
|
document.body.appendChild(video);
|
|
const captureWhenReady=(triesLeft)=>{
|
|
if(settled)return;
|
|
if(video.videoWidth>0&&video.videoHeight>0){
|
|
try{
|
|
const c=document.createElement('canvas');
|
|
c.width=video.videoWidth;c.height=video.videoHeight;
|
|
c.getContext('2d').drawImage(video,0,0,c.width,c.height);
|
|
finish(c.toDataURL('image/jpeg',0.7));
|
|
}catch{finish(null);}
|
|
return;
|
|
}
|
|
if(triesLeft<=0){finish(null);return;}
|
|
setTimeout(()=>captureWhenReady(triesLeft-1),200);
|
|
};
|
|
pc.ontrack=(ev)=>{
|
|
video.srcObject=ev.streams[0];
|
|
video.play().catch(()=>{});
|
|
video.onloadeddata=()=>captureWhenReady(15);
|
|
};
|
|
pc.addTransceiver('video',{direction:'sendrecv'});
|
|
let offerSent=false;
|
|
const sendOfferOnce=()=>{
|
|
if(offerSent||settled)return;
|
|
offerSent=true;
|
|
fetch('/api/k2/webrtc-signal',{method:'POST',headers:{'Content-Type':'text/plain',...authH()},body:btoa(JSON.stringify({type:'offer',sdp:pc.localDescription.sdp}))})
|
|
.then(r=>r.text()).then(txt=>{
|
|
const res=JSON.parse(atob(txt));
|
|
if(res.type==='answer'&&!settled)pc.setRemoteDescription(new RTCSessionDescription(res));
|
|
}).catch(()=>finish(null));
|
|
};
|
|
pc.onicecandidate=(ev)=>{if(ev.candidate===null)sendOfferOnce();};
|
|
pc.createOffer().then(d=>pc.setLocalDescription(d)).then(()=>{setTimeout(sendOfferOnce,1500);}).catch(()=>finish(null));
|
|
setTimeout(()=>finish(null),8000);
|
|
});
|
|
}
|
|
|
|
// ── K2 라이브 뷰(2026-09-24) ─────────────────────────────────────────────────
|
|
// ▶ 라이브 버튼으로 카드 안에서 상시 WebRTC 스트리밍(k2-app.html의 검증된 연결 로직
|
|
// 이식). 라이브 중엔 20초 스냅샷 그랩을 멈추고(refreshK2Widget 참조), 장비 탭을
|
|
// 벗어나면 stopEquipTimers가 연결을 닫는다 — 안 보는 동안엔 자원을 안 쓴다.
|
|
let k2LivePC=null;
|
|
function k2Note(msg){
|
|
const ph=document.getElementById('k2-placeholder');
|
|
const img=document.getElementById('k2-frame');
|
|
const video=document.getElementById('k2-live');
|
|
video.style.display='none';
|
|
img.style.display='none';
|
|
ph.style.display='block';
|
|
ph.textContent=msg;
|
|
}
|
|
function toggleK2Live(){
|
|
if(k2LivePC)stopK2Live();else startK2Live();
|
|
}
|
|
function startK2Live(){
|
|
if(k2LivePC)return;
|
|
const video=document.getElementById('k2-live');
|
|
const btn=document.getElementById('k2-live-btn');
|
|
btn.classList.add('on');btn.textContent='⏹ 라이브 종료';
|
|
const ph=document.getElementById('k2-placeholder');
|
|
ph.style.display='none';
|
|
// STUN을 빼면(host candidate만) 이 프린터의 ICE 응답이 실제로는 안 옴(DTLS failed,
|
|
// bytesReceived 0) — k2-app.html에서 2026-09-18 확인된 퀴크라 그대로 유지.
|
|
const pc=new RTCPeerConnection({iceServers:[{urls:'stun:stun.l.google.com:19302'}]});
|
|
k2LivePC=pc;
|
|
pc.ontrack=(ev)=>{
|
|
if(k2LivePC!==pc)return;
|
|
video.srcObject=ev.streams[0];
|
|
video.play().catch(()=>{});
|
|
ph.style.display='none';
|
|
};
|
|
pc.oniceconnectionstatechange=()=>{
|
|
if(k2LivePC!==pc)return;
|
|
const st=pc.iceConnectionState;
|
|
if(st==='failed'){
|
|
stopK2Live();
|
|
k2Note('라이브 연결 실패 — ▶ 라이브를 다시 눌러주세요');
|
|
}else if(st==='disconnected'){
|
|
k2Note('라이브 신호 끊김(일시적) — 복구되면 그려집니다');
|
|
}
|
|
};
|
|
// 오퍼는 ICE gathering 완전종료(null candidate) 또는 1.5초 중 빠른 쪽에 보낸다 —
|
|
// 완전종료가 이 네트워크에서 20초+ 걸리는 k2-app.html의 실측 퀴크 그대로.
|
|
let offerSent=false;
|
|
const sendOfferOnce=()=>{
|
|
if(offerSent||k2LivePC!==pc)return;
|
|
offerSent=true;
|
|
fetch('/api/k2/webrtc-signal',{method:'POST',headers:{'Content-Type':'text/plain',...authH()},body:btoa(JSON.stringify({type:'offer',sdp:pc.localDescription.sdp}))})
|
|
.then(r=>r.text()).then(txt=>{
|
|
const res=JSON.parse(atob(txt));
|
|
if(res.type==='answer'&&k2LivePC===pc)pc.setRemoteDescription(new RTCSessionDescription(res));
|
|
}).catch(()=>{if(k2LivePC===pc)stopK2Live();});
|
|
};
|
|
pc.onicecandidate=(ev)=>{if(ev.candidate===null)sendOfferOnce();};
|
|
pc.addTransceiver('video',{direction:'sendrecv'});
|
|
pc.createOffer().then(d=>pc.setLocalDescription(d)).then(()=>{setTimeout(sendOfferOnce,1500);}).catch(()=>{if(k2LivePC===pc)stopK2Live();});
|
|
}
|
|
function stopK2Live(){
|
|
if(!k2LivePC)return;
|
|
try{k2LivePC.close();}catch{}
|
|
k2LivePC=null;
|
|
const video=document.getElementById('k2-live');
|
|
try{video.pause();video.srcObject=null;}catch{}
|
|
video.style.display='none';
|
|
const btn=document.getElementById('k2-live-btn');
|
|
btn.classList.remove('on');btn.textContent='▶ 라이브';
|
|
// 스냅샷으로 즉시 복귀 — 단, 장비 탭을 벗어나며 stopEquipTimers가 부른 경우엔
|
|
// 안 그래도 된다(안 보는 탭에 프레임 그랩을 트리거하는 셈).
|
|
const pane=document.getElementById('pane-equip');
|
|
if(pane&&pane.classList.contains('active'))refreshK2Widget();
|
|
}
|
|
|
|
function fmtK2Duration(sec){
|
|
const h=Math.floor(sec/3600),m=Math.floor((sec%3600)/60);
|
|
if(h>0)return h+'시간 '+m+'분';
|
|
return m+'분';
|
|
}
|
|
const K2_STATE_LABEL={standby:'대기중',printing:'출력중',paused:'일시정지',complete:'완료',cancelled:'취소됨',error:'오류'};
|
|
|
|
async function refreshK2Widget(){
|
|
const img=document.getElementById('k2-frame');
|
|
const placeholder=document.getElementById('k2-placeholder');
|
|
// 라이브 연결이 살아있으면 프레임 그랩(WebRTC 풀협상)을 건너뛴다 — 비디오가 이미
|
|
// 실시간으로 흐르고 있고, 짧은 연결을 또 열면 둘이 간섭한다. 상태 조회는 계속한다.
|
|
if(!k2LivePC){
|
|
grabK2Frame().then(dataUrl=>{
|
|
if(dataUrl){img.src=dataUrl;img.style.display='block';placeholder.style.display='none';}
|
|
else if(img.style.display==='none'){placeholder.textContent='카메라를 불러올 수 없습니다';}
|
|
});
|
|
}
|
|
try{
|
|
const r=await fetch('/api/k2/status',{headers:authH()});
|
|
const d=await r.json();
|
|
const dot=document.getElementById('k2-dot');
|
|
const statusEl=document.getElementById('k2-status-text');
|
|
if(!d.online){
|
|
dot.className='rb-equip-dot off';
|
|
statusEl.innerHTML='<b>오프라인</b> — 프린터가 꺼져있거나 네트워크에 없음';
|
|
return;
|
|
}
|
|
dot.className='rb-equip-dot '+(d.state==='printing'?'on':'');
|
|
const stateLabel=K2_STATE_LABEL[d.state]||d.state;
|
|
let html='상태: <b>'+escAttr(stateLabel)+'</b>';
|
|
if(d.filename)html+=' · 파일: <b>'+escAttr(d.filename)+'</b>';
|
|
if(d.state==='printing'){
|
|
html+='<br>진행률: <b>'+d.progress+'%</b> · 경과: <b>'+fmtK2Duration(d.printDurationSec)+'</b>';
|
|
}
|
|
if(k2LivePC)html+=' · <b style="color:#ef4444">● 실시간</b>';
|
|
statusEl.innerHTML=html;
|
|
}catch{
|
|
document.getElementById('k2-status-text').textContent='상태 조회 실패';
|
|
}
|
|
}
|
|
|
|
// ── 부품 ──────────────────────────────────────────────────────────────────────
|
|
function fmtWon(n){return (n||0).toLocaleString('ko-KR')+'원';}
|
|
|
|
// 펼침 상태는 화면 전용(서버에 저장 안 함) — 카드 다시 그려도 유지되게 모듈 전역에 둔다.
|
|
const expandedParts=new Set();
|
|
function togglePartExpand(id){
|
|
if(expandedParts.has(id))expandedParts.delete(id);else expandedParts.add(id);
|
|
renderParts();
|
|
}
|
|
|
|
function renderParts(){
|
|
const container=document.getElementById('parts-container');
|
|
container.innerHTML=parts.map(p=>{
|
|
const expanded=expandedParts.has(p.id);
|
|
return `
|
|
<div class="rb-part-card${expanded?' expanded':''}" data-id="${escAttr(p.id)}">
|
|
<div class="rb-part-hdr" onclick="togglePartExpand('${p.id}')">
|
|
<span class="rb-part-chevron">▶</span>
|
|
<span class="rb-part-name">${escAttr(p.name)||'(이름 없음)'}</span>
|
|
<span class="rb-part-badge st-${escAttr(p.status)}">${escAttr(p.status)}</span>
|
|
<span class="rb-part-sub">${fmtWon(p.qty*p.unitPrice)}</span>
|
|
</div>
|
|
<div class="rb-part-body" onclick="event.stopPropagation()">
|
|
<div class="rb-part-row">
|
|
<div class="rb-part-field grow"><label>이름</label><input type="text" value="${escAttr(p.name)}" oninput="updatePart('${p.id}','name',this.value)"></div>
|
|
<div class="rb-part-field qty"><label>수량</label><input type="number" min="0" value="${escAttr(p.qty)}" oninput="updatePart('${p.id}','qty',this.value)"></div>
|
|
<div class="rb-part-field price"><label>단가</label><input type="number" min="0" value="${escAttr(p.unitPrice)}" oninput="updatePart('${p.id}','unitPrice',this.value)"></div>
|
|
<div class="rb-part-field status"><label>상태</label>
|
|
<select onchange="updatePart('${p.id}','status',this.value)">
|
|
<option value="검토중" ${p.status==='검토중'?'selected':''}>검토중</option>
|
|
<option value="주문완료" ${p.status==='주문완료'?'selected':''}>주문완료</option>
|
|
<option value="보유" ${p.status==='보유'?'selected':''}>보유</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div class="rb-part-row">
|
|
<div class="rb-part-field grow"><label>메모</label><input type="text" value="${escAttr(p.memo)}" oninput="updatePart('${p.id}','memo',this.value)"></div>
|
|
</div>
|
|
<div class="rb-part-links">
|
|
<label>정보(구매처/대체품/업그레이드/레포 등)</label>
|
|
${(p.links||[]).map(link=>`
|
|
<div class="rb-link-row" data-link-id="${escAttr(link.id)}">
|
|
<input type="text" class="rb-link-label" placeholder="라벨(예: 쿠팡)" value="${escAttr(link.label)}" oninput="updateLink('${p.id}','${link.id}','label',this.value)">
|
|
<input type="text" placeholder="URL" value="${escAttr(link.url)}" oninput="updateLink('${p.id}','${link.id}','url',this.value)">
|
|
${(safeUrl(link.url)?`<a class="rb-link-open" href="${escAttr(safeUrl(link.url))}" target="_blank" rel="noopener">↗</a>`:'')}
|
|
<button class="rb-del-btn" onclick="deleteLink('${p.id}','${link.id}')">✕</button>
|
|
</div>
|
|
`).join('')}
|
|
<button class="rb-link-add" onclick="addLink('${p.id}')">+ 링크 추가</button>
|
|
</div>
|
|
<div class="rb-part-actions"><button class="rb-del-btn" onclick="deletePart('${p.id}')">✕ 삭제</button></div>
|
|
</div>
|
|
</div>
|
|
`;}).join('');
|
|
}
|
|
|
|
function updatePart(id,field,value){
|
|
const p=parts.find(x=>x.id===id);
|
|
if(!p)return;
|
|
if(field==='qty'||field==='unitPrice')p[field]=Math.max(0,Number(value)||0);
|
|
else p[field]=value;
|
|
const card=document.querySelector(`.rb-part-card[data-id="${id}"]`);
|
|
if(card){
|
|
if(field==='qty'||field==='unitPrice')card.querySelector('.rb-part-sub').textContent=fmtWon(p.qty*p.unitPrice);
|
|
if(field==='name')card.querySelector('.rb-part-name').textContent=p.name||'(이름 없음)';
|
|
if(field==='status'){
|
|
const badge=card.querySelector('.rb-part-badge');
|
|
badge.className='rb-part-badge st-'+p.status;
|
|
badge.textContent=p.status;
|
|
}
|
|
}
|
|
updateBudget();
|
|
scheduleSave();
|
|
}
|
|
|
|
function addPart(){
|
|
const id=genId('p');
|
|
parts.push({id,name:'',qty:1,unitPrice:0,status:'검토중',memo:'',links:[]});
|
|
expandedParts.add(id);
|
|
renderParts();
|
|
scheduleSave();
|
|
}
|
|
|
|
function deletePart(id){
|
|
parts=parts.filter(x=>x.id!==id);
|
|
expandedParts.delete(id);
|
|
renderParts();
|
|
updateBudget();
|
|
scheduleSave();
|
|
}
|
|
|
|
function addLink(partId){
|
|
const p=parts.find(x=>x.id===partId);
|
|
if(!p)return;
|
|
if(!p.links)p.links=[];
|
|
p.links.push({id:genId('lk'),label:'',url:''});
|
|
renderParts();
|
|
scheduleSave();
|
|
}
|
|
|
|
function updateLink(partId,linkId,field,value){
|
|
const p=parts.find(x=>x.id===partId);
|
|
if(!p||!p.links)return;
|
|
const link=p.links.find(x=>x.id===linkId);
|
|
if(!link)return;
|
|
link[field]=value;
|
|
// url이 생기거나 없어지면 ↗ 열기 버튼만 DOM에서 직접 갱신 — 전체를 다시 그리면
|
|
// input이 리렌더되면서 타이핑 중 커서/포커스가 끊긴다.
|
|
if(field==='url'){
|
|
const row=document.querySelector(`.rb-part-card[data-id="${partId}"] .rb-link-row[data-link-id="${linkId}"]`);
|
|
if(row){
|
|
let a=row.querySelector('.rb-link-open');
|
|
if(value){
|
|
if(!a){
|
|
a=document.createElement('a');
|
|
a.className='rb-link-open'; a.target='_blank'; a.rel='noopener'; a.textContent='↗';
|
|
row.querySelector('.rb-del-btn').before(a);
|
|
}
|
|
a.href=value;
|
|
}else if(a){
|
|
a.remove();
|
|
}
|
|
}
|
|
}
|
|
scheduleSave();
|
|
}
|
|
|
|
function deleteLink(partId,linkId){
|
|
const p=parts.find(x=>x.id===partId);
|
|
if(!p||!p.links)return;
|
|
p.links=p.links.filter(x=>x.id!==linkId);
|
|
renderParts();
|
|
scheduleSave();
|
|
}
|
|
|
|
function updateBudget(){
|
|
const sum=status=>parts.filter(p=>p.status===status).reduce((s,p)=>s+p.qty*p.unitPrice,0);
|
|
const review=sum('검토중'),ordered=sum('주문완료'),owned=sum('보유');
|
|
document.getElementById('sum-review').textContent=fmtWon(review);
|
|
document.getElementById('sum-ordered').textContent=fmtWon(ordered);
|
|
document.getElementById('sum-owned').textContent=fmtWon(owned);
|
|
document.getElementById('sum-total').textContent=fmtWon(review+ordered+owned);
|
|
}
|
|
|
|
// ── 작업(단계) ────────────────────────────────────────────────────────────────
|
|
function renderPhases(){
|
|
const container=document.getElementById('phases-container');
|
|
container.innerHTML=phases.map(ph=>`
|
|
<div class="rb-phase" data-id="${escAttr(ph.id)}">
|
|
<div class="rb-phase-hdr">
|
|
<input class="rb-phase-name" value="${escAttr(ph.name)}" oninput="updatePhaseName('${ph.id}',this.value)">
|
|
<button class="rb-del-btn" onclick="deletePhase('${ph.id}')">✕</button>
|
|
</div>
|
|
<div class="rb-phase-tasks">
|
|
${ph.tasks.map(t=>`
|
|
<div class="rb-task-row" data-id="${escAttr(t.id)}">
|
|
<input type="checkbox" ${t.done?'checked':''} onchange="updateTask('${ph.id}','${t.id}','done',this.checked)">
|
|
<input type="text" class="${t.done?'done':''}" value="${escAttr(t.text)}" oninput="updateTask('${ph.id}','${t.id}','text',this.value)">
|
|
<button class="rb-del-btn" onclick="deleteTask('${ph.id}','${t.id}')">✕</button>
|
|
</div>
|
|
`).join('')}
|
|
<button class="rb-task-add" onclick="addTask('${ph.id}')">+ 작업 추가</button>
|
|
</div>
|
|
</div>
|
|
`).join('');
|
|
}
|
|
|
|
function updatePhaseName(phaseId,value){
|
|
const ph=phases.find(x=>x.id===phaseId);
|
|
if(ph){ph.name=value;scheduleSave();}
|
|
}
|
|
|
|
function addPhase(){
|
|
phases.push({id:genId('ph'),name:'새 단계',tasks:[]});
|
|
renderPhases();
|
|
scheduleSave();
|
|
}
|
|
|
|
function deletePhase(phaseId){
|
|
phases=phases.filter(x=>x.id!==phaseId);
|
|
renderPhases();
|
|
scheduleSave();
|
|
}
|
|
|
|
function updateTask(phaseId,taskId,field,value){
|
|
const ph=phases.find(x=>x.id===phaseId);
|
|
if(!ph)return;
|
|
const t=ph.tasks.find(x=>x.id===taskId);
|
|
if(!t)return;
|
|
t[field]=value;
|
|
if(field==='done'){
|
|
const input=document.querySelector(`.rb-phase[data-id="${phaseId}"] .rb-task-row[data-id="${taskId}"] input[type=text]`);
|
|
if(input)input.classList.toggle('done',value);
|
|
}
|
|
scheduleSave();
|
|
}
|
|
|
|
function addTask(phaseId){
|
|
const ph=phases.find(x=>x.id===phaseId);
|
|
if(!ph)return;
|
|
ph.tasks.push({id:genId('t'),text:'',done:false});
|
|
renderPhases();
|
|
scheduleSave();
|
|
}
|
|
|
|
function deleteTask(phaseId,taskId){
|
|
const ph=phases.find(x=>x.id===phaseId);
|
|
if(!ph)return;
|
|
ph.tasks=ph.tasks.filter(x=>x.id!==taskId);
|
|
renderPhases();
|
|
scheduleSave();
|
|
}
|
|
|
|
// ── 파일 ──────────────────────────────────────────────────────────────────────
|
|
// 부품/작업/메모와 달리 workshopData(JSON)에 안 담고, 서버 디스크에 실제 파일로
|
|
// 저장된다(case-storage.ts, doctor/lawyer 앱과 동일 패턴) — /api/workshop/files 로
|
|
// 목록을 받아 그때그때 다시 그린다.
|
|
const FILE_ICONS={photo:'🖼️',video:'🎬',document:'📄',other:'📦'};
|
|
let filesState={files:[],folders:[]};
|
|
let stlSelection=new Set(); // 슬라이서로 보낼 STL relPath 다중선택(09-22, K2 슬라이서 연동)
|
|
// 파일 목록 요청 일련번호 — 빠른 프로젝트 전환 시 오래된 응답이 나중에 도착해
|
|
// 이전 프로젝트 파일 목록을 새 판에 그리는 레이스를 막는다(응답 도착 시점 검사).
|
|
let filesLoadSeq=0;
|
|
|
|
async function loadFiles(){
|
|
const proj=currentProject();
|
|
if(!proj)return;
|
|
const seq=++filesLoadSeq;
|
|
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();
|
|
if(seq!==filesLoadSeq)return; // 도착 사이에 더 새로운 요청이 갔다 — 옛 프로젝트 데이터라 폐기
|
|
filesState.files=Array.isArray(d.files)?d.files:[];
|
|
filesState.folders=Array.isArray(d.folders)?d.folders:[];
|
|
}catch{
|
|
if(seq===filesLoadSeq)container.innerHTML='<div class="rb-files-empty">파일 목록을 불러오지 못했습니다.</div>';
|
|
return;
|
|
}
|
|
// 삭제된 파일이 남아있지 않게 선택 상태 정리.
|
|
const liveRelPaths=new Set(filesState.files.map(f=>f.relPath));
|
|
for(const rp of [...stlSelection])if(!liveRelPaths.has(rp))stlSelection.delete(rp);
|
|
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('${escJs(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 btn=document.getElementById('stl-slice-btn');
|
|
const countEl=document.getElementById('stl-sel-count');
|
|
if(countEl)countEl.textContent=String(stlSelection.size);
|
|
if(btn)btn.disabled=stlSelection.size===0;
|
|
}
|
|
|
|
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('${escJs(file.relPath)}')"`:`href="${escAttr(file.url)}" target="_blank" rel="noopener"`;
|
|
// STL만 슬라이서로 보낼 다중선택 체크박스(09-22) — 여러 부품 STL을 한 판에 얹어 같이 슬라이싱.
|
|
const checked=stlSelection.has(file.relPath);
|
|
const selBox=isStl?`<input type="checkbox" class="rb-file-stl-sel" title="슬라이서로 보내기 선택" ${checked?'checked':''} onclick="event.stopPropagation()" onchange="toggleStlSelect('${escJs(file.relPath)}',this.checked)">`:'';
|
|
return `<div class="rb-file-card${checked?' stl-selected':''}" data-rel="${escAttr(file.relPath)}">
|
|
${selBox}
|
|
<button class="rb-file-del" onclick="deleteFile('${escJs(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>`;
|
|
}
|
|
|
|
// 작업실 프로젝트 relPath → 워크스페이스 전체 기준 상대경로. K2 CAD/슬라이서 라우트
|
|
// (/api/cad/file, /api/cad/list 등)는 findStlFiles()로 워크스페이스 전체를 훑으므로
|
|
// 이 접두어(workshop/<projectId>/)만 붙이면 작업실 파일도 그대로 잡힌다.
|
|
function workshopFilePath(relPath){
|
|
const proj=currentProject();
|
|
return proj?('workshop/'+proj.id+'/'+relPath):null;
|
|
}
|
|
|
|
// K2 프린터 앱의 "🔧 STL 편집기"(Three.js, cad-editor-app.html)를 그대로 재사용 — ?path= 로
|
|
// 어느 파일을 열지 지정하면 편집기가 자동으로 로드한다(cad-editor-view.js 09-22 추가).
|
|
function openCadEditorForFile(relPath){
|
|
const workspacePath=workshopFilePath(relPath);
|
|
if(!workspacePath)return;
|
|
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`);
|
|
}
|
|
|
|
// ── 슬라이서 연동(09-22) ────────────────────────────────────────────────────
|
|
// STL 카드 체크박스로 여러 부품(예: 스캐너 rig의 base_plate+head_plate+turntable 등)을
|
|
// 골라 K2 슬라이서 팝업(slicer-app.html)에 한 번에 올린다. 실제 로드는 slicer-view.js의
|
|
// ?load=<워크스페이스 상대경로,...> 지원(09-22 추가)이 처리.
|
|
function toggleStlSelect(relPath,checked){
|
|
if(checked)stlSelection.add(relPath);else stlSelection.delete(relPath);
|
|
const card=document.querySelector(`.rb-file-card[data-rel="${CSS.escape(relPath)}"]`);
|
|
if(card)card.classList.toggle('stl-selected',checked);
|
|
const btn=document.getElementById('stl-slice-btn');
|
|
const countEl=document.getElementById('stl-sel-count');
|
|
if(countEl)countEl.textContent=String(stlSelection.size);
|
|
if(btn)btn.disabled=stlSelection.size===0;
|
|
}
|
|
|
|
function openSlicerForSelected(){
|
|
if(!stlSelection.size)return;
|
|
const paths=[...stlSelection].map(workshopFilePath).filter(Boolean);
|
|
if(!paths.length)return;
|
|
const w=1320,h=860;
|
|
const left=Math.max(0,(screen.width-w)/2), top=Math.max(0,(screen.height-h)/2);
|
|
// 쉼표가 파일명에 있으면 join(',') 구분자와 겹쳐 슬라이서 쪽 split(',')에서 깨지므로
|
|
// JSON 배열로 인코딩한다(슬라이서 쪽은 JSON 우선, 옛 쉼표 형식도 폴백 해석).
|
|
window.open('/html/slicer-app.html?load='+encodeURIComponent(JSON.stringify(paths)),'k2-slicer',`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;
|
|
scheduleSave();
|
|
}
|
|
|
|
// ── 개요 ──────────────────────────────────────────────────────────────────────
|
|
// 메모 탭과 같은 구조 — 프로젝트 서술형 설명(description). 채팅 도구 set_description
|
|
// 으로도 쓸 수 있고, 채팅 응답 후 softRefreshWorkshop이 갱신해준다.
|
|
function onOverviewChange(){
|
|
overviewDesc=document.getElementById('overview-textarea').value;
|
|
scheduleSave();
|
|
}
|
|
|
|
// ── 채팅 패널 ─────────────────────────────────────────────────────────────────
|
|
// 세션ID를 프로젝트별로 분리한다("ws_" 접두어는 유지 — tool-scope.ts가 이 세션에서는
|
|
// 키워드 없이도 workshop_project 도구를 항상 열어주고, 메인 사이드바 목록에서도 걸러진다).
|
|
// projectId 기반 결정적 파생이므로 앱세션 저장소(app-sessions.json) 없이도 새로고침/
|
|
// 재접속 시 같은 프로젝트의 이전 대화를 그대로 이어받는다. 옛 앱 전용 세션(ws_main/랜덤id)의
|
|
// 대화는 앱 전체 공용이던 터라 프로젝트 귀속이 불가 — 어느 프로젝트에도 더 안 보인다.
|
|
function getSessionId(){
|
|
const proj=currentProject();
|
|
return 'ws_'+(proj?proj.id:'noproject');
|
|
}
|
|
|
|
let _chatLoadSeq=0;
|
|
async function loadHistory(){
|
|
const sid=getSessionId();
|
|
const seq=++_chatLoadSeq;
|
|
try{
|
|
const r=await fetch('/api/chat/sessions/'+encodeURIComponent(sid),{headers:authH()});
|
|
if(!r.ok)return;
|
|
const d=await r.json();
|
|
const msgs=(d.history||[]).filter(m=>m.role==='user'||m.role==='assistant');
|
|
if(!msgs.length||seq!==_chatLoadSeq)return;
|
|
for(const m of msgs.slice(-30)){
|
|
if(m.role==='user')addMsg('user',escHtml(m.content||''));
|
|
else addMsg('assistant',renderMd(m.content||''));
|
|
}
|
|
}catch{}
|
|
}
|
|
|
|
function escHtml(s){return String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));}
|
|
|
|
// 부품 참고링크 href용 — http(s)(프로토콜 상대 포함)만 통과시키고 나머지는 빈 값으로
|
|
// 뭉갠다. javascript:/data: 스킴 링크는 채팅 도구(add_link)나 오타로도 들어올 수 있고
|
|
// href에 그대로 쓰면 클릭 한 번에 스크립트가 돈다.
|
|
function safeUrl(u){u=String(u??'').trim();return /^(https?:|\/\/)/i.test(u)?u:'';}
|
|
|
|
function addMsg(role,html){
|
|
const msgs=document.getElementById('chat-msgs');
|
|
const div=document.createElement('div');div.className='rb-msg '+role;
|
|
const b=document.createElement('div');b.className='rb-bubble';b.innerHTML=html;
|
|
div.appendChild(b);
|
|
msgs.appendChild(div);msgs.scrollTop=msgs.scrollHeight;
|
|
return b;
|
|
}
|
|
|
|
function renderMd(text){
|
|
// 코드펜스를 먼저 자리표시자로 빼두고 모든 인라인/단락 처리가 끝난 뒤 복원한다 —
|
|
// 그래야 예전처럼 \n\n→<p> 치환이 <pre> 안까지 파고들어 코드 블록의 빈 줄을
|
|
// </p><p> 로 끊어먹는 사고가 안 난다. 인라인 `코드`/굵게 처리도 pre 안을 건드리지 못한다.
|
|
const blocks=[];
|
|
let s=String(text||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
|
s=s.replace(/```[\w]*\n?([\s\S]*?)```/g,(m,code)=>{
|
|
blocks.push('<pre><code>'+code.replace(/^\n/,'')+'</code></pre>');
|
|
return '\n\n\u0000B'+(blocks.length-1)+'\u0000\n\n';
|
|
});
|
|
s=s.replace(/`([^`]+)`/g,'<code>$1</code>')
|
|
.replace(/\*\*(.+?)\*\*/g,'<strong>$1</strong>')
|
|
.replace(/^#{1,3}\s+(.+)$/gm,'<h3>$1</h3>');
|
|
// 리스트 — 연속하는 [-*] 줄을 하나의 <ul>로 묶는다(항목별 <ul>이 따로 생기던 버그 수정).
|
|
s=s.replace(/(^|\n)([-*]\s+.+(?:\n[-*]\s+.+)*)/g,(m,lead,grp)=>{
|
|
const items=grp.split('\n').map(line=>'<li>'+line.replace(/^[-*]\s+/,'')+'</li>').join('');
|
|
return lead+'<ul style="padding-left:16px;margin:3px 0">'+items+'</ul>';
|
|
});
|
|
// 단락 — 빈 줄 단위로 split. 이미 블록 태그(<h3>/<ul>/코드 자리표시자)로 시작하면
|
|
// <p>로 감싸지 않는다.
|
|
s=s.split(/\n{2,}/).map(chunk=>{
|
|
const t=chunk.trim();
|
|
if(!t)return '';
|
|
if(/^\u0000B\d+\u0000$/.test(t))return t;
|
|
if(/^<(h3|ul|pre)\b/.test(t))return t;
|
|
return '<p>'+t+'</p>';
|
|
}).join('');
|
|
s=s.replace(/\u0000B(\d+)\u0000/g,(m,i)=>blocks[+i]||'');
|
|
return s;
|
|
}
|
|
|
|
function chatGreeting(){
|
|
// 프로젝트 이름은 addMsg→innerHTML로 들어가므로 반드시 이스케이프 — 이름에
|
|
// <script>/태그 문자가 섞이면(예: 채팅으로 지은 프로젝트 이름) 채팅창에서 실행된다.
|
|
return '🛠️ 작업실 채팅입니다. 현재 프로젝트: "'+escHtml(currentProject()?.name||'')+'". 부품 구매/상태 변경, 작업 완료 체크, 예산 확인, 새 프로젝트 생성 등을 물어보세요.';
|
|
}
|
|
|
|
// 프로젝트 전환(수동/생성/삭제) 시 채팅창을 그 프로젝트의 세션 기록으로 리셋한다 —
|
|
// 세션ID가 프로젝트별로 분리돼 있으므로 여기서 지우고 다시 당겨오는 것으로 충분하다.
|
|
async function refreshChatForProject(){
|
|
document.getElementById('chat-msgs').innerHTML='';
|
|
await loadHistory();
|
|
addMsg('system',chatGreeting());
|
|
}
|
|
|
|
async function sendMessage(){
|
|
const input=document.getElementById('chat-input');
|
|
const msg=input.value.trim();
|
|
if(!msg||document.getElementById('send-btn').disabled)return;
|
|
input.value='';autoResize(input);
|
|
addMsg('user',escHtml(msg));
|
|
await callChat(msg);
|
|
}
|
|
|
|
async function callChat(message){
|
|
const btn=document.getElementById('send-btn');
|
|
btn.disabled=true;btn.textContent='...';
|
|
const msgs=document.getElementById('chat-msgs');
|
|
const typingDiv=document.createElement('div');typingDiv.className='rb-msg assistant';
|
|
typingDiv.innerHTML='<div class="rb-typing"><div class="rb-dot"></div><div class="rb-dot"></div><div class="rb-dot"></div></div>';
|
|
msgs.appendChild(typingDiv);msgs.scrollTop=msgs.scrollHeight;
|
|
let bubble=null,textBuf='';
|
|
try{
|
|
const body={
|
|
message,sessionId:getSessionId(),useTools:true,
|
|
skillContext:'이 대화는 "작업실"(여러 메이커 프로젝트 관리) 대시보드 페이지의 전용 채팅입니다. 현재 선택된 프로젝트: "'+(currentProject()?.name||'')+'". workshop_project 도구로 이 프로젝트의 부품/작업/메모를 바로 조회·수정하세요(project_name을 생략하면 이 프로젝트가 대상입니다. 다른 프로젝트를 다루려면 project_name을 명시하거나 set_active_project로 먼저 전환하세요).',
|
|
};
|
|
const r=await fetch('/api/chat',{method:'POST',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify(body)});
|
|
if(!r.ok)throw new Error('서버 오류 '+r.status);
|
|
typingDiv.remove();
|
|
const msgDiv=document.createElement('div');msgDiv.className='rb-msg assistant';
|
|
bubble=document.createElement('div');bubble.className='rb-bubble';
|
|
msgDiv.appendChild(bubble);msgs.appendChild(msgDiv);
|
|
const reader=r.body.getReader();const dec=new TextDecoder();let buf='';
|
|
while(true){
|
|
const{done,value}=await reader.read();if(done)break;
|
|
buf+=dec.decode(value,{stream:true});
|
|
// 전역 부품 배열 parts를 가리지 않게 SSE 조각은 sseParts로.
|
|
const sseParts=buf.split('\n\n');buf=sseParts.pop()||'';
|
|
for(const part of sseParts){
|
|
if(!part.startsWith('data: '))continue;
|
|
let ev;try{ev=JSON.parse(part.slice(6));}catch{continue;}
|
|
if(ev.type==='token'||ev.type==='text_delta'){
|
|
textBuf+=(ev.text||'');
|
|
if(bubble){bubble.innerHTML=renderMd(textBuf);msgs.scrollTop=msgs.scrollHeight;}
|
|
}
|
|
}
|
|
}
|
|
}catch(e){
|
|
typingDiv.remove();
|
|
if(e.name!=='AbortError'){if(!bubble)bubble=addMsg('assistant','');bubble.innerHTML='<span style="color:#ef4444">오류: '+escHtml(e.message)+'</span>';}
|
|
}finally{btn.disabled=false;btn.textContent='전송';}
|
|
// 채팅으로 데이터가 바뀌었을 수 있으니(workshop_project 도구 호출) 대시보드를 새로고침한다.
|
|
// 단, 데이터가 안 바뀌었으면(도구가 get 처럼 읽기만 한 경우) 화면을 건드리지 않는다 —
|
|
// 펼쳐둔 부품 카드가 접히거나 편집 중이던 input의 포커스가 날아가는 사고를 막기 위함.
|
|
await softRefreshWorkshop();
|
|
// 09-22: 채팅으로 scad_to_stl 등 파일도구를 불러 새 파일이 생겨도 파일탭이 이미 열려있으면
|
|
// 안 새로고침되던 문제(사용자 신고: "저장되었는데 보이지 않네") — 탭 전환 없이도 갱신.
|
|
if(document.getElementById('pane-files').classList.contains('active'))loadFiles();
|
|
}
|
|
|
|
async function clearChat(){
|
|
const sid=getSessionId();
|
|
document.getElementById('chat-msgs').innerHTML='';
|
|
try{await fetch('/api/chat/sessions/'+encodeURIComponent(sid),{method:'DELETE',headers:authH()});}catch{}
|
|
addMsg('system','새 대화가 시작되었습니다.');
|
|
}
|
|
|
|
function handleKey(e){if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();sendMessage();}}
|
|
function autoResize(el){el.style.height='auto';el.style.height=Math.min(el.scrollHeight,120)+'px';}
|
|
|
|
// ── 채팅 패널 리사이즈 ────────────────────────────────────────────────────────
|
|
(function(){
|
|
const CHAT_W='rb_chat_width';
|
|
const handle=document.getElementById('resize-handle');
|
|
const panel=document.getElementById('chat-panel');
|
|
let dragging=false,startX=0,startW=0;
|
|
panel.style.width=(parseInt(localStorage.getItem(CHAT_W)||'340',10))+'px';
|
|
handle.addEventListener('mousedown',e=>{dragging=true;startX=e.clientX;startW=panel.offsetWidth;handle.classList.add('dragging');document.body.style.userSelect='none';document.body.style.cursor='col-resize';e.preventDefault();});
|
|
document.addEventListener('mousemove',e=>{if(!dragging)return;const nw=Math.max(260,Math.min(600,startW+(startX-e.clientX)));panel.style.width=nw+'px';});
|
|
document.addEventListener('mouseup',()=>{if(!dragging)return;dragging=false;handle.classList.remove('dragging');document.body.style.userSelect='';document.body.style.cursor='';try{localStorage.setItem(CHAT_W,panel.offsetWidth);}catch{};});
|
|
})();
|
|
|
|
// ── 모델 pill ─────────────────────────────────────────────────────────────────
|
|
// 경량 버전: 메인 채팅의 설정 모달(settings-provider.js)을 통째로 들여오지 않고, 같은 백엔드
|
|
// 엔드포인트(/api/settings/model, /api/settings/provider, /api/models/test)만 재사용해서
|
|
// 전역 models.primary를 바꾼다. ollama provider(로컬+클라우드 모델 태그) 안에서만 전환됨 —
|
|
// provider 자체(openai/anthropic/google 등)를 바꾸는 건 메인 설정에서 해야 한다.
|
|
let _modelDropdownLoaded=false;
|
|
async function loadModelPill(){
|
|
try{
|
|
const r=await fetch('/api/settings/model',{headers:authH()});
|
|
if(!r.ok)return;
|
|
const d=await r.json();
|
|
const btn=document.getElementById('model-pill-btn');
|
|
if(btn && d.primary) btn.textContent=d.primary;
|
|
}catch{}
|
|
}
|
|
async function toggleModelDropdown(ev){
|
|
if(ev) ev.stopPropagation();
|
|
const dd=document.getElementById('model-dropdown');
|
|
if(dd.classList.contains('open')){dd.classList.remove('open');return;}
|
|
dd.classList.add('open');
|
|
if(_modelDropdownLoaded)return;
|
|
dd.innerHTML='<div class="rb-model-dropdown-msg">모델 불러오는 중…</div>';
|
|
try{
|
|
const pr=await fetch('/api/settings/provider',{headers:authH()});
|
|
const pd=await pr.json();
|
|
const tr=await fetch('/api/models/test',{method:'POST',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({llm:pd.llm})});
|
|
const td=await tr.json();
|
|
const models=(td.models||[]).map(m=>typeof m==='string'?m:(m.name||String(m)));
|
|
if(!models.length){dd.innerHTML='<div class="rb-model-dropdown-msg">모델 목록을 가져오지 못했습니다.</div>';return;}
|
|
const current=(document.getElementById('model-pill-btn').textContent||'').trim();
|
|
dd.innerHTML=models.map(m=>`<div class="rb-model-opt${m===current?' current':''}" data-model="${escHtml(m)}">${escHtml(m)}</div>`).join('');
|
|
dd.querySelectorAll('.rb-model-opt').forEach(el=>el.addEventListener('click',()=>selectModel(el.dataset.model)));
|
|
_modelDropdownLoaded=true;
|
|
}catch(e){dd.innerHTML='<div class="rb-model-dropdown-msg">오류: '+escHtml(e.message)+'</div>';}
|
|
}
|
|
async function selectModel(name){
|
|
document.getElementById('model-dropdown').classList.remove('open');
|
|
const btn=document.getElementById('model-pill-btn');
|
|
const prev=btn.textContent;
|
|
if(name===prev)return;
|
|
btn.textContent=name;
|
|
try{
|
|
const r=await fetch('/api/settings/model',{method:'POST',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({primary:name})});
|
|
if(!r.ok)throw new Error('저장 실패');
|
|
_modelDropdownLoaded=false;
|
|
}catch(e){
|
|
btn.textContent=prev;
|
|
addMsg('system','모델 변경 실패: '+escHtml(e.message));
|
|
}
|
|
}
|
|
document.addEventListener('click',(e)=>{
|
|
const pill=document.querySelector('.rb-model-pill');
|
|
if(pill && !pill.contains(e.target)) document.getElementById('model-dropdown')?.classList.remove('open');
|
|
});
|
|
|
|
// ── Init ──────────────────────────────────────────────────────────────────────
|
|
(async function init(){
|
|
if(!(await checkAuth()))return;
|
|
{const tb=document.getElementById('theme-btn');if(tb)tb.textContent=document.documentElement.getAttribute('data-theme')==='light'?'☀️':'🌙';}
|
|
await loadWorkshopData();
|
|
await loadHistory();
|
|
loadModelPill();
|
|
addMsg('system',chatGreeting());
|
|
document.getElementById('chat-input').focus();
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|