- 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>
129 lines
4.2 KiB
Vue
129 lines
4.2 KiB
Vue
<script setup lang="ts">
|
|
interface Host {
|
|
id: number
|
|
name: string
|
|
hostname: string
|
|
port: number
|
|
protocol: 'ssh' | 'rdp'
|
|
tags: string[]
|
|
notes: string | null
|
|
color: string | null
|
|
lastConnectedAt: string | null
|
|
group: { id: number; name: string } | null
|
|
}
|
|
|
|
const props = defineProps<{
|
|
host: Host
|
|
selected?: boolean
|
|
}>()
|
|
|
|
const emit = defineEmits<{
|
|
(e: 'edit'): void
|
|
(e: 'delete'): void
|
|
(e: 'connect'): void
|
|
}>()
|
|
|
|
function formatLastConnected(ts: string | null): string {
|
|
if (!ts) return 'Never'
|
|
const d = new Date(ts)
|
|
const now = new Date()
|
|
const diffMs = now.getTime() - d.getTime()
|
|
const diffDays = Math.floor(diffMs / 86400000)
|
|
if (diffDays === 0) return 'Today'
|
|
if (diffDays === 1) return 'Yesterday'
|
|
if (diffDays < 30) return `${diffDays}d ago`
|
|
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>
|
|
|
|
<template>
|
|
<div
|
|
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'"
|
|
>
|
|
<!-- Color indicator strip — always visible, defaults to protocol color -->
|
|
<div
|
|
class="absolute top-0 left-0 w-1 h-full rounded-l-lg"
|
|
:style="{ backgroundColor: host.color || defaultStrip(host.protocol) }"
|
|
/>
|
|
|
|
<!-- Header row -->
|
|
<div class="flex items-start justify-between gap-2 pl-2">
|
|
<div class="min-w-0 flex-1">
|
|
<h3 class="font-semibold text-white truncate">{{ host.name }}</h3>
|
|
<p class="text-sm text-gray-500 truncate">{{ host.hostname }}:{{ host.port }}</p>
|
|
</div>
|
|
|
|
<!-- Protocol badge -->
|
|
<span
|
|
class="text-xs font-medium px-2 py-0.5 rounded shrink-0"
|
|
:class="host.protocol === 'rdp'
|
|
? 'bg-purple-900/50 text-purple-300 border border-purple-800'
|
|
: 'bg-wraith-900/50 text-wraith-300 border border-wraith-800'"
|
|
>{{ host.protocol.toUpperCase() }}</span>
|
|
</div>
|
|
|
|
<!-- Group + last connected -->
|
|
<div class="mt-2 pl-2 flex items-center justify-between text-xs">
|
|
<span v-if="host.group" class="truncate text-gray-600">{{ host.group.name }}</span>
|
|
<span v-else class="italic text-gray-600">Ungrouped</span>
|
|
<span :class="recencyClass(host.lastConnectedAt)">{{ formatLastConnected(host.lastConnectedAt) }}</span>
|
|
</div>
|
|
|
|
<!-- Tags -->
|
|
<div v-if="host.tags?.length" class="mt-2 pl-2 flex flex-wrap gap-1">
|
|
<span
|
|
v-for="tag in host.tags"
|
|
:key="tag"
|
|
class="text-xs px-1.5 py-0.5 rounded border"
|
|
:class="tagColor(tag)"
|
|
>{{ tag }}</span>
|
|
</div>
|
|
|
|
<!-- Action buttons (show on hover) -->
|
|
<div
|
|
class="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity flex gap-1"
|
|
@click.stop
|
|
>
|
|
<button
|
|
@click="emit('edit')"
|
|
class="text-xs bg-gray-800 hover:bg-gray-700 text-gray-400 hover:text-white px-2 py-1 rounded"
|
|
>Edit</button>
|
|
<button
|
|
@click="emit('delete')"
|
|
class="text-xs bg-gray-800 hover:bg-red-900 text-gray-400 hover:text-red-300 px-2 py-1 rounded"
|
|
>Delete</button>
|
|
</div>
|
|
</div>
|
|
</template>
|