multica/apps/gateway/public/index.html

746 lines
22 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<title>Multica Client</title>
<meta name="theme-color" content="#0f172a">
<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="Multica">
<link rel="manifest" href="manifest.json">
<link rel="icon" href="icon.png" type="image/png">
<link rel="apple-touch-icon" href="icon.png">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
:root {
--bg: #0f172a;
--surface: #1e293b;
--surface-2: #334155;
--border: rgba(148,163,184,0.08);
--text: #f1f5f9;
--text-dim: #94a3b8;
--text-muted: #64748b;
--accent: #6366f1;
--accent-light: #818cf8;
--accent-glow: rgba(99,102,241,0.12);
--green: #34d399;
--yellow: #fbbf24;
--red: #f87171;
--safe-top: env(safe-area-inset-top, 0px);
--safe-bottom: env(safe-area-inset-bottom, 0px);
}
body {
font-family: -apple-system, BlinkMacSystemFont, "SF Pro", "Segoe UI", Roboto, sans-serif;
background: var(--bg);
color: var(--text);
height: 100vh;
height: 100dvh;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* ── Header ── */
.header {
padding: calc(var(--safe-top) + 14px) 20px 14px;
background: linear-gradient(180deg, #1e293b 0%, rgba(30,41,59,0.95) 100%);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
z-index: 10;
}
.header-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.header-left {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.header-logo {
width: 32px;
height: 32px;
border-radius: 8px;
flex-shrink: 0;
}
.header-info { min-width: 0; }
.header h1 {
font-size: 1.05rem;
font-weight: 700;
letter-spacing: -0.02em;
line-height: 1.2;
}
.device-label {
font-size: 0.68rem;
color: var(--text-muted);
margin-top: 1px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: "SF Mono", "Fira Code", monospace;
cursor: pointer;
}
.header-right {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.status-pill {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.72rem;
font-weight: 500;
color: var(--text-dim);
background: rgba(15,23,42,0.6);
padding: 5px 10px;
border-radius: 20px;
border: 1px solid var(--border);
}
.status-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--text-muted);
transition: background 0.3s, box-shadow 0.3s;
}
.status-dot.on {
background: var(--green);
box-shadow: 0 0 6px rgba(52,211,153,0.6);
animation: pulse 2s ease-in-out infinite;
}
.status-dot.connecting {
background: var(--yellow);
animation: blink 1s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { box-shadow: 0 0 6px rgba(52,211,153,0.6); }
50% { box-shadow: 0 0 12px rgba(52,211,153,0.9); }
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
/* Disconnect button in header */
.btn-disconnect {
display: none;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 10px;
border: 1px solid rgba(248,113,113,0.2);
background: rgba(248,113,113,0.1);
color: var(--red);
cursor: pointer;
transition: transform 0.1s, background 0.2s;
flex-shrink: 0;
}
.btn-disconnect:active { transform: scale(0.9); }
.btn-disconnect.visible { display: flex; }
/* ── Setup Screen ── */
.setup-screen {
flex: 1;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 24px;
}
.setup-card {
width: 100%;
max-width: 400px;
background: var(--surface);
border-radius: 20px;
padding: 28px 24px;
border: 1px solid var(--border);
box-shadow: 0 8px 32px rgba(0,0,0,0.3);
}
.setup-header {
text-align: center;
margin-bottom: 28px;
}
.setup-header img {
width: 56px;
height: 56px;
border-radius: 14px;
margin-bottom: 14px;
}
.setup-header h2 {
font-size: 1.2rem;
font-weight: 700;
margin-bottom: 4px;
}
.setup-header p {
font-size: 0.82rem;
color: var(--text-muted);
}
.input-group { margin-bottom: 16px; }
.input-label {
display: block;
font-size: 0.72rem;
font-weight: 600;
color: var(--text-dim);
margin-bottom: 6px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
input[type="text"] {
width: 100%;
background: var(--bg);
border: 1px solid rgba(148,163,184,0.12);
border-radius: 12px;
padding: 13px 14px;
color: var(--text);
font-size: 0.92rem;
font-family: inherit;
transition: border-color 0.2s, box-shadow 0.2s;
}
input[type="text"]:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-glow);
}
input[type="text"]::placeholder { color: var(--text-muted); }
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
width: 100%;
min-height: 50px;
border: none;
border-radius: 14px;
font-size: 0.95rem;
font-weight: 600;
font-family: inherit;
cursor: pointer;
transition: transform 0.1s, opacity 0.2s, box-shadow 0.2s;
-webkit-user-select: none;
user-select: none;
}
.btn:active { transform: scale(0.97); }
.btn:disabled { opacity: 0.4; pointer-events: none; }
.btn-accent {
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
color: white;
box-shadow: 0 4px 16px rgba(99,102,241,0.35);
}
.btn-accent:active {
box-shadow: 0 2px 8px rgba(99,102,241,0.25);
}
/* ── Chat Screen ── */
.chat-container {
display: none;
flex-direction: column;
flex: 1;
min-height: 0;
}
.chat-container.active { display: flex; }
/* Target bar as a collapsible strip */
.target-bar {
padding: 0 16px;
background: var(--surface);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
overflow: hidden;
transition: max-height 0.3s ease, padding 0.3s ease;
max-height: 0;
}
.target-bar.open {
max-height: 180px;
padding: 12px 16px;
}
.target-fields {
display: flex;
flex-direction: column;
gap: 10px;
}
.target-field {
flex: 1;
min-width: 0;
}
.target-field label {
display: block;
font-size: 0.65rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-muted);
margin-bottom: 4px;
}
.target-field input {
width: 100%;
background: var(--bg);
border: 1px solid rgba(148,163,184,0.1);
border-radius: 10px;
padding: 9px 10px;
color: var(--text);
font-size: 0.8rem;
font-family: inherit;
transition: border-color 0.2s;
}
.target-field input:focus {
outline: none;
border-color: var(--accent);
}
/* Toggle button for target bar */
.target-toggle {
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
padding: 6px 16px;
background: transparent;
border: none;
border-bottom: 1px solid var(--border);
color: var(--text-muted);
font-size: 0.7rem;
font-weight: 500;
font-family: inherit;
cursor: pointer;
width: 100%;
flex-shrink: 0;
transition: color 0.2s;
}
.target-toggle:active { color: var(--accent-light); }
.target-toggle svg {
transition: transform 0.3s;
}
.target-toggle.open svg {
transform: rotate(180deg);
}
/* Messages */
.messages {
flex: 1;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
padding: 16px;
}
.msg-wrap {
display: flex;
flex-direction: column;
margin-bottom: 4px;
}
.msg-wrap + .msg-wrap { margin-top: 2px; }
.msg-wrap.self { align-items: flex-end; }
.msg-wrap.in { align-items: flex-start; }
.msg-wrap.system { align-items: center; margin-top: 8px; margin-bottom: 8px; }
.msg {
max-width: 82%;
padding: 10px 14px;
border-radius: 18px;
font-size: 0.9rem;
line-height: 1.45;
word-break: break-word;
}
.msg-self {
background: linear-gradient(135deg, #6366f1 0%, #7c3aed 100%);
color: white;
border-bottom-right-radius: 6px;
}
.msg-in {
background: var(--surface);
color: var(--text);
border: 1px solid var(--border);
border-bottom-left-radius: 6px;
}
.msg-system {
background: rgba(100,116,139,0.1);
color: var(--text-muted);
font-size: 0.72rem;
padding: 4px 12px;
border-radius: 12px;
max-width: 100%;
}
.msg-from {
font-size: 0.68rem;
color: var(--text-muted);
margin-bottom: 2px;
padding-left: 4px;
}
/* Empty state */
.empty-state {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: var(--text-muted);
gap: 8px;
padding: 40px;
}
.empty-state svg { opacity: 0.3; }
.empty-state p {
font-size: 0.85rem;
text-align: center;
}
/* Compose bar */
.compose {
display: flex;
align-items: flex-end;
gap: 8px;
padding: 10px 12px calc(var(--safe-bottom) + 10px);
background: linear-gradient(180deg, rgba(30,41,59,0.95) 0%, #1e293b 100%);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-top: 1px solid var(--border);
flex-shrink: 0;
}
.compose-input-wrap {
flex: 1;
position: relative;
}
.compose input {
width: 100%;
background: var(--bg);
border: 1px solid rgba(148,163,184,0.1);
border-radius: 22px;
padding: 12px 16px;
color: var(--text);
font-size: 0.95rem;
font-family: inherit;
transition: border-color 0.2s, box-shadow 0.2s;
}
.compose input:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-glow);
}
.compose input::placeholder { color: var(--text-muted); }
.btn-send {
display: inline-flex;
align-items: center;
justify-content: center;
width: 46px;
height: 46px;
border-radius: 50%;
border: none;
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
color: white;
cursor: pointer;
flex-shrink: 0;
transition: transform 0.1s, box-shadow 0.2s;
box-shadow: 0 3px 12px rgba(99,102,241,0.35);
}
.btn-send:active {
transform: scale(0.9);
box-shadow: 0 1px 6px rgba(99,102,241,0.25);
}
</style>
</head>
<body>
<!-- Header -->
<div class="header">
<div class="header-row">
<div class="header-left">
<img src="icon.png" alt="Geneva" class="header-logo">
<div class="header-info">
<h1>Multica</h1>
<div class="device-label" id="device-label" onclick="copyDeviceId()"></div>
</div>
</div>
<div class="header-right">
<div class="status-pill">
<span class="status-dot" id="status-dot"></span>
<span id="status-text">Disconnected</span>
</div>
<button class="btn-disconnect" id="btn-disconnect" onclick="doDisconnect()" title="Disconnect">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M18 6L6 18M6 6l12 12"/></svg>
</button>
</div>
</div>
</div>
<!-- Setup Screen -->
<div class="setup-screen" id="setup-screen">
<div class="setup-card">
<div class="setup-header">
<img src="icon.png" alt="Geneva">
<h2>Multica Client</h2>
<p>Connect to a Gateway to start messaging</p>
</div>
<div class="input-group">
<label class="input-label">Gateway URL</label>
<input type="text" id="gateway-url" placeholder="http://localhost:3000">
</div>
<button class="btn btn-accent" id="btn-connect" onclick="doConnect()">Connect</button>
</div>
</div>
<!-- Chat Screen -->
<div class="chat-container" id="chat-screen">
<button class="target-toggle" id="target-toggle" onclick="toggleTargetBar()">
<span id="target-toggle-label">Set target</span>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><polyline points="6 9 12 15 18 9"/></svg>
</button>
<div class="target-bar" id="target-bar">
<div class="target-fields">
<div class="target-field">
<label>Device ID</label>
<input type="text" id="target-device-id" placeholder="Target device">
</div>
<div class="target-field">
<label>Agent ID</label>
<input type="text" id="target-agent-id" placeholder="Agent">
</div>
</div>
</div>
<div class="messages" id="messages">
<div class="empty-state" id="empty-state">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/></svg>
<p>Send a message to get started</p>
</div>
</div>
<div class="compose">
<div class="compose-input-wrap">
<input type="text" id="msg-input" placeholder="Message..." onkeydown="if(event.key==='Enter')doSend()">
</div>
<button class="btn-send" onclick="doSend()">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
</button>
</div>
</div>
<script src="https://cdn.socket.io/4.8.3/socket.io.min.js"></script>
<script>
// --- Device ID ---
const STORAGE_KEY = 'geneva-client-device-id';
let deviceId = localStorage.getItem(STORAGE_KEY);
if (!deviceId) {
deviceId = crypto.randomUUID();
localStorage.setItem(STORAGE_KEY, deviceId);
}
document.getElementById('device-label').textContent = deviceId.slice(0, 8) + '...';
document.getElementById('device-label').title = deviceId;
function copyDeviceId() {
navigator.clipboard.writeText(deviceId).then(() => {
const el = document.getElementById('device-label');
const orig = el.textContent;
el.textContent = 'Copied!';
setTimeout(() => { el.textContent = orig; }, 1200);
});
}
// Restore saved values
document.getElementById('gateway-url').value =
localStorage.getItem('geneva-client-gateway-url') || window.location.origin;
let socket = null;
let hasMessages = false;
function setStatus(state) {
const dot = document.getElementById('status-dot');
const text = document.getElementById('status-text');
dot.className = 'status-dot' + ({
disconnected: '',
connecting: ' connecting',
connected: ' on',
registered: ' on'
}[state] || '');
text.textContent = state.charAt(0).toUpperCase() + state.slice(1);
}
function hideEmptyState() {
if (!hasMessages) {
hasMessages = true;
const es = document.getElementById('empty-state');
if (es) es.remove();
}
}
function appendMsg(type, text, from) {
hideEmptyState();
const el = document.getElementById('messages');
const wrap = document.createElement('div');
wrap.className = 'msg-wrap ' + type;
if (from && type === 'in') {
const label = document.createElement('div');
label.className = 'msg-from';
label.textContent = from;
wrap.appendChild(label);
}
const div = document.createElement('div');
div.className = 'msg msg-' + type;
div.textContent = text;
wrap.appendChild(div);
el.appendChild(wrap);
el.scrollTop = el.scrollHeight;
}
// --- Target bar toggle ---
let targetOpen = false;
function toggleTargetBar() {
targetOpen = !targetOpen;
document.getElementById('target-bar').classList.toggle('open', targetOpen);
document.getElementById('target-toggle').classList.toggle('open', targetOpen);
updateTargetLabel();
}
function updateTargetLabel() {
const did = document.getElementById('target-device-id').value.trim();
const aid = document.getElementById('target-agent-id').value.trim();
const label = document.getElementById('target-toggle-label');
if (!targetOpen && did && aid) {
label.textContent = did.slice(0, 8) + ' / ' + aid.slice(0, 8);
} else {
label.textContent = (did || aid) ? 'Target' : 'Set target';
}
}
function showChat() {
document.getElementById('setup-screen').style.display = 'none';
document.getElementById('chat-screen').classList.add('active');
document.getElementById('btn-disconnect').classList.add('visible');
// Restore targets
document.getElementById('target-device-id').value =
localStorage.getItem('geneva-client-target-device-id') || '';
document.getElementById('target-agent-id').value =
localStorage.getItem('geneva-client-target-agent-id') || '';
// Auto-open target bar if not set
const did = document.getElementById('target-device-id').value.trim();
const aid = document.getElementById('target-agent-id').value.trim();
if (!did || !aid) {
targetOpen = true;
document.getElementById('target-bar').classList.add('open');
document.getElementById('target-toggle').classList.add('open');
}
updateTargetLabel();
}
function showSetup() {
document.getElementById('setup-screen').style.display = 'flex';
document.getElementById('chat-screen').classList.remove('active');
document.getElementById('btn-disconnect').classList.remove('visible');
document.getElementById('btn-connect').disabled = false;
}
function doConnect() {
const url = document.getElementById('gateway-url').value.trim();
if (!url) return;
localStorage.setItem('geneva-client-gateway-url', url);
setStatus('connecting');
document.getElementById('btn-connect').disabled = true;
socket = io(url, {
path: '/ws',
query: { deviceId, deviceType: 'client' },
reconnection: true,
reconnectionDelay: 1000,
});
socket.on('connect', () => {
setStatus('connected');
});
socket.on('registered', (res) => {
if (res.success) {
setStatus('registered');
showChat();
appendMsg('system', 'Connected to gateway');
} else {
appendMsg('system', 'Registration failed: ' + res.error);
setStatus('disconnected');
document.getElementById('btn-connect').disabled = false;
}
});
socket.on('receive', (msg) => {
const p = msg.payload;
const content = typeof p === 'string' ? p : (p && p.content ? p.content : JSON.stringify(p));
appendMsg('in', content, msg.from);
});
socket.on('send_error', (err) => {
appendMsg('system', 'Error: ' + err.error);
});
socket.on('disconnect', (reason) => {
setStatus('disconnected');
appendMsg('system', 'Disconnected: ' + reason);
});
socket.on('connect_error', (err) => {
appendMsg('system', 'Connection error: ' + err.message);
setStatus('disconnected');
document.getElementById('btn-connect').disabled = false;
});
}
function doDisconnect() {
if (socket) { socket.disconnect(); socket = null; }
setStatus('disconnected');
showSetup();
}
function doSend() {
const input = document.getElementById('msg-input');
const text = input.value.trim();
const targetDeviceId = document.getElementById('target-device-id').value.trim();
const targetAgentId = document.getElementById('target-agent-id').value.trim();
if (!text || !socket) return;
if (!targetDeviceId || !targetAgentId) {
// Open target bar if not set
if (!targetOpen) toggleTargetBar();
return;
}
localStorage.setItem('geneva-client-target-device-id', targetDeviceId);
localStorage.setItem('geneva-client-target-agent-id', targetAgentId);
// Collapse target bar after first send
if (targetOpen) toggleTargetBar();
socket.emit('send', {
id: crypto.randomUUID(),
uid: null,
from: deviceId,
to: targetDeviceId,
action: 'message',
payload: { agentId: targetAgentId, conversationId: targetAgentId, content: text },
});
appendMsg('self', text);
input.value = '';
}
// Update target label on blur
document.getElementById('target-device-id').addEventListener('blur', updateTargetLabel);
document.getElementById('target-agent-id').addEventListener('blur', updateTargetLabel);
// --- PWA: Register Service Worker ---
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('sw.js', { scope: '/client/' }).catch(() => {});
}
</script>
</body>
</html>