287 lines
13 KiB
TypeScript
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>
|
|
)
|
|
}
|