feat: Termius-inspired UI — right sidebar, host counts, terminal themes

Left sidebar:
- Groups now show recursive host count badges
- Hosts in tree show up to 3 tags inline

Right sidebar (Host Details panel):
- Click any host card to open details panel on the right
- Shows address, port, protocol, group, credential, tags, color, notes
- Connect, Edit, Delete action buttons at bottom
- Selected card gets ring highlight

Terminal themes (10 prebuilt):
- Wraith (default), Dracula, Nord, Solarized Dark, Monokai, One Dark,
  Gruvbox Dark, Tokyo Night, Catppuccin Mocha, Cyberpunk
- Visual theme picker in Settings with color preview + sample text
- Persisted to /api/settings and localStorage for immediate use
- useTerminal reads theme on terminal creation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell 2026-03-13 10:19:57 -04:00
parent b88878b171
commit 3b14a7c1d1
6 changed files with 578 additions and 55 deletions

View File

@ -14,6 +14,7 @@ interface Host {
const props = defineProps<{ const props = defineProps<{
host: Host host: Host
selected?: boolean
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@ -37,8 +38,8 @@ function formatLastConnected(ts: string | null): string {
<template> <template>
<div <div
class="relative bg-gray-900 border border-gray-800 rounded-lg p-4 hover:border-wraith-700 transition-colors group cursor-pointer" class="relative bg-gray-900 border rounded-lg p-4 hover:border-wraith-700 transition-colors group cursor-pointer"
@click="emit('connect')" :class="selected ? 'border-wraith-600 ring-1 ring-wraith-600/30' : 'border-gray-800'"
> >
<!-- Color indicator strip --> <!-- Color indicator strip -->
<div <div

View File

@ -6,6 +6,7 @@ interface Host {
port: number port: number
protocol: 'ssh' | 'rdp' protocol: 'ssh' | 'rdp'
color: string | null color: string | null
tags?: string[]
} }
interface HostGroup { interface HostGroup {
@ -38,6 +39,15 @@ function toggleGroup(id: number) {
function isExpanded(id: number) { function isExpanded(id: number) {
return expanded.value.has(id) return expanded.value.has(id)
} }
// Recursively count all hosts in a group and its children
function countHosts(group: HostGroup): number {
let count = group.hosts?.length || 0
for (const child of group.children || []) {
count += countHosts(child)
}
return count
}
</script> </script>
<template> <template>
@ -52,6 +62,7 @@ function isExpanded(id: number) {
> >
<span class="text-xs w-3">{{ isExpanded(group.id) ? '▾' : '▸' }}</span> <span class="text-xs w-3">{{ isExpanded(group.id) ? '▾' : '▸' }}</span>
<span class="font-medium truncate flex-1">{{ group.name }}</span> <span class="font-medium truncate flex-1">{{ group.name }}</span>
<span class="text-xs text-gray-600 tabular-nums mr-1">{{ countHosts(group) }}</span>
<button <button
@click.stop="emit('new-host', group.id)" @click.stop="emit('new-host', group.id)"
class="text-xs text-gray-600 hover:text-wraith-400 px-1" class="text-xs text-gray-600 hover:text-wraith-400 px-1"
@ -73,7 +84,7 @@ function isExpanded(id: number) {
<div <div
v-for="host in group.hosts" v-for="host in group.hosts"
:key="host.id" :key="host.id"
class="flex items-center gap-2 px-3 py-1 cursor-pointer hover:bg-gray-800 text-gray-300 hover:text-white" class="flex items-center gap-2 px-3 py-1.5 cursor-pointer hover:bg-gray-800 text-gray-300 hover:text-white group/host"
@click="emit('select-host', host)" @click="emit('select-host', host)"
> >
<span <span
@ -81,9 +92,19 @@ function isExpanded(id: number) {
:style="host.color ? { backgroundColor: host.color } : {}" :style="host.color ? { backgroundColor: host.color } : {}"
:class="!host.color ? 'bg-gray-600' : ''" :class="!host.color ? 'bg-gray-600' : ''"
/> />
<span class="truncate flex-1">{{ host.name }}</span> <div class="min-w-0 flex-1">
<span class="truncate block">{{ host.name }}</span>
<div v-if="host.tags?.length" class="flex gap-1 mt-0.5 flex-wrap">
<span
v-for="tag in host.tags.slice(0, 3)"
:key="tag"
class="text-[10px] leading-tight px-1 py-px rounded bg-gray-800 text-gray-500"
>{{ tag }}</span>
<span v-if="host.tags.length > 3" class="text-[10px] text-gray-600">+{{ host.tags.length - 3 }}</span>
</div>
</div>
<span <span
class="text-xs px-1 rounded" class="text-xs px-1 rounded shrink-0"
:class="host.protocol === 'rdp' ? 'text-purple-400 bg-purple-900/30' : 'text-wraith-400 bg-wraith-900/30'" :class="host.protocol === 'rdp' ? 'text-purple-400 bg-purple-900/30' : 'text-wraith-400 bg-wraith-900/30'"
>{{ host.protocol.toUpperCase() }}</span> >{{ host.protocol.toUpperCase() }}</span>
</div> </div>

View File

@ -5,24 +5,23 @@ import { WebLinksAddon } from '@xterm/addon-web-links'
import { WebglAddon } from '@xterm/addon-webgl' import { WebglAddon } from '@xterm/addon-webgl'
import { useAuthStore } from '~/stores/auth.store' import { useAuthStore } from '~/stores/auth.store'
import { useSessionStore } from '~/stores/session.store' import { useSessionStore } from '~/stores/session.store'
import { getTerminalTheme } from '~/composables/useTerminalThemes'
export function useTerminal() { export function useTerminal() {
const auth = useAuthStore() const auth = useAuthStore()
const sessions = useSessionStore() const sessions = useSessionStore()
let ws: WebSocket | null = null let ws: WebSocket | null = null
function createTerminal(container: HTMLElement, options?: Partial<{ fontSize: number; scrollback: number }>) { function createTerminal(container: HTMLElement, options?: Partial<{ fontSize: number; scrollback: number; themeId: string }>) {
const themeId = options?.themeId || localStorage.getItem('wraith_terminal_theme') || 'wraith'
const theme = getTerminalTheme(themeId)
const term = new Terminal({ const term = new Terminal({
cursorBlink: true, cursorBlink: true,
fontSize: options?.fontSize || 14, fontSize: options?.fontSize || parseInt(localStorage.getItem('wraith_font_size') || '14'),
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace", fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace",
scrollback: options?.scrollback || 10000, scrollback: options?.scrollback || parseInt(localStorage.getItem('wraith_scrollback') || '10000'),
theme: { theme,
background: '#0a0a0f',
foreground: '#e4e4ef',
cursor: '#5c7cfa',
selectionBackground: '#364fc744',
},
}) })
const fitAddon = new FitAddon() const fitAddon = new FitAddon()

View File

@ -0,0 +1,285 @@
import type { ITheme } from '@xterm/xterm'
export interface TerminalTheme {
name: string
id: string
theme: ITheme
}
export const terminalThemes: TerminalTheme[] = [
{
name: 'Wraith',
id: 'wraith',
theme: {
background: '#0a0a0f',
foreground: '#e4e4ef',
cursor: '#5c7cfa',
cursorAccent: '#0a0a0f',
selectionBackground: '#364fc744',
black: '#1a1a2e',
red: '#e94560',
green: '#0cce6b',
yellow: '#ffc145',
blue: '#5c7cfa',
magenta: '#c77dff',
cyan: '#56d4e0',
white: '#e4e4ef',
brightBlack: '#3a3a4e',
brightRed: '#ff6b81',
brightGreen: '#2ee89a',
brightYellow: '#ffd366',
brightBlue: '#7c9cfa',
brightMagenta: '#d9a0ff',
brightCyan: '#7aecf5',
brightWhite: '#ffffff',
},
},
{
name: 'Dracula',
id: 'dracula',
theme: {
background: '#282a36',
foreground: '#f8f8f2',
cursor: '#f8f8f2',
cursorAccent: '#282a36',
selectionBackground: '#44475a',
black: '#21222c',
red: '#ff5555',
green: '#50fa7b',
yellow: '#f1fa8c',
blue: '#bd93f9',
magenta: '#ff79c6',
cyan: '#8be9fd',
white: '#f8f8f2',
brightBlack: '#6272a4',
brightRed: '#ff6e6e',
brightGreen: '#69ff94',
brightYellow: '#ffffa5',
brightBlue: '#d6acff',
brightMagenta: '#ff92df',
brightCyan: '#a4ffff',
brightWhite: '#ffffff',
},
},
{
name: 'Nord',
id: 'nord',
theme: {
background: '#2e3440',
foreground: '#d8dee9',
cursor: '#d8dee9',
cursorAccent: '#2e3440',
selectionBackground: '#434c5e',
black: '#3b4252',
red: '#bf616a',
green: '#a3be8c',
yellow: '#ebcb8b',
blue: '#81a1c1',
magenta: '#b48ead',
cyan: '#88c0d0',
white: '#e5e9f0',
brightBlack: '#4c566a',
brightRed: '#bf616a',
brightGreen: '#a3be8c',
brightYellow: '#ebcb8b',
brightBlue: '#81a1c1',
brightMagenta: '#b48ead',
brightCyan: '#8fbcbb',
brightWhite: '#eceff4',
},
},
{
name: 'Solarized Dark',
id: 'solarized-dark',
theme: {
background: '#002b36',
foreground: '#839496',
cursor: '#839496',
cursorAccent: '#002b36',
selectionBackground: '#073642',
black: '#073642',
red: '#dc322f',
green: '#859900',
yellow: '#b58900',
blue: '#268bd2',
magenta: '#d33682',
cyan: '#2aa198',
white: '#eee8d5',
brightBlack: '#586e75',
brightRed: '#cb4b16',
brightGreen: '#586e75',
brightYellow: '#657b83',
brightBlue: '#839496',
brightMagenta: '#6c71c4',
brightCyan: '#93a1a1',
brightWhite: '#fdf6e3',
},
},
{
name: 'Monokai',
id: 'monokai',
theme: {
background: '#272822',
foreground: '#f8f8f2',
cursor: '#f8f8f0',
cursorAccent: '#272822',
selectionBackground: '#49483e',
black: '#272822',
red: '#f92672',
green: '#a6e22e',
yellow: '#f4bf75',
blue: '#66d9ef',
magenta: '#ae81ff',
cyan: '#a1efe4',
white: '#f8f8f2',
brightBlack: '#75715e',
brightRed: '#f92672',
brightGreen: '#a6e22e',
brightYellow: '#f4bf75',
brightBlue: '#66d9ef',
brightMagenta: '#ae81ff',
brightCyan: '#a1efe4',
brightWhite: '#f9f8f5',
},
},
{
name: 'One Dark',
id: 'one-dark',
theme: {
background: '#282c34',
foreground: '#abb2bf',
cursor: '#528bff',
cursorAccent: '#282c34',
selectionBackground: '#3e4451',
black: '#282c34',
red: '#e06c75',
green: '#98c379',
yellow: '#e5c07b',
blue: '#61afef',
magenta: '#c678dd',
cyan: '#56b6c2',
white: '#abb2bf',
brightBlack: '#5c6370',
brightRed: '#e06c75',
brightGreen: '#98c379',
brightYellow: '#e5c07b',
brightBlue: '#61afef',
brightMagenta: '#c678dd',
brightCyan: '#56b6c2',
brightWhite: '#ffffff',
},
},
{
name: 'Gruvbox Dark',
id: 'gruvbox-dark',
theme: {
background: '#282828',
foreground: '#ebdbb2',
cursor: '#ebdbb2',
cursorAccent: '#282828',
selectionBackground: '#3c3836',
black: '#282828',
red: '#cc241d',
green: '#98971a',
yellow: '#d79921',
blue: '#458588',
magenta: '#b16286',
cyan: '#689d6a',
white: '#a89984',
brightBlack: '#928374',
brightRed: '#fb4934',
brightGreen: '#b8bb26',
brightYellow: '#fabd2f',
brightBlue: '#83a598',
brightMagenta: '#d3869b',
brightCyan: '#8ec07c',
brightWhite: '#ebdbb2',
},
},
{
name: 'Tokyo Night',
id: 'tokyo-night',
theme: {
background: '#1a1b26',
foreground: '#a9b1d6',
cursor: '#c0caf5',
cursorAccent: '#1a1b26',
selectionBackground: '#33467c',
black: '#15161e',
red: '#f7768e',
green: '#9ece6a',
yellow: '#e0af68',
blue: '#7aa2f7',
magenta: '#bb9af7',
cyan: '#7dcfff',
white: '#a9b1d6',
brightBlack: '#414868',
brightRed: '#f7768e',
brightGreen: '#9ece6a',
brightYellow: '#e0af68',
brightBlue: '#7aa2f7',
brightMagenta: '#bb9af7',
brightCyan: '#7dcfff',
brightWhite: '#c0caf5',
},
},
{
name: 'Catppuccin Mocha',
id: 'catppuccin-mocha',
theme: {
background: '#1e1e2e',
foreground: '#cdd6f4',
cursor: '#f5e0dc',
cursorAccent: '#1e1e2e',
selectionBackground: '#45475a',
black: '#45475a',
red: '#f38ba8',
green: '#a6e3a1',
yellow: '#f9e2af',
blue: '#89b4fa',
magenta: '#f5c2e7',
cyan: '#94e2d5',
white: '#bac2de',
brightBlack: '#585b70',
brightRed: '#f38ba8',
brightGreen: '#a6e3a1',
brightYellow: '#f9e2af',
brightBlue: '#89b4fa',
brightMagenta: '#f5c2e7',
brightCyan: '#94e2d5',
brightWhite: '#a6adc8',
},
},
{
name: 'Cyberpunk',
id: 'cyberpunk',
theme: {
background: '#0d0221',
foreground: '#0abdc6',
cursor: '#ff2079',
cursorAccent: '#0d0221',
selectionBackground: '#541388',
black: '#0d0221',
red: '#ff2079',
green: '#0abdc6',
yellow: '#f3e600',
blue: '#711c91',
magenta: '#ea00d9',
cyan: '#0abdc6',
white: '#d7d7d7',
brightBlack: '#541388',
brightRed: '#ff4492',
brightGreen: '#33e1ed',
brightYellow: '#f5ed37',
brightBlue: '#9945b5',
brightMagenta: '#ff00ff',
brightCyan: '#33e1ed',
brightWhite: '#ffffff',
},
},
]
export function getTerminalTheme(id: string): ITheme {
const found = terminalThemes.find(t => t.id === id)
return found?.theme || terminalThemes[0].theme
}

View File

@ -3,6 +3,8 @@ import { useTerminal } from '~/composables/useTerminal'
const connections = useConnectionStore() const connections = useConnectionStore()
const sessions = useSessionStore() const sessions = useSessionStore()
const vault = useVault()
const showHostDialog = ref(false) const showHostDialog = ref(false)
const editingHost = ref<any>(null) const editingHost = ref<any>(null)
const showGroupDialog = ref(false) const showGroupDialog = ref(false)
@ -10,6 +12,11 @@ const searchQuery = ref('')
const showSavePrompt = ref(false) const showSavePrompt = ref(false)
const pendingQuickConnect = ref<any>(null) const pendingQuickConnect = ref<any>(null)
// Right sidebar: selected host details
const selectedHost = ref<any>(null)
const hostCredential = ref<any>(null)
const loadingCredential = ref(false)
// Terminal composable for connect-on-click // Terminal composable for connect-on-click
const { createTerminal, connectToHost } = useTerminal() const { createTerminal, connectToHost } = useTerminal()
@ -27,9 +34,27 @@ function openEditHost(host: any) {
showHostDialog.value = true showHostDialog.value = true
} }
// Select a host show right sidebar details
async function selectHost(host: any) {
selectedHost.value = host
hostCredential.value = null
if (host.credentialId) {
loadingCredential.value = true
try {
const creds = await vault.listCredentials() as any[]
hostCredential.value = creds.find((c: any) => c.id === host.credentialId) || null
} catch { /* ignore */ }
loadingCredential.value = false
}
}
function closeDetails() {
selectedHost.value = null
hostCredential.value = null
}
function connectHost(host: any) { function connectHost(host: any) {
if (host.protocol !== 'ssh') { if (host.protocol !== 'ssh') {
// RDP handled via quick connect for now
return return
} }
const pendingId = `pending-${Date.now()}` const pendingId = `pending-${Date.now()}`
@ -43,7 +68,7 @@ function connectHost(host: any) {
}) })
} }
// Quick connect: launch a temporary session without saving // Quick connect
function handleQuickConnect(params: { hostname: string; port: number; username: string; protocol: 'ssh' | 'rdp' }) { function handleQuickConnect(params: { hostname: string; port: number; username: string; protocol: 'ssh' | 'rdp' }) {
const sessionId = `quick-${Date.now()}` const sessionId = `quick-${Date.now()}`
const displayName = params.username const displayName = params.username
@ -67,7 +92,6 @@ function handleQuickConnect(params: { hostname: string; port: number; username:
showSavePrompt.value = true showSavePrompt.value = true
} }
// Save a quick-connect as a permanent host
function saveQuickConnectHost() { function saveQuickConnectHost() {
if (!pendingQuickConnect.value) return if (!pendingQuickConnect.value) return
const p = pendingQuickConnect.value const p = pendingQuickConnect.value
@ -87,7 +111,7 @@ function dismissSavePrompt() {
pendingQuickConnect.value = null pendingQuickConnect.value = null
} }
// Inline modal state (bypasses component issues) // Inline modal state
const inlineHost = ref({ name: '', hostname: '', port: 22, protocol: 'ssh' as 'ssh' | 'rdp' }) const inlineHost = ref({ name: '', hostname: '', port: 22, protocol: 'ssh' as 'ssh' | 'rdp' })
async function createGroupInline() { async function createGroupInline() {
@ -120,7 +144,7 @@ const filteredHosts = computed(() => {
) )
}) })
// Recent connections hosts sorted by lastConnectedAt, non-null only, top 5 // Recent connections
const recentHosts = computed(() => { const recentHosts = computed(() => {
return [...connections.hosts] return [...connections.hosts]
.filter((h: any) => h.lastConnectedAt) .filter((h: any) => h.lastConnectedAt)
@ -129,6 +153,13 @@ const recentHosts = computed(() => {
) )
.slice(0, 5) .slice(0, 5)
}) })
async function deleteSelectedHost() {
if (!selectedHost.value) return
if (!confirm(`Delete "${selectedHost.value.name}"?`)) return
await connections.deleteHost(selectedHost.value.id)
selectedHost.value = null
}
</script> </script>
<template> <template>
@ -145,7 +176,7 @@ const recentHosts = computed(() => {
</div> </div>
<div class="flex flex-1 overflow-hidden"> <div class="flex flex-1 overflow-hidden">
<!-- Sidebar: Group tree --> <!-- Left Sidebar: Group tree -->
<aside class="w-64 bg-gray-900 border-r border-gray-800 flex flex-col overflow-y-auto shrink-0"> <aside class="w-64 bg-gray-900 border-r border-gray-800 flex flex-col overflow-y-auto shrink-0">
<div class="p-3 flex items-center justify-between"> <div class="p-3 flex items-center justify-between">
<span class="text-sm font-medium text-gray-400">Connections</span> <span class="text-sm font-medium text-gray-400">Connections</span>
@ -154,7 +185,7 @@ const recentHosts = computed(() => {
<button @click="openNewHost()" class="text-xs text-gray-500 hover:text-wraith-400" title="New Host">+ Host</button> <button @click="openNewHost()" class="text-xs text-gray-500 hover:text-wraith-400" title="New Host">+ Host</button>
</div> </div>
</div> </div>
<ConnectionsHostTree :groups="connections.groups" @select-host="openEditHost" @new-host="openNewHost" /> <ConnectionsHostTree :groups="connections.groups" @select-host="selectHost" @new-host="openNewHost" />
</aside> </aside>
<!-- Main: host grid with search + recent --> <!-- Main: host grid with search + recent -->
@ -169,7 +200,7 @@ const recentHosts = computed(() => {
/> />
</div> </div>
<!-- Recent connections (only when not searching) --> <!-- Recent connections -->
<div v-if="!searchQuery && recentHosts.length > 0" class="mb-6"> <div v-if="!searchQuery && recentHosts.length > 0" class="mb-6">
<h3 class="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2">Recent</h3> <h3 class="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2">Recent</h3>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-2"> <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-2">
@ -199,6 +230,8 @@ const recentHosts = computed(() => {
v-for="host in filteredHosts" v-for="host in filteredHosts"
:key="host.id" :key="host.id"
:host="host" :host="host"
:selected="selectedHost?.id === host.id"
@click.stop="selectHost(host)"
@connect="connectHost(host)" @connect="connectHost(host)"
@edit="openEditHost(host)" @edit="openEditHost(host)"
@delete="connections.deleteHost(host.id)" @delete="connections.deleteHost(host.id)"
@ -212,9 +245,121 @@ const recentHosts = computed(() => {
</p> </p>
</div> </div>
</main> </main>
<!-- Right Sidebar: Host Details -->
<aside
v-if="selectedHost"
class="w-80 bg-gray-900 border-l border-gray-800 flex flex-col overflow-y-auto shrink-0"
>
<!-- Header -->
<div class="p-4 border-b border-gray-800 flex items-center justify-between">
<h3 class="text-sm font-semibold text-white">Host Details</h3>
<button @click="closeDetails" class="text-gray-500 hover:text-gray-300 text-lg leading-none">&times;</button>
</div>
<div class="p-4 space-y-5 flex-1">
<!-- Host name + protocol badge -->
<div>
<h4 class="text-lg font-bold text-white">{{ selectedHost.name }}</h4>
<span
class="text-xs font-medium px-2 py-0.5 rounded mt-1 inline-block"
:class="selectedHost.protocol === 'rdp'
? 'bg-purple-900/50 text-purple-300 border border-purple-800'
: 'bg-wraith-900/50 text-wraith-300 border border-wraith-800'"
>{{ selectedHost.protocol.toUpperCase() }}</span>
</div>
<!-- Address -->
<div>
<label class="text-xs text-gray-500 uppercase tracking-wider">Address</label>
<p class="text-sm text-white font-mono mt-0.5">{{ selectedHost.hostname }}</p>
</div>
<!-- Port -->
<div>
<label class="text-xs text-gray-500 uppercase tracking-wider">Port</label>
<p class="text-sm text-white font-mono mt-0.5">{{ selectedHost.port }}</p>
</div>
<!-- Group -->
<div v-if="selectedHost.group">
<label class="text-xs text-gray-500 uppercase tracking-wider">Group</label>
<p class="text-sm text-gray-300 mt-0.5">{{ selectedHost.group.name }}</p>
</div>
<!-- Credential -->
<div>
<label class="text-xs text-gray-500 uppercase tracking-wider">Credential</label>
<div v-if="loadingCredential" class="text-xs text-gray-600 mt-0.5">Loading...</div>
<div v-else-if="hostCredential" class="mt-1 bg-gray-800 rounded p-2 border border-gray-700">
<p class="text-sm text-white">{{ hostCredential.name }}</p>
<p class="text-xs text-gray-400">{{ hostCredential.username || 'No username' }}</p>
<span
class="text-[10px] mt-1 inline-block px-1.5 py-0.5 rounded"
:class="hostCredential.type === 'ssh_key' ? 'bg-purple-900/40 text-purple-300' : 'bg-blue-900/40 text-blue-300'"
>{{ hostCredential.type === 'ssh_key' ? 'SSH Key' : 'Password' }}</span>
</div>
<p v-else class="text-xs text-gray-600 mt-0.5 italic">None assigned</p>
</div>
<!-- Tags -->
<div>
<label class="text-xs text-gray-500 uppercase tracking-wider">Tags</label>
<div v-if="selectedHost.tags?.length" class="flex flex-wrap gap-1 mt-1">
<span
v-for="tag in selectedHost.tags"
:key="tag"
class="text-xs bg-gray-800 text-gray-300 px-2 py-0.5 rounded border border-gray-700"
>{{ tag }}</span>
</div>
<p v-else class="text-xs text-gray-600 mt-0.5 italic">No tags</p>
</div>
<!-- Color -->
<div v-if="selectedHost.color">
<label class="text-xs text-gray-500 uppercase tracking-wider">Color</label>
<div class="flex items-center gap-2 mt-1">
<span class="w-4 h-4 rounded-full border border-gray-600" :style="{ backgroundColor: selectedHost.color }" />
<span class="text-xs text-gray-400 font-mono">{{ selectedHost.color }}</span>
</div>
</div>
<!-- Notes -->
<div v-if="selectedHost.notes">
<label class="text-xs text-gray-500 uppercase tracking-wider">Notes</label>
<p class="text-sm text-gray-400 mt-0.5 whitespace-pre-wrap">{{ selectedHost.notes }}</p>
</div>
<!-- Last Connected -->
<div>
<label class="text-xs text-gray-500 uppercase tracking-wider">Last Connected</label>
<p class="text-xs text-gray-400 mt-0.5">
{{ selectedHost.lastConnectedAt ? new Date(selectedHost.lastConnectedAt).toLocaleString() : 'Never' }}
</p>
</div>
</div>
<!-- Action buttons -->
<div class="p-4 border-t border-gray-800 space-y-2">
<button
@click="connectHost(selectedHost)"
class="w-full py-2 bg-wraith-600 hover:bg-wraith-700 text-white rounded text-sm font-medium"
>Connect</button>
<div class="flex gap-2">
<button
@click="openEditHost(selectedHost)"
class="flex-1 py-1.5 bg-gray-800 hover:bg-gray-700 text-gray-300 hover:text-white rounded text-sm border border-gray-700"
>Edit</button>
<button
@click="deleteSelectedHost"
class="flex-1 py-1.5 bg-gray-800 hover:bg-red-900/50 text-gray-400 hover:text-red-300 rounded text-sm border border-gray-700 hover:border-red-800"
>Delete</button>
</div>
</div>
</aside>
</div> </div>
<!-- Debug: inline test modal --> <!-- Inline modal for New Group / New Host -->
<div v-if="showGroupDialog || showHostDialog" class="fixed inset-0 z-[9999] flex items-center justify-center" style="z-index: 99999;"> <div v-if="showGroupDialog || showHostDialog" class="fixed inset-0 z-[9999] flex items-center justify-center" style="z-index: 99999;">
<div class="absolute inset-0 bg-black bg-opacity-70" @click="showGroupDialog = false; showHostDialog = false" /> <div class="absolute inset-0 bg-black bg-opacity-70" @click="showGroupDialog = false; showHostDialog = false" />
<div class="relative bg-gray-900 border border-gray-700 rounded-lg shadow-2xl p-6 w-96" style="background: #1a1a2e; border: 2px solid #e94560;"> <div class="relative bg-gray-900 border border-gray-700 rounded-lg shadow-2xl p-6 w-96" style="background: #1a1a2e; border: 2px solid #e94560;">

View File

@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { terminalThemes } from '~/composables/useTerminalThemes'
const auth = useAuthStore() const auth = useAuthStore()
const settings = ref<Record<string, string>>({}) const settings = ref<Record<string, string>>({})
const loading = ref(false) const loading = ref(false)
@ -8,8 +10,17 @@ onMounted(async () => {
settings.value = await $fetch('/api/settings', { settings.value = await $fetch('/api/settings', {
headers: { Authorization: `Bearer ${auth.token}` }, headers: { Authorization: `Bearer ${auth.token}` },
}) as Record<string, string> }) as Record<string, string>
// Apply theme on load
applyTheme(settings.value.theme || 'dark') applyTheme(settings.value.theme || 'dark')
// Sync terminal theme from server or fallback to localStorage
if (settings.value.terminalTheme) {
localStorage.setItem('wraith_terminal_theme', settings.value.terminalTheme)
}
if (settings.value.fontSize) {
localStorage.setItem('wraith_font_size', settings.value.fontSize)
}
if (settings.value.scrollbackLines) {
localStorage.setItem('wraith_scrollback', settings.value.scrollbackLines)
}
}) })
async function save() { async function save() {
@ -22,6 +33,10 @@ async function save() {
loading.value = false loading.value = false
saved.value = true saved.value = true
applyTheme(settings.value.theme || 'dark') applyTheme(settings.value.theme || 'dark')
// Persist terminal settings to localStorage for immediate use by useTerminal
localStorage.setItem('wraith_terminal_theme', settings.value.terminalTheme || 'wraith')
localStorage.setItem('wraith_font_size', settings.value.fontSize || '14')
localStorage.setItem('wraith_scrollback', settings.value.scrollbackLines || '10000')
setTimeout(() => (saved.value = false), 2000) setTimeout(() => (saved.value = false), 2000)
} }
@ -37,54 +52,111 @@ function applyTheme(t: string) {
const theme = computed({ const theme = computed({
get: () => settings.value.theme || 'dark', get: () => settings.value.theme || 'dark',
set: (v: string) => { set: (v: string) => { settings.value.theme = v },
settings.value.theme = v })
},
const terminalTheme = computed({
get: () => settings.value.terminalTheme || 'wraith',
set: (v: string) => { settings.value.terminalTheme = v },
}) })
const scrollback = computed({ const scrollback = computed({
get: () => settings.value.scrollbackLines || '10000', get: () => settings.value.scrollbackLines || '10000',
set: (v: string) => { set: (v: string) => { settings.value.scrollbackLines = v },
settings.value.scrollbackLines = v
},
}) })
const fontSize = computed({ const fontSize = computed({
get: () => settings.value.fontSize || '14', get: () => settings.value.fontSize || '14',
set: (v: string) => { set: (v: string) => { settings.value.fontSize = v },
settings.value.fontSize = v
},
}) })
</script> </script>
<template> <template>
<div class="max-w-2xl mx-auto p-6"> <div class="max-w-3xl mx-auto p-6">
<h2 class="text-xl font-bold text-white dark:text-white mb-6">Settings</h2> <h2 class="text-xl font-bold text-white mb-6">Settings</h2>
<div class="space-y-6"> <div class="space-y-8">
<div> <!-- Appearance -->
<label class="block text-sm text-gray-400 mb-1">Theme</label> <section>
<select v-model="theme" class="bg-gray-800 text-white px-3 py-2 rounded border border-gray-700"> <h3 class="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3">Appearance</h3>
<option value="dark">Dark</option> <div class="space-y-4">
<option value="light">Light</option> <div>
</select> <label class="block text-sm text-gray-400 mb-1">App Theme</label>
</div> <select v-model="theme" class="bg-gray-800 text-white px-3 py-2 rounded border border-gray-700 text-sm">
<option value="dark">Dark</option>
<option value="light">Light</option>
</select>
</div>
</div>
</section>
<div> <!-- Terminal -->
<label class="block text-sm text-gray-400 mb-1">Terminal Font Size</label> <section>
<input v-model="fontSize" type="number" min="8" max="32" <h3 class="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3">Terminal</h3>
class="bg-gray-800 text-white px-3 py-2 rounded border border-gray-700 w-24" /> <div class="space-y-4">
<span class="text-xs text-gray-500 ml-2">px</span> <!-- Terminal Theme Picker -->
</div> <div>
<label class="block text-sm text-gray-400 mb-2">Terminal Theme</label>
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-2">
<button
v-for="t in terminalThemes"
:key="t.id"
@click="terminalTheme = t.id"
class="relative rounded-lg border p-2 text-left transition-all cursor-pointer"
:class="terminalTheme === t.id
? 'border-wraith-500 ring-1 ring-wraith-500/30'
: 'border-gray-700 hover:border-gray-600'"
>
<!-- Color preview bar -->
<div
class="h-16 rounded-md mb-1.5 flex items-end p-1.5 gap-px overflow-hidden"
:style="{ backgroundColor: (t.theme.background as string) }"
>
<!-- Sample text -->
<div class="w-full text-[9px] font-mono leading-tight">
<span :style="{ color: (t.theme.green as string) }">user</span><span :style="{ color: (t.theme.foreground as string) }">@</span><span :style="{ color: (t.theme.blue as string) }">host</span><span :style="{ color: (t.theme.foreground as string) }">:~$ </span><span :style="{ color: (t.theme.foreground as string) }">ls -la</span>
<br>
<span :style="{ color: (t.theme.cyan as string) }">drwxr-xr-x</span>
<span :style="{ color: (t.theme.yellow as string) }"> README.md</span>
<br>
<span :style="{ color: (t.theme.red as string) }">error:</span>
<span :style="{ color: (t.theme.foreground as string) }"> not found</span>
</div>
</div>
<!-- Color swatches -->
<div class="flex gap-0.5 mb-1">
<span v-for="c in [t.theme.red, t.theme.green, t.theme.yellow, t.theme.blue, t.theme.magenta, t.theme.cyan]"
:key="(c as string)" class="w-3 h-2 rounded-sm" :style="{ backgroundColor: (c as string) }" />
</div>
<p class="text-xs text-gray-300">{{ t.name }}</p>
<!-- Selected indicator -->
<div v-if="terminalTheme === t.id" class="absolute top-1 right-1 w-2 h-2 rounded-full bg-wraith-500" />
</button>
</div>
<p class="text-xs text-gray-600 mt-2">Theme applies to new terminal sessions after saving.</p>
</div>
<div> <div class="flex gap-6">
<label class="block text-sm text-gray-400 mb-1">Scrollback Lines</label> <div>
<input v-model="scrollback" type="number" min="1000" max="100000" step="1000" <label class="block text-sm text-gray-400 mb-1">Font Size</label>
class="bg-gray-800 text-white px-3 py-2 rounded border border-gray-700 w-32" /> <div class="flex items-center gap-2">
</div> <input v-model="fontSize" type="number" min="8" max="32"
class="bg-gray-800 text-white px-3 py-2 rounded border border-gray-700 w-20 text-sm" />
<span class="text-xs text-gray-500">px</span>
</div>
</div>
<div>
<label class="block text-sm text-gray-400 mb-1">Scrollback Lines</label>
<input v-model="scrollback" type="number" min="1000" max="100000" step="1000"
class="bg-gray-800 text-white px-3 py-2 rounded border border-gray-700 w-28 text-sm" />
</div>
</div>
</div>
</section>
<button @click="save" :disabled="loading" <button @click="save" :disabled="loading"
class="px-4 py-2 bg-wraith-600 hover:bg-wraith-700 text-white rounded disabled:opacity-50"> class="px-5 py-2 bg-wraith-600 hover:bg-wraith-700 text-white rounded text-sm font-medium disabled:opacity-50">
{{ saved ? 'Saved!' : loading ? 'Saving...' : 'Save Settings' }} {{ saved ? 'Saved!' : loading ? 'Saving...' : 'Save Settings' }}
</button> </button>
</div> </div>