tapback 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/html.js ADDED
@@ -0,0 +1,594 @@
1
+ exports.mainPage = (appURL, quickButtons = []) => {
2
+ const customButtonsHtml = quickButtons
3
+ .map((b) => {
4
+ const cmd = b.command.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
5
+ return `<button class="btn bq bc" data-v="${cmd}">${b.label}</button>`;
6
+ })
7
+ .join('');
8
+
9
+ const appLinkStyle = appURL
10
+ ? '.app-link{display:block;padding:8px 14px;background:#1f3a5f;color:#58a6ff;text-align:center;text-decoration:none;font-size:13px;border-bottom:1px solid #30363d}'
11
+ : '';
12
+ const appLinkHtml = appURL ? `<a class="app-link" href="${appURL}">Open App</a>` : '';
13
+ const customRow = customButtonsHtml
14
+ ? `<div class="row quick cust">${customButtonsHtml}</div>`
15
+ : '';
16
+
17
+ return `<!DOCTYPE html>
18
+ <html><head>
19
+ <meta charset="UTF-8">
20
+ <meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no,viewport-fit=cover">
21
+ <title>Tapback</title>
22
+ <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect width='100' height='100' rx='20' fill='%230d1117'/><text x='50' y='68' font-size='55' text-anchor='middle' fill='%2358a6ff'>▶</text></svg>">
23
+ <style>
24
+ *{box-sizing:border-box;margin:0;padding:0}
25
+ html,body{height:100%;height:100dvh;overflow:hidden}
26
+ body{font-family:-apple-system,BlinkMacSystemFont,monospace;background:#0d1117;color:#c9d1d9}
27
+ #sidebar{position:fixed;top:0;left:0;bottom:0;width:44px;background:#161b22;border-right:1px solid #30363d;display:flex;flex-direction:column;padding-top:env(safe-area-inset-top);overflow:hidden;z-index:10;transition:width 0.2s}
28
+ #sidebar.expanded{width:200px;box-shadow:4px 0 20px rgba(0,0,0,0.5)}
29
+ #toggle-btn{width:100%;height:36px;display:flex;align-items:center;justify-content:center;font-size:18px;cursor:pointer;color:#8b949e;border-bottom:1px solid #30363d}
30
+ #legend-btn{width:100%;height:36px;display:flex;align-items:center;justify-content:center;font-size:14px;cursor:pointer;color:#8b949e;border-top:1px solid #30363d;margin-top:auto}
31
+ #legend{display:none;position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);background:#161b22;border:1px solid #30363d;border-radius:10px;padding:16px;z-index:200;min-width:200px}
32
+ #legend.show{display:block}
33
+ #legend-overlay{display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.5);z-index:199}
34
+ #legend-overlay.show{display:block}
35
+ #legend h3{margin:0 0 12px;font-size:14px;color:#c9d1d9}
36
+ .legend-item{display:flex;align-items:center;gap:10px;margin:8px 0;font-size:13px}
37
+ .legend-color{width:20px;height:20px;border-radius:4px;display:flex;align-items:center;justify-content:center}
38
+ .legend-color.starting{background:#3d2f1a}
39
+ .legend-color.processing{background:#1a3d1a}
40
+ .legend-color.idle{background:#1a2a3f}
41
+ .legend-color.waiting{background:#3d3520}
42
+ .legend-color.ended{background:#2d2d2d}
43
+ .sess{width:100%;height:44px;display:flex;align-items:center;padding:0 10px;font-size:16px;cursor:pointer;border:2px solid transparent;transition:background 0.2s;gap:8px;background:#21262d}
44
+ .sess .name{display:none;font-size:14px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:#c9d1d9}
45
+ #sidebar.expanded .sess .name{display:block}
46
+ .sess.active{border-color:#fff}
47
+ .sess.status-starting{background:#3d2f1a}
48
+ .sess.status-processing{background:#1a3d1a}
49
+ .sess.status-idle{background:#1a2a3f}
50
+ .sess.status-waiting{background:#3d3520}
51
+ .sess.status-ended{background:#2d2d2d}
52
+ @keyframes pulse{0%,100%{opacity:1}50%{opacity:0.5}}
53
+ .sess.status-processing .icon{animation:pulse 1s infinite}
54
+ #main{display:flex;flex-direction:column;height:100%;margin-left:44px;overflow:hidden}
55
+ #h{padding:10px 14px;padding-top:max(10px,env(safe-area-inset-top));background:#161b22;border-bottom:1px solid #30363d;display:flex;justify-content:space-between;align-items:center;flex-shrink:0}
56
+ #h .t{font-weight:bold;font-size:16px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
57
+ #h .t.status-starting{color:#f0883e}
58
+ #h .t.status-processing{color:#3fb950}
59
+ #h .t.status-idle{color:#58a6ff}
60
+ #h .t.status-waiting{color:#d29922}
61
+ #h .t.status-ended{color:#8b949e}
62
+ #h .s{font-size:13px;flex-shrink:0}
63
+ #sound-toggle{font-size:18px;cursor:pointer;margin-left:8px}
64
+ .on{color:#3fb950}.off{color:#f85149}
65
+ ${appLinkStyle}
66
+ #term-contents{flex:1;overflow-y:auto;-webkit-overflow-scrolling:touch;padding:14px;font-size:13px;line-height:1.5;white-space:pre-wrap;word-break:break-all;font-family:monospace}
67
+ #in{padding:12px;padding-bottom:max(12px,env(safe-area-inset-bottom));background:#161b22;border-top:1px solid #30363d;flex-shrink:0}
68
+ .row{display:flex;gap:8px;align-items:center}
69
+ .quick{margin-bottom:8px}
70
+ .btn{padding:12px 18px;font-size:15px;font-weight:600;border:none;border-radius:10px;cursor:pointer;-webkit-tap-highlight-color:transparent}
71
+ .btn:active{opacity:0.7}
72
+ .bq{flex:1;background:#21262d;color:#c9d1d9}
73
+ .cust{overflow-x:auto;flex-wrap:nowrap;-webkit-overflow-scrolling:touch}
74
+ .bc{flex:none;background:#1f3a5f;color:#58a6ff;font-size:13px;padding:10px 14px}
75
+ #txt{flex:1;padding:12px 14px;font-size:16px;background:#0d1117;color:#c9d1d9;border:1px solid #30363d;border-radius:10px;min-width:0}
76
+ #txt:focus{outline:none;border-color:#8b5cf6}
77
+ .bsend{background:#8b5cf6;color:#fff}
78
+ .empty{color:#8b949e;text-align:center;padding:20px}
79
+ </style></head>
80
+ <body>
81
+ <div id="legend-overlay"></div>
82
+ <div id="legend">
83
+ <h3>ステータス</h3>
84
+ <div class="legend-item"><span class="legend-color starting">🔄</span><span>starting - 開始中</span></div>
85
+ <div class="legend-item"><span class="legend-color processing">⚡</span><span>processing - 処理中</span></div>
86
+ <div class="legend-item"><span class="legend-color idle">💤</span><span>idle - 待機中</span></div>
87
+ <div class="legend-item"><span class="legend-color waiting">⏳</span><span>waiting - 入力待ち</span></div>
88
+ <div class="legend-item"><span class="legend-color ended">⏹</span><span>ended - 終了</span></div>
89
+ </div>
90
+ <div id="sidebar">
91
+ <div id="toggle-btn">☰</div>
92
+ <div id="sessions"></div>
93
+ <div id="legend-btn">❓</div>
94
+ </div>
95
+ <div id="main">
96
+ <div id="h"><span class="t" id="title">Tapback</span><span id="sound-toggle">🔇</span><span class="s" id="st">...</span></div>
97
+ ${appLinkHtml}
98
+ <div id="term-contents"></div>
99
+ <div id="in">
100
+ <div class="row quick">
101
+ <button class="btn bq" data-v="1">1</button>
102
+ <button class="btn bq" data-v="2">2</button>
103
+ <button class="btn bq" data-v="3">3</button>
104
+ <button class="btn bq" data-v="4">4</button>
105
+ <button class="btn bq" data-v="5">5</button>
106
+ </div>
107
+ ${customRow}
108
+ <div class="row">
109
+ <input type="text" id="txt" placeholder="Input..." autocomplete="off" enterkeyhint="send">
110
+ <button class="btn bsend" id="send">Send</button>
111
+ </div>
112
+ </div>
113
+ </div>
114
+ <script>
115
+ const st=document.getElementById('st'),txt=document.getElementById('txt');
116
+ const contents=document.getElementById('term-contents');
117
+ const title=document.getElementById('title');
118
+ const sidebar=document.getElementById('sidebar');
119
+ const sessionsEl=document.getElementById('sessions');
120
+ const legend=document.getElementById('legend');
121
+ const legendOverlay=document.getElementById('legend-overlay');
122
+ const legendBtn=document.getElementById('legend-btn');
123
+ const soundToggle=document.getElementById('sound-toggle');
124
+ let ws,activeId='',sessions=[],outputs={},sessionPaths={},claudeStatuses={};
125
+ let soundEnabled=localStorage.getItem('tapback_sound')==='1';
126
+ let prevStatuses={};
127
+
128
+ function updateSoundIcon(){soundToggle.textContent=soundEnabled?'🔔':'🔇';}
129
+ updateSoundIcon();
130
+ soundToggle.onclick=()=>{soundEnabled=!soundEnabled;localStorage.setItem('tapback_sound',soundEnabled?'1':'0');updateSoundIcon();};
131
+
132
+ function playTone(freq,duration,type='sine'){
133
+ if(!soundEnabled)return;
134
+ try{
135
+ const ctx=new(window.AudioContext||window.webkitAudioContext)();
136
+ const osc=ctx.createOscillator();
137
+ const gain=ctx.createGain();
138
+ osc.connect(gain);gain.connect(ctx.destination);
139
+ osc.frequency.value=freq;
140
+ osc.type=type;
141
+ gain.gain.setValueAtTime(1.0,ctx.currentTime);
142
+ gain.gain.exponentialRampToValueAtTime(0.01,ctx.currentTime+duration);
143
+ osc.start();osc.stop(ctx.currentTime+duration);
144
+ }catch(e){}
145
+ }
146
+ function playIdleSound(){playTone(600,0.3,'sine');}
147
+ function playWaitingSound(){
148
+ playTone(700,0.2,'sine');
149
+ setTimeout(()=>playTone(800,0.2,'sine'),250);
150
+ }
151
+
152
+ const statusIcons={starting:'🔄',processing:'⚡',idle:'💤',waiting:'⏳',ended:'⏹'};
153
+
154
+ function getProjectName(sessionName){
155
+ const path=sessionPaths[sessionName];
156
+ if(!path)return sessionName;
157
+ const parts=path.split('/').filter(p=>p);
158
+ return parts[parts.length-1]||sessionName;
159
+ }
160
+
161
+ function getStatusForSession(sessionName){
162
+ const sessionPath=sessionPaths[sessionName];
163
+ if(!sessionPath)return null;
164
+ if(claudeStatuses[sessionPath])return claudeStatuses[sessionPath].status;
165
+ for(const[dir,s]of Object.entries(claudeStatuses)){
166
+ if(sessionPath.startsWith(dir+'/'))return s.status;
167
+ }
168
+ return null;
169
+ }
170
+
171
+ function renderSidebar(){
172
+ const prevActive=activeId;
173
+ sessionsEl.innerHTML='';
174
+ if(sessions.length===0){
175
+ contents.innerHTML='<div class="empty">No tmux sessions found</div>';
176
+ activeId='';
177
+ title.textContent='Tapback';
178
+ return;
179
+ }
180
+ if(!prevActive||!sessions.find(s=>s.name===prevActive)){
181
+ activeId=sessions[0].name;
182
+ }
183
+ sessions.forEach(s=>{
184
+ const btn=document.createElement('div');
185
+ const status=getStatusForSession(s.name);
186
+ btn.className='sess'+(s.name===activeId?' active':'')+(status?' status-'+status:'');
187
+ btn.dataset.id=s.name;
188
+ const icon=document.createElement('span');
189
+ icon.className='icon';
190
+ icon.textContent=status?statusIcons[status]:'📁';
191
+ btn.appendChild(icon);
192
+ const name=document.createElement('span');
193
+ name.className='name';
194
+ name.textContent=getProjectName(s.name);
195
+ btn.appendChild(name);
196
+ btn.onclick=()=>selectSession(s.name);
197
+ sessionsEl.appendChild(btn);
198
+ });
199
+ updateContent();
200
+ }
201
+
202
+ function selectSession(id){
203
+ activeId=id;
204
+ document.querySelectorAll('.sess').forEach(el=>{
205
+ const status=getStatusForSession(el.dataset.id);
206
+ el.className='sess'+(el.dataset.id===activeId?' active':'')+(status?' status-'+status:'');
207
+ });
208
+ updateContent();
209
+ }
210
+
211
+ function updateSidebar(){
212
+ document.querySelectorAll('.sess').forEach(el=>{
213
+ const id=el.dataset.id;
214
+ const status=getStatusForSession(id);
215
+ const icon=el.querySelector('.icon');
216
+ const name=el.querySelector('.name');
217
+ el.className='sess'+(id===activeId?' active':'')+(status?' status-'+status:'');
218
+ if(icon)icon.textContent=status?statusIcons[status]:'📁';
219
+ if(name)name.textContent=getProjectName(id);
220
+ });
221
+ updateTitle();
222
+ }
223
+
224
+ function updateTitle(){
225
+ if(activeId){
226
+ const status=getStatusForSession(activeId);
227
+ const icon=status?statusIcons[status]+' ':'';
228
+ title.textContent=icon+getProjectName(activeId);
229
+ title.className='t'+(status?' status-'+status:'');
230
+ }else{
231
+ title.textContent='Tapback';
232
+ title.className='t';
233
+ }
234
+ }
235
+
236
+ function updateContent(){
237
+ if(!activeId){contents.innerHTML='';return;}
238
+ const text=outputs[activeId]||'(waiting for output...)';
239
+ const wasAtBottom=contents.scrollHeight-contents.scrollTop-contents.clientHeight<50;
240
+ contents.innerHTML=escapeHtml(filterOutput(text));
241
+ if(wasAtBottom)contents.scrollTop=contents.scrollHeight;
242
+ updateTitle();
243
+ }
244
+
245
+ function escapeHtml(t){
246
+ return t.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
247
+ }
248
+
249
+ function filterOutput(t){
250
+ return t.split('\\n').filter(line=>{
251
+ const trimmed=line.trim();
252
+ if(/^[─]+$/.test(trimmed))return false;
253
+ if(/^❯\\s*$/.test(trimmed))return false;
254
+ return true;
255
+ }).join('\\n');
256
+ }
257
+
258
+ function connect(){
259
+ const p=location.protocol==='https:'?'wss:':'ws:';
260
+ ws=new WebSocket(p+'//'+location.host+'/ws');
261
+ ws.onopen=()=>{st.textContent='Connected';st.className='s on'};
262
+ ws.onmessage=(e)=>{
263
+ const d=JSON.parse(e.data);
264
+ if(d.t==='o'){
265
+ outputs[d.id]=d.c;
266
+ if(d.path)sessionPaths[d.id]=d.path;
267
+ if(d.id===activeId)updateContent();
268
+ if(!sessions.find(s=>s.name===d.id)){
269
+ sessions.push({name:d.id});
270
+ renderSidebar();
271
+ }
272
+ updateSidebar();
273
+ }else if(d.t==='status'){
274
+ const s=d.d;
275
+ const prev=prevStatuses[s.project_dir];
276
+ if(prev!==s.status){
277
+ if(s.status==='idle')playIdleSound();
278
+ else if(s.status==='waiting')playWaitingSound();
279
+ }
280
+ prevStatuses[s.project_dir]=s.status;
281
+ claudeStatuses[s.project_dir]=s;
282
+ updateSidebar();
283
+ }
284
+ };
285
+ ws.onclose=()=>{st.textContent='Reconnecting...';st.className='s off';setTimeout(connect,2000)};
286
+ ws.onerror=()=>ws.close();
287
+ }
288
+
289
+ function send(v){if(ws&&ws.readyState===1&&activeId)ws.send(JSON.stringify({t:'i',id:activeId,c:v}))}
290
+
291
+ document.querySelectorAll('.bq').forEach(b=>b.onclick=()=>send(b.dataset.v));
292
+ document.getElementById('send').onclick=()=>{send(txt.value);txt.value='';};
293
+ txt.onkeypress=(e)=>{if(e.key==='Enter'){send(txt.value);txt.value='';}};
294
+
295
+ document.getElementById('toggle-btn').onclick=()=>{sidebar.classList.toggle('expanded');};
296
+ legendBtn.onclick=()=>{legend.classList.add('show');legendOverlay.classList.add('show');};
297
+ legendOverlay.onclick=()=>{legend.classList.remove('show');legendOverlay.classList.remove('show');};
298
+ contents.onclick=()=>sidebar.classList.remove('expanded');
299
+
300
+ async function loadSessions(){
301
+ try{
302
+ const r=await fetch('/api/sessions');
303
+ const newSessions=await r.json();
304
+ const newNames=newSessions.map(s=>s.name).sort().join(',');
305
+ const oldNames=sessions.map(s=>s.name).sort().join(',');
306
+ if(newNames!==oldNames){
307
+ sessions=newSessions;
308
+ renderSidebar();
309
+ }
310
+ }catch(e){console.error(e)}
311
+ }
312
+
313
+ async function loadStatuses(){
314
+ try{
315
+ const r=await fetch('/api/claude-status');
316
+ const statuses=await r.json();
317
+ statuses.forEach(s=>{claudeStatuses[s.project_dir]=s});
318
+ updateSidebar();
319
+ }catch(e){console.error(e)}
320
+ }
321
+
322
+ loadSessions();
323
+ loadStatuses();
324
+ connect();
325
+ setInterval(loadSessions,5000);
326
+ </script>
327
+ </body></html>`;
328
+ };
329
+
330
+ exports.settingsPage = (config) => {
331
+ const proxyRows = Object.entries(config.proxyPorts || {})
332
+ .sort(([a], [b]) => Number(a) - Number(b))
333
+ .map(
334
+ ([target, external]) =>
335
+ `<div class="item" data-proxy="${target}"><div class="item-content"><span class="item-icon">⇌</span><div class="item-text"><span class="item-label">localhost:${target}</span><span class="item-arrow">→</span><span class="item-value">:${external}</span></div></div><button class="del-btn" onclick="delProxy('${target}')"><svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg></button></div>`,
336
+ )
337
+ .join('');
338
+
339
+ const buttonRows = (config.quickButtons || [])
340
+ .map(
341
+ (b, i) =>
342
+ `<div class="item" data-btn="${i}"><div class="item-content"><span class="item-icon">⚡</span><div class="item-text"><span class="item-label">${b.label}</span><span class="item-arrow">→</span><span class="item-value">${b.command}</span></div></div><button class="del-btn" onclick="delBtn(${i})"><svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg></button></div>`,
343
+ )
344
+ .join('');
345
+
346
+ return `<!DOCTYPE html>
347
+ <html><head>
348
+ <meta charset="UTF-8">
349
+ <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
350
+ <title>Tapback — Settings</title>
351
+ <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect width='100' height='100' rx='20' fill='%230d1117'/><text x='50' y='68' font-size='55' text-anchor='middle' fill='%2358a6ff'>▶</text></svg>">
352
+ <link rel="preconnect" href="https://fonts.googleapis.com">
353
+ <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Outfit:wght@300;400;500;600;700&display=swap" rel="stylesheet">
354
+ <style>
355
+ :root{
356
+ --bg:#0a0e14;--surface:#12171f;--surface2:#1a2030;--border:#252d3a;--border-hover:#3a4558;
357
+ --text:#d4dae4;--text2:#8892a2;--text3:#555e6e;
358
+ --accent:#8b5cf6;--accent2:#a78bfa;--accent-glow:rgba(139,92,246,0.15);
359
+ --red:#ef4444;--red-bg:rgba(239,68,68,0.08);
360
+ --green:#22c55e;--green-bg:rgba(34,197,94,0.08);
361
+ --radius:14px;--radius-sm:10px;
362
+ }
363
+ *{box-sizing:border-box;margin:0;padding:0}
364
+ html{background:var(--bg)}
365
+ body{font-family:'Outfit',sans-serif;background:var(--bg);color:var(--text);min-height:100vh;min-height:100dvh;padding:0 0 40px;overflow-x:hidden;-webkit-font-smoothing:antialiased}
366
+
367
+ /* Header */
368
+ .header{position:sticky;top:0;z-index:50;padding:16px 20px;padding-top:max(16px,env(safe-area-inset-top));background:rgba(10,14,20,0.85);backdrop-filter:blur(20px);-webkit-backdrop-filter:blur(20px);border-bottom:1px solid var(--border)}
369
+ .header-inner{display:flex;align-items:center;justify-content:space-between;max-width:520px;margin:0 auto}
370
+ .back-link{display:flex;align-items:center;gap:6px;color:var(--accent2);text-decoration:none;font-size:14px;font-weight:500;transition:opacity .2s}
371
+ .back-link:active{opacity:.6}
372
+ .back-link svg{transition:transform .2s}
373
+ .back-link:active svg{transform:translateX(-2px)}
374
+ .header-title{font-size:18px;font-weight:600;letter-spacing:-0.02em}
375
+ .header-badge{font-family:'JetBrains Mono',monospace;font-size:11px;color:var(--accent);background:var(--accent-glow);padding:3px 8px;border-radius:6px;font-weight:500}
376
+
377
+ /* Content */
378
+ .content{max-width:520px;margin:0 auto;padding:24px 20px}
379
+
380
+ /* Section */
381
+ .section{margin-bottom:28px;animation:fadeUp .4s ease both}
382
+ .section:nth-child(1){animation-delay:.05s}
383
+ .section:nth-child(2){animation-delay:.1s}
384
+ .section:nth-child(3){animation-delay:.15s}
385
+ @keyframes fadeUp{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:translateY(0)}}
386
+
387
+ .section-header{display:flex;align-items:center;gap:10px;margin-bottom:12px}
388
+ .section-icon{width:32px;height:32px;border-radius:8px;display:flex;align-items:center;justify-content:center;font-size:16px;flex-shrink:0}
389
+ .section-icon.auth{background:linear-gradient(135deg,#8b5cf620,#8b5cf640)}
390
+ .section-icon.proxy{background:linear-gradient(135deg,#3b82f620,#3b82f640)}
391
+ .section-icon.buttons{background:linear-gradient(135deg,#f59e0b20,#f59e0b40)}
392
+ .section-title{font-size:15px;font-weight:600;letter-spacing:-0.01em}
393
+ .section-desc{font-size:12px;color:var(--text3);margin-top:2px}
394
+
395
+ /* Card */
396
+ .card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);overflow:hidden;transition:border-color .2s}
397
+
398
+ /* Toggle row */
399
+ .toggle-row{display:flex;align-items:center;justify-content:space-between;padding:16px 18px}
400
+ .toggle-label{font-size:14px;font-weight:500}
401
+ .toggle-sub{font-size:12px;color:var(--text3);margin-top:3px}
402
+ .switch{position:relative;width:48px;height:28px;flex-shrink:0}
403
+ .switch input{opacity:0;width:0;height:0;position:absolute}
404
+ .slider{position:absolute;cursor:pointer;inset:0;background:var(--surface2);border:1px solid var(--border);border-radius:28px;transition:all .3s cubic-bezier(.4,0,.2,1)}
405
+ .slider:before{content:"";position:absolute;height:20px;width:20px;left:3px;bottom:3px;background:var(--text3);border-radius:50%;transition:all .3s cubic-bezier(.4,0,.2,1);box-shadow:0 1px 3px rgba(0,0,0,.3)}
406
+ input:checked+.slider{background:var(--accent);border-color:var(--accent)}
407
+ input:checked+.slider:before{transform:translateX(20px);background:#fff}
408
+
409
+ /* Item list */
410
+ .item-list{min-height:0}
411
+ .item{display:flex;align-items:center;justify-content:space-between;padding:12px 18px;border-bottom:1px solid var(--border);transition:background .15s}
412
+ .item:last-child{border-bottom:none}
413
+ .item:active{background:var(--surface2)}
414
+ .item-content{display:flex;align-items:center;gap:12px;min-width:0;flex:1}
415
+ .item-icon{font-size:15px;width:28px;height:28px;display:flex;align-items:center;justify-content:center;background:var(--surface2);border-radius:7px;flex-shrink:0}
416
+ .item-text{display:flex;align-items:center;gap:6px;font-family:'JetBrains Mono',monospace;font-size:13px;min-width:0;flex-wrap:wrap}
417
+ .item-label{color:var(--text);font-weight:500}
418
+ .item-arrow{color:var(--text3);font-size:11px}
419
+ .item-value{color:var(--accent2)}
420
+ .del-btn{display:flex;align-items:center;justify-content:center;width:32px;height:32px;border-radius:8px;border:none;background:transparent;color:var(--text3);cursor:pointer;transition:all .15s;flex-shrink:0}
421
+ .del-btn:active{background:var(--red-bg);color:var(--red);transform:scale(.9)}
422
+ .empty-state{padding:24px 18px;text-align:center}
423
+ .empty-icon{font-size:24px;margin-bottom:8px;opacity:.4}
424
+ .empty-text{font-size:13px;color:var(--text3)}
425
+
426
+ /* Add form */
427
+ .add-form{padding:14px 18px;border-top:1px solid var(--border);background:var(--surface2);display:flex;gap:8px;align-items:center}
428
+ .add-form input{flex:1;padding:10px 12px;font-size:14px;font-family:'JetBrains Mono',monospace;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:var(--radius-sm);transition:border-color .2s;min-width:0}
429
+ .add-form input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-glow)}
430
+ .add-form input::placeholder{color:var(--text3);font-family:'Outfit',sans-serif}
431
+ .add-btn{display:flex;align-items:center;justify-content:center;width:40px;height:40px;border-radius:var(--radius-sm);border:none;background:var(--accent);color:#fff;cursor:pointer;transition:all .15s;flex-shrink:0;font-size:20px;font-weight:300}
432
+ .add-btn:active{transform:scale(.92);background:var(--accent2)}
433
+
434
+ /* Toast */
435
+ .toast{position:fixed;bottom:24px;left:50%;transform:translateX(-50%) translateY(80px);padding:12px 20px;border-radius:12px;font-size:13px;font-weight:500;pointer-events:none;z-index:100;transition:transform .4s cubic-bezier(.4,0,.2,1),opacity .4s;opacity:0;backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px)}
436
+ .toast.show{transform:translateX(-50%) translateY(0);opacity:1}
437
+ .toast.ok{background:rgba(34,197,94,0.15);color:var(--green);border:1px solid rgba(34,197,94,0.2)}
438
+ .toast.err{background:rgba(239,68,68,0.15);color:var(--red);border:1px solid rgba(239,68,68,0.2)}
439
+
440
+ /* Restart hint */
441
+ .restart-hint{display:flex;align-items:center;gap:8px;padding:14px 18px;background:rgba(245,158,11,0.06);border:1px solid rgba(245,158,11,0.12);border-radius:var(--radius-sm);margin-top:20px;animation:fadeUp .4s ease both;animation-delay:.2s}
442
+ .restart-hint-icon{font-size:14px}
443
+ .restart-hint-text{font-size:12px;color:#f59e0b;line-height:1.4}
444
+ </style></head>
445
+ <body>
446
+
447
+ <div class="header">
448
+ <div class="header-inner">
449
+ <a href="/" class="back-link"><svg width="18" height="18" viewBox="0 0 18 18" fill="none"><path d="M11 4L6 9l5 5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>Back</a>
450
+ <span class="header-title">Settings</span>
451
+ <span class="header-badge">v1.0</span>
452
+ </div>
453
+ </div>
454
+
455
+ <div class="content">
456
+
457
+ <!-- Auth Section -->
458
+ <div class="section">
459
+ <div class="section-header">
460
+ <div class="section-icon auth">🔐</div>
461
+ <div><div class="section-title">Authentication</div><div class="section-desc">Require PIN to access terminal</div></div>
462
+ </div>
463
+ <div class="card">
464
+ <div class="toggle-row">
465
+ <div>
466
+ <div class="toggle-label">PIN Authentication</div>
467
+ <div class="toggle-sub">4-digit PIN on main page</div>
468
+ </div>
469
+ <label class="switch"><input type="checkbox" id="pinToggle" ${config.pinEnabled ? 'checked' : ''} onchange="savePin()"><span class="slider"></span></label>
470
+ </div>
471
+ <div id="pinDisplay" style="margin-top:10px;padding:10px 14px;background:#161b22;border-radius:8px;font-family:monospace;font-size:18px;letter-spacing:6px;text-align:center;color:#58a6ff;display:${config.pinEnabled ? 'block' : 'none'}">
472
+ <span style="font-size:11px;letter-spacing:normal;color:#8b949e;display:block;margin-bottom:4px">Current PIN</span>
473
+ <span id="pinValue">Loading...</span>
474
+ </div>
475
+ </div>
476
+ </div>
477
+
478
+ <!-- Proxy Section -->
479
+ <div class="section">
480
+ <div class="section-header">
481
+ <div class="section-icon proxy">🔀</div>
482
+ <div><div class="section-title">Proxy Ports</div><div class="section-desc">Forward localhost to external ports</div></div>
483
+ </div>
484
+ <div class="card">
485
+ <div class="item-list" id="proxyList">${proxyRows || '<div class="empty-state"><div class="empty-icon">🌐</div><div class="empty-text">No proxy ports configured</div></div>'}</div>
486
+ <div class="add-form">
487
+ <input type="number" id="proxyTarget" placeholder="3000" inputmode="numeric">
488
+ <input type="number" id="proxyExternal" placeholder="3001" inputmode="numeric">
489
+ <button class="add-btn" onclick="addProxy()">+</button>
490
+ </div>
491
+ </div>
492
+ </div>
493
+
494
+ <!-- Quick Buttons Section -->
495
+ <div class="section">
496
+ <div class="section-header">
497
+ <div class="section-icon buttons">⚡</div>
498
+ <div><div class="section-title">Quick Buttons</div><div class="section-desc">Custom commands for mobile UI</div></div>
499
+ </div>
500
+ <div class="card">
501
+ <div class="item-list" id="btnList">${buttonRows || '<div class="empty-state"><div class="empty-icon">🎛️</div><div class="empty-text">No custom buttons</div></div>'}</div>
502
+ <div class="add-form">
503
+ <input type="text" id="btnLabel" placeholder="Label">
504
+ <input type="text" id="btnCmd" placeholder="Command">
505
+ <button class="add-btn" onclick="addBtn()">+</button>
506
+ </div>
507
+ </div>
508
+ </div>
509
+
510
+ <div class="restart-hint">
511
+ <span class="restart-hint-icon">⚠️</span>
512
+ <span class="restart-hint-text">Changes to proxy ports and quick buttons take effect after restarting Tapback.</span>
513
+ </div>
514
+
515
+ </div>
516
+
517
+ <div class="toast" id="toast"></div>
518
+
519
+ <script>
520
+ async function api(method,body){
521
+ const r=await fetch('/api/settings',{method,headers:{'Content-Type':'application/json'},body:body?JSON.stringify(body):undefined});
522
+ return r.json();
523
+ }
524
+ function toast(ok,text){
525
+ const t=document.getElementById('toast');
526
+ t.className='toast '+(ok?'ok':'err');
527
+ t.textContent=text;
528
+ requestAnimationFrame(()=>{t.classList.add('show');});
529
+ setTimeout(()=>{t.classList.remove('show');},2200);
530
+ }
531
+ async function savePin(){
532
+ const enabled=document.getElementById('pinToggle').checked;
533
+ const res=await api('PUT',{pinEnabled:enabled});
534
+ document.getElementById('pinDisplay').style.display=enabled?'block':'none';
535
+ toast(res.ok,res.ok?'Saved':'Error');
536
+ }
537
+ async function loadPin(){
538
+ const res=await api('GET');
539
+ if(res.pin)document.getElementById('pinValue').textContent=res.pin;
540
+ }
541
+ loadPin();
542
+ async function addProxy(){
543
+ const t=document.getElementById('proxyTarget').value,e=document.getElementById('proxyExternal').value;
544
+ if(!t||!e)return;
545
+ const res=await api('PUT',{addProxy:{target:Number(t),external:Number(e)}});
546
+ if(res.ok)location.reload();else toast(false,'Error');
547
+ }
548
+ async function delProxy(target){
549
+ const el=document.querySelector('[data-proxy="'+target+'"]');
550
+ if(el){el.style.transition='all .25s';el.style.opacity='0';el.style.transform='translateX(20px)';
551
+ setTimeout(async()=>{const res=await api('PUT',{delProxy:Number(target)});if(res.ok)location.reload();},250);
552
+ }else{const res=await api('PUT',{delProxy:Number(target)});if(res.ok)location.reload();}
553
+ }
554
+ async function addBtn(){
555
+ const l=document.getElementById('btnLabel').value,c=document.getElementById('btnCmd').value;
556
+ if(!l||!c)return;
557
+ const res=await api('PUT',{addButton:{label:l,command:c}});
558
+ if(res.ok)location.reload();else toast(false,'Error');
559
+ }
560
+ async function delBtn(idx){
561
+ const el=document.querySelector('[data-btn="'+idx+'"]');
562
+ if(el){el.style.transition='all .25s';el.style.opacity='0';el.style.transform='translateX(20px)';
563
+ setTimeout(async()=>{const res=await api('PUT',{delButton:idx});if(res.ok)location.reload();},250);
564
+ }else{const res=await api('PUT',{delButton:idx});if(res.ok)location.reload();}
565
+ }
566
+ </script>
567
+ </body></html>`;
568
+ };
569
+
570
+ exports.pinPage = (error, action = '/auth') => {
571
+ const errorHtml = error ? `<div class="e">${error}</div>` : '';
572
+ return `<!DOCTYPE html>
573
+ <html><head>
574
+ <meta charset="UTF-8">
575
+ <meta name="viewport" content="width=device-width,initial-scale=1">
576
+ <title>Tapback</title>
577
+ <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect width='100' height='100' rx='20' fill='%230d1117'/><text x='50' y='68' font-size='55' text-anchor='middle' fill='%2358a6ff'>▶</text></svg>">
578
+ <style>
579
+ *{box-sizing:border-box;margin:0;padding:0}
580
+ body{font-family:sans-serif;background:#0d1117;color:#c9d1d9;min-height:100vh;display:flex;align-items:center;justify-content:center}
581
+ .c{max-width:320px;width:100%;padding:20px;text-align:center}
582
+ .l{font-size:2rem;margin-bottom:1.5rem;color:#8b5cf6}
583
+ .p{width:100%;padding:1.2rem;font-size:2rem;text-align:center;letter-spacing:0.8rem;border:1px solid #30363d;border-radius:8px;background:#161b22;color:#c9d1d9;margin-bottom:1rem}
584
+ .b{width:100%;padding:1rem;font-size:1.1rem;border:none;border-radius:8px;background:#8b5cf6;color:#fff;cursor:pointer}
585
+ .e{color:#f85149;margin-top:1rem}
586
+ </style></head>
587
+ <body><div class="c">
588
+ <div class="l">Tapback</div>
589
+ <form method="POST" action="${action}">
590
+ <input type="text" name="pin" class="p" maxlength="4" inputmode="numeric" placeholder="----" required autofocus>
591
+ <button type="submit" class="b">Auth</button>
592
+ </form>${errorHtml}
593
+ </div></body></html>`;
594
+ };