feat: add posimai-dev — self-hosted terminal portal with xterm.js
This commit is contained in:
parent
29c8bb9c9e
commit
6f58397f89
|
|
@ -53,4 +53,5 @@
|
|||
| posimai-journal | `#80CAEE` Sky-Blue | `#0284C7` | CMS 系・静かで知的な印象 |
|
||||
| posimai-site | `#80CAEE` Sky-Blue | `#0284C7` | journal と同系統の公開サイト |
|
||||
| posimai-atlas | `#22D3EE` Cyan | `#0891B2` | インフラ/ネットワーク管理 — ターミナル・サイバー感。背景も `#0C1221` navy に変更 |
|
||||
| posimai-dev | `#A78BFA` Violet | `#7C3AED` | 開発ポータル — コード・AI・ターミナルの融合。Atlas と差別化 |
|
||||
| ponshu-room | `#D4A574` 琥珀(Amber) | `#D4A574` | **Posimai デザインシステム適用外**。独自テーマ(和紙×墨×琥珀)を使用 |
|
||||
|
|
|
|||
|
|
@ -0,0 +1,296 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="ja" data-app-id="posimai-dev">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<script>
|
||||
(function () {
|
||||
var t = localStorage.getItem('posimai-dev-theme') || 'dark';
|
||||
document.documentElement.setAttribute('data-theme', t === 'light' ? 'light' : 'dark');
|
||||
document.documentElement.setAttribute('data-theme-pref', t);
|
||||
})();
|
||||
</script>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||
<meta name="description" content="posimai development portal">
|
||||
<meta name="color-scheme" content="dark light">
|
||||
<meta name="theme-color" content="#0D0D0D" media="(prefers-color-scheme: dark)">
|
||||
<meta name="theme-color" content="#F9FAFB" media="(prefers-color-scheme: light)">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="apple-mobile-web-app-title" content="posimai-dev">
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
<link rel="icon" type="image/png" href="/logo.png">
|
||||
<link rel="apple-touch-icon" href="/logo.png">
|
||||
<title>posimai-dev</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://posimai-ui.vercel.app/v1/base.css">
|
||||
<link rel="stylesheet" href="https://unpkg.com/@xterm/xterm@6.0.0/css/xterm.css">
|
||||
<script src="https://unpkg.com/lucide@0.344.0/dist/umd/lucide.min.js"></script>
|
||||
<script src="https://unpkg.com/@xterm/xterm@6.0.0/lib/xterm.js"></script>
|
||||
<script src="https://unpkg.com/@xterm/addon-fit@0.11.0/lib/addon-fit.js"></script>
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--accent: #A78BFA;
|
||||
--accent-dim: rgba(167, 139, 250, 0.15);
|
||||
}
|
||||
[data-theme="light"] {
|
||||
--accent: #7C3AED;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100dvh;
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 16px;
|
||||
height: 48px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.header-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
padding: 2px 8px;
|
||||
border-radius: 20px;
|
||||
background: var(--accent-dim);
|
||||
color: var(--accent);
|
||||
transition: background 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
.status-badge.disconnected {
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
color: #F87171;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
#terminal-container {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
padding: 12px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
#terminal-container .xterm {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#terminal-container .xterm-viewport {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* スマホ用キーボード表示ボタン */
|
||||
#keyboard-btn {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (pointer: coarse) {
|
||||
#keyboard-btn {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
.overlay { display: none; }
|
||||
.settings-panel { z-index: 1000; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<aside class="settings-panel" id="settingsPanel" role="complementary">
|
||||
<div class="settings-panel-header">
|
||||
<span class="settings-panel-title">設定</span>
|
||||
<button class="icon-btn" id="settingsCloseBtn" aria-label="設定を閉じる">
|
||||
<i data-lucide="x" style="width:18px;height:18px;stroke-width:1.75"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="settings-panel-body">
|
||||
<div>
|
||||
<div class="settings-group-label">外観</div>
|
||||
<div class="settings-item">
|
||||
<div class="settings-item-label">テーマ</div>
|
||||
<div class="theme-selector">
|
||||
<button class="theme-btn" data-theme-val="dark"><i data-lucide="moon" style="width:12px;height:12px;stroke-width:1.75"></i>ダーク</button>
|
||||
<button class="theme-btn" data-theme-val="light"><i data-lucide="sun" style="width:12px;height:12px;stroke-width:1.75"></i>ライト</button>
|
||||
<button class="theme-btn" data-theme-val="system"><i data-lucide="monitor" style="width:12px;height:12px;stroke-width:1.75"></i>自動</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<div class="overlay" id="overlay" aria-hidden="true"></div>
|
||||
|
||||
<header class="header">
|
||||
<div class="header-left">
|
||||
<div class="header-dot" aria-hidden="true"></div>
|
||||
<span class="header-title">posimai-dev</span>
|
||||
<span class="status-badge disconnected" id="statusBadge">接続中...</span>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<button class="icon-btn" id="keyboard-btn" aria-label="キーボードを表示">
|
||||
<i data-lucide="keyboard" style="width:18px;height:18px;stroke-width:1.5"></i>
|
||||
</button>
|
||||
<button class="icon-btn" id="settingsBtn" aria-label="設定" aria-expanded="false">
|
||||
<i data-lucide="settings" style="width:18px;height:18px;stroke-width:1.5"></i>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div id="terminal-container"></div>
|
||||
|
||||
<script src="https://posimai-ui.vercel.app/v1/base.js" defer></script>
|
||||
<script>
|
||||
(function () {
|
||||
const statusBadge = document.getElementById('statusBadge');
|
||||
const container = document.getElementById('terminal-container');
|
||||
|
||||
// xterm.js setup
|
||||
const term = new Terminal({
|
||||
fontFamily: '"JetBrains Mono", "Fira Code", "Cascadia Code", monospace',
|
||||
fontSize: 14,
|
||||
lineHeight: 1.4,
|
||||
cursorBlink: true,
|
||||
theme: {
|
||||
background: '#0D0D0D',
|
||||
foreground: '#F3F4F6',
|
||||
cursor: '#A78BFA',
|
||||
selectionBackground: 'rgba(167, 139, 250, 0.3)',
|
||||
black: '#1A1A1A',
|
||||
brightBlack: '#4B5563',
|
||||
red: '#F87171',
|
||||
brightRed: '#FCA5A5',
|
||||
green: '#6EE7B7',
|
||||
brightGreen: '#A7F3D0',
|
||||
yellow: '#FCD34D',
|
||||
brightYellow: '#FDE68A',
|
||||
blue: '#60A5FA',
|
||||
brightBlue: '#93C5FD',
|
||||
magenta: '#A78BFA',
|
||||
brightMagenta: '#C4B5FD',
|
||||
cyan: '#22D3EE',
|
||||
brightCyan: '#67E8F9',
|
||||
white: '#F3F4F6',
|
||||
brightWhite: '#FFFFFF'
|
||||
}
|
||||
});
|
||||
|
||||
const fitAddon = new FitAddon.FitAddon();
|
||||
term.loadAddon(fitAddon);
|
||||
term.open(container);
|
||||
fitAddon.fit();
|
||||
|
||||
// WebSocket connection
|
||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
let ws;
|
||||
|
||||
function connect() {
|
||||
ws = new WebSocket(`${proto}//${location.host}/terminal`);
|
||||
|
||||
ws.onopen = () => {
|
||||
statusBadge.textContent = '接続済み';
|
||||
statusBadge.classList.remove('disconnected');
|
||||
const { cols, rows } = term;
|
||||
ws.send(JSON.stringify({ type: 'resize', cols, rows }));
|
||||
};
|
||||
|
||||
ws.onmessage = (e) => {
|
||||
try {
|
||||
const msg = JSON.parse(e.data);
|
||||
if (msg.type === 'output') term.write(msg.data);
|
||||
} catch {}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
statusBadge.textContent = '切断';
|
||||
statusBadge.classList.add('disconnected');
|
||||
term.write('\r\n\x1b[31m[切断されました。再接続中...]\x1b[0m\r\n');
|
||||
setTimeout(connect, 3000);
|
||||
};
|
||||
|
||||
ws.onerror = () => ws.close();
|
||||
}
|
||||
|
||||
connect();
|
||||
|
||||
// Terminal input
|
||||
term.onData((data) => {
|
||||
if (ws && ws.readyState === 1) {
|
||||
ws.send(JSON.stringify({ type: 'input', data }));
|
||||
}
|
||||
});
|
||||
|
||||
// Resize handling
|
||||
const ro = new ResizeObserver(() => {
|
||||
fitAddon.fit();
|
||||
if (ws && ws.readyState === 1) {
|
||||
ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
|
||||
}
|
||||
});
|
||||
ro.observe(container);
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
fitAddon.fit();
|
||||
});
|
||||
|
||||
// スマホ用キーボードボタン
|
||||
document.getElementById('keyboard-btn').addEventListener('click', () => {
|
||||
term.focus();
|
||||
});
|
||||
|
||||
// Settings panel
|
||||
const settingsBtn = document.getElementById('settingsBtn');
|
||||
const settingsPanel = document.getElementById('settingsPanel');
|
||||
const settingsCloseBtn = document.getElementById('settingsCloseBtn');
|
||||
|
||||
settingsBtn.addEventListener('click', () => {
|
||||
settingsPanel.classList.toggle('open');
|
||||
settingsBtn.setAttribute('aria-expanded', settingsPanel.classList.contains('open'));
|
||||
});
|
||||
|
||||
settingsCloseBtn.addEventListener('click', () => {
|
||||
settingsPanel.classList.remove('open');
|
||||
settingsBtn.setAttribute('aria-expanded', 'false');
|
||||
});
|
||||
|
||||
lucide.createIcons();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"name": "posimai-dev",
|
||||
"short_name": "dev",
|
||||
"description": "posimai development portal",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#0D0D0D",
|
||||
"theme_color": "#0D0D0D",
|
||||
"orientation": "any",
|
||||
"icons": [
|
||||
{ "src": "/logo.png", "sizes": "192x192", "type": "image/png" },
|
||||
{ "src": "/logo.png", "sizes": "512x512", "type": "image/png" }
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"name": "posimai-dev",
|
||||
"version": "0.1.0",
|
||||
"description": "posimai development portal — self-hosted terminal + AI dev environment",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "4.19.2",
|
||||
"node-pty": "1.0.0",
|
||||
"ws": "8.18.0"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
'use strict';
|
||||
const express = require('express');
|
||||
const { WebSocketServer } = require('ws');
|
||||
const pty = require('node-pty');
|
||||
const http = require('http');
|
||||
const path = require('path');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3333;
|
||||
|
||||
app.use(express.static(path.join(__dirname)));
|
||||
|
||||
const server = http.createServer(app);
|
||||
const wss = new WebSocketServer({ server, path: '/terminal' });
|
||||
|
||||
wss.on('connection', (ws) => {
|
||||
const shell = process.env.SHELL || '/bin/bash';
|
||||
const ptyProc = pty.spawn(shell, [], {
|
||||
name: 'xterm-256color',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: process.env.HOME || '/home/ubuntu-pc',
|
||||
env: process.env
|
||||
});
|
||||
|
||||
ptyProc.onData((data) => {
|
||||
if (ws.readyState === 1) {
|
||||
ws.send(JSON.stringify({ type: 'output', data }));
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('message', (raw) => {
|
||||
try {
|
||||
const msg = JSON.parse(raw.toString());
|
||||
if (msg.type === 'input') ptyProc.write(msg.data);
|
||||
if (msg.type === 'resize') ptyProc.resize(Number(msg.cols), Number(msg.rows));
|
||||
} catch {}
|
||||
});
|
||||
|
||||
ws.on('close', () => { try { ptyProc.kill(); } catch {} });
|
||||
ptyProc.onExit(() => { try { ws.close(); } catch {} });
|
||||
});
|
||||
|
||||
server.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`posimai-dev running on http://0.0.0.0:${PORT}`);
|
||||
});
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
const CACHE = 'posimai-dev-v1';
|
||||
const SHELL = ['/'];
|
||||
|
||||
self.addEventListener('install', (e) => {
|
||||
e.waitUntil(caches.open(CACHE).then((c) => c.addAll(SHELL)));
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener('activate', (e) => {
|
||||
e.waitUntil(
|
||||
caches.keys().then((keys) =>
|
||||
Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))
|
||||
)
|
||||
);
|
||||
self.clients.claim();
|
||||
});
|
||||
|
||||
// ターミナルのWebSocket通信はキャッシュしない
|
||||
self.addEventListener('fetch', (e) => {
|
||||
if (e.request.url.includes('/terminal')) return;
|
||||
e.respondWith(
|
||||
caches.match(e.request).then((r) => r || fetch(e.request))
|
||||
);
|
||||
});
|
||||
Loading…
Reference in New Issue