Files
ai-rpg/frontend/src/pages/PlayPage.tsx

234 lines
8.9 KiB
TypeScript
Raw Normal View History

2026-06-20 19:13:05 +03:00
import { useEffect, useState } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useSessionStore } from "@/stores/sessionStore";
import { useToastStore } from "@/stores/toastStore";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { Spinner } from "@/components/ui/Spinner";
import { ChatView } from "@/components/sessions/ChatView";
import { ActionInput } from "@/components/sessions/ActionInput";
import { SseStatus } from "@/components/sessions/SseStatus";
import type { Environment, PlotRail } from "@/types";
export function PlayPage() {
const { id } = useParams<{ id: string }>();
const { t } = useTranslation();
const navigate = useNavigate();
const pushToast = useToastStore((s) => s.push);
const world = useSessionStore((s) => s.world);
const environment = useSessionStore((s) => s.environment);
const nextActions = useSessionStore((s) => s.nextActions);
const loading = useSessionStore((s) => s.loading);
const error = useSessionStore((s) => s.error);
const submitting = useSessionStore((s) => s.submitting);
const sseStatus = useSessionStore((s) => s.sseStatus);
const fetchState = useSessionStore((s) => s.fetchState);
const sendAction = useSessionStore((s) => s.sendAction);
const retry = useSessionStore((s) => s.retry);
const rollback = useSessionStore((s) => s.rollback);
const reset = useSessionStore((s) => s.reset);
const [rollbackOpen, setRollbackOpen] = useState(false);
useEffect(() => {
if (!id) return;
void fetchState(id).catch(() => pushToast("error", t("play.load_failed")));
return () => {
reset();
};
}, [id, fetchState, reset, pushToast, t]);
if (!id) return null;
if (loading && !world) {
return (
<div className="flex items-center gap-2 p-8 text-sm text-fg-muted">
<Spinner /> {t("common.loading")}
</div>
);
}
if (error && !world) {
return (
<div className="p-4">
<p className="text-sm text-err">{t("play.load_failed")}: {error}</p>
<Button className="mt-3" variant="secondary" onClick={() => navigate("/worlds")}>
{t("common.back")}
</Button>
</div>
);
}
if (!world) return null;
const player = environment?.player;
const plotRails: PlotRail[] = Array.isArray(world.plot_rails) ? world.plot_rails : [];
const handleSend = (action: string) => {
void sendAction(id, action, "manual").catch((err) => {
pushToast("error", err instanceof Error ? err.message : "Failed");
});
};
const handleSuggested = (action: string) => {
void sendAction(id, action, "suggested").catch((err) => {
pushToast("error", err instanceof Error ? err.message : "Failed");
});
};
const handleRetry = () => {
void retry(id).catch((err) => {
pushToast("error", err instanceof Error ? err.message : "Failed");
});
};
const handleRollback = async () => {
setRollbackOpen(false);
try {
await rollback(id);
pushToast("success", t("play.rolled_back"));
} catch (err) {
pushToast("error", err instanceof Error ? err.message : "Failed");
}
};
return (
<div className="mx-auto flex h-[calc(100vh-3.5rem)] max-w-7xl flex-col lg:flex-row gap-3 p-3">
{/* Environment panel */}
<aside className="order-2 lg:order-1 lg:w-72 lg:shrink-0 overflow-y-auto space-y-3">
<Card title={t("play.environment")}>
<dl className="space-y-1 text-sm">
<EnvRow label={t("play.location")} value={environment?.location} />
<EnvRow label={t("play.time_of_day")} value={environment?.time_of_day} />
<EnvRow label={t("play.weather")} value={environment?.weather} />
</dl>
</Card>
{player && (
<Card title={t("play.player")}>
<dl className="space-y-1 text-sm">
<EnvRow label={t("common.name")} value={player.name} />
{typeof player.hp === "number" && (
<EnvRow
label={t("play.hp")}
value={player.max_hp != null ? `${player.hp} / ${player.max_hp}` : String(player.hp)}
/>
)}
{typeof player.level === "number" && (
<EnvRow label={t("play.level")} value={String(player.level)} />
)}
{Array.isArray(player.conditions) && player.conditions.length > 0 && (
<div>
<p className="text-xs uppercase text-fg-dim">{t("play.conditions")}</p>
<div className="mt-1 flex flex-wrap gap-1">
{player.conditions.map((c, i) => (
<span key={i} className="badge bg-warn/15 text-warn">{c}</span>
))}
</div>
</div>
)}
{Array.isArray(player.inventory) && player.inventory.length > 0 && (
<div>
<p className="text-xs uppercase text-fg-dim">{t("play.inventory")}</p>
<ul className="mt-1 list-disc pl-5 text-fg-muted">
{player.inventory.slice(0, 8).map((it, i) => (
<li key={i}>{typeof it === "string" ? it : (it as { name?: string }).name || JSON.stringify(it)}</li>
))}
{player.inventory.length > 8 && <li> +{player.inventory.length - 8}</li>}
</ul>
</div>
)}
</dl>
</Card>
)}
{plotRails.length > 0 && (
<Card title={t("play.plot_rails")}>
<ul className="space-y-1.5 text-sm">
{plotRails.map((r, i) => (
<li key={r.id || i} className="rounded-md bg-bg-soft p-2">
<p className="font-medium text-fg">{r.title || `Rail ${i + 1}`}</p>
{r.description && <p className="text-xs text-fg-muted">{r.description}</p>}
{r.status && (
<span className="badge mt-1 bg-bg-card text-fg-muted">{r.status}</span>
)}
</li>
))}
</ul>
</Card>
)}
<Card>
<div className="flex flex-col gap-2">
<Button size="sm" variant="secondary" onClick={handleRetry} disabled={submitting}>
{t("play.retry_last")}
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => setRollbackOpen(true)}
disabled={submitting}
>
{t("play.rollback")}
</Button>
</div>
</Card>
</aside>
{/* Chat area */}
<main className="order-1 lg:order-2 flex min-h-0 flex-1 flex-col">
<Card className="flex min-h-0 flex-1 flex-col p-0">
<header className="flex items-center justify-between border-b border-fg-dim/20 p-3">
<div>
<h2 className="text-base font-semibold text-fg">{world.name}</h2>
<p className="text-xs text-fg-muted">
{world.current_time ? `${t("worlds.current_time")}: ${world.current_time}` : ""}
</p>
</div>
<SseStatus status={sseStatus} />
</header>
<ChatView className="flex-1 min-h-0" />
<footer className="border-t border-fg-dim/20 p-3">
<ActionInput
onSubmit={handleSend}
onSuggestedClick={handleSuggested}
submitting={submitting}
suggestedActions={nextActions}
placeholder={t("play.action_placeholder")}
/>
</footer>
</Card>
</main>
{/* Rollback confirm modal */}
{rollbackOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/60" onClick={() => setRollbackOpen(false)} />
<div className="relative z-10 w-full max-w-sm rounded-lg border border-fg-dim/30 bg-bg-card p-4 shadow-2xl">
<p className="text-sm text-fg">{t("play.rollback_confirm")}</p>
<div className="mt-4 flex justify-end gap-2">
<Button variant="ghost" onClick={() => setRollbackOpen(false)}>
{t("common.cancel")}
</Button>
<Button variant="danger" onClick={handleRollback}>
{t("play.rollback")}
</Button>
</div>
</div>
</div>
)}
</div>
);
}
function EnvRow({ label, value }: { label: string; value: unknown }) {
if (value == null || value === "") return null;
const text = typeof value === "string" ? value : typeof value === "object" ? JSON.stringify(value) : String(value);
return (
<div className="flex items-start justify-between gap-2">
<dt className="text-xs text-fg-dim">{label}</dt>
<dd className="text-right text-sm text-fg">{text}</dd>
</div>
);
}