diff --git a/src/gateway/routes-arduino.ts b/src/gateway/routes-arduino.ts
index 4907083..c52cf30 100644
--- a/src/gateway/routes-arduino.ts
+++ b/src/gateway/routes-arduino.ts
@@ -1953,6 +1953,24 @@ app.post('/api/arduino/renode/start', async (req, res) => {
// SystemClock_Config() 제거 — HAL이 비트밴드(0x42xxxxxx)로 RCC에 접근하는데
// Renode가 비트밴드를 처리 못해서 무한 루프 → 호출 제거 (Renode는 클럭 설정 불필요)
patchedCode = patchedCode.replace(/SystemClock_Config\s*\(\s*\)\s*;/g, '/* SystemClock_Config skipped for Renode */;');
+ // GPIO 텔레메트리 — digitalWrite() 호출 시 STX "P:name:val" ETX 형식으로 핀 상태 전송
+ // Serial.write()로 STX(0x02)/ETX(0x03) 바이트를 직접 전송 (print("\x02")보다 명확)
+ // _RN_PNAME: digitalPinToPinName()으로 런타임에 핀 이름 복원 (PA0=192 같은 숫자 매크로 대응)
+ const gpioHeader = [
+ 'static char _rn_pbuf[8];',
+ 'static const char* _RN_PNAME(uint32_t p) {',
+ ' PinName pn = digitalPinToPinName(p);',
+ ' if (pn == NC) return "?";',
+ ' int port = ((uint32_t)pn >> 4) & 0xF;',
+ ' int pin = (uint32_t)pn & 0xF;',
+ ' _rn_pbuf[0]=\'P\'; _rn_pbuf[1]=\'A\'+port;',
+ ' if (pin>=10){_rn_pbuf[2]=\'1\';_rn_pbuf[3]=\'0\'+(pin-10);_rn_pbuf[4]=0;}',
+ ' else {_rn_pbuf[2]=\'0\'+pin;_rn_pbuf[3]=0;}',
+ ' return _rn_pbuf;',
+ '}',
+ '#define _RN_DW(p,v) do{uint32_t _rv=(uint32_t)(v);digitalWrite(p,_rv);Serial.write(2);Serial.print("P:");Serial.print(_RN_PNAME((uint32_t)(p)));Serial.print(":");Serial.print(_rv);Serial.write(3);}while(0)',
+ ].join('\n') + '\n';
+ patchedCode = gpioHeader + patchedCode.replace(/\bdigitalWrite\s*\(/g, '_RN_DW(');
require('fs').writeFileSync(sketchDir + '/sketch.ino', patchedCode);
const execQ = (cmd: string, ms = 60000): Promise<{ stdout: string; stderr: string }> =>
diff --git a/src/gateway/server-v2.ts b/src/gateway/server-v2.ts
index 63f4e91..eaede09 100644
--- a/src/gateway/server-v2.ts
+++ b/src/gateway/server-v2.ts
@@ -10102,7 +10102,7 @@ app.get('/api/status', async (_req, res) => {
provider,
currentModel: activeModel,
workspace: (config as any).workspace?.path || '',
- search: rawCfg.search?.google_api_key ? 'google' : (rawCfg.search?.tavily_api_key ? 'tavily' : 'none'),
+ search: rawCfg.search?.google_api_key ? 'google' : (rawCfg.search?.tavily_api_key ? 'tavily' : (rawCfg.search?.searxng_url ? 'searxng' : 'none')),
orchestration: orchCfg ? {
enabled: orchCfg.enabled,
secondary: orchCfg.secondary,
diff --git a/web-ui/app.js b/web-ui/app.js
index 8ef399a..ff20be1 100644
--- a/web-ui/app.js
+++ b/web-ui/app.js
@@ -8925,8 +8925,11 @@ function mobCloseMenu() {
const RIGHT_COLLAPSE_KEY = 'rightPanelCollapsed';
function applyRightPanelCollapsed(collapsed) {
document.body.classList.toggle('right-collapsed', !!collapsed);
- // Only override on desktop — narrow viewports use the mobile single-column layout.
- const isDesktop = window.matchMedia('(min-width: 1025px)').matches;
+ // Only override on the true 3-column desktop layout. At ≤1200px the tablet/mobile
+ // media queries own the grid (sidebar hidden), so inline 3-column styles here would
+ // fight them — e.g. restoring a wide saved width and letting the context panel eat
+ // most of the screen. Clear inline styles below that width so CSS takes over.
+ const isDesktop = window.matchMedia('(min-width: 1201px)').matches;
if (!isDesktop) { document.body.style.gridTemplateColumns = ''; return; }
if (collapsed) {
document.body.style.gridTemplateColumns = '280px 1fr 36px';
@@ -8949,6 +8952,13 @@ function toggleRightPanel() {
applyRightPanelCollapsed(collapsed);
})();
+// Re-evaluate on viewport change (e.g. tablet rotation crossing the 1200px boundary)
+// so inline desktop grid styles are cleared/applied to match the active CSS layout.
+window.addEventListener('resize', () => {
+ const stored = localStorage.getItem(RIGHT_COLLAPSE_KEY);
+ applyRightPanelCollapsed(stored === null ? true : stored === '1');
+});
+
// ── Right-panel resize ──────────────────────────────────────────────────────
(function initRightPanelResize() {
const MIN_W = 280;
@@ -8963,8 +8973,10 @@ function toggleRightPanel() {
return Math.max(MIN_W, Math.min(Math.floor(window.innerWidth * MAX_W_RATIO), px));
}
- // Restore saved width (only if not collapsed)
- if (!document.body.classList.contains('right-collapsed')) {
+ // Restore saved width — desktop only. Below 1201px the tablet/mobile CSS owns the
+ // grid, so applying a saved (often wide) width here would override it.
+ const isDesktop = () => window.matchMedia('(min-width: 1201px)').matches;
+ if (isDesktop() && !document.body.classList.contains('right-collapsed')) {
const saved = parseInt(localStorage.getItem(STORE_KEY), 10);
if (saved && saved >= MIN_W) applyWidth(clamp(saved));
}
diff --git a/web-ui/arduino-emulator.html b/web-ui/arduino-emulator.html
index fbc7d34..391ffe7 100644
--- a/web-ui/arduino-emulator.html
+++ b/web-ui/arduino-emulator.html
@@ -3023,8 +3023,153 @@ function initEsp32Mode(sessionId, fname, fqbn) {
let _renodeSessionId = null;
let _renodeEvtSource = null;
+// Arduino 번호 → STM32 핀 이름 (STM32duino BLUEPILL_F103C8 순서)
+const _STM32_ARDUINO_PINS = [
+ 'PA0','PA1','PA2','PA3','PA4','PA5','PA6','PA7', // D0-D7
+ 'PB0','PB1','PB10','PB11','PB12','PB13','PB14','PB15', // D8-D15
+ 'PA8','PA9','PA10','PA11','PA12','PA15', // D16-D21
+ 'PB3','PB4','PB5','PB6','PB7','PB8','PB9', // D22-D28
+ 'PC13','PC14','PC15', // D29-D31
+];
+
+// HIGH/LOW/1/0/0x1/0x0 → 0 or 1
+// Arduino: #define HIGH 0x1 이므로 _RN_S(HIGH) → "0x1" 로 전달됨
+function _stm32PinVal(v) {
+ if (v === 'HIGH' || v === 'true') return 1;
+ if (v === 'LOW' || v === 'false') return 0;
+ const n = parseInt(v, 16);
+ return (!isNaN(n) && n !== 0) ? 1 : 0;
+}
+
+function _stm32UpdatePin(pinName, val) {
+ const resolved = _stm32PinResolve(pinName);
+ const on = _stm32PinVal(val);
+ if (resolved === 'PC13' || resolved === 'LED_BUILTIN') {
+ // PC13 active LOW — 핀 서클 + 온보드 LED 둘 다 업데이트
+ const lit = on === 0;
+ const fill = lit ? '#ffcc00' : '#1a1a0a';
+ const flt = lit ? 'drop-shadow(0 0 5px #ffcc00)' : 'none';
+ ['stm32-pc13', 'stm32-led-pc13'].forEach(id => {
+ const el = document.getElementById(id);
+ if (el) { el.style.fill = fill; el.style.filter = flt; }
+ });
+ } else {
+ const id = 'stm32-' + resolved.toLowerCase();
+ const el = document.getElementById(id);
+ if (!el) return;
+ el.style.fill = on ? '#22cc55' : '#0d2a0d';
+ el.style.filter = on ? 'drop-shadow(0 0 3px #22cc55)' : 'none';
+ }
+}
+
+// Arduino 핀 번호/PinName 숫자 → STM32 포트·핀 이름
+function _stm32PinResolve(s) {
+ if (!s) return '?';
+ // 이미 이름 형식("PA0", "PC13" 등)이면 그대로 반환
+ if (/^P[A-Z]\d/.test(s) || s === 'LED_BUILTIN') return s;
+ const num = parseInt(s, 0); // "0xC0"→192, "192"→192, "45"→45
+ if (isNaN(num)) return s;
+ // Digital pin 0-31 (STMduino BLUEPILL 순서)
+ if (num >= 0 && num < _STM32_ARDUINO_PINS.length) return _STM32_ARDUINO_PINS[num];
+ // Analog pin (PNUM_ANALOG_BASE=0xC0): PA0-PA7, PB0, PB1
+ const _AP = ['PA0','PA1','PA2','PA3','PA4','PA5','PA6','PA7','PB0','PB1'];
+ if ((num & 0xC0) === 0xC0 && (num - 0xC0) < _AP.length) return _AP[num - 0xC0];
+ // PinName 인코딩: 상위 니블=포트, 하위 니블=핀 (PA_0=0x00, PC_13=0x2D)
+ if (num < 0xC0) {
+ const port = (num >> 4) & 0xF, pin = num & 0xF;
+ if (port < 12) return 'P' + String.fromCharCode(65 + port) + pin;
+ }
+ return 'D' + num;
+}
+
+let _renodeGpioBuf = '';
+function _stm32ParseGpio(raw) {
+ // 텔레메트리: STX(0x02) "P:name:val" ETX(0x03) 패킷 추출, 나머지는 시리얼로
+ _renodeGpioBuf += raw;
+ let out = '';
+ let i = 0;
+ while (i <= _renodeGpioBuf.length) {
+ const s = _renodeGpioBuf.indexOf('\x02', i);
+ if (s < 0) { out += _renodeGpioBuf.slice(i); _renodeGpioBuf = ''; break; }
+ out += _renodeGpioBuf.slice(i, s);
+ const e = _renodeGpioBuf.indexOf('\x03', s + 1);
+ if (e < 0) { _renodeGpioBuf = _renodeGpioBuf.slice(s); break; } // 패킷 미완성
+ const pkt = _renodeGpioBuf.slice(s + 1, e); // "P:PA0:1"
+ _renodeGpioBuf = _renodeGpioBuf.slice(e + 1);
+ i = 0;
+ const parts = pkt.split(':');
+ if (parts[0] === 'P' && parts.length >= 3)
+ _stm32UpdatePin(_stm32PinResolve(parts[1]), parts[2]);
+ }
+ return out;
+}
+
+function _buildBluePillSVG(isF4) {
+ const leftPins = [
+ ['GND','#666'],['GND','#666'],['3V3','#c0c090'],['NRST','#b0b090'],
+ ['PA0','#0d2a0d'],['PA1','#0d2a0d'],['PA2','#0d2a0d'],['PA3','#0d2a0d'],
+ ['PA4','#0d2a0d'],['PA5','#0d2a0d'],['PA6','#0d2a0d'],['PA7','#0d2a0d'],
+ ['PB0','#0d2a0d'],['PB1','#0d2a0d'],['PB10','#0d2a0d'],['PB11','#0d2a0d'],
+ ['PB12','#0d2a0d'],['PB13','#0d2a0d'],['PB14','#0d2a0d'],['PB15','#0d2a0d'],
+ ];
+ const rightPins = [
+ ['GND','#666'],['GND','#666'],['3V3','#c0c090'],['PA8','#0d2a0d'],
+ ['PA9','#0d2a0d','TX'],['PA10','#0d2a0d','RX'],['PA11','#0d2a0d'],['PA12','#0d2a0d'],
+ ['PA15','#0d2a0d'],['PB3','#0d2a0d'],['PB4','#0d2a0d'],['PB5','#0d2a0d'],
+ ['PB6','#0d2a0d'],['PB7','#0d2a0d'],['PB8','#0d2a0d'],['PB9','#0d2a0d'],
+ ['PC13','#1a1a0a'],['PC14','#0d2a0d'],['PC15','#0d2a0d'],['5V','#c09090'],
+ ];
+ // sp=핀 간격, y0=시작 y, cx_l/r=좌우 서클 x
+ const sp = 9, y0 = 18, cx_l = 5, cx_r = 95;
+ let lp = '', rp = '';
+ leftPins.forEach(([name, fill], i) => {
+ const y = y0 + i * sp;
+ const isGpio = name.startsWith('P');
+ const id = isGpio ? ` id="stm32-${name.toLowerCase()}"` : '';
+ const sc = isGpio ? '#1a5a1a' : '#555';
+ lp += ``;
+ lp += `${name}`;
+ });
+ rightPins.forEach(([name, fill, tag], i) => {
+ const y = y0 + i * sp;
+ const isGpio = name.startsWith('P');
+ const id = isGpio ? ` id="stm32-${name.toLowerCase()}"` : '';
+ const sc = isGpio ? '#1a5a1a' : '#555';
+ rp += ``;
+ const lbl = tag ? `${name} ${tag}` : name;
+ rp += `${lbl}`;
+ });
+ const chipLines = isF4 ? ['STM32F4','F411CE'] : ['STM32F1','F103C8T6'];
+ const h = y0 + leftPins.length * sp + 16; // 보드 높이 자동
+ const w = 100;
+ const chipY = Math.round(h * 0.28), chipH = Math.round(h * 0.42);
+ const ledY = Math.round(chipY * 0.55);
+ return ``;
+}
+
function initSTM32RenodeMode(sessionId, fname, fqbn) {
_renodeSessionId = sessionId;
+ _renodeGpioBuf = '';
document.querySelector('.header h1').textContent = '🖥️ STM32 Emulator (Renode)';
document.getElementById('filename').textContent = fname;
@@ -3032,13 +3177,23 @@ function initSTM32RenodeMode(sessionId, fname, fqbn) {
const badge = document.createElement('span');
badge.id = 'chip-badge';
badge.style.cssText = 'display:inline-block;padding:2px 7px;border-radius:4px;font-size:10px;font-weight:600;border:1px solid;flex-shrink:0;';
- const family = fqbn.includes('GenF4') || fqbn.includes('f4') ? 'STM32F4' : 'STM32F1';
- badge.textContent = family + ' · Renode';
+ const isF4 = fqbn.includes('GenF4') || fqbn.includes('f4');
+ badge.textContent = (isF4 ? 'STM32F4' : 'STM32F1') + ' · Renode';
badge.style.background = '#1a0a3a'; badge.style.color = '#c084fc'; badge.style.borderColor = '#7c3aed';
document.getElementById('filename').after(badge);
- document.querySelector('.board-svg')?.style && (document.querySelector('.board-svg').style.display = 'none');
- document.querySelector('.board-title')?.style && (document.querySelector('.board-title').style.display = 'none');
+ // 보드 SVG를 Blue Pill로 교체
+ const boardSvgEl = document.querySelector('.board-svg');
+ const boardTitleEl = document.querySelector('.board-title');
+ if (boardSvgEl) { boardSvgEl.innerHTML = _buildBluePillSVG(isF4); boardSvgEl.style.display = ''; }
+ if (boardTitleEl) { boardTitleEl.textContent = isF4 ? 'Black Pill · STM32F4' : 'Blue Pill · STM32F1'; boardTitleEl.style.display = ''; }
+
+ // PWR LED 켜기
+ setTimeout(() => {
+ const pwr = document.getElementById('stm32-pwr-led');
+ if (pwr) { pwr.style.fill = '#22cc44'; pwr.style.filter = 'drop-shadow(0 0 3px #22cc44)'; }
+ }, 500);
+
document.getElementById('btn-stop')?.style && (document.getElementById('btn-stop').style.display = 'none');
document.getElementById('btn-reset')?.style && (document.getElementById('btn-reset').style.display = 'none');
@@ -3052,6 +3207,8 @@ function initSTM32RenodeMode(sessionId, fname, fqbn) {
fetch('/api/arduino/renode/' + _renodeSessionId, { method: 'DELETE', credentials: 'include', keepalive: true }).catch(() => {});
if (_renodeEvtSource) { _renodeEvtSource.close(); _renodeEvtSource = null; }
setStatus('off', '정지됨');
+ const pwr = document.getElementById('stm32-pwr-led');
+ if (pwr) { pwr.style.fill = '#0d2a0d'; pwr.style.filter = 'none'; }
};
toolbar.append(stopBtn);
}
@@ -3077,11 +3234,44 @@ function initSTM32RenodeMode(sessionId, fname, fqbn) {
_renodeEvtSource = new EventSource('/api/arduino/renode/' + sessionId + '/output', { withCredentials: true });
_renodeEvtSource.onopen = () => setStatus('running', '실행 중');
_renodeEvtSource.onerror = () => setStatus('error', '연결 오류');
+ // TX 핀(PA9) 깜빡임 — 시리얼 데이터 수신 시
+ let _txTimer = null;
+ function _blinkTx() {
+ const el = document.getElementById('stm32-pa9');
+ if (!el) return;
+ el.style.fill = '#ff8800'; el.style.filter = 'drop-shadow(0 0 2px #ff8800)';
+ clearTimeout(_txTimer);
+ _txTimer = setTimeout(() => { el.style.fill = '#0d2a0d'; el.style.filter = 'none'; }, 80);
+ }
+ // RX 핀(PA10) 깜빡임 — 사용자 입력 전송 시
+ let _rxTimer = null;
+ function _blinkRx() {
+ const el = document.getElementById('stm32-pa10');
+ if (!el) return;
+ el.style.fill = '#00aaff'; el.style.filter = 'drop-shadow(0 0 2px #00aaff)';
+ clearTimeout(_rxTimer);
+ _rxTimer = setTimeout(() => { el.style.fill = '#0d2a0d'; el.style.filter = 'none'; }, 80);
+ }
+ // doSend에 RX 깜빡임 추가
+ const _origDoSend = doSend;
+ const doSendWithRx = () => { _blinkRx(); _origDoSend(); };
+ serialInput?.removeEventListener('keydown', e => { if (e.key === 'Enter') doSend(); });
+ serialSend?.removeEventListener('click', doSend);
+ serialInput?.addEventListener('keydown', e => { if (e.key === 'Enter') doSendWithRx(); });
+ serialSend?.addEventListener('click', doSendWithRx);
+
_renodeEvtSource.onmessage = e => {
try {
const d = JSON.parse(e.data);
- if (d.serial) appendSerial(d.serial);
- if (d.exit !== undefined) setStatus('off', '종료');
+ if (d.serial) {
+ const visible = _stm32ParseGpio(d.serial);
+ if (visible) { appendSerial(visible); _blinkTx(); }
+ }
+ if (d.exit !== undefined) {
+ setStatus('off', '종료');
+ const pwr = document.getElementById('stm32-pwr-led');
+ if (pwr) { pwr.style.fill = '#0d2a0d'; pwr.style.filter = 'none'; }
+ }
} catch {}
};
window.addEventListener('beforeunload', () => {
diff --git a/web-ui/code.js b/web-ui/code.js
index f89bdc6..b81e4eb 100644
--- a/web-ui/code.js
+++ b/web-ui/code.js
@@ -2823,6 +2823,7 @@ async function _codeAiOneRound(messages, onToken) {
tools: CLIENT_TOOL_DEFS,
sessionId: codeAiSessionId,
...(codeAiModel ? { model: codeAiModel } : {}),
+ ...(_csbSelBoard?.fqbn ? { boardContext: `${_csbSelBoard.name} (${_csbSelBoard.fqbn})` } : {}),
}),
});
if (!resp.ok) throw new Error(`/api/code/round ${resp.status}: ${await resp.text()}`);
diff --git a/web-ui/index.html b/web-ui/index.html
index bdf865b..8d4f5d2 100644
--- a/web-ui/index.html
+++ b/web-ui/index.html
@@ -806,12 +806,12 @@
웹 검색
diff --git a/web-ui/music-app.html b/web-ui/music-app.html
index 53d8544..be402b7 100644
--- a/web-ui/music-app.html
+++ b/web-ui/music-app.html
@@ -456,6 +456,45 @@ input.tb-num{background:#1e1e30;border:1px solid rgba(255,255,255,.1);color:#c8c
}
.drum-back-btn:hover{background:rgba(147,51,234,.3);border-color:var(--studio-accent2);color:#e9d5ff;}
+/* ── Folder popup ── */
+#fp-overlay{display:none;position:fixed;inset:0;z-index:950;background:rgba(0,0,0,.6);backdrop-filter:blur(3px);}
+#fp-overlay.open{display:block;}
+#fp-sheet{
+ position:absolute;bottom:0;left:0;right:0;
+ max-height:72vh;background:var(--studio-panel);
+ border-radius:16px 16px 0 0;
+ border-top:1px solid var(--studio-border2);
+ display:flex;flex-direction:column;overflow:hidden;
+ box-shadow:0 -8px 40px rgba(0,0,0,.5);
+ transform:translateY(100%);transition:transform .25s ease;
+}
+#fp-overlay.open #fp-sheet{transform:translateY(0);}
+@media(min-width:1025px){
+ #fp-sheet{
+ position:absolute;top:50%;left:50%;bottom:auto;right:auto;
+ width:400px;max-height:65vh;
+ border-radius:14px;border:1px solid var(--studio-border2);
+ box-shadow:0 20px 60px rgba(0,0,0,.6);
+ transform:translate(-50%,-46%);transition:none;
+ }
+ #fp-overlay.open #fp-sheet{transform:translate(-50%,-50%);}
+}
+.fp-hdr{display:flex;align-items:center;padding:13px 16px 10px;flex-shrink:0;border-bottom:1px solid var(--studio-border);gap:6px;}
+.fp-title{font-size:13px;font-weight:700;color:var(--studio-text);flex:1;}
+.fp-icon-btn{background:none;border:none;color:var(--studio-muted);cursor:pointer;font-size:14px;padding:3px 6px;border-radius:6px;transition:.15s;font-family:inherit;}
+.fp-icon-btn:hover{color:var(--studio-accent2);background:var(--studio-glow2);}
+.fp-tabs{display:flex;gap:4px;padding:8px 12px 6px;flex-shrink:0;}
+.fp-tab{flex:1;padding:6px;font-size:11px;font-weight:700;border:1px solid var(--studio-border2);border-radius:8px;background:transparent;color:var(--studio-muted);cursor:pointer;font-family:inherit;transition:.2s;}
+.fp-tab.active{background:var(--studio-accent);border-color:var(--studio-accent);color:#fff;box-shadow:0 0 10px var(--studio-glow);}
+.fp-body{flex:1;overflow-y:auto;padding:4px 8px 14px;min-height:0;}
+.fp-item{display:flex;align-items:center;gap:6px;padding:7px 8px;border-radius:8px;transition:.15s;}
+.fp-item:hover{background:var(--studio-glow2);}
+.fp-item-icon{font-size:15px;flex-shrink:0;}
+.fp-item-name{flex:1;font-size:12px;color:var(--studio-text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;cursor:pointer;}
+.fp-item-del{background:none;border:none;color:var(--studio-muted);cursor:pointer;font-size:13px;padding:2px 5px;border-radius:4px;flex-shrink:0;transition:.15s;line-height:1;}
+.fp-item-del:hover{color:var(--studio-red);}
+.fp-empty{font-size:12px;color:var(--studio-muted);padding:24px 12px;text-align:center;}
+
/* ── Mobile ── */
@media(max-width:1024px){
.ms-hdr{padding:0 10px;gap:6px;}
@@ -564,6 +603,7 @@ input.tb-num{background:#1e1e30;border:1px solid rgba(255,255,255,.1);color:#c8c
MIDI 다운로드
+
@@ -581,6 +621,7 @@ input.tb-num{background:#1e1e30;border:1px solid rgba(255,255,255,.1);color:#c8c
Guitar Pro
+
@@ -4378,7 +4419,106 @@ function meStatus(msg){const el=document.getElementById('status-txt');if(!el)ret
document.getElementById('chat-input').focus();
}
})();
+
+// ── Folder popup ──────────────────────────────────────────────────────────────
+let _fpTab = 'midi';
+
+function openFolderPopup(tab = 'midi') {
+ _fpTab = tab;
+ document.getElementById('fp-overlay').classList.add('open');
+ fpSetTab(tab);
+ fpLoadMidi();
+ fpLoadGp();
+}
+
+function closeFolderPopup() {
+ document.getElementById('fp-overlay').classList.remove('open');
+}
+
+function fpSetTab(tab) {
+ _fpTab = tab;
+ document.getElementById('fp-tab-midi').classList.toggle('active', tab === 'midi');
+ document.getElementById('fp-tab-gp').classList.toggle('active', tab === 'gp');
+ document.getElementById('fp-midi-body').style.display = tab === 'midi' ? '' : 'none';
+ document.getElementById('fp-gp-body').style.display = tab === 'gp' ? '' : 'none';
+}
+
+function fpReload() { fpLoadMidi(); fpLoadGp(); }
+
+async function fpLoadMidi() {
+ const el = document.getElementById('fp-midi-body');
+ el.innerHTML = '
로딩 중...
';
+ try {
+ const r = await fetch('/api/music/list', { headers: authH() });
+ const data = await r.json();
+ if (!data.files?.length) { el.innerHTML = '
저장된 MIDI 파일 없음
'; return; }
+ el.innerHTML = data.files.map(f => `
+
+ 🎵
+ ${escHtml(f.name)}
+
+
`).join('');
+ } catch { el.innerHTML = '
로드 실패
'; }
+}
+
+async function fpLoadGp() {
+ const el = document.getElementById('fp-gp-body');
+ el.innerHTML = '
로딩 중...
';
+ try {
+ const r = await fetch('/api/music/gp/list', { headers: authH() });
+ const data = await r.json();
+ if (!data.files?.length) { el.innerHTML = '
저장된 GP 파일 없음
'; return; }
+ el.innerHTML = data.files.map(f => `
+
+ 🎸
+ ${escHtml(f.name)}
+
+
`).join('');
+ } catch { el.innerHTML = '
로드 실패
'; }
+}
+
+async function fpOpenMidi(path, name) { closeFolderPopup(); await loadMidiToStage(path, name); }
+async function fpOpenGp(path, name) { closeFolderPopup(); await loadGpFromServer(path, name); }
+
+async function fpDeleteMidi(name, btn) {
+ if (!confirm(`"${name}" 삭제할까요?`)) return;
+ try {
+ const r = await fetch('/api/music/workspace-file/' + encodeURIComponent(name), { method: 'DELETE', headers: authH() });
+ if (!r.ok) throw new Error((await r.json()).error || '삭제 실패');
+ btn.closest('.fp-item').remove();
+ loadMidiFiles();
+ } catch(e) { alert('삭제 실패: ' + e.message); }
+}
+
+async function fpDeleteGp(name, btn) {
+ if (!confirm(`"${name}" 삭제할까요?`)) return;
+ try {
+ const r = await fetch(`/api/music/gp-file/${encodeURIComponent(name)}`, { method: 'DELETE', headers: authH() });
+ if (!r.ok) throw new Error((await r.json()).error || '삭제 실패');
+ btn.closest('.fp-item').remove();
+ loadGpFileList();
+ } catch(e) { alert('삭제 실패: ' + e.message); }
+}
+
+
+
+
+ 📂 저장된 파일
+
+
+
+
+
+
+
+
+
+
+
diff --git a/web-ui/styles.css b/web-ui/styles.css
index 3a3243b..1d08683 100644
--- a/web-ui/styles.css
+++ b/web-ui/styles.css
@@ -1866,12 +1866,20 @@
@media (max-width: 1200px) {
body {
- grid-template-columns: 250px 1fr;
+ /* main(채팅)이 유연한 공간을, 컨텍스트 패널은 제한된 폭을 갖도록.
+ 이전엔 컬럼이 뒤바뀌어 컨텍스트가 1fr(화면 절반 이상)을 차지했음. */
+ grid-template-columns: 1fr minmax(160px, 200px);
grid-template-areas:
"header header"
"main right";
}
aside { display: none; }
+ /* No manual resize on tablet — CSS owns the column widths here. */
+ #right-panel-resize-handle { display: none !important; }
+ /* Collapsed → shrink the right column to just the rail instead of a 300px gap. */
+ body.right-collapsed {
+ grid-template-columns: 1fr 44px;
+ }
}
@media (max-width: 1024px) {
diff --git a/web-ui/traffic-app.html b/web-ui/traffic-app.html
index aa888c9..6ce031a 100644
--- a/web-ui/traffic-app.html
+++ b/web-ui/traffic-app.html
@@ -66,6 +66,10 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
.vt-btn.active{border-color:var(--brand);color:var(--brand);background:rgba(245,158,11,.1);}
.count-badge{font-size:11px;color:var(--muted);padding:0 4px;}
+.loc-btn{width:100%;margin-top:6px;padding:6px 10px;background:none;border:1.5px solid var(--line);border-radius:7px;font-size:12px;font-weight:600;color:var(--muted);cursor:pointer;font-family:inherit;transition:.15s;display:flex;align-items:center;justify-content:center;gap:5px;}
+.loc-btn:hover{border-color:var(--brand);color:var(--brand);}
+.loc-btn:disabled{opacity:.5;cursor:not-allowed;}
+.ci-dist{font-size:10px;color:var(--brand);float:right;margin-left:4px;}
/* ── 탭 ── */
.tab-bar{display:flex;gap:0;border-bottom:1px solid var(--line);background:var(--panel);flex-shrink:0;}
@@ -77,6 +81,43 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
/* ── UTIC iframe ── */
.utic-frame{flex:1;border:none;width:100%;height:100%;}
+
+/* ── Mobile ── */
+@media(max-width:768px){
+ /* Sidebar becomes overlay */
+ .sidebar{
+ position:fixed;top:0;left:0;bottom:0;z-index:500;
+ width:82vw;max-width:300px;
+ transform:translateX(-100%);transition:transform .25s ease;
+ box-shadow:4px 0 24px rgba(0,0,0,.4);
+ }
+ .sidebar.mob-open{transform:translateX(0);}
+ /* Backdrop */
+ #mob-backdrop{display:none;position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:499;}
+ #mob-backdrop.show{display:block;}
+ /* Main fills full width */
+ #pane-its{flex-direction:column;}
+ .main{width:100%;}
+ /* Region buttons: horizontal scroll */
+ .region-bar{flex-wrap:nowrap;overflow-x:auto;-webkit-overflow-scrolling:touch;}
+ .region-bar::-webkit-scrollbar{display:none;}
+ .r-btn{flex-shrink:0;}
+ /* Grid: smaller cards */
+ .grid-wrap{grid-template-columns:repeat(auto-fill,minmax(150px,1fr));}
+ /* Viewer header wraps */
+ .viewer-hdr{flex-wrap:wrap;gap:4px;padding:6px 8px;}
+ .viewer-title{font-size:12px;}
+ /* iOS auto-zoom prevention */
+ .search-box,input{font-size:16px !important;}
+ /* Tab buttons smaller */
+ .tab-btn{padding:6px 10px;font-size:11px;}
+ /* Show mob list button */
+ #mob-list-btn{display:inline-flex !important;}
+}
+@media(min-width:769px){
+ #mob-list-btn{display:none !important;}
+ #mob-backdrop{display:none !important;}
+}
@@ -91,22 +132,18 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
-
-
-
-
-
-
-
+
+
-