refactor: S06.1 reorganize frontend routes into subdirectories (media/tools/admin)

This commit is contained in:
Gan, Jimmy
2026-07-10 23:06:10 +08:00
parent 30a8196c12
commit eece551ea9
11 changed files with 26 additions and 26 deletions
@@ -0,0 +1,123 @@
<script>
import { onMount } from "svelte";
import { get, post } from "../../lib/api.js";
let dates = $state([]);
let selectedDate = $state("");
let summary = $state(null);
let messages = $state([]);
let showMessages = $state(false);
let loading = $state(false);
let triggering = $state(false);
let error = $state("");
onMount(async () => {
try {
dates = await get("/api/chat-summary/dates");
if (dates.length) selectDate(dates[0]);
} catch (e) {
error = e.body?.detail || "Failed to load dates";
}
});
async function selectDate(d) {
selectedDate = d;
showMessages = false;
loading = true;
error = "";
try {
summary = await get(`/api/chat-summary/summary/${d}`);
} catch (e) {
summary = null;
if (e.status !== 404) error = e.body?.detail || "Failed to load summary";
} finally {
loading = false;
}
}
async function loadMessages() {
if (showMessages) { showMessages = false; return; }
try {
messages = await get(`/api/chat-summary/messages/${selectedDate}`);
showMessages = true;
} catch (e) {
error = "Failed to load messages";
}
}
async function trigger() {
triggering = true;
try {
await post("/api/chat-summary/trigger");
} catch (e) {
error = "Trigger failed";
} finally {
triggering = false;
}
}
function markdownToHtml(md) {
if (!md) return "";
return md
.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
.replace(/^### (.+)$/gm, '<h3 class="text-lg font-semibold mt-4 mb-2">$1</h3>')
.replace(/^## (.+)$/gm, '<h2 class="text-xl font-bold mt-5 mb-2">$1</h2>')
.replace(/^# (.+)$/gm, '<h1 class="text-2xl font-bold mt-6 mb-3">$1</h1>')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
.replace(/^- (.+)$/gm, '<li class="ml-4">$1</li>')
.replace(/\n{2,}/g, '<br><br>')
.replace(/\n/g, '<br>');
}
</script>
<div>
<div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-bold text-surface-800 dark:text-surface-100">Chat Digest</h2>
<button onclick={trigger} disabled={triggering}
class="px-3 py-1.5 text-sm font-medium rounded-lg bg-primary-600 text-white hover:bg-primary-700 disabled:opacity-50">
{triggering ? "Generating..." : "Generate Now"}
</button>
</div>
{#if error}
<div class="mb-4 p-3 rounded-lg bg-red-50 text-red-700 text-sm dark:bg-red-900/20 dark:text-red-400">{error}</div>
{/if}
<div class="flex gap-2 mb-6 flex-wrap">
{#each dates as d}
<button onclick={() => selectDate(d)}
class="px-3 py-1.5 text-sm rounded-lg transition-colors {selectedDate === d ? 'bg-primary-600 text-white' : 'bg-surface-100 text-surface-600 hover:bg-surface-200 dark:bg-surface-700 dark:text-surface-300 dark:hover:bg-surface-600'}">
{d}
</button>
{/each}
{#if !dates.length && !error}
<p class="text-sm text-surface-400">No summaries yet.</p>
{/if}
</div>
{#if loading}
<div class="flex justify-center py-12"><div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div></div>
{:else if summary}
<div class="prose dark:prose-invert max-w-none bg-white dark:bg-surface-800 rounded-xl p-6 shadow-sm border border-surface-200 dark:border-surface-700">
{@html markdownToHtml(summary.content)}
</div>
<button onclick={loadMessages}
class="mt-4 text-sm text-primary-600 hover:text-primary-700 dark:text-primary-400">
{showMessages ? "Hide raw messages" : "Show raw messages"}
</button>
{#if showMessages}
<div class="mt-3 bg-white dark:bg-surface-800 rounded-xl p-4 shadow-sm border border-surface-200 dark:border-surface-700 max-h-96 overflow-y-auto">
{#each messages as m}
<div class="py-1.5 border-b border-surface-100 dark:border-surface-700 last:border-0 text-sm">
<span class="text-surface-400 text-xs">{m.time?.slice(11,16)}</span>
<span class="font-medium text-primary-600 dark:text-primary-400 ml-1">[{m.group}] {m.sender}:</span>
<span class="text-surface-700 dark:text-surface-300 ml-1">{m.text}</span>
</div>
{/each}
</div>
{/if}
{/if}
</div>
@@ -0,0 +1,306 @@
<script>
import { onMount } from "svelte";
import { get, post } from "../../lib/api.js";
let dates = $state([]);
let selectedDate = $state("");
let summary = $state(null);
let conversations = $state([]);
let selectedConversation = $state(null);
let messages = $state([]);
let searchQuery = $state("");
let searchResults = $state([]);
let stats = $state(null);
let loading = $state(false);
let triggering = $state(false);
let error = $state("");
let view = $state("summary"); // summary, list, detail, search, stats
onMount(async () => {
try {
dates = await get("/conversations/dates");
if (dates.length) selectDate(dates[0]);
loadStats();
} catch (e) {
error = e.body?.detail || "Failed to load dates";
}
});
async function selectDate(d) {
selectedDate = d;
view = "summary";
loading = true;
error = "";
try {
summary = await get(`/conversations/summary/${d}`);
conversations = await get(`/conversations/list?date=${d}`);
} catch (e) {
summary = null;
if (e.status !== 404) error = e.body?.detail || "Failed to load summary";
} finally {
loading = false;
}
}
async function selectConversation(sessionId) {
view = "detail";
loading = true;
try {
selectedConversation = await get(`/conversations/${sessionId}`);
messages = await get(`/conversations/${sessionId}/messages`);
} catch (e) {
error = "Failed to load conversation";
} finally {
loading = false;
}
}
async function search() {
if (!searchQuery || searchQuery.length < 2) return;
view = "search";
loading = true;
try {
searchResults = await get(`/conversations/search?q=${encodeURIComponent(searchQuery)}`);
} catch (e) {
error = "Search failed";
} finally {
loading = false;
}
}
async function loadStats() {
try {
stats = await get("/conversations/stats");
} catch (e) {
console.error("Failed to load stats", e);
}
}
async function trigger() {
triggering = true;
try {
await post("/conversations/trigger");
setTimeout(() => selectDate(selectedDate), 5000);
} catch (e) {
error = "Trigger failed";
} finally {
triggering = false;
}
}
function markdownToHtml(md) {
if (!md) return "";
return md
.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
.replace(/^### (.+)$/gm, '<h3 class="text-lg font-semibold mt-4 mb-2">$1</h3>')
.replace(/^## (.+)$/gm, '<h2 class="text-xl font-bold mt-5 mb-2">$1</h2>')
.replace(/^# (.+)$/gm, '<h1 class="text-2xl font-bold mt-6 mb-3">$1</h1>')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
.replace(/^- (.+)$/gm, '<li class="ml-4">$1</li>')
.replace(/\n{2,}/g, '<br><br>')
.replace(/\n/g, '<br>');
}
function formatTokens(n) {
return n ? n.toLocaleString() : "0";
}
</script>
<div>
<div class="flex items-center justify-between mb-6">
<h2 class="text-xl font-bold text-surface-800 dark:text-surface-100">Code Sessions</h2>
<div class="flex gap-2">
<button onclick={() => view = "stats"} class="px-3 py-1.5 text-sm font-medium rounded-lg bg-surface-100 hover:bg-surface-200 dark:bg-surface-700 dark:hover:bg-surface-600 transition-colors">
Stats
</button>
<button onclick={trigger} disabled={triggering}
class="px-3 py-1.5 text-sm font-medium rounded-lg bg-primary-600 text-white hover:bg-primary-700 disabled:opacity-50">
{triggering ? "Generating..." : "Generate Summary"}
</button>
</div>
</div>
{#if error}
<div class="mb-4 p-3 rounded-lg bg-red-50 text-red-700 text-sm dark:bg-red-900/20 dark:text-red-400">{error}</div>
{/if}
<!-- Search Bar -->
<div class="mb-4">
<input
type="text"
bind:value={searchQuery}
onkeydown={(e) => e.key === "Enter" && search()}
placeholder="Search conversations..."
class="w-full px-4 py-2 text-sm border border-surface-200 dark:border-surface-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-surface-700 dark:text-white"
/>
</div>
<!-- Date Selector -->
{#if view !== "search" && view !== "stats"}
<div class="flex gap-2 mb-6 flex-wrap">
{#each dates as d}
<button onclick={() => selectDate(d)}
class="px-3 py-1.5 text-sm rounded-lg transition-colors {selectedDate === d ? 'bg-primary-600 text-white' : 'bg-surface-100 text-surface-600 hover:bg-surface-200 dark:bg-surface-700 dark:text-surface-300 dark:hover:bg-surface-600'}">
{d}
</button>
{/each}
{#if !dates.length && !error}
<p class="text-sm text-surface-400">No conversations yet.</p>
{/if}
</div>
{/if}
{#if loading}
<div class="flex justify-center py-12"><div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div></div>
{:else if view === "summary" && summary}
<!-- Summary View -->
<div class="mb-4 grid grid-cols-4 gap-3">
<div class="bg-white dark:bg-surface-800 rounded-lg p-3 border border-surface-200 dark:border-surface-700">
<div class="text-xs text-surface-400">Conversations</div>
<div class="text-xl font-bold text-surface-900 dark:text-white">{summary.conversation_count}</div>
</div>
<div class="bg-white dark:bg-surface-800 rounded-lg p-3 border border-surface-200 dark:border-surface-700">
<div class="text-xs text-surface-400">Messages</div>
<div class="text-xl font-bold text-surface-900 dark:text-white">{summary.total_messages}</div>
</div>
<div class="bg-white dark:bg-surface-800 rounded-lg p-3 border border-surface-200 dark:border-surface-700">
<div class="text-xs text-surface-400">Tokens</div>
<div class="text-xl font-bold text-surface-900 dark:text-white">{formatTokens(summary.total_tokens)}</div>
</div>
<div class="bg-white dark:bg-surface-800 rounded-lg p-3 border border-surface-200 dark:border-surface-700">
<div class="text-xs text-surface-400">Projects</div>
<div class="text-sm font-medium text-surface-900 dark:text-white truncate">{summary.projects?.split(',')[0] || 'N/A'}</div>
</div>
</div>
<div class="prose dark:prose-invert max-w-none bg-white dark:bg-surface-800 rounded-xl p-6 shadow-sm border border-surface-200 dark:border-surface-700 mb-4">
{@html markdownToHtml(summary.content)}
</div>
<!-- Conversation List -->
<div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 shadow-sm overflow-hidden">
<div class="px-4 py-2 text-xs font-semibold uppercase tracking-wider text-surface-400 border-b border-surface-100 dark:border-surface-700">
Conversations ({conversations.length})
</div>
{#each conversations as conv}
<button
onclick={() => selectConversation(conv.session_id)}
class="w-full px-4 py-3 hover:bg-surface-50 dark:hover:bg-surface-700 border-b border-surface-100 dark:border-surface-700 last:border-0 transition-colors text-left">
<div class="flex items-center justify-between">
<div class="flex-1 min-w-0">
<div class="text-sm font-medium text-surface-700 dark:text-surface-200 truncate">{conv.slug || 'Untitled'}</div>
<div class="text-xs text-surface-400 truncate">{conv.project_path?.split('/').pop() || 'unknown'}</div>
</div>
<div class="flex items-center gap-3 text-xs text-surface-400">
<span>{conv.message_count} msgs</span>
<span>{formatTokens(conv.total_tokens)} tokens</span>
</div>
</div>
</button>
{/each}
</div>
{:else if view === "detail" && selectedConversation}
<!-- Conversation Detail -->
<button onclick={() => view = "summary"} class="mb-4 text-sm text-primary-600 hover:text-primary-700">← Back to summary</button>
<div class="bg-white dark:bg-surface-800 rounded-xl p-4 border border-surface-200 dark:border-surface-700 mb-4">
<h3 class="text-lg font-bold text-surface-900 dark:text-white mb-2">{selectedConversation.slug || 'Untitled'}</h3>
<div class="grid grid-cols-2 gap-2 text-sm">
<div><span class="text-surface-400">Project:</span> <span class="text-surface-700 dark:text-surface-300">{selectedConversation.project_path?.split('/').pop()}</span></div>
<div><span class="text-surface-400">Branch:</span> <span class="text-surface-700 dark:text-surface-300">{selectedConversation.git_branch || 'N/A'}</span></div>
<div><span class="text-surface-400">Messages:</span> <span class="text-surface-700 dark:text-surface-300">{selectedConversation.message_count}</span></div>
<div><span class="text-surface-400">Tokens:</span> <span class="text-surface-700 dark:text-surface-300">{formatTokens(selectedConversation.total_input_tokens + selectedConversation.total_output_tokens)}</span></div>
</div>
</div>
<!-- Messages -->
<div class="space-y-3">
{#each messages as msg}
<div class="bg-white dark:bg-surface-800 rounded-lg p-4 border border-surface-200 dark:border-surface-700">
<div class="flex items-center justify-between mb-2">
<span class="text-xs font-semibold uppercase {msg.role === 'user' ? 'text-blue-600' : 'text-green-600'}">{msg.role}</span>
<span class="text-xs text-surface-400">{msg.timestamp?.slice(11, 19)}</span>
</div>
<div class="text-sm text-surface-700 dark:text-surface-300 whitespace-pre-wrap">{msg.content?.slice(0, 500)}{msg.content?.length > 500 ? '...' : ''}</div>
{#if msg.tool_calls?.length}
<div class="mt-2 pt-2 border-t border-surface-100 dark:border-surface-700">
<div class="text-xs font-medium text-surface-400 mb-1">Tools: {msg.tool_calls.map(t => t.name).join(', ')}</div>
</div>
{/if}
{#if msg.input_tokens || msg.output_tokens}
<div class="mt-2 text-xs text-surface-400">
{msg.input_tokens ? `${msg.input_tokens} in` : ''} {msg.output_tokens ? `${msg.output_tokens} out` : ''}
</div>
{/if}
</div>
{/each}
</div>
{:else if view === "search"}
<!-- Search Results -->
<button onclick={() => view = "summary"} class="mb-4 text-sm text-primary-600 hover:text-primary-700">← Back</button>
<div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 shadow-sm overflow-hidden">
<div class="px-4 py-2 text-xs font-semibold uppercase tracking-wider text-surface-400 border-b border-surface-100 dark:border-surface-700">
Search Results ({searchResults.length})
</div>
{#each searchResults as conv}
<button
onclick={() => selectConversation(conv.session_id)}
class="w-full px-4 py-3 hover:bg-surface-50 dark:hover:bg-surface-700 border-b border-surface-100 dark:border-surface-700 last:border-0 transition-colors text-left">
<div class="flex items-center justify-between">
<div class="flex-1 min-w-0">
<div class="text-sm font-medium text-surface-700 dark:text-surface-200 truncate">{conv.slug || 'Untitled'}</div>
<div class="text-xs text-surface-400">{conv.started_at?.slice(0, 10)}{conv.project_path?.split('/').pop()}</div>
</div>
<div class="text-xs text-surface-400">{conv.message_count} msgs</div>
</div>
</button>
{/each}
{#if searchResults.length === 0}
<div class="px-4 py-8 text-center text-sm text-surface-400">No results found</div>
{/if}
</div>
{:else if view === "stats" && stats}
<!-- Statistics -->
<button onclick={() => view = "summary"} class="mb-4 text-sm text-primary-600 hover:text-primary-700">← Back</button>
<div class="grid grid-cols-3 gap-4 mb-6">
<div class="bg-white dark:bg-surface-800 rounded-lg p-4 border border-surface-200 dark:border-surface-700">
<div class="text-sm text-surface-400">Total Conversations</div>
<div class="text-2xl font-bold text-surface-900 dark:text-white">{stats.total_conversations}</div>
</div>
<div class="bg-white dark:bg-surface-800 rounded-lg p-4 border border-surface-200 dark:border-surface-700">
<div class="text-sm text-surface-400">Total Messages</div>
<div class="text-2xl font-bold text-surface-900 dark:text-white">{stats.total_messages}</div>
</div>
<div class="bg-white dark:bg-surface-800 rounded-lg p-4 border border-surface-200 dark:border-surface-700">
<div class="text-sm text-surface-400">Total Tokens</div>
<div class="text-2xl font-bold text-surface-900 dark:text-white">{formatTokens(stats.total_tokens)}</div>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="bg-white dark:bg-surface-800 rounded-lg p-4 border border-surface-200 dark:border-surface-700">
<h3 class="text-sm font-semibold text-surface-700 dark:text-surface-200 mb-3">Top Projects</h3>
{#each stats.top_projects as proj}
<div class="flex justify-between text-sm py-1">
<span class="text-surface-600 dark:text-surface-300 truncate">{proj.path?.split('/').pop()}</span>
<span class="text-surface-400">{proj.count}</span>
</div>
{/each}
</div>
<div class="bg-white dark:bg-surface-800 rounded-lg p-4 border border-surface-200 dark:border-surface-700">
<h3 class="text-sm font-semibold text-surface-700 dark:text-surface-200 mb-3">Top Tools</h3>
{#each stats.top_tools as tool}
<div class="flex justify-between text-sm py-1">
<span class="text-surface-600 dark:text-surface-300">{tool.name}</span>
<span class="text-surface-400">{tool.count}</span>
</div>
{/each}
</div>
</div>
{/if}
</div>
@@ -0,0 +1,177 @@
<script>
import { onMount } from "svelte";
import { get, del, upload, download } from "../../lib/api.js";
let entries = $state([]);
let currentPath = $state("");
let loading = $state(true);
let uploading = $state(false);
let error = $state("");
let fileInput;
async function browse(path) {
loading = true;
currentPath = path;
const data = await get(`/files/browse?path=${encodeURIComponent(path)}`);
entries = data?.entries || [];
loading = false;
}
function goUp() {
const parts = currentPath.split("/").filter(Boolean);
parts.pop();
browse(parts.join("/"));
}
async function remove(name) {
if (!confirm(`Delete "${name}"? This cannot be undone.`)) return;
const path = currentPath ? `${currentPath}/${name}` : name;
await del(`/files/delete?path=${encodeURIComponent(path)}`);
browse(currentPath);
}
async function handleDownload(name) {
const path = currentPath ? `${currentPath}/${name}` : name;
error = "";
try {
await download(path, name);
} catch (e) {
error = `Failed to download "${name}": ${e.message}`;
console.error("Download failed:", e);
}
}
async function handleUpload() {
const file = fileInput?.files?.[0];
if (!file) return;
uploading = true;
await upload(currentPath, file);
fileInput.value = "";
uploading = false;
browse(currentPath);
}
function formatSize(bytes) {
if (!bytes) return "—";
const u = ["B", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return (bytes / Math.pow(1024, i)).toFixed(1) + " " + u[i];
}
function fileIcon(name) {
const ext = name.split(".").pop().toLowerCase();
if (["jpg", "jpeg", "png", "gif", "webp", "svg"].includes(ext)) return "image";
if (["mp4", "mkv", "avi", "mov"].includes(ext)) return "video";
if (["mp3", "flac", "wav", "m4a", "ogg"].includes(ext)) return "audio";
if (["zip", "tar", "gz", "7z", "rar"].includes(ext)) return "archive";
if (["py", "js", "ts", "go", "rs", "sh", "yml", "yaml", "json", "md"].includes(ext)) return "code";
return "file";
}
const crumbs = $derived(currentPath.split("/").filter(Boolean));
onMount(() => browse(""));
</script>
<div class="space-y-5">
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-surface-900 dark:text-white tracking-tight">Files</h1>
<p class="text-sm text-surface-400 mt-1">/volume1{currentPath ? "/" + currentPath : ""}</p>
</div>
<div class="flex items-center gap-2">
<input type="file" bind:this={fileInput} onchange={handleUpload} class="hidden" />
<button
onclick={() => fileInput.click()}
class="text-xs font-medium text-primary-600 bg-primary-50 hover:bg-primary-100 px-3 py-1.5 rounded-lg transition-colors disabled:opacity-50"
disabled={uploading}
>
{uploading ? "Uploading..." : "Upload"}
</button>
</div>
</div>
<!-- Error message -->
{#if error}
<div class="bg-rose-50 dark:bg-rose-900/20 border border-rose-200 dark:border-rose-800 rounded-lg px-4 py-3 flex items-start gap-3">
<svg class="w-5 h-5 text-rose-500 shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div class="flex-1">
<p class="text-sm text-rose-700 dark:text-rose-300">{error}</p>
</div>
<button onclick={() => error = ""} class="text-rose-400 hover:text-rose-600 transition-colors">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/if}
<!-- Breadcrumbs -->
<div class="flex items-center gap-1 text-sm flex-wrap">
<button class="text-primary-600 hover:text-primary-700 font-medium transition-colors" onclick={() => browse("")}>volume1</button>
{#each crumbs as part, i}
<svg class="w-3.5 h-3.5 text-surface-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" /></svg>
<button
class="text-primary-600 hover:text-primary-700 font-medium transition-colors"
onclick={() => browse(crumbs.slice(0, i + 1).join("/"))}
>{part}</button>
{/each}
{#if currentPath}
<button
class="ml-auto text-xs font-medium text-surface-500 bg-surface-100 dark:bg-surface-700 hover:bg-surface-200 dark:hover:bg-surface-600 px-2.5 py-1 rounded-lg transition-colors flex items-center gap-1"
onclick={goUp}
>
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M5 10l7-7m0 0l7 7m-7-7v18" /></svg>
Up
</button>
{/if}
</div>
<!-- File List -->
{#if loading}
<div class="space-y-1">
{#each Array(8) as _}
<div class="h-11 rounded-lg bg-surface-100 dark:bg-surface-800 animate-pulse"></div>
{/each}
</div>
{:else}
<div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 shadow-sm overflow-hidden">
<!-- Header -->
<div class="grid grid-cols-[1fr_100px_100px] px-4 py-2 text-[11px] font-semibold uppercase tracking-wider text-surface-400 border-b border-surface-100 dark:border-surface-700">
<span>Name</span>
<span class="text-right">Size</span>
<span class="text-right">Actions</span>
</div>
<!-- Entries -->
{#each entries as e}
<div class="grid grid-cols-[1fr_100px_100px] items-center px-4 py-2.5 hover:bg-surface-50 dark:hover:bg-surface-700 border-b border-surface-100 dark:border-surface-700 last:border-0 transition-colors group">
{#if e.is_dir}
<button class="flex items-center gap-2.5 text-sm font-medium text-surface-700 dark:text-surface-200 hover:text-primary-600 transition-colors text-left truncate" onclick={() => browse(currentPath ? `${currentPath}/${e.name}` : e.name)}>
<svg class="w-4 h-4 text-amber-400 shrink-0" fill="currentColor" viewBox="0 0 20 20"><path d="M2 6a2 2 0 012-2h5l2 2h5a2 2 0 012 2v6a2 2 0 01-2 2H4a2 2 0 01-2-2V6z" /></svg>
{e.name}
</button>
{:else}
<span class="flex items-center gap-2.5 text-sm text-surface-600 dark:text-surface-300 truncate">
<svg class="w-4 h-4 text-surface-300 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" /></svg>
{e.name}
</span>
{/if}
<span class="text-xs text-surface-400 text-right">{e.is_dir ? "—" : formatSize(e.size)}</span>
<div class="flex items-center justify-end gap-1 opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-opacity">
{#if !e.is_dir}
<button onclick={() => handleDownload(e.name)} class="text-[11px] text-primary-500 hover:text-primary-700 font-medium transition-colors">
DL
</button>
{/if}
<button class="text-[11px] text-rose-400 hover:text-rose-600 font-medium transition-colors" onclick={() => remove(e.name)}>Del</button>
</div>
</div>
{/each}
{#if entries.length === 0}
<div class="px-4 py-8 text-center text-sm text-surface-400">Empty directory</div>
{/if}
</div>
{/if}
</div>
@@ -0,0 +1,124 @@
<script>
import { onMount } from "svelte";
import { get } from "../../lib/api.js";
let repos = $state([]);
let commits = $state([]);
let selectedRepo = $state("");
let loading = $state(true);
let loadingCommits = $state(false);
async function load() {
loading = true;
const data = await get("/gitea/repos");
repos = Array.isArray(data) ? data : data?.data || [];
loading = false;
}
async function showCommits(owner, repo) {
selectedRepo = `${owner}/${repo}`;
loadingCommits = true;
const data = await get(`/gitea/repos/${owner}/${repo}/commits`);
commits = Array.isArray(data) ? data : [];
loadingCommits = false;
}
function timeAgo(d) {
const s = Math.floor((Date.now() - new Date(d)) / 1000);
if (s < 60) return "just now";
if (s < 3600) return Math.floor(s / 60) + "m ago";
if (s < 86400) return Math.floor(s / 3600) + "h ago";
return Math.floor(s / 86400) + "d ago";
}
const langColors = {
Python: "#3572A5", JavaScript: "#f1e05a", Shell: "#89e051", Go: "#00ADD8",
TypeScript: "#3178c6", Markdown: "#083fa1", CSS: "#563d7c", HTML: "#e34c26",
};
onMount(load);
</script>
<div class="space-y-6">
<div>
<h1 class="text-2xl font-bold text-surface-900 dark:text-white tracking-tight">Repositories</h1>
<p class="text-sm text-surface-400 mt-1">{repos.length} repositories on Gitea</p>
</div>
{#if loading}
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
{#each Array(4) as _}
<div class="h-[100px] rounded-xl bg-surface-100 dark:bg-surface-800 animate-pulse"></div>
{/each}
</div>
{:else}
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
{#each repos as r}
<button
class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-4 text-left shadow-sm hover:shadow-md hover:border-primary-200 dark:hover:border-primary-700 transition-all duration-200 group {selectedRepo === `${r.owner?.login}/${r.name}` ? 'ring-2 ring-primary-500 border-primary-300' : ''}"
onclick={() => showCommits(r.owner?.login, r.name)}
>
<div class="flex items-start justify-between">
<div class="min-w-0">
<p class="text-sm font-semibold text-surface-800 dark:text-surface-100 group-hover:text-primary-600 transition-colors truncate">{r.name}</p>
<p class="text-xs text-surface-400 mt-1 line-clamp-1">{r.description || "No description"}</p>
</div>
{#if r.language}
<span class="flex items-center gap-1 text-[11px] text-surface-500 shrink-0 ml-2">
<span class="w-2 h-2 rounded-full" style="background: {langColors[r.language] || '#888'}"></span>
{r.language}
</span>
{/if}
</div>
<p class="text-[11px] text-surface-400 mt-2.5">Updated {timeAgo(r.updated_at)}</p>
</button>
{/each}
</div>
{/if}
<!-- Commits Panel -->
{#if selectedRepo}
<div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-5 shadow-sm">
<div class="flex items-center justify-between mb-4">
<div>
<h3 class="text-sm font-bold text-surface-800 dark:text-surface-100">Recent Commits</h3>
<p class="text-xs text-surface-400 mt-0.5">{selectedRepo}</p>
</div>
<button
aria-label="Close commits"
class="text-xs text-surface-400 hover:text-surface-600 transition-colors"
onclick={() => { selectedRepo = ""; commits = []; }}
>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /></svg>
</button>
</div>
{#if loadingCommits}
<div class="space-y-3">
{#each Array(3) as _}
<div class="h-12 rounded-lg bg-surface-100 animate-pulse"></div>
{/each}
</div>
{:else}
<div class="space-y-0">
{#each commits.slice(0, 15) as c, i}
<div class="flex gap-3 py-2.5 {i < commits.length - 1 ? 'border-b border-surface-100 dark:border-surface-700' : ''}">
<div class="flex flex-col items-center pt-1">
<div class="w-2 h-2 rounded-full bg-primary-400 shrink-0"></div>
{#if i < commits.length - 1}
<div class="w-px flex-1 bg-surface-200 mt-1"></div>
{/if}
</div>
<div class="min-w-0 flex-1">
<p class="text-sm text-surface-700 dark:text-surface-200 truncate">{c.commit?.message?.split("\n")[0]}</p>
<p class="text-[11px] text-surface-400 mt-0.5">
<span class="font-medium">{c.commit?.author?.name}</span> · {timeAgo(c.commit?.author?.date)}
</p>
</div>
<span class="text-[10px] font-mono text-surface-400 bg-surface-50 dark:bg-surface-700 px-1.5 py-0.5 rounded h-fit shrink-0">{c.sha?.slice(0, 7)}</span>
</div>
{/each}
</div>
{/if}
</div>
{/if}
</div>
@@ -0,0 +1,108 @@
<script>
import { onMount } from "svelte";
import { getLiteLLMHealth } from "../../lib/api.js";
let loading = $state(true);
let health = $state(null);
let requestError = $state("");
async function loadHealth() {
loading = true;
requestError = "";
try {
health = await getLiteLLMHealth();
} catch (e) {
requestError = e?.body?.detail || e?.message || "Failed to load LiteLLM health";
health = null;
} finally {
loading = false;
}
}
function statusBadgeClass(status) {
if (status === "up") {
return "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300";
}
if (status === "auth_required") {
return "bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300";
}
return "bg-rose-100 text-rose-700 dark:bg-rose-900/40 dark:text-rose-300";
}
function valueOrDash(value) {
return value === null || value === undefined || value === "" ? "—" : value;
}
onMount(loadHealth);
</script>
<div class="space-y-6">
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-surface-900 dark:text-white tracking-tight">LiteLLM</h1>
<p class="text-sm text-surface-400 mt-1">Gateway health and connectivity status</p>
<div class="mt-2 flex flex-wrap items-center gap-2 text-[11px] font-medium">
<span class="px-2 py-0.5 rounded-full bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300">up</span>
<span class="px-2 py-0.5 rounded-full bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300">auth_required</span>
<span class="px-2 py-0.5 rounded-full bg-rose-100 text-rose-700 dark:bg-rose-900/40 dark:text-rose-300">down</span>
</div>
</div>
<button onclick={loadHealth} disabled={loading} class="px-3 py-1.5 text-xs font-medium text-primary-600 bg-primary-50 hover:bg-primary-100 rounded-lg transition-colors disabled:opacity-50 dark:bg-primary-900/30 dark:text-primary-300">
{loading ? "Loading..." : "Refresh"}
</button>
</div>
{#if requestError}
<div class="bg-rose-50 dark:bg-rose-900/20 border border-rose-200 dark:border-rose-700 rounded-xl p-4 shadow-sm">
<p class="text-sm font-medium text-rose-700 dark:text-rose-300">Failed to fetch LiteLLM health</p>
<p class="text-xs text-rose-600 dark:text-rose-400 mt-1">{requestError}</p>
</div>
{/if}
<div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 p-5 shadow-sm">
{#if loading && !health}
<div class="space-y-3">
<div class="h-6 w-36 rounded bg-surface-100 dark:bg-surface-700 animate-pulse"></div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
{#each Array(6) as _}
<div class="h-16 rounded-lg bg-surface-100 dark:bg-surface-700 animate-pulse"></div>
{/each}
</div>
</div>
{:else if health}
<div class="flex items-center justify-between mb-4">
<h3 class="text-sm font-semibold text-surface-700 dark:text-surface-200">Health Status</h3>
<span class="px-2.5 py-1 text-xs font-semibold rounded-full {statusBadgeClass(health.status)}">{valueOrDash(health.status)}</span>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
<div class="rounded-lg border border-surface-200 dark:border-surface-700 p-3">
<p class="text-xs font-medium text-surface-400 uppercase tracking-wider">OK</p>
<p class="text-sm font-semibold text-surface-800 dark:text-surface-100 mt-1">{String(Boolean(health.ok))}</p>
</div>
<div class="rounded-lg border border-surface-200 dark:border-surface-700 p-3">
<p class="text-xs font-medium text-surface-400 uppercase tracking-wider">Latency</p>
<p class="text-sm font-semibold text-surface-800 dark:text-surface-100 mt-1">{health.latency_ms === null || health.latency_ms === undefined ? "—" : `${health.latency_ms} ms`}</p>
</div>
<div class="rounded-lg border border-surface-200 dark:border-surface-700 p-3">
<p class="text-xs font-medium text-surface-400 uppercase tracking-wider">Endpoint</p>
<p class="text-sm font-semibold text-surface-800 dark:text-surface-100 mt-1">{valueOrDash(health.endpoint)}</p>
</div>
<div class="rounded-lg border border-surface-200 dark:border-surface-700 p-3">
<p class="text-xs font-medium text-surface-400 uppercase tracking-wider">HTTP Status</p>
<p class="text-sm font-semibold text-surface-800 dark:text-surface-100 mt-1">{valueOrDash(health.http_status)}</p>
</div>
<div class="rounded-lg border border-surface-200 dark:border-surface-700 p-3 sm:col-span-2 lg:col-span-2">
<p class="text-xs font-medium text-surface-400 uppercase tracking-wider">Error</p>
<p class="text-sm font-semibold text-surface-800 dark:text-surface-100 mt-1 break-all">{valueOrDash(health.error)}</p>
</div>
</div>
{:else}
<p class="text-sm text-surface-400">No health data available.</p>
{/if}
</div>
</div>
@@ -0,0 +1,208 @@
<script>
import { onMount, onDestroy } from "svelte";
import KanbanBoard from "../../components/opc/KanbanBoard.svelte";
import TaskModal from "../../components/opc/TaskModal.svelte";
import AgentPanel from "../../components/opc/AgentPanel.svelte";
import { getTasks, getAgents } from "../../lib/opc-api.js";
import * as opcWs from "../../lib/opc-ws.js";
let tasks = $state([]);
let agents = $state([]);
let loading = $state(true);
let showTaskModal = $state(false);
let editingTask = $state(null);
let showAgentPanel = $state(
typeof localStorage !== 'undefined'
? localStorage.getItem('opc_showAgentPanel') !== 'false'
: true
);
let unsubscribe = null;
// Persist agent panel visibility
$effect(() => {
if (typeof localStorage !== 'undefined') {
localStorage.setItem('opc_showAgentPanel', showAgentPanel);
}
});
async function loadData() {
loading = true;
try {
const [tasksRes, agentsRes] = await Promise.all([
getTasks(),
getAgents()
]);
tasks = tasksRes.items || [];
agents = agentsRes.items || [];
} catch (e) {
console.error("Failed to load data:", e);
} finally {
loading = false;
}
}
function handleWebSocketMessage(message) {
if (message.type === "task_update") {
const { task_id, action, data } = message;
if (action === "created") {
tasks = [...tasks, data];
} else if (action === "updated" || action === "moved") {
tasks = tasks.map(t => t.id === task_id ? data : t);
} else if (action === "deleted") {
tasks = tasks.filter(t => t.id !== task_id);
}
} else if (message.type === "agent_execution") {
// Could show agent status in UI
console.log("Agent execution update:", message);
}
}
function handleCreateTask() {
editingTask = null;
showTaskModal = true;
}
function handleEditTask(task) {
editingTask = task;
showTaskModal = true;
}
function handleDeleteTask(taskId) {
tasks = tasks.filter(t => t.id !== taskId);
}
function handleAssignTask(taskId, assignedTo, assignedType) {
const task = tasks.find(t => t.id === taskId);
if (task) {
task.assigned_to = assignedTo;
task.assigned_type = assignedType;
tasks = [...tasks];
}
}
function handleTaskMoved(taskId, newStatus) {
const task = tasks.find(t => t.id === taskId);
if (task) {
task.status = newStatus;
tasks = [...tasks];
}
}
function handleModalClose() {
showTaskModal = false;
editingTask = null;
}
function handleModalSave() {
loadData();
}
onMount(() => {
loadData();
// Connect WebSocket
opcWs.connect();
unsubscribe = opcWs.subscribe(handleWebSocketMessage);
});
onDestroy(() => {
if (unsubscribe) {
unsubscribe();
}
opcWs.disconnect();
});
</script>
<div class="space-y-6 h-full flex flex-col">
<!-- Header -->
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-surface-900 dark:text-surface-100">
OPC Management
</h1>
<p class="text-surface-600 dark:text-surface-400 mt-1">
Manage your tasks with AI agents
</p>
</div>
<div class="flex gap-2">
<button
onclick={() => showAgentPanel = !showAgentPanel}
class="px-4 py-2 bg-surface-200 dark:bg-surface-700 text-surface-900 dark:text-surface-100 rounded-lg hover:bg-surface-300 dark:hover:bg-surface-600 flex items-center gap-2"
>
<span>🤖</span>
<span>{showAgentPanel ? "Hide" : "Show"} Agents</span>
</button>
<button
onclick={handleCreateTask}
class="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 flex items-center gap-2"
>
<span></span>
<span>New Task</span>
</button>
</div>
</div>
<!-- Stats -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<div class="bg-white dark:bg-surface-800 rounded-lg p-4 border border-surface-200 dark:border-surface-700">
<div class="text-sm text-surface-600 dark:text-surface-400">Total Tasks</div>
<div class="text-2xl font-bold text-surface-900 dark:text-surface-100 mt-1">
{tasks.length}
</div>
</div>
<div class="bg-white dark:bg-surface-800 rounded-lg p-4 border border-surface-200 dark:border-surface-700">
<div class="text-sm text-surface-600 dark:text-surface-400">Parking Lot</div>
<div class="text-2xl font-bold text-slate-600 dark:text-slate-400 mt-1">
{tasks.filter(t => t.status === "parking_lot").length}
</div>
</div>
<div class="bg-white dark:bg-surface-800 rounded-lg p-4 border border-surface-200 dark:border-surface-700">
<div class="text-sm text-surface-600 dark:text-surface-400">In Progress</div>
<div class="text-2xl font-bold text-blue-600 dark:text-blue-400 mt-1">
{tasks.filter(t => t.status === "in_progress").length}
</div>
</div>
<div class="bg-white dark:bg-surface-800 rounded-lg p-4 border border-surface-200 dark:border-surface-700">
<div class="text-sm text-surface-600 dark:text-surface-400">Done</div>
<div class="text-2xl font-bold text-emerald-600 dark:text-emerald-400 mt-1">
{tasks.filter(t => t.status === "done").length}
</div>
</div>
</div>
<!-- Kanban Board -->
{#if loading}
<div class="flex-1 flex items-center justify-center">
<div class="text-surface-600 dark:text-surface-400">Loading...</div>
</div>
{:else}
<div class="flex-1 overflow-hidden">
<KanbanBoard
{tasks}
{agents}
onEdit={handleEditTask}
onDelete={handleDeleteTask}
onAssign={handleAssignTask}
onTaskMoved={handleTaskMoved}
/>
</div>
{/if}
<!-- Agent Panel -->
{#if showAgentPanel}
<div class="mt-6">
<AgentPanel />
</div>
{/if}
</div>
<!-- Task Modal -->
{#if showTaskModal}
<TaskModal
task={editingTask}
{agents}
onClose={handleModalClose}
onSave={handleModalSave}
/>
{/if}
@@ -0,0 +1,803 @@
<script>
import { onMount, onDestroy } from "svelte";
import { VoiceSession } from "../../lib/voice.js";
import { tryRefreshSession } from "../../lib/api.js";
const PERSISTENCE_STATUS_PREFIX = "__DASHBOARD_PERSISTENCE__:";
const PERSISTENCE_STATUS_PREFIX_BYTES = new TextEncoder().encode(PERSISTENCE_STATUS_PREFIX);
const hosts = [
{ id: "nas", label: "NAS" },
{ id: "vps", label: "VPS" },
{ id: "caddy-vps", label: "Caddy VPS" },
{ id: "mac", label: "Mac" },
{ id: "claude-dev", label: "Claude Dev", persistent: true },
];
const DARK_THEME = {
background: "#0f172a", foreground: "#e2e8f0", cursor: "#6366f1",
cursorAccent: "#0f172a", selectionBackground: "#334155",
black: "#0f172a", red: "#f43f5e", green: "#10b981",
yellow: "#f59e0b", blue: "#3b82f6", magenta: "#a855f7",
cyan: "#06b6d4", white: "#e2e8f0",
};
const LIGHT_THEME = {
background: "#f8fafc", foreground: "#0f172a", cursor: "#4f46e5",
cursorAccent: "#f8fafc", selectionBackground: "#cbd5e1",
black: "#0f172a", red: "#e11d48", green: "#059669",
yellow: "#d97706", blue: "#2563eb", magenta: "#9333ea",
cyan: "#0891b2", white: "#334155",
};
let tabs = $state([]);
let activeTab = $state(null);
let showMenu = $state(false);
let voiceTick = $state(0);
let isDark = $state(false);
let isFullscreen = $state(false);
let showActionButtons = $state(false);
let tabCounter = 0;
const tabData = new Map();
function currentTheme() {
return isDark ? DARK_THEME : LIGHT_THEME;
}
function hostById(hostId) {
return hosts.find((h) => h.id === hostId);
}
function openFreshSession(tab) {
const url = new URL(window.location.href);
url.searchParams.set("page", "terminal");
url.searchParams.set("host", tab.hostId);
window.open(`${url.pathname}${url.search}${url.hash}`, "_blank", "noopener");
}
function persistenceLabel(status, reconnecting) {
if (status === "created") return "created new persistent session";
if (reconnecting) return "reattached to persistent session";
return "attached to persistent session";
}
function applyThemeToTerminals() {
const theme = currentTheme();
tabData.forEach((d) => {
d.term?.options && (d.term.options.theme = theme);
});
}
function syncTheme() {
isDark = document.documentElement.classList.contains("dark");
applyThemeToTerminals();
}
const themeObserver = new MutationObserver((mutations) => {
if (mutations.some((m) => m.attributeName === "class")) syncTheme();
});
onMount(() => {
syncTheme();
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
const requestedHost = new URLSearchParams(window.location.search).get("host");
if (requestedHost && !tabs.length && hostById(requestedHost)) addTab(requestedHost);
});
function cleanName(name) {
return (name || "image")
.replace(/[^a-zA-Z0-9._-]/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "") || "image";
}
function extensionFor(type) {
if (type === "image/png") return "png";
if (type === "image/jpeg") return "jpg";
if (type === "image/webp") return "webp";
if (type === "image/gif") return "gif";
return "bin";
}
async function fileToBase64(file) {
const buf = await file.arrayBuffer();
let binary = "";
const bytes = new Uint8Array(buf);
const chunkSize = 0x8000;
for (let i = 0; i < bytes.length; i += chunkSize) {
binary += String.fromCharCode(...bytes.subarray(i, i + chunkSize));
}
return btoa(binary);
}
async function sendDroppedImage(term, ws, file) {
if (!file?.type?.startsWith("image/")) return;
if (ws.readyState !== 1) return;
const ext = extensionFor(file.type);
const baseName = cleanName(file.name).replace(/\.[a-zA-Z0-9]+$/, "");
const stamp = Date.now();
const imagePath = `/tmp/claude-images/${baseName || "image"}-${stamp}.${ext}`;
const b64 = await fileToBase64(file);
const cmd = [
"python3 - <<'PY'",
"import base64, pathlib",
`p = pathlib.Path(${JSON.stringify(imagePath)})`,
"p.parent.mkdir(parents=True, exist_ok=True)",
`p.write_bytes(base64.b64decode(${JSON.stringify(b64)}))`,
`print(f'saved image -> {p}')`,
"PY",
"",
].join("\n");
ws.send(new TextEncoder().encode(cmd));
term.write(`\r\n\x1b[90m[Uploaded image to ${imagePath}]\x1b[0m\r\n`);
}
function addTab(hostId) {
const id = ++tabCounter;
const host = hostById(hostId);
tabs = [...tabs, {
id,
hostId,
label: host.label,
connected: false,
error: "",
statusBanner: host.persistent ? "Connecting to persistent session..." : "",
persistent: !!host.persistent,
}];
activeTab = id;
showMenu = false;
}
function closeTab(id) {
const d = tabData.get(id);
if (d) {
d.closedManually = true;
if (d.reconnectTimer) clearTimeout(d.reconnectTimer);
d.voice?.destroy();
d.ro?.disconnect();
if (d.heartbeat) clearInterval(d.heartbeat);
d.term?.element?.removeEventListener("dragover", d.handleDragOver);
d.term?.element?.removeEventListener("drop", d.handleDrop);
d.term?.textarea?.removeEventListener("paste", d.handlePaste);
d.ws?.close();
d.term?.dispose();
tabData.delete(id);
}
tabs = tabs.filter((t) => t.id !== id);
if (activeTab === id) activeTab = tabs.length ? tabs[tabs.length - 1].id : null;
}
function updateTab(tabId, props) {
tabs = tabs.map((t) => (t.id === tabId ? { ...t, ...props } : t));
}
function initTerminal(el, tab) {
const tabId = tab.id;
(async () => {
const { Terminal } = await import("@xterm/xterm");
const { FitAddon } = await import("@xterm/addon-fit");
await import("@xterm/xterm/css/xterm.css");
const term = new Terminal({
cursorBlink: true, fontSize: 13,
fontFamily: "'JetBrains Mono', 'Fira Code', 'SF Mono', 'Menlo', monospace",
lineHeight: 1.4,
theme: currentTheme(),
});
const fit = new FitAddon();
term.loadAddon(fit);
term.open(el);
fit.fit();
const proto = location.protocol === "https:" ? "wss:" : "ws:";
let heartbeat = null;
let pongTimeout = null;
let reconnectTimer = null;
let closedManually = false;
let reconnecting = false;
let authRecoveryInFlight = false;
let reconnectAttempts = 0;
let consecutiveAuthFailures = 0;
const MAX_RECONNECT_DELAY = 30000;
const MAX_AUTH_FAILURES = 3;
const PING_INTERVAL = 30000; // 30s between pings
const PONG_TIMEOUT = 10000; // 10s to receive pong
function clearHeartbeat() {
if (heartbeat) {
clearInterval(heartbeat);
heartbeat = null;
}
if (pongTimeout) {
clearTimeout(pongTimeout);
pongTimeout = null;
}
}
function handleAuthExpired(reason = "Session expired — please log in again") {
clearHeartbeat();
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
reconnecting = false;
updateTab(tabId, {
connected: false,
error: reason,
statusBanner: "",
});
term.write(`\r\n\x1b[90m[${reason}]\x1b[0m`);
const d = tabData.get(tabId);
if (d) {
d.ws = null;
d.heartbeat = heartbeat;
d.reconnectTimer = reconnectTimer;
d.closedManually = closedManually;
d.reconnecting = reconnecting;
}
}
function scheduleReconnect(reason) {
clearHeartbeat();
if (closedManually || reconnectTimer) return;
reconnecting = true;
reconnectAttempts++;
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts - 1), MAX_RECONNECT_DELAY);
updateTab(tabId, {
connected: false,
error: `${reason} — reconnecting in ${Math.round(delay / 1000)}s (attempt ${reconnectAttempts})...`,
statusBanner: tab.persistent ? "Reconnecting to persistent session..." : "",
});
term.write(`\r\n\x1b[90m[${reason} — reconnecting in ${Math.round(delay / 1000)}s...]\x1b[0m`);
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
if (!tabData.has(tabId) || closedManually) return;
connect(true);
}, delay);
}
function sendSize(ws) {
if (ws.readyState === 1) {
const buf = new Uint8Array(5);
buf[0] = 0x01;
new DataView(buf.buffer).setUint16(1, term.cols);
new DataView(buf.buffer).setUint16(3, term.rows);
ws.send(buf);
}
}
async function connect(forceRefresh = false) {
let d = tabData.get(tabId);
if (forceRefresh) {
const refreshed = await tryRefreshSession();
if (!refreshed) {
handleAuthExpired();
return null;
}
}
const ws = new WebSocket(
`${proto}//${location.host}/ws/terminal?host=${encodeURIComponent(tab.hostId)}`
);
ws.binaryType = "arraybuffer";
ws.onopen = () => {
reconnectAttempts = 0;
consecutiveAuthFailures = 0; // Reset auth failure counter on successful connection
updateTab(tabId, {
connected: true,
error: "",
statusBanner: tab.persistent ? "Connected. Waiting for persistent session status..." : "",
});
sendSize(ws);
clearHeartbeat();
heartbeat = setInterval(() => {
if (ws.readyState === 1) {
ws.send("__ping__");
// Set timeout to detect missing pong
pongTimeout = setTimeout(() => {
// Check if still the same WebSocket instance and still open
const currentTab = tabData.get(tabId);
if (currentTab?.ws === ws && ws.readyState === 1) {
ws.close(1000, "keepalive ping timeout");
}
}, PONG_TIMEOUT);
}
}, PING_INTERVAL);
const d = tabData.get(tabId);
if (d) {
d.ws = ws;
d.heartbeat = heartbeat;
d.reconnectTimer = reconnectTimer;
d.closedManually = closedManually;
d.reconnecting = reconnecting;
d.voice?.attach(term, ws);
}
};
ws.onmessage = (e) => {
// Check for text pong response
if (typeof e.data === 'string' && e.data === '__pong__') {
// Clear the pong timeout - connection is alive
if (pongTimeout) {
clearTimeout(pongTimeout);
pongTimeout = null;
}
return;
}
const data = new Uint8Array(e.data);
const isPersistenceMessage = tab.persistent
&& data.length >= PERSISTENCE_STATUS_PREFIX_BYTES.length
&& PERSISTENCE_STATUS_PREFIX_BYTES.every((byte, index) => data[index] === byte);
if (isPersistenceMessage) {
const status = new TextDecoder().decode(data.slice(PERSISTENCE_STATUS_PREFIX_BYTES.length)).trim();
updateTab(tabId, { statusBanner: persistenceLabel(status, reconnecting) });
reconnecting = false;
const d = tabData.get(tabId);
if (d) d.reconnecting = reconnecting;
return;
}
term.write(data);
};
ws.onclose = async (e) => {
const reason = e.reason || `Connection closed (code ${e.code})`;
const tabState = tabData.get(tabId);
if (tabState) tabState.ws = null;
// Handle auth failures (code 1008)
if (e.code === 1008) {
consecutiveAuthFailures++;
// Stop after MAX_AUTH_FAILURES consecutive auth failures
if (consecutiveAuthFailures >= MAX_AUTH_FAILURES) {
handleAuthExpired(`Session expired after ${MAX_AUTH_FAILURES} attempts — please refresh page`);
return;
}
// Try to refresh session
if (!closedManually && !authRecoveryInFlight) {
authRecoveryInFlight = true;
try {
const refreshed = await tryRefreshSession();
if (refreshed && !closedManually) {
reconnecting = true;
updateTab(tabId, {
connected: false,
error: `Refreshing session (attempt ${consecutiveAuthFailures}/${MAX_AUTH_FAILURES})...`,
statusBanner: tab.persistent ? "Reconnecting to persistent session..." : "",
});
term.write(`\r\n\x1b[90m[Refreshing session...]\x1b[0m`);
void connect(false);
return;
}
} catch (err) {
console.error("Session refresh failed:", err);
} finally {
authRecoveryInFlight = false;
}
}
handleAuthExpired("Session expired — please log in again");
return;
}
// Non-auth failures: continue with normal reconnection
if (!closedManually) scheduleReconnect(reason);
else {
clearHeartbeat();
updateTab(tabId, { connected: false, error: reason });
term.write(`\r\n\x1b[90m[${reason}]\x1b[0m`);
}
};
ws.onerror = () => {
const tabState = tabData.get(tabId);
if (tabState) tabState.ws = null;
if (!closedManually) scheduleReconnect("Connection failed");
else updateTab(tabId, { connected: false, error: "Connection failed" });
};
d = tabData.get(tabId);
if (d) {
d.ws = ws;
d.heartbeat = heartbeat;
d.reconnectTimer = reconnectTimer;
d.closedManually = closedManually;
d.reconnecting = reconnecting;
}
return ws;
}
let ws = null;
void connect().then((connectedWs) => {
ws = connectedWs;
if (connectedWs) voice.attach(term, connectedWs);
});
term.onData((data) => {
const currentWs = tabData.get(tabId)?.ws;
if (currentWs?.readyState === 1) currentWs.send(new TextEncoder().encode(data));
});
const handleDragOver = (e) => {
e.preventDefault();
};
const handleDrop = async (e) => {
e.preventDefault();
if (activeTab !== tabId) return;
const currentWs = tabData.get(tabId)?.ws;
const files = [...(e.dataTransfer?.files || [])].filter((f) => f.type?.startsWith("image/"));
for (const file of files) {
await sendDroppedImage(term, currentWs, file);
}
};
const handlePaste = async (e) => {
if (activeTab !== tabId) return;
const currentWs = tabData.get(tabId)?.ws;
const items = [...(e.clipboardData?.items || [])]
.filter((it) => it.kind === "file" && it.type?.startsWith("image/"));
for (const item of items) {
const file = item.getAsFile();
if (file) await sendDroppedImage(term, currentWs, file);
}
};
term.element?.addEventListener("dragover", handleDragOver);
term.element?.addEventListener("drop", handleDrop);
term.textarea?.addEventListener("paste", handlePaste);
const ro = new ResizeObserver(() => {
fit.fit();
const currentWs = tabData.get(tabId)?.ws;
if (currentWs) sendSize(currentWs);
});
ro.observe(el);
const voice = new VoiceSession(() => { voiceTick++; });
voice.attach(term, ws);
tabData.set(tabId, { ws, term, ro, el, voice, heartbeat, reconnectTimer, closedManually, reconnecting, handleDragOver, handleDrop, handlePaste });
})();
}
function activeVoice() { return voiceTick >= 0 && tabData.get(activeTab)?.voice; }
function toggleFullscreen() {
const elem = document.documentElement;
if (!document.fullscreenElement) {
elem.requestFullscreen().then(() => {
isFullscreen = true;
showActionButtons = true;
}).catch(err => {
console.error('Failed to enter fullscreen:', err);
});
} else {
document.exitFullscreen().then(() => {
isFullscreen = false;
showActionButtons = false;
}).catch(err => {
console.error('Failed to exit fullscreen:', err);
});
}
}
function sendKey(key) {
const currentWs = tabData.get(activeTab)?.ws;
if (currentWs?.readyState === 1) {
currentWs.send(new TextEncoder().encode(key));
}
}
onMount(() => {
syncTheme();
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
const requestedHost = new URLSearchParams(window.location.search).get("host");
if (requestedHost && !tabs.length && hostById(requestedHost)) addTab(requestedHost);
// Listen for fullscreen changes
document.addEventListener('fullscreenchange', () => {
isFullscreen = !!document.fullscreenElement;
showActionButtons = !!document.fullscreenElement;
});
});
onDestroy(() => {
themeObserver.disconnect();
tabData.forEach((d) => {
d.closedManually = true;
if (d.reconnectTimer) clearTimeout(d.reconnectTimer);
d.voice?.destroy();
d.ro?.disconnect();
if (d.heartbeat) clearInterval(d.heartbeat);
d.term?.element?.removeEventListener("dragover", d.handleDragOver);
d.term?.element?.removeEventListener("drop", d.handleDrop);
d.term?.textarea?.removeEventListener("paste", d.handlePaste);
d.ws?.close();
d.term?.dispose();
});
});
</script>
<div class={activeVoice()?.voiceMode ? 'flex flex-col h-[calc(100vh-4rem)] overflow-hidden gap-1' : 'space-y-2'}>
{#if !activeVoice()?.voiceMode}
<h1 class="text-2xl font-bold text-surface-900 dark:text-surface-100 tracking-tight">Terminal</h1>
{/if}
<!-- iPhone Landscape Action Buttons (only in fullscreen) -->
{#if showActionButtons && activeTab}
<!-- Top Left Pocket -->
<div class="actions-top-left">
<button class="action-btn" onclick={() => sendKey('\x1b')} title="ESC">ESC</button>
<button class="action-btn" onclick={() => sendKey('\t')} title="TAB">TAB</button>
</div>
<!-- Bottom Left Pocket -->
<div class="actions-bottom-left">
<button class="action-btn" onclick={() => sendKey('\x01')} title="CTRL+A">^A</button>
<button class="action-btn" onclick={() => sendKey('\x03')} title="CTRL+C">^C</button>
</div>
<!-- Top Right Pocket -->
<div class="actions-top-right">
<button class="action-btn" onclick={() => sendKey('\x0c')} title="CTRL+L">^L</button>
<button class="action-btn" onclick={() => sendKey('\x1a')} title="CTRL+Z">^Z</button>
</div>
<!-- Bottom Right Pocket -->
<div class="actions-bottom-right">
<button class="action-btn" onclick={() => sendKey('\x04')} title="CTRL+D">^D</button>
<button class="action-btn" onclick={() => sendKey('\x12')} title="CTRL+R">^R</button>
</div>
{/if}
<div class="flex items-center gap-1 border-b border-surface-300 dark:border-surface-700 pb-1 relative">
{#each tabs as tab (tab.id)}
<button
class="flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-t-lg transition-colors {activeTab === tab.id ? 'bg-surface-200 dark:bg-surface-800 text-surface-900 dark:text-surface-100' : 'text-surface-600 dark:text-surface-400 hover:text-surface-800 dark:hover:text-surface-200 hover:bg-surface-100 dark:hover:bg-surface-800/50'}"
onclick={() => activeTab = tab.id}
>
{#if tab.connected}
<span class="w-1.5 h-1.5 rounded-full bg-emerald-500"></span>
{:else if tab.error}
<span class="w-1.5 h-1.5 rounded-full bg-rose-500"></span>
{:else}
<span class="w-1.5 h-1.5 rounded-full bg-amber-400 animate-pulse"></span>
{/if}
{tab.label}
{#if tab.persistent}
<span class="rounded bg-indigo-100 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-indigo-700 dark:bg-indigo-500/20 dark:text-indigo-300">tmux</span>
{/if}
<span
class="ml-1 text-surface-500 hover:text-sky-500 cursor-pointer"
role="button"
tabindex="0"
title="Open fresh session in new browser tab"
aria-label={`Open fresh ${tab.label} session in new browser tab`}
onclick={(e) => { e.stopPropagation(); openFreshSession(tab); }}
onkeydown={(e) => e.key === 'Enter' && openFreshSession(tab)}
>↗</span>
<span
class="ml-1 text-surface-500 hover:text-rose-400 cursor-pointer"
role="button"
tabindex="0"
onclick={(e) => { e.stopPropagation(); closeTab(tab.id); }}
onkeydown={(e) => e.key === 'Enter' && closeTab(tab.id)}
>&times;</span>
</button>
{/each}
<div class="relative">
<button
class="px-2 py-1 text-surface-600 dark:text-surface-400 hover:text-surface-900 dark:hover:text-surface-100 hover:bg-surface-200 dark:hover:bg-surface-800 rounded text-lg leading-none"
onclick={() => showMenu = !showMenu}
>+</button>
{#if showMenu}
<div class="absolute top-full left-0 mt-1 bg-surface-100 dark:bg-surface-800 border border-surface-300 dark:border-surface-600 rounded-lg shadow-xl z-10 py-1 min-w-[140px]">
{#each hosts as h}
<button
class="flex w-full items-center justify-between gap-2 px-3 py-1.5 text-left text-sm text-surface-800 dark:text-surface-200 hover:bg-surface-200 dark:hover:bg-surface-700"
onclick={() => addTab(h.id)}
>
<span>{h.label}</span>
{#if h.persistent}
<span class="rounded bg-indigo-100 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-indigo-700 dark:bg-indigo-500/20 dark:text-indigo-300">persistent</span>
{/if}
</button>
{/each}
</div>
{/if}
</div>
{#if activeTab && tabs.find(t => t.id === activeTab)?.connected}
<div class="ml-auto flex items-center gap-1">
<button
class="px-2 py-1 rounded text-sm transition-colors text-surface-600 dark:text-surface-500 hover:text-surface-800 dark:hover:text-surface-300"
title={isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'}
onclick={toggleFullscreen}
>
{#if isFullscreen}
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 3v3a2 2 0 01-2 2H3m18 0h-3a2 2 0 01-2-2V3m0 18v-3a2 2 0 012-2h3M3 16h3a2 2 0 012 2v3"/></svg>
{:else}
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 3H5a2 2 0 00-2 2v3m18 0V5a2 2 0 00-2-2h-3m0 18h3a2 2 0 002-2v-3M3 16v3a2 2 0 002 2h3"/></svg>
{/if}
</button>
{#if activeVoice()}
{@const v = activeVoice()}
<button
class="px-2 py-1 rounded text-sm transition-colors {v.voiceMode ? 'text-rose-400' : v.sttAvailable ? 'text-surface-600 dark:text-surface-500 hover:text-surface-800 dark:hover:text-surface-300' : 'text-surface-300 dark:text-surface-700 cursor-not-allowed'}"
title={!v.sttAvailable ? 'STT not supported' : v.voiceMode ? 'Voice mode on' : 'Enter voice mode'}
disabled={!v.sttAvailable}
onclick={() => { if (!v.voiceMode) v.enterVoiceMode(); else v.exitVoiceMode(); voiceTick++; }}
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 1a3 3 0 00-3 3v8a3 3 0 006 0V4a3 3 0 00-3-3z"/><path d="M19 10v2a7 7 0 01-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>
</button>
{/if}
</div>
{/if}
</div>
<p class="text-xs text-surface-600 dark:text-surface-500">
Tip: drag & drop or paste images into the active terminal tab to upload them to <code class="text-surface-700 dark:text-surface-400">/tmp/claude-images</code>.
</p>
{#if activeTab && tabs.find(t => t.id === activeTab)?.statusBanner}
<div class="rounded-lg border border-indigo-200 bg-indigo-50 px-3 py-2 text-sm text-indigo-800 dark:border-indigo-500/30 dark:bg-indigo-500/10 dark:text-indigo-200">
{tabs.find(t => t.id === activeTab)?.statusBanner}
</div>
{/if}
{#if !tabs.length}
<div class="flex items-center justify-center h-[calc(100vh-12rem)] text-surface-600 dark:text-surface-500 text-sm">
Click + to open a terminal
</div>
{/if}
{#each tabs as tab (tab.id)}
<div class="{activeTab === tab.id ? '' : 'hidden'} {activeVoice()?.voiceMode ? 'flex-1 min-h-0' : ''}">
<div
use:initTerminal={tab}
class="h-full rounded-xl overflow-hidden shadow-lg border border-surface-300 dark:border-surface-700 bg-surface-50 dark:bg-[#0f172a] transition-all duration-300"
style="{activeVoice()?.voiceMode ? '' : 'height: calc(100vh - 10rem);'}"
></div>
</div>
{/each}
{#if activeVoice()?.voiceMode}
{@const v = activeVoice()}
<div class="mt-1 shrink-0 rounded-xl bg-surface-100/95 dark:bg-surface-800/95 border border-surface-300 dark:border-surface-700 shadow-xl overflow-hidden animate-slide-up">
<!-- Header -->
<div class="flex items-center justify-between px-3 py-1.5 border-b border-surface-300/50 dark:border-surface-700/50">
<span class="text-xs font-medium text-surface-700 dark:text-surface-300">Voice Mode</span>
<div class="flex items-center gap-2">
<select
class="bg-surface-200 dark:bg-surface-700 text-surface-800 dark:text-surface-300 text-xs rounded px-1 py-0.5 outline-none max-w-[120px]"
onchange={(e) => { v.setVoice(+e.target.value); voiceTick++; }}
>
{#each v.voices as voice, i}
<option value={i} selected={i === v.voiceIdx}>{voice.name}</option>
{/each}
</select>
<button
class="px-1.5 py-0.5 rounded text-xs font-medium text-surface-600 dark:text-surface-400 hover:text-surface-800 dark:hover:text-surface-200 hover:bg-surface-200 dark:hover:bg-surface-700"
onclick={() => { v.cycleLang(); voiceTick++; }}
>{v.langLabel}</button>
<button
class="px-1.5 py-0.5 rounded text-xs font-medium text-surface-600 dark:text-surface-400 hover:text-surface-800 dark:hover:text-surface-200 hover:bg-surface-200 dark:hover:bg-surface-700"
onclick={() => { v.cycleTalkMode(); voiceTick++; }}
>{v.modeLabel}</button>
<button
class="px-1.5 py-0.5 rounded text-xs text-surface-500 hover:text-rose-400"
onclick={() => { v.exitVoiceMode(); voiceTick++; }}
>&times;</button>
</div>
</div>
<!-- Transcript + Mic -->
<div class="flex items-center gap-3 px-3 py-2">
{#if v.talkMode === "hold"}
<button
aria-label="Hold to talk"
class="w-10 h-10 shrink-0 rounded-full flex items-center justify-center transition-colors {v.listening ? 'bg-rose-500/30 text-rose-400' : 'bg-surface-200 dark:bg-surface-700 text-surface-700 dark:text-surface-300 hover:bg-surface-300 dark:hover:bg-surface-600'}"
onpointerdown={() => { v.startHold(); voiceTick++; }}
onpointerup={() => { v.releaseHold(); voiceTick++; }}
onpointerleave={() => { if (v.listening) { v.releaseHold(); voiceTick++; } }}
>
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 1a3 3 0 00-3 3v8a3 3 0 006 0V4a3 3 0 00-3-3z"/><path d="M19 10v2a7 7 0 01-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>
</button>
{:else if v.talkMode === "tap"}
<button
aria-label="Tap to talk"
class="w-10 h-10 shrink-0 rounded-full flex items-center justify-center transition-colors {v.listening ? 'bg-rose-500/30 text-rose-400' : 'bg-surface-200 dark:bg-surface-700 text-surface-700 dark:text-surface-300 hover:bg-surface-300 dark:hover:bg-surface-600'}"
onclick={() => { v.tapMic(); voiceTick++; }}
>
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 1a3 3 0 00-3 3v8a3 3 0 006 0V4a3 3 0 00-3-3z"/><path d="M19 10v2a7 7 0 01-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>
</button>
{:else}
<div class="w-10 h-10 shrink-0 rounded-full flex items-center justify-center {v.listening ? 'bg-emerald-500/20 text-emerald-400 animate-pulse' : 'bg-surface-200 dark:bg-surface-700 text-surface-500'}">
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 1a3 3 0 00-3 3v8a3 3 0 006 0V4a3 3 0 00-3-3z"/><path d="M19 10v2a7 7 0 01-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/></svg>
</div>
{/if}
<div class="flex-1 min-w-0">
{#if v.transcript}
<p class="text-sm text-surface-600 dark:text-surface-400 italic truncate">"{v.transcript}"</p>
{:else}
<p class="text-xs text-surface-500 dark:text-surface-600">{v.speaking ? 'Speaking...' : v.listening ? 'Listening...' : 'Ready'}</p>
{/if}
</div>
</div>
</div>
{/if}
</div>
<style>
@keyframes slide-up {
from { transform: translateY(100%); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
.animate-slide-up { animation: slide-up 0.25s ease-out; }
/* iPhone 17 Pro Landscape Action Button Zones */
.actions-top-left,
.actions-bottom-left,
.actions-top-right,
.actions-bottom-right {
position: fixed;
display: flex;
flex-direction: column;
gap: 5px;
z-index: 9999;
}
.actions-top-left {
top: 5px;
left: 5px;
width: 52px;
height: 125px;
}
.actions-bottom-left {
bottom: 26px;
left: 5px;
width: 52px;
height: 110px;
}
.actions-top-right {
top: 5px;
right: 5px;
width: 52px;
height: 125px;
}
.actions-bottom-right {
bottom: 26px;
right: 5px;
width: 52px;
height: 110px;
}
.action-btn {
flex: 1;
background: rgba(99, 102, 241, 0.9);
color: white;
border: none;
border-radius: 8px;
font-size: 11px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
backdrop-filter: blur(10px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
display: flex;
align-items: center;
justify-content: center;
}
.action-btn:active {
background: rgba(79, 70, 229, 1);
transform: scale(0.95);
}
/* Terminal container respects safe areas */
.terminal-safe-container {
padding-left: env(safe-area-inset-left);
padding-right: env(safe-area-inset-right);
padding-bottom: env(safe-area-inset-bottom);
box-sizing: border-box;
}
</style>