246 lines
9.1 KiB
TypeScript
246 lines
9.1 KiB
TypeScript
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
import type { ComponentType } from 'react';
|
|
import { AlertTriangle, ArrowLeft, Film, FolderClock, Loader2, RefreshCcw, Sparkles } from 'lucide-react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { Button } from '@/components/ui/button';
|
|
import { cn } from '@/lib/utils';
|
|
import { hostApiFetch } from '@/lib/host-api';
|
|
|
|
const DEFAULT_NIANXX_PLAY_URL = 'http://127.0.0.1:3000';
|
|
|
|
type NianxxPlayRoute = '/' | '/projects' | '/planning';
|
|
type ServiceState = 'checking' | 'starting' | 'running' | 'error';
|
|
|
|
type NianxxPlayServiceStatus = {
|
|
success: boolean;
|
|
running: boolean;
|
|
starting: boolean;
|
|
managed: boolean;
|
|
baseUrl: string;
|
|
port: number;
|
|
projectDir?: string;
|
|
pid?: number;
|
|
error?: string;
|
|
};
|
|
|
|
const NIANXX_PLAY_NAV: Array<{
|
|
path: NianxxPlayRoute;
|
|
labelKey: string;
|
|
icon: ComponentType<{ className?: string }>;
|
|
}> = [
|
|
{ path: '/', labelKey: 'host.nav.create', icon: Film },
|
|
{ path: '/projects', labelKey: 'host.nav.projects', icon: FolderClock },
|
|
{ path: '/planning', labelKey: 'host.nav.planning', icon: Sparkles },
|
|
];
|
|
|
|
function buildEmbeddedSrc(baseUrl: string, route: NianxxPlayRoute, reloadKey: number) {
|
|
try {
|
|
const url = new URL(route, baseUrl);
|
|
url.searchParams.set('zhinianEmbed', '1');
|
|
url.searchParams.set('zhinianHostReload', String(reloadKey));
|
|
return url.toString();
|
|
} catch {
|
|
const normalizedBase = baseUrl.replace(/\/$/, '');
|
|
return `${normalizedBase}${route}?zhinianEmbed=1&zhinianHostReload=${reloadKey}`;
|
|
}
|
|
}
|
|
|
|
export function NianxxPlay() {
|
|
const { t } = useTranslation('appCenter');
|
|
const navigate = useNavigate();
|
|
const [webviewKey, setWebviewKey] = useState(0);
|
|
const [currentRoute, setCurrentRoute] = useState<NianxxPlayRoute>('/');
|
|
const [loadError, setLoadError] = useState<string | null>(null);
|
|
const configuredAppUrl = useMemo(() => (
|
|
import.meta.env.VITE_NIANXX_PLAY_URL?.trim() || ''
|
|
), []);
|
|
const [serviceState, setServiceState] = useState<ServiceState>(configuredAppUrl ? 'running' : 'checking');
|
|
const [serviceError, setServiceError] = useState<string | null>(null);
|
|
const [appUrl, setAppUrl] = useState(configuredAppUrl || DEFAULT_NIANXX_PLAY_URL);
|
|
const initialSrc = useMemo(
|
|
() => buildEmbeddedSrc(appUrl, currentRoute, webviewKey),
|
|
[appUrl, currentRoute, webviewKey],
|
|
);
|
|
|
|
const ensureService = useCallback(async () => {
|
|
if (configuredAppUrl) {
|
|
setAppUrl(configuredAppUrl);
|
|
setServiceState('running');
|
|
setServiceError(null);
|
|
return true;
|
|
}
|
|
|
|
setServiceState('checking');
|
|
setServiceError(null);
|
|
try {
|
|
const status = await hostApiFetch<NianxxPlayServiceStatus>('/api/apps/nianxx-play/status');
|
|
if (status.running) {
|
|
setAppUrl(status.baseUrl);
|
|
setServiceState('running');
|
|
return true;
|
|
}
|
|
|
|
setServiceState('starting');
|
|
const started = await hostApiFetch<NianxxPlayServiceStatus>('/api/apps/nianxx-play/start', {
|
|
method: 'POST',
|
|
});
|
|
setAppUrl(started.baseUrl || DEFAULT_NIANXX_PLAY_URL);
|
|
if (started.running) {
|
|
setServiceState('running');
|
|
setServiceError(null);
|
|
return true;
|
|
}
|
|
setServiceState('error');
|
|
setServiceError(started.error || t('host.serviceFailed'));
|
|
return false;
|
|
} catch (error) {
|
|
setServiceState('error');
|
|
setServiceError(error instanceof Error ? error.message : String(error));
|
|
return false;
|
|
}
|
|
}, [configuredAppUrl, t]);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
void (async () => {
|
|
const ok = await ensureService();
|
|
if (cancelled || !ok) return;
|
|
setWebviewKey((value) => value + 1);
|
|
})();
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [ensureService]);
|
|
|
|
const reloadApp = async () => {
|
|
setLoadError(null);
|
|
const ok = await ensureService();
|
|
if (ok) {
|
|
setWebviewKey((value) => value + 1);
|
|
}
|
|
};
|
|
|
|
const openRoute = (route: NianxxPlayRoute) => {
|
|
setLoadError(null);
|
|
setCurrentRoute(route);
|
|
setWebviewKey((value) => value + 1);
|
|
};
|
|
|
|
return (
|
|
<div data-testid="nianxx-play-page" className="-m-6 flex h-[calc(100vh-2.5rem)] min-h-0 flex-col overflow-hidden bg-white text-slate-950 dark:bg-slate-950 dark:text-slate-50">
|
|
<header className="grid h-14 shrink-0 grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center gap-3 border-b border-slate-200/80 bg-white/70 px-4 backdrop-blur dark:border-white/10 dark:bg-slate-950/80">
|
|
<div className="flex min-w-0 items-center gap-2">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => navigate('/app-center')}
|
|
data-testid="nianxx-play-back"
|
|
className="shrink-0"
|
|
>
|
|
<ArrowLeft className="h-4 w-4" />
|
|
{t('host.back')}
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="icon"
|
|
onClick={reloadApp}
|
|
aria-label={t('host.reload')}
|
|
title={t('host.reload')}
|
|
className="h-9 w-9 shrink-0"
|
|
>
|
|
<RefreshCcw className="h-4 w-4" />
|
|
</Button>
|
|
<div className="min-w-0">
|
|
<h1 className="truncate text-base font-semibold tracking-normal">
|
|
{t('host.title')}
|
|
</h1>
|
|
</div>
|
|
</div>
|
|
<nav className="flex shrink-0 items-center gap-1 justify-self-center rounded-lg border border-slate-200/70 bg-white/60 p-1 dark:border-white/10 dark:bg-white/5" aria-label={t('host.nav.label')}>
|
|
{NIANXX_PLAY_NAV.map((item) => {
|
|
const Icon = item.icon;
|
|
const active = currentRoute === item.path;
|
|
return (
|
|
<button
|
|
key={item.path}
|
|
type="button"
|
|
onClick={() => openRoute(item.path)}
|
|
data-testid={`nianxx-play-nav-${item.path === '/' ? 'create' : item.path.slice(1)}`}
|
|
className={cn(
|
|
'inline-flex h-8 items-center gap-1.5 rounded-md px-3 text-xs font-medium transition-colors',
|
|
active
|
|
? 'bg-white text-[#075985] shadow-sm dark:bg-slate-950 dark:text-blue-200'
|
|
: 'text-slate-600 hover:bg-white/70 hover:text-slate-950 dark:text-slate-300 dark:hover:bg-white/10 dark:hover:text-white',
|
|
)}
|
|
>
|
|
<Icon className="h-3.5 w-3.5" />
|
|
{t(item.labelKey)}
|
|
</button>
|
|
);
|
|
})}
|
|
</nav>
|
|
<div aria-hidden className="min-w-0" />
|
|
</header>
|
|
|
|
<section className="relative min-h-0 flex-1 overflow-hidden bg-white dark:bg-slate-950">
|
|
{serviceState === 'running' && (
|
|
<iframe
|
|
key={webviewKey}
|
|
src={initialSrc}
|
|
title={t('host.title')}
|
|
className="absolute inset-0 h-full w-full border-0 bg-white dark:bg-slate-950"
|
|
onLoad={() => setLoadError(null)}
|
|
onError={() => setLoadError(t('host.loadFailed'))}
|
|
/>
|
|
)}
|
|
{serviceState !== 'running' && (
|
|
<div className="absolute inset-0 flex items-center justify-center bg-white/95 p-6 text-center dark:bg-slate-950/95">
|
|
<div className="max-w-md">
|
|
<div className="mx-auto flex h-11 w-11 items-center justify-center rounded-lg bg-[#EAF5FA] text-[#075985] dark:bg-[#1E3A8A]/30 dark:text-blue-100">
|
|
{serviceState === 'error'
|
|
? <AlertTriangle className="h-5 w-5" />
|
|
: <Loader2 className="h-5 w-5 animate-spin" />}
|
|
</div>
|
|
<div className="mt-4 text-base font-semibold text-slate-950 dark:text-slate-50">
|
|
{serviceState === 'error' ? t('host.serviceFailed') : t('host.serviceStarting')}
|
|
</div>
|
|
<p className="mt-2 text-sm leading-6 text-muted-foreground">
|
|
{serviceError || t('host.serviceStartingDesc')}
|
|
</p>
|
|
<div className="mt-4 flex justify-center gap-2">
|
|
<Button type="button" variant="outline" size="sm" onClick={reloadApp}>
|
|
<RefreshCcw className="h-4 w-4" />
|
|
{t('host.reload')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{serviceState === 'running' && loadError && (
|
|
<div className="absolute inset-0 flex items-center justify-center bg-white/95 p-6 text-center dark:bg-slate-950/95">
|
|
<div className="max-w-md">
|
|
<div className="text-base font-semibold text-slate-950 dark:text-slate-50">
|
|
{t('host.loadFailed')}
|
|
</div>
|
|
<p className="mt-2 text-sm leading-6 text-muted-foreground">
|
|
{loadError}
|
|
</p>
|
|
<div className="mt-4 flex justify-center gap-2">
|
|
<Button type="button" variant="outline" size="sm" onClick={reloadApp}>
|
|
<RefreshCcw className="h-4 w-4" />
|
|
{t('host.reload')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</section>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default NianxxPlay;
|