Files
opsxshell/src/components/sessions/WelcomeView.tsx
T

287 lines
13 KiB
TypeScript

import { Plus, LogIn, Clock, Server, ChevronRight, Laptop, Terminal, Monitor, Cast } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import HostDistroIcon from '../common/HostDistroIcon'
import { useEffect, useRef, useState, useCallback, useMemo } from 'react'
import { useSessionStore } from '../../stores'
import { useTabStore } from '../../stores/tabStore'
import { useEntitlementStore } from '../../stores/entitlementStore'
import EmptyState from '../common/EmptyState'
import { openLocalShellTab, type ShellOption } from '../layout/sidebar/LocalShellMenu'
import appIconUrl from '../../assets/opsxshell-app-icon.png'
import { shellDisplayFromPath } from '../../utils/shellName'
import { fallbackLocalShells, modKeyLabel } from '../../utils/platformShells'
import { buildOpenTabPayload, defaultPortForProtocol, sessionProtocol } from '../../utils/sessionProtocol'
interface WelcomeViewProps {
onNewSession?: () => void
onQuickConnect?: () => void
onRdpConnect?: () => void
onVncConnect?: () => void
onImportSessions?: () => void
}
function Kbd({ children }: { children: string }) {
return (
<kbd className="border-dark-600/80 bg-dark-900/80 rounded border px-1.5 py-0.5 font-mono text-[10px] font-medium text-txt-faint">
{children}
</kbd>
)
}
export default function WelcomeView({
onNewSession,
onQuickConnect,
onRdpConnect,
onVncConnect,
onImportSessions,
}: WelcomeViewProps) {
const { t } = useTranslation('sessions')
const { addTab } = useTabStore()
const requirePro = useEntitlementStore((s) => s.requirePro)
const sessions = useSessionStore((s) => s.sessions)
const recentSessions = sessions.slice(-8).reverse()
const modKey = useMemo(() => modKeyLabel(), [])
const [shells, setShells] = useState<ShellOption[]>([])
const [shellMenuOpen, setShellMenuOpen] = useState(false)
const shellMenuRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const fallback = () => setShells(fallbackLocalShells() as ShellOption[])
void window.opsxApi
?.localListShells?.()
.then((res) => {
if (res?.success && res.shells?.length) setShells(res.shells)
else fallback()
})
.catch(fallback)
}, [])
useEffect(() => {
if (!shellMenuOpen) return
const onDoc = (e: MouseEvent) => {
if (shellMenuRef.current && !shellMenuRef.current.contains(e.target as Node)) {
setShellMenuOpen(false)
}
}
document.addEventListener('mousedown', onDoc)
return () => document.removeEventListener('mousedown', onDoc)
}, [shellMenuOpen])
const defaultShell = shells.find((s) => s.isDefault) || shells[0]
const defaultShellLabel = defaultShell ? shellDisplayFromPath(defaultShell.path || defaultShell.name).title : 'Shell'
const openDefaultLocal = useCallback(() => {
openLocalShellTab(
defaultShell ? { path: defaultShell.path, kind: defaultShell.kind, name: defaultShell.name } : undefined
)
}, [defaultShell])
const handleSessionClick = (session: (typeof sessions)[number]) => {
const protocol = session.protocol ?? 'ssh'
if ((protocol === 'rdp' || protocol === 'vnc') && !requirePro(protocol)) return
addTab(buildOpenTabPayload(session))
}
return (
<div className="flex h-full flex-col overflow-y-auto bg-dark-900">
<div className="mx-auto flex w-full max-w-3xl flex-1 flex-col px-8 py-12 sm:py-16">
<header className="mb-10 flex items-center gap-3.5">
<img
src={appIconUrl}
alt=""
width={40}
height={40}
className="h-10 w-10 flex-shrink-0 rounded-xl object-cover"
draggable={false}
/>
<div className="min-w-0">
<h1 className="text-lg font-semibold tracking-tight text-content-primary">OpsXShell</h1>
<p className="text-xs text-txt-muted">{t('welcome.tagline')}</p>
</div>
</header>
<section aria-label={t('welcome.sectionStart')} className="mb-12">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<button
type="button"
onClick={() => onQuickConnect?.()}
className="border-dark-700/60 bg-dark-800/50 group flex flex-col gap-4 rounded-xl border p-4 text-left transition-colors hover:border-accent-blue/35 hover:bg-dark-800"
>
<div className="flex items-center justify-between">
<span className="flex h-9 w-9 items-center justify-center rounded-lg bg-accent-blue/15 text-accent-blue">
<LogIn className="h-4 w-4" strokeWidth={2} />
</span>
<Kbd>{`${modKey}K`}</Kbd>
</div>
<div>
<div className="text-sm font-medium text-content-primary">{t('welcome.quickConnect.title')}</div>
<div className="mt-1 text-xs leading-relaxed text-txt-muted">{t('welcome.quickConnect.desc')}</div>
</div>
</button>
<button
type="button"
onClick={() => onNewSession?.()}
className="border-dark-700/60 bg-dark-800/50 group flex flex-col gap-4 rounded-xl border p-4 text-left transition-colors hover:border-accent-green/35 hover:bg-dark-800"
>
<div className="flex items-center justify-between">
<span className="flex h-9 w-9 items-center justify-center rounded-lg bg-accent-green/15 text-accent-green">
<Plus className="h-4 w-4" strokeWidth={2} />
</span>
<Kbd>{`${modKey}N`}</Kbd>
</div>
<div>
<div className="text-sm font-medium text-content-primary">{t('welcome.newSession.title')}</div>
<div className="mt-1 text-xs leading-relaxed text-txt-muted">{t('welcome.newSession.desc')}</div>
</div>
</button>
<div ref={shellMenuRef} className="relative">
<div className="border-dark-700/60 bg-dark-800/50 flex h-full overflow-hidden rounded-xl border transition-colors hover:border-dark-600 hover:bg-dark-800">
<button
type="button"
onClick={openDefaultLocal}
className="flex min-w-0 flex-1 flex-col gap-4 p-4 text-left"
>
<div className="flex items-center justify-between">
<span className="flex h-9 w-9 items-center justify-center rounded-lg bg-dark-700 text-content-secondary">
<Laptop className="h-4 w-4" strokeWidth={2} />
</span>
<Terminal className="h-3.5 w-3.5 text-txt-faint" />
</div>
<div>
<div className="text-sm font-medium text-content-primary">{t('welcome.localShell.title')}</div>
<div className="mt-1 truncate text-xs leading-relaxed text-txt-muted">{defaultShellLabel}</div>
</div>
</button>
<button
type="button"
aria-label={t('welcome.localShell.selectShell')}
aria-expanded={shellMenuOpen}
onClick={() => setShellMenuOpen((v) => !v)}
className="border-dark-700/60 hover:bg-dark-700/40 flex w-9 flex-shrink-0 items-center justify-center border-l text-txt-faint transition-colors hover:text-content-secondary"
>
<ChevronRight className={`h-4 w-4 transition-transform ${shellMenuOpen ? 'rotate-90' : ''}`} />
</button>
</div>
{shellMenuOpen && shells.length > 0 && (
<div className="border-dark-600/80 absolute left-0 right-0 top-[calc(100%+6px)] z-50 overflow-hidden rounded-xl border bg-dark-800 py-1 shadow-xl">
{shells.map((s) => (
<button
key={s.path}
type="button"
onClick={() => {
openLocalShellTab({ path: s.path, kind: s.kind, name: s.name })
setShellMenuOpen(false)
}}
className="hover:bg-dark-700/70 flex w-full items-center gap-2.5 px-3 py-2 text-left transition-colors"
>
<span className="w-10 font-mono text-[10px] uppercase text-txt-faint">{s.kind}</span>
<span className="flex-1 text-sm text-content-primary">{s.name}</span>
{s.isDefault && (
<span className="text-[10px] text-txt-faint">{t('welcome.localShell.default')}</span>
)}
</button>
))}
</div>
)}
</div>
</div>
</section>
<section aria-label={t('welcome.sectionDesktop')} className="mb-10">
<div className="mb-3 flex items-center gap-2">
<h2 className="text-[11px] font-semibold uppercase tracking-wider text-txt-faint">
{t('welcome.sectionDesktop')}
</h2>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<button
type="button"
onClick={() => {
if (!requirePro('rdp')) return
onRdpConnect?.()
}}
className="border-dark-700/50 bg-dark-800/30 flex items-center gap-3 rounded-xl border px-4 py-3 text-left transition-colors hover:border-accent-blue/40"
>
<span className="flex h-9 w-9 items-center justify-center rounded-lg bg-accent-blue/15 text-accent-blue">
<Monitor className="h-4 w-4" />
</span>
<div className="min-w-0">
<div className="text-sm font-medium text-content-primary">{t('welcome.rdp.title')}</div>
<div className="text-[11px] text-txt-faint">{t('welcome.rdp.desc')}</div>
</div>
</button>
<button
type="button"
onClick={() => {
if (!requirePro('vnc')) return
onVncConnect?.()
}}
className="border-dark-700/50 bg-dark-800/30 flex items-center gap-3 rounded-xl border px-4 py-3 text-left transition-colors hover:border-accent-blue/40"
>
<span className="flex h-9 w-9 items-center justify-center rounded-lg bg-accent-blue/15 text-accent-blue">
<Cast className="h-4 w-4" />
</span>
<div className="min-w-0">
<div className="text-sm font-medium text-content-primary">{t('welcome.vnc.title')}</div>
<div className="text-[11px] text-txt-faint">{t('welcome.vnc.desc')}</div>
</div>
</button>
</div>
</section>
<section aria-label={t('welcome.sectionRecent')} className="min-h-0 flex-1">
<div className="mb-3 flex items-center gap-2">
<Clock className="h-3.5 w-3.5 text-txt-faint" />
<h2 className="text-[11px] font-semibold uppercase tracking-wider text-txt-faint">
{t('welcome.sectionRecent')}
</h2>
{recentSessions.length > 0 && <span className="text-[11px] text-txt-dim">{recentSessions.length}</span>}
</div>
{recentSessions.length === 0 ? (
<div className="border-dark-700/70 rounded-xl border border-dashed px-4 py-10">
<EmptyState
icon={Server}
title={t('welcome.recentEmpty.title')}
description={t('welcome.recentEmpty.desc')}
action={onImportSessions ? { label: t('welcome.import.title'), onClick: onImportSessions } : undefined}
/>
</div>
) : (
<ul className="divide-dark-700/40 border-dark-700/50 divide-y overflow-hidden rounded-xl border">
{recentSessions.map((session) => (
<li key={session.id}>
<button
type="button"
onClick={() => handleSessionClick(session)}
className="hover:bg-dark-800/80 group flex w-full items-center gap-3 px-3.5 py-2.5 text-left transition-colors"
>
<span className="flex h-8 w-8 flex-shrink-0 items-center justify-center">
<HostDistroIcon distro={session.distro} protocol={session.protocol} size="md" />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm text-content-primary">{session.name}</span>
<span className="block truncate font-mono text-[11px] text-txt-faint">
{session.username}@{session.host}:
{session.port || defaultPortForProtocol(sessionProtocol(session))}
</span>
</span>
{session.group ? (
<span className="hidden flex-shrink-0 text-[11px] text-txt-dim sm:inline">{session.group}</span>
) : null}
<ChevronRight className="h-3.5 w-3.5 flex-shrink-0 text-txt-dim opacity-0 transition-opacity group-hover:opacity-100" />
</button>
</li>
))}
</ul>
)}
</section>
</div>
</div>
)
}