rebase
This commit is contained in:
233
frontend/src/pages/PlayPage.tsx
Normal file
233
frontend/src/pages/PlayPage.tsx
Normal file
@@ -0,0 +1,233 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user