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:
parent
b88878b171
commit
3b14a7c1d1
@ -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
|
||||||
|
|||||||
@ -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
|
<span
|
||||||
class="text-xs px-1 rounded"
|
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
|
||||||
|
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>
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
285
frontend/composables/useTerminalThemes.ts
Normal file
285
frontend/composables/useTerminalThemes.ts
Normal 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
|
||||||
|
}
|
||||||
@ -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">×</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Debug: inline test modal -->
|
<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>
|
||||||
|
|
||||||
|
<!-- 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;">
|
||||||
|
|||||||
@ -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">
|
||||||
|
<!-- Appearance -->
|
||||||
|
<section>
|
||||||
|
<h3 class="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3">Appearance</h3>
|
||||||
|
<div class="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm text-gray-400 mb-1">Theme</label>
|
<label class="block text-sm text-gray-400 mb-1">App Theme</label>
|
||||||
<select v-model="theme" class="bg-gray-800 text-white px-3 py-2 rounded border border-gray-700">
|
<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="dark">Dark</option>
|
||||||
<option value="light">Light</option>
|
<option value="light">Light</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Terminal -->
|
||||||
|
<section>
|
||||||
|
<h3 class="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-3">Terminal</h3>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<!-- Terminal Theme Picker -->
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm text-gray-400 mb-1">Terminal Font Size</label>
|
<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 class="flex gap-6">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm text-gray-400 mb-1">Font Size</label>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
<input v-model="fontSize" type="number" min="8" max="32"
|
<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-24" />
|
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 ml-2">px</span>
|
<span class="text-xs text-gray-500">px</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm text-gray-400 mb-1">Scrollback Lines</label>
|
<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"
|
<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-32" />
|
class="bg-gray-800 text-white px-3 py-2 rounded border border-gray-700 w-28 text-sm" />
|
||||||
</div>
|
</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>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user