feat(ui): add color accents across the connection manager
- Default protocol color strips on all host cards (wraith-blue for SSH, purple for RDP) - Deterministic tag colors from 8-color palette (teal, amber, violet, rose, emerald, sky, orange, indigo) - Last-connected recency coloring (green=today, amber=this week, gray=older) - Section header dots (wraith-400 for Recent, gray for All Hosts) - Active nav link highlighting (wraith-400) - Group headers get subtle wraith-500 left border accent - Tree host dots default to protocol color instead of gray - Fixed rogue modal using hardcoded #1a1a2e/#e94560 — now uses design system - Fixed sky-600 save buttons → wraith-600 for brand consistency - Credential type badges: SSH Key=wraith, Password=amber (was purple/blue) - Colored tags in right sidebar detail panel Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
adbfd854a6
commit
5abbffca9b
@ -34,6 +34,36 @@ function formatLastConnected(ts: string | null): string {
|
|||||||
if (diffDays < 30) return `${diffDays}d ago`
|
if (diffDays < 30) return `${diffDays}d ago`
|
||||||
return d.toLocaleDateString()
|
return d.toLocaleDateString()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function recencyClass(ts: string | null): string {
|
||||||
|
if (!ts) return 'text-gray-600'
|
||||||
|
const diffDays = Math.floor((Date.now() - new Date(ts).getTime()) / 86400000)
|
||||||
|
if (diffDays === 0) return 'text-emerald-400'
|
||||||
|
if (diffDays <= 7) return 'text-amber-400'
|
||||||
|
return 'text-gray-600'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deterministic tag color from string hash
|
||||||
|
const TAG_COLORS = [
|
||||||
|
'bg-teal-900/40 text-teal-300 border-teal-700/50',
|
||||||
|
'bg-amber-900/40 text-amber-300 border-amber-700/50',
|
||||||
|
'bg-violet-900/40 text-violet-300 border-violet-700/50',
|
||||||
|
'bg-rose-900/40 text-rose-300 border-rose-700/50',
|
||||||
|
'bg-emerald-900/40 text-emerald-300 border-emerald-700/50',
|
||||||
|
'bg-sky-900/40 text-sky-300 border-sky-700/50',
|
||||||
|
'bg-orange-900/40 text-orange-300 border-orange-700/50',
|
||||||
|
'bg-indigo-900/40 text-indigo-300 border-indigo-700/50',
|
||||||
|
]
|
||||||
|
|
||||||
|
function tagColor(tag: string): string {
|
||||||
|
let hash = 0
|
||||||
|
for (let i = 0; i < tag.length; i++) hash = ((hash << 5) - hash + tag.charCodeAt(i)) | 0
|
||||||
|
return TAG_COLORS[Math.abs(hash) % TAG_COLORS.length]
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultStrip(protocol: string): string {
|
||||||
|
return protocol === 'rdp' ? '#a855f7' : '#5c7cfa'
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -41,11 +71,10 @@ function formatLastConnected(ts: string | null): string {
|
|||||||
class="relative bg-gray-900 border 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"
|
||||||
:class="selected ? 'border-wraith-600 ring-1 ring-wraith-600/30' : 'border-gray-800'"
|
:class="selected ? 'border-wraith-600 ring-1 ring-wraith-600/30' : 'border-gray-800'"
|
||||||
>
|
>
|
||||||
<!-- Color indicator strip -->
|
<!-- Color indicator strip — always visible, defaults to protocol color -->
|
||||||
<div
|
<div
|
||||||
v-if="host.color"
|
|
||||||
class="absolute top-0 left-0 w-1 h-full rounded-l-lg"
|
class="absolute top-0 left-0 w-1 h-full rounded-l-lg"
|
||||||
:style="{ backgroundColor: host.color }"
|
:style="{ backgroundColor: host.color || defaultStrip(host.protocol) }"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Header row -->
|
<!-- Header row -->
|
||||||
@ -65,10 +94,10 @@ function formatLastConnected(ts: string | null): string {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Group + last connected -->
|
<!-- Group + last connected -->
|
||||||
<div class="mt-2 pl-2 flex items-center justify-between text-xs text-gray-600">
|
<div class="mt-2 pl-2 flex items-center justify-between text-xs">
|
||||||
<span v-if="host.group" class="truncate">{{ host.group.name }}</span>
|
<span v-if="host.group" class="truncate text-gray-600">{{ host.group.name }}</span>
|
||||||
<span v-else class="italic">Ungrouped</span>
|
<span v-else class="italic text-gray-600">Ungrouped</span>
|
||||||
<span>{{ formatLastConnected(host.lastConnectedAt) }}</span>
|
<span :class="recencyClass(host.lastConnectedAt)">{{ formatLastConnected(host.lastConnectedAt) }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tags -->
|
<!-- Tags -->
|
||||||
@ -76,7 +105,8 @@ function formatLastConnected(ts: string | null): string {
|
|||||||
<span
|
<span
|
||||||
v-for="tag in host.tags"
|
v-for="tag in host.tags"
|
||||||
:key="tag"
|
:key="tag"
|
||||||
class="text-xs bg-gray-800 text-gray-400 px-1.5 py-0.5 rounded"
|
class="text-xs px-1.5 py-0.5 rounded border"
|
||||||
|
:class="tagColor(tag)"
|
||||||
>{{ tag }}</span>
|
>{{ tag }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -49,6 +49,24 @@ function countHosts(group: HostGroup): number {
|
|||||||
}
|
}
|
||||||
return count
|
return count
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tag color — same algorithm as HostCard
|
||||||
|
const TAG_COLORS = [
|
||||||
|
'bg-teal-900/40 text-teal-300',
|
||||||
|
'bg-amber-900/40 text-amber-300',
|
||||||
|
'bg-violet-900/40 text-violet-300',
|
||||||
|
'bg-rose-900/40 text-rose-300',
|
||||||
|
'bg-emerald-900/40 text-emerald-300',
|
||||||
|
'bg-sky-900/40 text-sky-300',
|
||||||
|
'bg-orange-900/40 text-orange-300',
|
||||||
|
'bg-indigo-900/40 text-indigo-300',
|
||||||
|
]
|
||||||
|
|
||||||
|
function tagColor(tag: string): string {
|
||||||
|
let hash = 0
|
||||||
|
for (let i = 0; i < tag.length; i++) hash = ((hash << 5) - hash + tag.charCodeAt(i)) | 0
|
||||||
|
return TAG_COLORS[Math.abs(hash) % TAG_COLORS.length]
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -58,7 +76,7 @@ function countHosts(group: HostGroup): number {
|
|||||||
<div>
|
<div>
|
||||||
<!-- Group header -->
|
<!-- Group header -->
|
||||||
<div
|
<div
|
||||||
class="flex items-center gap-1 px-3 py-1.5 cursor-pointer hover:bg-gray-800 text-gray-400 hover:text-gray-200 select-none group/grp"
|
class="flex items-center gap-1 px-3 py-1.5 cursor-pointer hover:bg-gray-800 text-gray-400 hover:text-gray-200 select-none group/grp border-l-2 border-wraith-500/30"
|
||||||
@click="toggleGroup(group.id)"
|
@click="toggleGroup(group.id)"
|
||||||
>
|
>
|
||||||
<span class="text-xs w-3">{{ isExpanded(group.id) ? '▾' : '▸' }}</span>
|
<span class="text-xs w-3">{{ isExpanded(group.id) ? '▾' : '▸' }}</span>
|
||||||
@ -96,8 +114,7 @@ function countHosts(group: HostGroup): number {
|
|||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class="w-2 h-2 rounded-full shrink-0"
|
class="w-2 h-2 rounded-full shrink-0"
|
||||||
:style="host.color ? { backgroundColor: host.color } : {}"
|
:style="{ backgroundColor: host.color || (host.protocol === 'rdp' ? '#a855f7' : '#5c7cfa') }"
|
||||||
:class="!host.color ? 'bg-gray-600' : ''"
|
|
||||||
/>
|
/>
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<span class="truncate block">{{ host.name }}</span>
|
<span class="truncate block">{{ host.name }}</span>
|
||||||
@ -105,7 +122,8 @@ function countHosts(group: HostGroup): number {
|
|||||||
<span
|
<span
|
||||||
v-for="tag in host.tags.slice(0, 3)"
|
v-for="tag in host.tags.slice(0, 3)"
|
||||||
:key="tag"
|
:key="tag"
|
||||||
class="text-[10px] leading-tight px-1 py-px rounded bg-gray-800 text-gray-500"
|
class="text-[10px] leading-tight px-1 py-px rounded"
|
||||||
|
:class="tagColor(tag)"
|
||||||
>{{ tag }}</span>
|
>{{ tag }}</span>
|
||||||
<span v-if="host.tags.length > 3" class="text-[10px] text-gray-600">+{{ host.tags.length - 3 }}</span>
|
<span v-if="host.tags.length > 3" class="text-[10px] text-gray-600">+{{ host.tags.length - 3 }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -67,10 +67,10 @@ const fontSize = computed({
|
|||||||
<img src="/wraith-logo.png" alt="Wraith" class="h-8" />
|
<img src="/wraith-logo.png" alt="Wraith" class="h-8" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<NuxtLink to="/" class="text-sm text-gray-400 hover:text-white" @click="sessions.goHome()">Home</NuxtLink>
|
<NuxtLink to="/" class="text-sm hover:text-white" :class="$route.path === '/' ? 'text-wraith-400' : 'text-gray-400'" @click="sessions.goHome()">Home</NuxtLink>
|
||||||
<NuxtLink to="/vault" class="text-sm text-gray-400 hover:text-white" @click="sessions.goHome()">Vault</NuxtLink>
|
<NuxtLink to="/vault" class="text-sm hover:text-white" :class="$route.path.startsWith('/vault') ? 'text-wraith-400' : 'text-gray-400'" @click="sessions.goHome()">Vault</NuxtLink>
|
||||||
<NuxtLink v-if="auth.isAdmin" to="/admin/users" class="text-sm text-gray-400 hover:text-white" @click="sessions.goHome()">Users</NuxtLink>
|
<NuxtLink v-if="auth.isAdmin" to="/admin/users" class="text-sm hover:text-white" :class="$route.path.startsWith('/admin') ? 'text-wraith-400' : 'text-gray-400'" @click="sessions.goHome()">Users</NuxtLink>
|
||||||
<NuxtLink to="/profile" class="text-sm text-gray-400 hover:text-white" @click="sessions.goHome()">Profile</NuxtLink>
|
<NuxtLink to="/profile" class="text-sm hover:text-white" :class="$route.path === '/profile' ? 'text-wraith-400' : 'text-gray-400'" @click="sessions.goHome()">Profile</NuxtLink>
|
||||||
<button @click="showSettings = !showSettings" class="text-sm text-gray-400 hover:text-white" :class="showSettings ? 'text-white' : ''">Settings</button>
|
<button @click="showSettings = !showSettings" class="text-sm text-gray-400 hover:text-white" :class="showSettings ? 'text-white' : ''">Settings</button>
|
||||||
<button @click="auth.logout()" class="text-sm text-gray-500 hover:text-red-400">Logout</button>
|
<button @click="auth.logout()" class="text-sm text-gray-500 hover:text-red-400">Logout</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -24,6 +24,23 @@ const allSshKeys = ref<any[]>([])
|
|||||||
// Terminal composable for connect-on-click
|
// Terminal composable for connect-on-click
|
||||||
const { createTerminal, connectToHost } = useTerminal()
|
const { createTerminal, connectToHost } = useTerminal()
|
||||||
|
|
||||||
|
// Deterministic tag color from string hash
|
||||||
|
const TAG_COLORS = [
|
||||||
|
'bg-teal-900/40 text-teal-300 border-teal-700/50',
|
||||||
|
'bg-amber-900/40 text-amber-300 border-amber-700/50',
|
||||||
|
'bg-violet-900/40 text-violet-300 border-violet-700/50',
|
||||||
|
'bg-rose-900/40 text-rose-300 border-rose-700/50',
|
||||||
|
'bg-emerald-900/40 text-emerald-300 border-emerald-700/50',
|
||||||
|
'bg-sky-900/40 text-sky-300 border-sky-700/50',
|
||||||
|
'bg-orange-900/40 text-orange-300 border-orange-700/50',
|
||||||
|
'bg-indigo-900/40 text-indigo-300 border-indigo-700/50',
|
||||||
|
]
|
||||||
|
function tagColor(tag: string): string {
|
||||||
|
let hash = 0
|
||||||
|
for (let i = 0; i < tag.length; i++) hash = ((hash << 5) - hash + tag.charCodeAt(i)) | 0
|
||||||
|
return TAG_COLORS[Math.abs(hash) % TAG_COLORS.length]
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await Promise.all([connections.fetchHosts(), connections.fetchTree()])
|
await Promise.all([connections.fetchHosts(), connections.fetchTree()])
|
||||||
})
|
})
|
||||||
@ -259,7 +276,7 @@ async function deleteSelectedHost() {
|
|||||||
|
|
||||||
<!-- Recent connections -->
|
<!-- 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 flex items-center gap-1.5"><span class="w-1.5 h-1.5 rounded-full bg-wraith-400" />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">
|
||||||
<button
|
<button
|
||||||
v-for="host in recentHosts"
|
v-for="host in recentHosts"
|
||||||
@ -279,8 +296,8 @@ async function deleteSelectedHost() {
|
|||||||
<!-- All hosts grid -->
|
<!-- All hosts grid -->
|
||||||
<div>
|
<div>
|
||||||
<h3 v-if="!searchQuery && recentHosts.length > 0"
|
<h3 v-if="!searchQuery && recentHosts.length > 0"
|
||||||
class="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2">
|
class="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2 flex items-center gap-1.5">
|
||||||
All Hosts
|
<span class="w-1.5 h-1.5 rounded-full bg-gray-500" />All Hosts
|
||||||
</h3>
|
</h3>
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||||
<ConnectionsHostCard
|
<ConnectionsHostCard
|
||||||
@ -352,8 +369,8 @@ async function deleteSelectedHost() {
|
|||||||
<p class="text-sm text-white">{{ hostCredential.name }}</p>
|
<p class="text-sm text-white">{{ hostCredential.name }}</p>
|
||||||
<p class="text-xs text-gray-400">{{ hostCredential.username || 'No username' }}</p>
|
<p class="text-xs text-gray-400">{{ hostCredential.username || 'No username' }}</p>
|
||||||
<span
|
<span
|
||||||
class="text-[10px] mt-1 inline-block px-1.5 py-0.5 rounded"
|
class="text-[10px] mt-1 inline-block px-1.5 py-0.5 rounded border"
|
||||||
:class="hostCredential.type === 'ssh_key' ? 'bg-purple-900/40 text-purple-300' : 'bg-blue-900/40 text-blue-300'"
|
:class="hostCredential.type === 'ssh_key' ? 'bg-wraith-900/40 text-wraith-300 border-wraith-700/50' : 'bg-amber-900/40 text-amber-300 border-amber-700/50'"
|
||||||
>{{ hostCredential.type === 'ssh_key' ? 'SSH Key' : 'Password' }}</span>
|
>{{ hostCredential.type === 'ssh_key' ? 'SSH Key' : 'Password' }}</span>
|
||||||
</div>
|
</div>
|
||||||
<p v-else class="text-xs text-gray-600 mt-0.5 italic">None assigned</p>
|
<p v-else class="text-xs text-gray-600 mt-0.5 italic">None assigned</p>
|
||||||
@ -366,7 +383,8 @@ async function deleteSelectedHost() {
|
|||||||
<span
|
<span
|
||||||
v-for="tag in selectedHost.tags"
|
v-for="tag in selectedHost.tags"
|
||||||
:key="tag"
|
:key="tag"
|
||||||
class="text-xs bg-gray-800 text-gray-300 px-2 py-0.5 rounded border border-gray-700"
|
class="text-xs px-2 py-0.5 rounded border"
|
||||||
|
:class="tagColor(tag)"
|
||||||
>{{ tag }}</span>
|
>{{ tag }}</span>
|
||||||
</div>
|
</div>
|
||||||
<p v-else class="text-xs text-gray-600 mt-0.5 italic">No tags</p>
|
<p v-else class="text-xs text-gray-600 mt-0.5 italic">No tags</p>
|
||||||
@ -419,7 +437,7 @@ async function deleteSelectedHost() {
|
|||||||
<!-- Inline modal for New Group / New Host -->
|
<!-- 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-wraith-600 rounded-lg shadow-2xl p-6 w-96">
|
||||||
<h3 class="text-lg font-bold text-white mb-4">{{ showGroupDialog ? 'New Group' : (editingHost?.id ? 'Edit Host' : 'New Host') }}</h3>
|
<h3 class="text-lg font-bold text-white mb-4">{{ showGroupDialog ? 'New Group' : (editingHost?.id ? 'Edit Host' : 'New Host') }}</h3>
|
||||||
<div v-if="showGroupDialog" class="space-y-3">
|
<div v-if="showGroupDialog" class="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
@ -429,7 +447,7 @@ async function deleteSelectedHost() {
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end gap-2 pt-2">
|
<div class="flex justify-end gap-2 pt-2">
|
||||||
<button @click="showGroupDialog = false" class="px-4 py-2 text-sm text-gray-400 hover:text-white">Cancel</button>
|
<button @click="showGroupDialog = false" class="px-4 py-2 text-sm text-gray-400 hover:text-white">Cancel</button>
|
||||||
<button @click="createGroupInline()" class="px-4 py-2 text-sm bg-sky-600 hover:bg-sky-700 text-white rounded">Save</button>
|
<button @click="createGroupInline()" class="px-4 py-2 text-sm bg-wraith-600 hover:bg-wraith-700 text-white rounded">Save</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="space-y-3">
|
<div v-else class="space-y-3">
|
||||||
@ -481,7 +499,7 @@ async function deleteSelectedHost() {
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end gap-2 pt-2">
|
<div class="flex justify-end gap-2 pt-2">
|
||||||
<button @click="showHostDialog = false" class="px-4 py-2 text-sm text-gray-400 hover:text-white">Cancel</button>
|
<button @click="showHostDialog = false" class="px-4 py-2 text-sm text-gray-400 hover:text-white">Cancel</button>
|
||||||
<button @click="saveHostInline()" class="px-4 py-2 text-sm bg-sky-600 hover:bg-sky-700 text-white rounded">{{ editingHost?.id ? 'Update' : 'Save' }}</button>
|
<button @click="saveHostInline()" class="px-4 py-2 text-sm bg-wraith-600 hover:bg-wraith-700 text-white rounded">{{ editingHost?.id ? 'Update' : 'Save' }}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user