rebase
This commit is contained in:
70
frontend/src/pages/AdminPage.tsx
Normal file
70
frontend/src/pages/AdminPage.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAuthStore } from "@/stores/authStore";
|
||||
import { SettingsPanel } from "@/components/admin/SettingsPanel";
|
||||
import { LlmLogsTable } from "@/components/admin/LlmLogsTable";
|
||||
import { UsersTable } from "@/components/admin/UsersTable";
|
||||
import { StatsPanel } from "@/components/admin/StatsPanel";
|
||||
import { TestButtons } from "@/components/admin/TestButtons";
|
||||
import { IconsPanel } from "@/components/admin/IconsPanel";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
type Tab = "settings" | "logs" | "users" | "stats" | "test" | "icons";
|
||||
|
||||
const TABS: Array<{ id: Tab; labelKey: string }> = [
|
||||
{ id: "settings", labelKey: "admin.tab_settings" },
|
||||
{ id: "logs", labelKey: "admin.tab_logs" },
|
||||
{ id: "users", labelKey: "admin.tab_users" },
|
||||
{ id: "stats", labelKey: "admin.tab_stats" },
|
||||
{ id: "test", labelKey: "admin.tab_test" },
|
||||
{ id: "icons", labelKey: "admin.tab_icons" },
|
||||
];
|
||||
|
||||
export function AdminPage() {
|
||||
const { t } = useTranslation();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const [tab, setTab] = useState<Tab>("stats");
|
||||
|
||||
if (!user?.is_admin) {
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl p-8 text-center">
|
||||
<p className="text-sm text-err">{t("admin.not_admin")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl space-y-4 p-4">
|
||||
<header>
|
||||
<h1 className="text-xl font-semibold text-fg">{t("admin.title")}</h1>
|
||||
</header>
|
||||
|
||||
<nav className="flex flex-wrap gap-1 border-b border-fg-dim/20">
|
||||
{TABS.map((tabDef) => (
|
||||
<button
|
||||
key={tabDef.id}
|
||||
onClick={() => setTab(tabDef.id)}
|
||||
className={cn(
|
||||
"px-3 py-2 text-sm font-medium border-b-2 -mb-px transition-colors",
|
||||
tab === tabDef.id
|
||||
? "border-accent text-fg"
|
||||
: "border-transparent text-fg-muted hover:text-fg hover:border-fg-dim/30",
|
||||
)}
|
||||
aria-current={tab === tabDef.id ? "page" : undefined}
|
||||
>
|
||||
{t(tabDef.labelKey)}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<section>
|
||||
{tab === "settings" && <SettingsPanel />}
|
||||
{tab === "logs" && <LlmLogsTable />}
|
||||
{tab === "users" && <UsersTable />}
|
||||
{tab === "stats" && <StatsPanel />}
|
||||
{tab === "test" && <TestButtons />}
|
||||
{tab === "icons" && <IconsPanel />}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,655 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { adminApi, uiApi } from "@/api";
|
||||
import type { LlmLog, SettingsOut } from "@/types";
|
||||
import { useUiStore } from "@/store/ui";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Input } from "@/components/ui/Input";
|
||||
import { Card, CardBody, CardHeader } from "@/components/ui/Card";
|
||||
import { Save, ArrowLeft, Activity, Users, Zap, Ban, CheckCircle2, Terminal, Wrench } from "lucide-react";
|
||||
|
||||
export function AdminPanelPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [settings, setSettings] = useState<SettingsOut | null>(null);
|
||||
const [values, setValues] = useState<Record<string, any>>({});
|
||||
const [logs, setLogs] = useState<LlmLog[]>([]);
|
||||
const [users, setUsers] = useState<any[]>([]);
|
||||
const [tab, setTab] = useState<"settings" | "logs" | "users">("settings");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [embeddingTest, setEmbeddingTest] = useState<null | { ok: boolean; msg: string }>(null);
|
||||
const [testingEmbeddings, setTestingEmbeddings] = useState(false);
|
||||
const [llmTest, setLlmTest] = useState<null | { ok: boolean; msg: string }>(null);
|
||||
const [testingLlm, setTestingLlm] = useState(false);
|
||||
const [llmToolsTest, setLlmToolsTest] = useState<null | { ok: boolean; msg: string }>(null);
|
||||
const [testingLlmTools, setTestingLlmTools] = useState(false);
|
||||
const [userActionError, setUserActionError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const s = await adminApi.getSettings();
|
||||
setSettings(s);
|
||||
setValues(s.values);
|
||||
const l = await adminApi.listLlmLogs(50);
|
||||
setLogs(l);
|
||||
const u = await adminApi.listUsers();
|
||||
setUsers(u);
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || t("errors.unknown"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const save = async () => {
|
||||
setError("");
|
||||
setSaving(true);
|
||||
setSaved(false);
|
||||
try {
|
||||
// Strip masked api_key unless user typed a new one
|
||||
const payload: Record<string, any> = { ...values };
|
||||
for (const k of ["llm.api_key", "embedding.api_key"]) {
|
||||
if (typeof payload[k] === "string" && payload[k].includes("***")) {
|
||||
delete payload[k];
|
||||
}
|
||||
}
|
||||
const s = await adminApi.updateSettings(payload);
|
||||
setSettings(s);
|
||||
setValues(s.values);
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 2000);
|
||||
// If the admin changed the logo URL, refresh the public-UI cache so
|
||||
// the navbar/favicon update live without a full page reload.
|
||||
if ("ui.logo_url" in payload) {
|
||||
uiApi.resetCache();
|
||||
await useUiStore.getState().load(true);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || t("errors.unknown"));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const testEmbeddings = async () => {
|
||||
setError("");
|
||||
setTestingEmbeddings(true);
|
||||
setEmbeddingTest(null);
|
||||
try {
|
||||
// Build overrides from current form values (excluding masked api_key)
|
||||
const overrides: Record<string, any> = {};
|
||||
for (const k of [
|
||||
"embedding.provider",
|
||||
"embedding.base_url",
|
||||
"embedding.api_key",
|
||||
"embedding.model",
|
||||
"embedding.dim",
|
||||
"embedding.request_timeout",
|
||||
"llm.base_url",
|
||||
"llm.api_key",
|
||||
]) {
|
||||
const v = values[k];
|
||||
if (v !== undefined && v !== null && !(typeof v === "string" && v.includes("***"))) {
|
||||
overrides[k] = v;
|
||||
}
|
||||
}
|
||||
const r = await adminApi.testEmbeddings(overrides);
|
||||
if (r.ok) {
|
||||
setEmbeddingTest({
|
||||
ok: true,
|
||||
msg: t("admin.embedding_test_ok", {
|
||||
provider: r.provider,
|
||||
dim: r.dim,
|
||||
norm: r.sample_norm,
|
||||
}),
|
||||
});
|
||||
} else {
|
||||
setEmbeddingTest({ ok: false, msg: t("admin.embedding_test_fail", { error: r.error || "unknown" }) });
|
||||
}
|
||||
} catch (err: any) {
|
||||
setEmbeddingTest({
|
||||
ok: false,
|
||||
msg: t("admin.embedding_test_fail", { error: err.response?.data?.detail || err.message }),
|
||||
});
|
||||
} finally {
|
||||
setTestingEmbeddings(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Build LLM overrides from current form values (excluding masked api_key).
|
||||
// Used by both testLlm and testLlmTools so the operator can tweak base_url
|
||||
// / model / api_key in the form and test before saving.
|
||||
const buildLlmOverrides = (): Record<string, any> => {
|
||||
const overrides: Record<string, any> = {};
|
||||
for (const k of ["llm.base_url", "llm.api_key", "llm.model", "llm.request_timeout"]) {
|
||||
const v = values[k];
|
||||
if (v !== undefined && v !== null && !(typeof v === "string" && v.includes("***"))) {
|
||||
overrides[k] = v;
|
||||
}
|
||||
}
|
||||
return overrides;
|
||||
};
|
||||
|
||||
const testLlm = async () => {
|
||||
setError("");
|
||||
setTestingLlm(true);
|
||||
setLlmTest(null);
|
||||
try {
|
||||
const r = await adminApi.testLlm(buildLlmOverrides());
|
||||
if (r.ok) {
|
||||
setLlmTest({
|
||||
ok: true,
|
||||
msg: t("admin.llm_test_ok", {
|
||||
latency: r.latency_ms ?? 0,
|
||||
preview: (r.response_preview || "").slice(0, 80),
|
||||
}),
|
||||
});
|
||||
} else {
|
||||
setLlmTest({
|
||||
ok: false,
|
||||
msg: t("admin.llm_test_fail", { error: r.error || `(${r.error_type || "unknown"})` }),
|
||||
});
|
||||
}
|
||||
} catch (err: any) {
|
||||
setLlmTest({
|
||||
ok: false,
|
||||
msg: t("admin.llm_test_fail", { error: err.response?.data?.detail || err.message }),
|
||||
});
|
||||
} finally {
|
||||
setTestingLlm(false);
|
||||
}
|
||||
};
|
||||
|
||||
const testLlmTools = async () => {
|
||||
setError("");
|
||||
setTestingLlmTools(true);
|
||||
setLlmToolsTest(null);
|
||||
try {
|
||||
const r = await adminApi.testLlmTools(buildLlmOverrides());
|
||||
if (r.ok && r.tool_calls_returned) {
|
||||
// Model returned a proper tool_call — function-calling works.
|
||||
setLlmToolsTest({
|
||||
ok: true,
|
||||
msg: t("admin.llm_test_tools_ok_with_call", {
|
||||
name: r.tool_call_name || "?",
|
||||
args: JSON.stringify(r.tool_call_args || {}),
|
||||
latency: r.latency_ms ?? 0,
|
||||
}),
|
||||
});
|
||||
} else if (r.ok && !r.tool_calls_returned) {
|
||||
// Model responded but did NOT use the tool — function-calling is NOT
|
||||
// supported. The fallback JSON parser will still work, but tool-based
|
||||
// flows (orchestrator, step-writer) will be unreliable.
|
||||
setLlmToolsTest({
|
||||
ok: false,
|
||||
msg: t("admin.llm_test_tools_ok_no_call", {
|
||||
text: (r.text || "").slice(0, 120),
|
||||
}),
|
||||
});
|
||||
} else {
|
||||
setLlmToolsTest({
|
||||
ok: false,
|
||||
msg: t("admin.llm_test_fail", { error: r.error || `(${r.error_type || "unknown"})` }),
|
||||
});
|
||||
}
|
||||
} catch (err: any) {
|
||||
setLlmToolsTest({
|
||||
ok: false,
|
||||
msg: t("admin.llm_test_fail", { error: err.response?.data?.detail || err.message }),
|
||||
});
|
||||
} finally {
|
||||
setTestingLlmTools(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleUserActive = async (userId: string, currentActive: boolean) => {
|
||||
setUserActionError("");
|
||||
try {
|
||||
const updated = await adminApi.setUserActive(userId, !currentActive);
|
||||
setUsers((prev) => prev.map((u) => (u.id === userId ? { ...u, is_active: updated.is_active } : u)));
|
||||
} catch (err: any) {
|
||||
setUserActionError(err.response?.data?.detail || t("errors.unknown"));
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="p-8 text-center text-ink-400">{t("common.loading")}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto px-4 py-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-serif text-ink-100">{t("admin.title")}</h1>
|
||||
</div>
|
||||
<Button variant="ghost" onClick={() => navigate("/dashboard")}>
|
||||
<ArrowLeft size={14} className="mr-1" />
|
||||
{t("admin.back")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-red-400 mb-4">{error}</p>}
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 mb-4">
|
||||
<TabButton active={tab === "settings"} onClick={() => setTab("settings")}>
|
||||
{t("admin.settings")}
|
||||
</TabButton>
|
||||
<TabButton active={tab === "logs"} onClick={() => setTab("logs")}>
|
||||
<Activity size={12} className="mr-1" />
|
||||
{t("admin.llm_logs")}
|
||||
</TabButton>
|
||||
<TabButton active={tab === "users"} onClick={() => setTab("users")}>
|
||||
<Users size={12} className="mr-1" />
|
||||
{t("admin.users")}
|
||||
</TabButton>
|
||||
</div>
|
||||
|
||||
{tab === "settings" && settings && (
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader title={t("admin.settings")} />
|
||||
<CardBody className="space-y-3">
|
||||
<Input
|
||||
label={t("admin.base_url")}
|
||||
value={values["llm.base_url"] || ""}
|
||||
onChange={(e) => setValues({ ...values, "llm.base_url": e.target.value })}
|
||||
placeholder="http://localhost:1234/v1"
|
||||
/>
|
||||
<Input
|
||||
label={t("admin.api_key")}
|
||||
type="password"
|
||||
value={values["llm.api_key"] || ""}
|
||||
onChange={(e) => setValues({ ...values, "llm.api_key": e.target.value })}
|
||||
placeholder="sk-..."
|
||||
/>
|
||||
<Input
|
||||
label={t("admin.model")}
|
||||
value={values["llm.model"] || ""}
|
||||
onChange={(e) => setValues({ ...values, "llm.model": e.target.value })}
|
||||
placeholder="local-model"
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<NumberInput
|
||||
label={t("admin.temperature")}
|
||||
value={values["llm.temperature"]}
|
||||
onChange={(v) => setValues({ ...values, "llm.temperature": v })}
|
||||
step={0.1}
|
||||
/>
|
||||
<NumberInput
|
||||
label={t("admin.step_temperature")}
|
||||
value={values["llm.step_temperature"]}
|
||||
onChange={(v) => setValues({ ...values, "llm.step_temperature": v })}
|
||||
step={0.1}
|
||||
/>
|
||||
<NumberInput
|
||||
label={t("admin.summary_temperature")}
|
||||
value={values["llm.summary_temperature"]}
|
||||
onChange={(v) => setValues({ ...values, "llm.summary_temperature": v })}
|
||||
step={0.1}
|
||||
/>
|
||||
<NumberInput
|
||||
label={t("admin.max_tokens")}
|
||||
value={values["llm.max_tokens"]}
|
||||
onChange={(v) => setValues({ ...values, "llm.max_tokens": v })}
|
||||
/>
|
||||
<NumberInput
|
||||
label={t("admin.request_timeout")}
|
||||
value={values["llm.request_timeout"]}
|
||||
onChange={(v) => setValues({ ...values, "llm.request_timeout": v })}
|
||||
/>
|
||||
<label className="flex items-center gap-2 text-xs text-ink-300 self-end mb-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!values["llm.streaming"]}
|
||||
onChange={(e) => setValues({ ...values, "llm.streaming": e.target.checked })}
|
||||
className="rounded"
|
||||
/>
|
||||
{t("admin.streaming")}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* LLM connectivity tests — runs against current form values
|
||||
(so the operator can tweak base_url / model / api_key and
|
||||
test BEFORE saving). */}
|
||||
<div className="flex flex-wrap items-center gap-2 pt-1">
|
||||
<Button variant="ghost" onClick={testLlm} disabled={testingLlm}>
|
||||
<Terminal size={14} className="mr-1" />
|
||||
{testingLlm ? t("admin.llm_test_testing") : t("admin.llm_test")}
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={testLlmTools} disabled={testingLlmTools}>
|
||||
<Wrench size={14} className="mr-1" />
|
||||
{testingLlmTools ? t("admin.llm_test_tools_testing") : t("admin.llm_test_tools")}
|
||||
</Button>
|
||||
{llmTest && (
|
||||
<span className={`text-xs ${llmTest.ok ? "text-green-400" : "text-red-400"}`}>
|
||||
{llmTest.msg}
|
||||
</span>
|
||||
)}
|
||||
{llmToolsTest && (
|
||||
<span className={`text-xs ${llmToolsTest.ok ? "text-green-400" : "text-red-400"}`}>
|
||||
{llmToolsTest.msg}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* === Embeddings / RAG === */}
|
||||
<Card>
|
||||
<CardHeader
|
||||
title={t("admin.embedding_settings")}
|
||||
subtitle="hash = offline fallback · openai = real semantic search"
|
||||
/>
|
||||
<CardBody className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs text-ink-400 mb-1">{t("admin.embedding_provider")}</label>
|
||||
<select
|
||||
className="w-full bg-ink-900 border border-ink-700 rounded px-2 py-1.5 text-sm text-ink-100"
|
||||
value={values["embedding.provider"] || "hash"}
|
||||
onChange={(e) => setValues({ ...values, "embedding.provider": e.target.value })}
|
||||
>
|
||||
<option value="hash">{t("admin.embedding_provider_hash")}</option>
|
||||
<option value="openai">{t("admin.embedding_provider_openai")}</option>
|
||||
</select>
|
||||
</div>
|
||||
<Input
|
||||
label={t("admin.embedding_base_url")}
|
||||
value={values["embedding.base_url"] || ""}
|
||||
onChange={(e) => setValues({ ...values, "embedding.base_url": e.target.value })}
|
||||
placeholder="http://localhost:1234/v1"
|
||||
/>
|
||||
<Input
|
||||
label={t("admin.embedding_api_key")}
|
||||
type="password"
|
||||
value={values["embedding.api_key"] || ""}
|
||||
onChange={(e) => setValues({ ...values, "embedding.api_key": e.target.value })}
|
||||
placeholder="sk-... (пусто = как у LLM)"
|
||||
/>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<Input
|
||||
label={t("admin.embedding_model")}
|
||||
value={values["embedding.model"] || ""}
|
||||
onChange={(e) => setValues({ ...values, "embedding.model": e.target.value })}
|
||||
placeholder="text-embedding-3-small"
|
||||
/>
|
||||
<NumberInput
|
||||
label={t("admin.embedding_dim")}
|
||||
value={values["embedding.dim"]}
|
||||
onChange={(v) => setValues({ ...values, "embedding.dim": v })}
|
||||
/>
|
||||
<NumberInput
|
||||
label={t("admin.embedding_timeout")}
|
||||
value={values["embedding.request_timeout"]}
|
||||
onChange={(v) => setValues({ ...values, "embedding.request_timeout": v })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<Button variant="ghost" onClick={testEmbeddings} disabled={testingEmbeddings}>
|
||||
<Zap size={14} className="mr-1" />
|
||||
{testingEmbeddings ? t("admin.embedding_testing") : t("admin.embedding_test")}
|
||||
</Button>
|
||||
{embeddingTest && (
|
||||
<span className={`text-xs ${embeddingTest.ok ? "text-green-400" : "text-red-400"}`}>
|
||||
{embeddingTest.msg}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader title={t("admin.context_settings")} />
|
||||
<CardBody>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<NumberInput
|
||||
label={t("admin.recent_messages")}
|
||||
value={values["context.recent_messages"]}
|
||||
onChange={(v) => setValues({ ...values, "context.recent_messages": v })}
|
||||
/>
|
||||
<NumberInput
|
||||
label={t("admin.compress_threshold")}
|
||||
value={values["context.compress_threshold"]}
|
||||
onChange={(v) => setValues({ ...values, "context.compress_threshold": v })}
|
||||
/>
|
||||
<NumberInput
|
||||
label={t("admin.summary_messages")}
|
||||
value={values["context.summary_messages"]}
|
||||
onChange={(v) => setValues({ ...values, "context.summary_messages": v })}
|
||||
/>
|
||||
<NumberInput
|
||||
label={t("admin.max_tokens_total")}
|
||||
value={values["context.max_tokens_total"]}
|
||||
onChange={(v) => setValues({ ...values, "context.max_tokens_total": v })}
|
||||
/>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader title={t("admin.trigger_settings")} subtitle={t("admin.trigger_settings_desc")} />
|
||||
<CardBody>
|
||||
<label className="flex items-center gap-2 text-xs text-ink-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!values["triggers.enabled"]}
|
||||
onChange={(e) => setValues({ ...values, "triggers.enabled": e.target.checked })}
|
||||
/>
|
||||
{t("admin.triggers_enabled")}
|
||||
</label>
|
||||
<p className="text-xs text-ink-500 mt-2">{t("admin.triggers_enabled_desc")}</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader
|
||||
title={t("admin.ui_settings")}
|
||||
subtitle={t("admin.ui_settings_desc")}
|
||||
/>
|
||||
<CardBody className="space-y-3">
|
||||
<Input
|
||||
label={t("admin.ui_logo_url")}
|
||||
value={values["ui.logo_url"] || ""}
|
||||
onChange={(e) => setValues({ ...values, "ui.logo_url": e.target.value })}
|
||||
placeholder="/logo.png"
|
||||
/>
|
||||
<p className="text-xs text-ink-500">{t("admin.ui_logo_url_hint")}</p>
|
||||
{/* Live preview so the admin sees the configured logo before saving. */}
|
||||
<div className="flex items-center gap-3 pt-1">
|
||||
<span className="text-xs text-ink-400">{t("admin.ui_logo_preview")}:</span>
|
||||
<div className="w-10 h-10 rounded border border-ink-700 bg-ink-900 flex items-center justify-center overflow-hidden">
|
||||
{values["ui.logo_url"] ? (
|
||||
<img
|
||||
src={values["ui.logo_url"]}
|
||||
alt="preview"
|
||||
className="w-8 h-8 object-contain"
|
||||
onError={(e) => {
|
||||
(e.currentTarget as HTMLImageElement).style.display = "none";
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-[10px] text-ink-500">—</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
{saved && <span className="text-sm text-green-400 self-center">{t("admin.saved")}</span>}
|
||||
<Button onClick={save} disabled={saving}>
|
||||
<Save size={14} className="mr-1" />
|
||||
{saving ? t("common.loading") : t("admin.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "logs" && (
|
||||
<Card>
|
||||
<CardHeader title={t("admin.llm_logs")} subtitle={`${logs.length} recent calls`} />
|
||||
<CardBody>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="text-left text-ink-400 border-b border-ink-800">
|
||||
<th className="py-2 pr-3">Time</th>
|
||||
<th className="py-2 pr-3">Purpose</th>
|
||||
<th className="py-2 pr-3">Model</th>
|
||||
<th className="py-2 pr-3 text-right">Tokens</th>
|
||||
<th className="py-2 pr-3 text-right">Latency</th>
|
||||
<th className="py-2">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{logs.map((l) => (
|
||||
<tr key={l.id} className="border-b border-ink-900 hover:bg-ink-900/50">
|
||||
<td className="py-2 pr-3 text-ink-400">
|
||||
{new Date(l.created_at).toLocaleTimeString()}
|
||||
</td>
|
||||
<td className="py-2 pr-3 text-ink-100">{l.purpose}</td>
|
||||
<td className="py-2 pr-3 text-ink-300">{l.model}</td>
|
||||
<td className="py-2 pr-3 text-right text-ink-300">
|
||||
{l.total_tokens ?? "—"}
|
||||
</td>
|
||||
<td className="py-2 pr-3 text-right text-ink-300">
|
||||
{l.latency_ms ? `${l.latency_ms}ms` : "—"}
|
||||
</td>
|
||||
<td className="py-2">
|
||||
{l.error ? (
|
||||
<span className="text-red-400">err</span>
|
||||
) : (
|
||||
<span className="text-green-400">ok</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{tab === "users" && (
|
||||
<Card>
|
||||
<CardHeader title={t("admin.users")} subtitle={`${users.length} users`} />
|
||||
<CardBody>
|
||||
{userActionError && (
|
||||
<p className="text-sm text-red-400 mb-3">{userActionError}</p>
|
||||
)}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="text-left text-ink-400 border-b border-ink-800">
|
||||
<th className="py-2 pr-3">Email</th>
|
||||
<th className="py-2 pr-3">Username</th>
|
||||
<th className="py-2 pr-3">Role</th>
|
||||
<th className="py-2 pr-3">Active</th>
|
||||
<th className="py-2 pr-3">Created</th>
|
||||
<th className="py-2 text-right">{t("admin.users_actions")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((u) => (
|
||||
<tr key={u.id} className="border-b border-ink-900">
|
||||
<td className="py-2 pr-3 text-ink-100">{u.email}</td>
|
||||
<td className="py-2 pr-3 text-ink-300">{u.username}</td>
|
||||
<td className="py-2 pr-3">
|
||||
{u.is_admin ? (
|
||||
<span className="text-accent-400">admin</span>
|
||||
) : (
|
||||
<span className="text-ink-400">user</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 pr-3">
|
||||
{u.is_active ? (
|
||||
<span className="text-green-400">●</span>
|
||||
) : (
|
||||
<span className="text-red-400">●</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 pr-3 text-ink-400">
|
||||
{new Date(u.created_at).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="py-2 text-right">
|
||||
{u.is_admin ? (
|
||||
<span className="text-ink-500 text-xs">—</span>
|
||||
) : u.is_active ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => toggleUserActive(u.id, u.is_active)}
|
||||
>
|
||||
<Ban size={12} className="mr-1" />
|
||||
{t("admin.users_ban")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => toggleUserActive(u.id, u.is_active)}
|
||||
>
|
||||
<CheckCircle2 size={12} className="mr-1" />
|
||||
{t("admin.users_unban")}
|
||||
</Button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TabButton({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`text-xs px-3 py-1.5 rounded-t border-b-2 transition-colors ${
|
||||
active
|
||||
? "border-accent-500 text-accent-400 bg-ink-900/50"
|
||||
: "border-transparent text-ink-400 hover:text-ink-200"
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function NumberInput({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
step,
|
||||
}: {
|
||||
label: string;
|
||||
value: any;
|
||||
onChange: (v: number) => void;
|
||||
step?: number;
|
||||
}) {
|
||||
return (
|
||||
<Input
|
||||
label={label}
|
||||
type="number"
|
||||
step={step}
|
||||
value={value ?? 0}
|
||||
onChange={(e) => {
|
||||
const v = parseFloat(e.target.value);
|
||||
onChange(isNaN(v) ? 0 : v);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
119
frontend/src/pages/AdminRegisterPage.tsx
Normal file
119
frontend/src/pages/AdminRegisterPage.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { useNavigate, Link, useSearchParams } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAuthStore } from "@/stores/authStore";
|
||||
import { useToastStore } from "@/stores/toastStore";
|
||||
import { ApiError } from "@/lib/api";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Input } from "@/components/ui/Input";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
|
||||
export function AdminRegisterPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const registerAdmin = useAuthStore((s) => s.registerAdmin);
|
||||
const pushToast = useToastStore((s) => s.push);
|
||||
|
||||
const initialToken = searchParams.get("token") || "";
|
||||
const [token, setToken] = useState(initialToken);
|
||||
const [email, setEmail] = useState("");
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [passwordConfirm, setPasswordConfirm] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
const validate = (): boolean => {
|
||||
const next: Record<string, string> = {};
|
||||
if (!token.trim()) next.token = t("errors.validation");
|
||||
if (!email.includes("@")) next.email = t("errors.validation");
|
||||
if (username.trim().length < 3) next.username = t("errors.validation");
|
||||
if (password.length < 8) next.password = t("errors.validation");
|
||||
if (password !== passwordConfirm) next.password_confirm = t("errors.validation");
|
||||
setErrors(next);
|
||||
return Object.keys(next).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!validate()) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await registerAdmin({
|
||||
token: token.trim(),
|
||||
email: email.trim(),
|
||||
username: username.trim(),
|
||||
password,
|
||||
password_confirm: passwordConfirm,
|
||||
});
|
||||
pushToast("success", t("auth.admin_register_success"));
|
||||
navigate("/login");
|
||||
} catch (err) {
|
||||
const msg = err instanceof ApiError ? err.message : t("auth.register_failed");
|
||||
pushToast("error", msg);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex min-h-[calc(100vh-3.5rem)] max-w-md items-center p-4">
|
||||
<Card className="w-full" title={t("auth.admin_register_title")}>
|
||||
<form onSubmit={handleSubmit} className="space-y-3">
|
||||
<Input
|
||||
label={t("auth.admin_token")}
|
||||
value={token}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
required
|
||||
error={errors.token}
|
||||
hint="Provided by an existing administrator."
|
||||
/>
|
||||
<Input
|
||||
label={t("auth.email")}
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
autoComplete="email"
|
||||
required
|
||||
error={errors.email}
|
||||
/>
|
||||
<Input
|
||||
label={t("auth.username")}
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
autoComplete="username"
|
||||
required
|
||||
error={errors.username}
|
||||
/>
|
||||
<Input
|
||||
label={t("auth.password")}
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
required
|
||||
error={errors.password}
|
||||
/>
|
||||
<Input
|
||||
label={t("auth.password_confirm")}
|
||||
type="password"
|
||||
value={passwordConfirm}
|
||||
onChange={(e) => setPasswordConfirm(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
required
|
||||
error={errors.password_confirm}
|
||||
/>
|
||||
<Button type="submit" loading={submitting} fullWidth>
|
||||
{t("auth.register_button")}
|
||||
</Button>
|
||||
<p className="text-center text-sm text-fg-muted">
|
||||
<Link to="/login" className="text-accent hover:underline">
|
||||
{t("auth.have_account")}
|
||||
</Link>
|
||||
</p>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
import { FormEvent, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Shield } from "lucide-react";
|
||||
import { authApi } from "@/api";
|
||||
import { useAuthStore } from "@/store/auth";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Input } from "@/components/ui/Input";
|
||||
import { Card, CardBody, CardHeader } from "@/components/ui/Card";
|
||||
|
||||
export function AdminSetupPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { setAuth } = useAuthStore();
|
||||
const [token, setToken] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const onSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setLoading(true);
|
||||
try {
|
||||
const { access_token, user } = await authApi.adminSetup(token, email, username, password);
|
||||
setAuth(access_token, user);
|
||||
navigate("/admin");
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || t("errors.unknown"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-md mx-auto px-4 py-12">
|
||||
<div className="text-center mb-6">
|
||||
<Shield className="mx-auto text-accent-500 mb-2" size={36} />
|
||||
<h1 className="text-2xl font-serif text-ink-100">{t("auth.admin_setup_title")}</h1>
|
||||
<p className="text-sm text-ink-400 mt-2">{t("auth.admin_setup_desc")}</p>
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader title={t("auth.admin_setup_title")} />
|
||||
<CardBody>
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
<Input
|
||||
label={t("auth.admin_setup_token")}
|
||||
type="text"
|
||||
name="token"
|
||||
value={token}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
required
|
||||
placeholder="xxxxxxxxxxxxxxxx"
|
||||
/>
|
||||
<Input
|
||||
label={t("auth.email")}
|
||||
type="email"
|
||||
name="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label={t("auth.username")}
|
||||
type="text"
|
||||
name="username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label={t("auth.password")}
|
||||
type="password"
|
||||
name="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
{error && <p className="text-sm text-red-400">{error}</p>}
|
||||
<Button type="submit" disabled={loading} className="w-full">
|
||||
{loading ? t("common.loading") : t("auth.setup_btn")}
|
||||
</Button>
|
||||
</form>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { Plus, BookOpen, Play, Pencil, Trash2 } from "lucide-react";
|
||||
import { worldsApi, sessionsApi } from "@/api";
|
||||
import type { World } from "@/types";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Card, CardBody } from "@/components/ui/Card";
|
||||
|
||||
export function DashboardPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [worlds, setWorlds] = useState<World[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const ws = await worldsApi.list();
|
||||
setWorlds(ws);
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || t("errors.unknown"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm(t("common.delete") + "?")) return;
|
||||
try {
|
||||
await worldsApi.delete(id);
|
||||
await load();
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || t("errors.unknown"));
|
||||
}
|
||||
};
|
||||
|
||||
const handlePlay = async (world: World) => {
|
||||
try {
|
||||
const session = await sessionsApi.create(world.id);
|
||||
navigate(`/sessions/${session.id}`);
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || t("errors.unknown"));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto px-4 py-8">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-serif text-ink-100">{t("worlds.title")}</h1>
|
||||
<Link to="/worlds/new">
|
||||
<Button>
|
||||
<Plus size={16} className="mr-1" />
|
||||
{t("worlds.new")}
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-red-400 mb-4">{error}</p>}
|
||||
|
||||
{loading ? (
|
||||
<p className="text-ink-400">{t("common.loading")}</p>
|
||||
) : worlds.length === 0 ? (
|
||||
<Card>
|
||||
<CardBody className="text-center py-12">
|
||||
<BookOpen className="mx-auto text-ink-600 mb-3" size={32} />
|
||||
<p className="text-ink-400 mb-4">{t("worlds.empty")}</p>
|
||||
<Link to="/worlds/new">
|
||||
<Button>
|
||||
<Plus size={16} className="mr-1" />
|
||||
{t("worlds.new")}
|
||||
</Button>
|
||||
</Link>
|
||||
</CardBody>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{worlds.map((w) => (
|
||||
<Card key={w.id}>
|
||||
<CardBody>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-base font-semibold text-ink-100 mb-1">{w.name}</h3>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<StatusBadge status={w.status} t={t} />
|
||||
<span className="text-xs text-ink-500">{w.language.toUpperCase()}</span>
|
||||
{w.current_time && (
|
||||
<span className="text-xs text-ink-500">· {w.current_time}</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-ink-400 line-clamp-2">
|
||||
{w.definition?.setting_description?.slice(0, 160) || "—"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 mt-4">
|
||||
{w.status === "draft" ? (
|
||||
<Link to={`/worlds/${w.id}/edit`}>
|
||||
<Button size="sm" variant="secondary">
|
||||
<Pencil size={12} className="mr-1" />
|
||||
{t("worlds.edit")}
|
||||
</Button>
|
||||
</Link>
|
||||
) : (
|
||||
<Button size="sm" onClick={() => handlePlay(w)}>
|
||||
<Play size={12} className="mr-1" />
|
||||
{t("worlds.start")}
|
||||
</Button>
|
||||
)}
|
||||
<Link to={`/worlds/${w.id}/edit`}>
|
||||
<Button size="sm" variant="ghost">
|
||||
<Pencil size={12} className="mr-1" />
|
||||
{t("worlds.edit")}
|
||||
</Button>
|
||||
</Link>
|
||||
<Button size="sm" variant="ghost" onClick={() => handleDelete(w.id)}>
|
||||
<Trash2 size={12} className="mr-1" />
|
||||
{t("worlds.delete")}
|
||||
</Button>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status, t }: { status: string; t: any }) {
|
||||
const colors: Record<string, string> = {
|
||||
draft: "bg-yellow-500/10 text-yellow-400 border-yellow-500/30",
|
||||
ready: "bg-blue-500/10 text-blue-400 border-blue-500/30",
|
||||
active: "bg-green-500/10 text-green-400 border-green-500/30",
|
||||
archived: "bg-ink-500/10 text-ink-400 border-ink-500/30",
|
||||
};
|
||||
const labels: Record<string, string> = {
|
||||
draft: t("worlds.status_draft"),
|
||||
ready: t("worlds.status_ready"),
|
||||
active: t("worlds.status_active"),
|
||||
archived: t("worlds.status_archived"),
|
||||
};
|
||||
return (
|
||||
<span className={`text-xs px-2 py-0.5 rounded border ${colors[status] || colors.draft}`}>
|
||||
{labels[status] || status}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useAuthStore } from "@/store/auth";
|
||||
import { useUiStore } from "@/store/ui";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { BookOpen, Sparkles, Cog, Globe } from "lucide-react";
|
||||
|
||||
export function HomePage() {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useAuthStore();
|
||||
const logoUrl = useUiStore((s) => s.logoUrl);
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto px-4 py-12">
|
||||
<div className="text-center mb-12">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-accent-500/10 border border-accent-500/30 mb-4 overflow-hidden">
|
||||
{logoUrl ? (
|
||||
<img
|
||||
src={logoUrl}
|
||||
alt="logo"
|
||||
className="w-12 h-12 rounded object-contain"
|
||||
onError={(e) => {
|
||||
// Fall back to the BookOpen icon if the configured logo URL
|
||||
// fails to load (e.g. typo in admin settings, dead link).
|
||||
(e.currentTarget as HTMLImageElement).style.display = "none";
|
||||
const sib = (e.currentTarget as HTMLImageElement).nextElementSibling as HTMLElement | null;
|
||||
if (sib) sib.style.display = "block";
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<BookOpen
|
||||
className="text-accent-500"
|
||||
size={32}
|
||||
style={{ display: logoUrl ? "none" : "block" }}
|
||||
/>
|
||||
</div>
|
||||
<h1 className="text-4xl font-serif font-bold text-ink-100 mb-3">{t("app.title")}</h1>
|
||||
<p className="text-ink-400 max-w-2xl mx-auto">{t("app.subtitle")}</p>
|
||||
<div className="mt-6 flex gap-3 justify-center">
|
||||
{user ? (
|
||||
<Link to="/dashboard">
|
||||
<Button size="lg">{t("nav.dashboard")}</Button>
|
||||
</Link>
|
||||
) : (
|
||||
<>
|
||||
<Link to="/login">
|
||||
<Button size="lg" variant="primary">
|
||||
{t("nav.login")}
|
||||
</Button>
|
||||
</Link>
|
||||
<Link to="/register">
|
||||
<Button size="lg" variant="secondary">
|
||||
{t("nav.register")}
|
||||
</Button>
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mt-12">
|
||||
<FeatureCard
|
||||
icon={<Sparkles className="text-accent-500" size={24} />}
|
||||
title={t("worlds.builder_title")}
|
||||
desc={t("worlds.builder_desc")}
|
||||
/>
|
||||
<FeatureCard
|
||||
icon={<Cog className="text-accent-500" size={24} />}
|
||||
title={t("admin.settings")}
|
||||
desc={t("admin.base_url")}
|
||||
/>
|
||||
<FeatureCard
|
||||
icon={<Globe className="text-accent-500" size={24} />}
|
||||
title={t("nav.language")}
|
||||
desc="RU / EN"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FeatureCard({ icon, title, desc }: { icon: React.ReactNode; title: string; desc: string }) {
|
||||
return (
|
||||
<div className="p-6 bg-ink-900/50 border border-ink-800 rounded-xl">
|
||||
<div className="w-12 h-12 rounded-lg bg-ink-800 flex items-center justify-center mb-3">{icon}</div>
|
||||
<h3 className="text-sm font-semibold text-ink-100 mb-2">{title}</h3>
|
||||
<p className="text-xs text-ink-400">{desc}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,73 +1,68 @@
|
||||
import { FormEvent, useState } from "react";
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { useNavigate, Link } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { authApi } from "@/api";
|
||||
import { useAuthStore } from "@/store/auth";
|
||||
import { useAuthStore } from "@/stores/authStore";
|
||||
import { useToastStore } from "@/stores/toastStore";
|
||||
import { ApiError } from "@/lib/api";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Input } from "@/components/ui/Input";
|
||||
import { Card, CardBody } from "@/components/ui/Card";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
|
||||
export function LoginPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { setAuth } = useAuthStore();
|
||||
const [login, setLogin] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const login = useAuthStore((s) => s.login);
|
||||
const pushToast = useToastStore((s) => s.push);
|
||||
|
||||
const onSubmit = async (e: FormEvent) => {
|
||||
const [loginField, setLoginField] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setLoading(true);
|
||||
if (!loginField.trim() || !password) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
// `login` accepts either email or username.
|
||||
const { access_token, user } = await authApi.login(login, password);
|
||||
setAuth(access_token, user);
|
||||
navigate("/dashboard");
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || t("errors.unknown"));
|
||||
await login({ login: loginField.trim(), password });
|
||||
pushToast("success", t("auth.login_success"));
|
||||
navigate("/worlds");
|
||||
} catch (err) {
|
||||
const msg = err instanceof ApiError ? err.message : t("auth.login_failed");
|
||||
pushToast("error", msg);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-md mx-auto px-4 py-12">
|
||||
<h1 className="text-2xl font-serif text-center text-ink-100 mb-6">{t("auth.login_title")}</h1>
|
||||
<Card>
|
||||
<CardBody>
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
<Input
|
||||
label={t("auth.login_or_email")}
|
||||
type="text"
|
||||
name="login"
|
||||
value={login}
|
||||
onChange={(e) => setLogin(e.target.value)}
|
||||
required
|
||||
autoComplete="username"
|
||||
placeholder="alice / alice@example.com"
|
||||
/>
|
||||
<Input
|
||||
label={t("auth.password")}
|
||||
type="password"
|
||||
name="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
{error && <p className="text-sm text-red-400">{error}</p>}
|
||||
<Button type="submit" disabled={loading} className="w-full">
|
||||
{loading ? t("common.loading") : t("auth.login_btn")}
|
||||
</Button>
|
||||
</form>
|
||||
<p className="text-center mt-4 text-sm text-ink-400">
|
||||
<Link to="/register" className="text-accent-400 hover:underline">
|
||||
<div className="mx-auto flex min-h-[calc(100vh-3.5rem)] max-w-md items-center p-4">
|
||||
<Card className="w-full" title={t("auth.login_title")}>
|
||||
<form onSubmit={handleSubmit} className="space-y-3">
|
||||
<Input
|
||||
label={t("auth.login_field")}
|
||||
value={loginField}
|
||||
onChange={(e) => setLoginField(e.target.value)}
|
||||
autoComplete="username"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
<Input
|
||||
label={t("auth.password")}
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
/>
|
||||
<Button type="submit" loading={submitting} fullWidth>
|
||||
{t("auth.login_button")}
|
||||
</Button>
|
||||
<p className="text-center text-sm text-fg-muted">
|
||||
<Link to="/register" className="text-accent hover:underline">
|
||||
{t("auth.no_account")}
|
||||
</Link>
|
||||
</p>
|
||||
</CardBody>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,79 +1,105 @@
|
||||
import { FormEvent, useState } from "react";
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { useNavigate, Link } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { authApi } from "@/api";
|
||||
import { useAuthStore } from "@/store/auth";
|
||||
import { useAuthStore } from "@/stores/authStore";
|
||||
import { useToastStore } from "@/stores/toastStore";
|
||||
import { ApiError } from "@/lib/api";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Input } from "@/components/ui/Input";
|
||||
import { Card, CardBody } from "@/components/ui/Card";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
|
||||
export function RegisterPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { setAuth } = useAuthStore();
|
||||
const register = useAuthStore((s) => s.register);
|
||||
const pushToast = useToastStore((s) => s.push);
|
||||
|
||||
const [email, setEmail] = useState("");
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [passwordConfirm, setPasswordConfirm] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
const onSubmit = async (e: FormEvent) => {
|
||||
const validate = (): boolean => {
|
||||
const next: Record<string, string> = {};
|
||||
if (!email.includes("@")) next.email = t("errors.validation");
|
||||
if (username.trim().length < 3) next.username = t("errors.validation");
|
||||
if (password.length < 8) next.password = t("errors.validation");
|
||||
if (password !== passwordConfirm) next.password_confirm = t("errors.validation");
|
||||
setErrors(next);
|
||||
return Object.keys(next).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setLoading(true);
|
||||
if (!validate()) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const { access_token, user } = await authApi.register(email, username, password);
|
||||
setAuth(access_token, user);
|
||||
navigate("/dashboard");
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || t("errors.unknown"));
|
||||
await register({
|
||||
email: email.trim(),
|
||||
username: username.trim(),
|
||||
password,
|
||||
password_confirm: passwordConfirm,
|
||||
});
|
||||
pushToast("success", t("auth.register_success"));
|
||||
navigate("/login");
|
||||
} catch (err) {
|
||||
const msg = err instanceof ApiError ? err.message : t("auth.register_failed");
|
||||
pushToast("error", msg);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-md mx-auto px-4 py-12">
|
||||
<h1 className="text-2xl font-serif text-center text-ink-100 mb-6">{t("auth.register_title")}</h1>
|
||||
<Card>
|
||||
<CardBody>
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
<Input
|
||||
label={t("auth.email")}
|
||||
type="email"
|
||||
name="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label={t("auth.username")}
|
||||
type="text"
|
||||
name="username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label={t("auth.password")}
|
||||
type="password"
|
||||
name="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
{error && <p className="text-sm text-red-400">{error}</p>}
|
||||
<Button type="submit" disabled={loading} className="w-full">
|
||||
{loading ? t("common.loading") : t("auth.register_btn")}
|
||||
</Button>
|
||||
</form>
|
||||
<p className="text-center mt-4 text-sm text-ink-400">
|
||||
<Link to="/login" className="text-accent-400 hover:underline">
|
||||
<div className="mx-auto flex min-h-[calc(100vh-3.5rem)] max-w-md items-center p-4">
|
||||
<Card className="w-full" title={t("auth.register_title")}>
|
||||
<form onSubmit={handleSubmit} className="space-y-3">
|
||||
<Input
|
||||
label={t("auth.email")}
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
autoComplete="email"
|
||||
required
|
||||
error={errors.email}
|
||||
/>
|
||||
<Input
|
||||
label={t("auth.username")}
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
autoComplete="username"
|
||||
required
|
||||
error={errors.username}
|
||||
/>
|
||||
<Input
|
||||
label={t("auth.password")}
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
required
|
||||
error={errors.password}
|
||||
/>
|
||||
<Input
|
||||
label={t("auth.password_confirm")}
|
||||
type="password"
|
||||
value={passwordConfirm}
|
||||
onChange={(e) => setPasswordConfirm(e.target.value)}
|
||||
autoComplete="new-password"
|
||||
required
|
||||
error={errors.password_confirm}
|
||||
/>
|
||||
<Button type="submit" loading={submitting} fullWidth>
|
||||
{t("auth.register_button")}
|
||||
</Button>
|
||||
<p className="text-center text-sm text-fg-muted">
|
||||
<Link to="/login" className="text-accent hover:underline">
|
||||
{t("auth.have_account")}
|
||||
</Link>
|
||||
</p>
|
||||
</CardBody>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,440 +0,0 @@
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { fetchEventSource } from "@microsoft/fetch-event-source";
|
||||
import { sessionsApi, worldsApi, miscApi } from "@/api";
|
||||
import { useAuthStore } from "@/store/auth";
|
||||
import type { Message, Session, World, GlossaryEntry, Trigger } from "@/types";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Textarea } from "@/components/ui/Input";
|
||||
import { Card, CardBody } from "@/components/ui/Card";
|
||||
import { Modal } from "@/components/ui/Modal";
|
||||
import { GlossaryModal } from "@/components/world/GlossaryModal";
|
||||
import { CharacterSheet } from "@/components/world/CharacterSheet";
|
||||
import { Send, BookOpen, User, Pencil, Clock, Zap, Loader2, RefreshCw } from "lucide-react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
|
||||
export function SessionPage() {
|
||||
const { t } = useTranslation();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { token } = useAuthStore();
|
||||
|
||||
const [session, setSession] = useState<Session | null>(null);
|
||||
const [world, setWorld] = useState<World | null>(null);
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [actionText, setActionText] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [iterating, setIterating] = useState(false);
|
||||
const [status, setStatus] = useState<string>("");
|
||||
const [error, setError] = useState("");
|
||||
const [lastFailedAction, setLastFailedAction] = useState<string | null>(null);
|
||||
const [glossaryOpen, setGlossaryOpen] = useState(false);
|
||||
const [glossary, setGlossary] = useState<GlossaryEntry[]>([]);
|
||||
const [triggers, setTriggers] = useState<Trigger[]>([]);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const load = async () => {
|
||||
if (!id) return;
|
||||
try {
|
||||
const s = await sessionsApi.get(id);
|
||||
setSession(s);
|
||||
const w = await worldsApi.get(s.world_id);
|
||||
setWorld(w);
|
||||
const msgs = await sessionsApi.listMessages(id);
|
||||
setMessages(msgs);
|
||||
const g = await miscApi.listGlossary(s.world_id);
|
||||
setGlossary(g);
|
||||
const tr = await miscApi.listTriggers(id, false);
|
||||
setTriggers(tr);
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || t("errors.unknown"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [messages, status]);
|
||||
|
||||
// Auto-generate intro scene for fresh sessions (no messages yet).
|
||||
const introTriggeredRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!id || introTriggeredRef.current) return;
|
||||
if (messages.length === 0 && !iterating && !error) {
|
||||
introTriggeredRef.current = true;
|
||||
runIntro();
|
||||
}
|
||||
}, [id, messages.length, iterating, error]);
|
||||
|
||||
const runIntro = async () => {
|
||||
if (!id) return;
|
||||
setIterating(true);
|
||||
setStatus(t("session.status_writing_scene"));
|
||||
setError("");
|
||||
try {
|
||||
await fetchEventSource(`/api/sessions/${id}/intro`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ session_id: id }),
|
||||
openWhenHidden: true,
|
||||
onmessage(ev) {
|
||||
const eventName = ev.event;
|
||||
let data: any = {};
|
||||
try { data = JSON.parse(ev.data || "{}"); } catch { data = {}; }
|
||||
if (eventName === "status") {
|
||||
const msg = data.message || "";
|
||||
if (msg === "writing_scene") setStatus(t("session.status_writing_scene"));
|
||||
else setStatus(msg);
|
||||
} else if (eventName === "step_complete") {
|
||||
const stepMsg: Message = {
|
||||
id: data.message_id || `intro-${Date.now()}`,
|
||||
seq: data.seq || 1,
|
||||
role: "assistant",
|
||||
kind: "narrative_step",
|
||||
content: data.narrative || "",
|
||||
payload: { options: data.options || [], world_time: data.world_time, kind: "intro" },
|
||||
is_pinned: true,
|
||||
hidden: false,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
setMessages((prev) => [...prev, stepMsg]);
|
||||
if (world && data.world_time) {
|
||||
setWorld({ ...world, current_time: data.world_time, state: data.state || world.state });
|
||||
}
|
||||
} else if (eventName === "error") {
|
||||
setError(data.message || t("session.error_iter"));
|
||||
}
|
||||
},
|
||||
onclose() {
|
||||
setIterating(false);
|
||||
setStatus("");
|
||||
},
|
||||
onerror(err) {
|
||||
setIterating(false);
|
||||
setStatus("");
|
||||
setError(String(err) || t("session.error_iter"));
|
||||
throw err;
|
||||
},
|
||||
});
|
||||
} catch (err: any) {
|
||||
setIterating(false);
|
||||
setStatus("");
|
||||
setError(err.message || t("session.error_iter"));
|
||||
}
|
||||
};
|
||||
|
||||
const runIteration = async (overrideAction?: string) => {
|
||||
if (!id || iterating) return;
|
||||
const rawAction = overrideAction ?? actionText;
|
||||
if (!rawAction.trim()) return;
|
||||
setError("");
|
||||
setLastFailedAction(null);
|
||||
setIterating(true);
|
||||
setStatus(t("session.status_planning"));
|
||||
const action = rawAction.trim();
|
||||
if (overrideAction === undefined) setActionText("");
|
||||
|
||||
// Optimistic: show user action immediately (skip on retry if already shown)
|
||||
const alreadyShown = messages.some(
|
||||
(m) => m.kind === "player_action" && m.content === action && m.id?.startsWith("tmp-")
|
||||
);
|
||||
if (!alreadyShown) {
|
||||
const optimisticUserMsg: Message = {
|
||||
id: `tmp-${Date.now()}`,
|
||||
seq: messages.length + 1,
|
||||
role: "user",
|
||||
kind: "player_action",
|
||||
content: action,
|
||||
payload: {},
|
||||
is_pinned: true,
|
||||
hidden: false,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
setMessages((prev) => [...prev, optimisticUserMsg]);
|
||||
}
|
||||
|
||||
try {
|
||||
await fetchEventSource(`/api/sessions/${id}/iterate`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ session_id: id, action_text: action }),
|
||||
openWhenHidden: true,
|
||||
|
||||
onmessage(ev) {
|
||||
const eventName = ev.event;
|
||||
let data: any = {};
|
||||
try {
|
||||
data = JSON.parse(ev.data || "{}");
|
||||
} catch {
|
||||
data = {};
|
||||
}
|
||||
if (eventName === "status") {
|
||||
const msg = data.message || "";
|
||||
if (msg.startsWith("orchestrator")) setStatus(t("session.status_orchestrator_turn"));
|
||||
else if (msg === "writing_scene") setStatus(t("session.status_writing_scene"));
|
||||
else if (msg === "planning") setStatus(t("session.status_planning"));
|
||||
else setStatus(msg);
|
||||
} else if (eventName === "tool_call") {
|
||||
setStatus(`🔧 ${data.name}(...)` );
|
||||
} else if (eventName === "step_complete") {
|
||||
const stepMsg: Message = {
|
||||
id: data.message_id || `step-${Date.now()}`,
|
||||
seq: data.seq || messages.length + 2,
|
||||
role: "assistant",
|
||||
kind: "narrative_step",
|
||||
content: data.narrative || "",
|
||||
payload: { options: data.options || [], world_time: data.world_time },
|
||||
is_pinned: true,
|
||||
hidden: false,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
setMessages((prev) => [...prev, stepMsg]);
|
||||
if (world && data.world_time) {
|
||||
setWorld({ ...world, current_time: data.world_time, state: data.state || world.state });
|
||||
}
|
||||
// Refresh triggers
|
||||
if (id) {
|
||||
miscApi.listTriggers(id, false).then(setTriggers).catch(() => {});
|
||||
miscApi.listGlossary(world?.id || "").then(setGlossary).catch(() => {});
|
||||
}
|
||||
} else if (eventName === "error") {
|
||||
setError(data.message || t("session.error_iter"));
|
||||
setLastFailedAction(action);
|
||||
} else if (eventName === "done") {
|
||||
// Final reload to get fresh seq/order
|
||||
load();
|
||||
}
|
||||
},
|
||||
|
||||
onclose() {
|
||||
setIterating(false);
|
||||
setStatus("");
|
||||
},
|
||||
|
||||
onerror(err) {
|
||||
setIterating(false);
|
||||
setStatus("");
|
||||
setError(String(err) || t("session.error_iter"));
|
||||
setLastFailedAction(action);
|
||||
throw err; // stop retry
|
||||
},
|
||||
});
|
||||
} catch (err: any) {
|
||||
setIterating(false);
|
||||
setStatus("");
|
||||
setError(err.message || t("session.error_iter"));
|
||||
}
|
||||
};
|
||||
|
||||
const pickOption = (opt: string) => {
|
||||
setActionText(opt);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="p-8 text-center text-ink-400">{t("common.loading")}</div>;
|
||||
}
|
||||
|
||||
if (!session || !world) {
|
||||
return <div className="p-8 text-center text-red-400">{error || t("common.not_found")}</div>;
|
||||
}
|
||||
|
||||
const lastNarrative = [...messages].reverse().find((m) => m.kind === "narrative_step");
|
||||
const options: string[] = lastNarrative?.payload?.options || [];
|
||||
|
||||
return (
|
||||
<div className="h-[calc(100vh-3.5rem)] flex flex-col">
|
||||
{/* Top bar */}
|
||||
<div className="border-b border-ink-800 bg-ink-950/50 px-4 py-2 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<Button size="sm" variant="ghost" onClick={() => navigate("/dashboard")}>
|
||||
{t("session.back")}
|
||||
</Button>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-ink-100 truncate">{session.title}</div>
|
||||
<div className="text-xs text-ink-500 truncate flex items-center gap-1">
|
||||
<Clock size={10} />
|
||||
{world.current_time || "—"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<Button size="sm" variant="ghost" onClick={() => setGlossaryOpen(true)}>
|
||||
<BookOpen size={14} className="mr-1" />
|
||||
{t("session.glossary")}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => navigate(`/worlds/${world.id}/edit`)}>
|
||||
<Pencil size={14} className="mr-1" />
|
||||
{t("session.edit_world")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main area: chat + sidebar */}
|
||||
<div className="flex-1 flex min-h-0">
|
||||
{/* Chat */}
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
<div className="flex-1 overflow-y-auto px-4 py-6 space-y-4">
|
||||
{messages.length === 0 && !iterating && (
|
||||
<div className="text-center text-ink-500 py-12">{t("session.no_messages")}</div>
|
||||
)}
|
||||
{messages
|
||||
.filter((m) => !m.hidden)
|
||||
.map((m, i) => (
|
||||
<MessageBubble key={m.id || i} message={m} />
|
||||
))}
|
||||
{iterating && (
|
||||
<div className="flex items-center gap-2 text-ink-400 text-sm fade-in">
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
{status || t("session.sending")}
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Options + input */}
|
||||
<div className="border-t border-ink-800 bg-ink-950/50 p-3 space-y-2">
|
||||
{options.length > 0 && !iterating && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{options.map((opt, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => pickOption(opt)}
|
||||
className="text-xs px-3 py-1.5 rounded-lg bg-ink-800 hover:bg-ink-700 border border-ink-700 text-ink-200"
|
||||
>
|
||||
{opt}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{lastFailedAction ? (
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="flex-1 text-sm text-red-300 bg-red-950/40 border border-red-800/60 rounded-lg px-3 py-2">
|
||||
{error || t("session.error_iter")}
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => runIteration(lastFailedAction)}
|
||||
disabled={iterating}
|
||||
className="self-end"
|
||||
>
|
||||
<Loader2 className={iterating ? "animate-spin mr-1" : "hidden"} size={14} />
|
||||
{!iterating && <RefreshCw size={14} className="mr-1" />}
|
||||
{iterating ? t("session.sending") : t("session.retry")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<Textarea
|
||||
value={actionText}
|
||||
onChange={(e) => setActionText(e.target.value)}
|
||||
placeholder={t("session.action_placeholder")}
|
||||
disabled={iterating}
|
||||
rows={2}
|
||||
className="min-h-[44px]"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
runIteration();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button onClick={() => runIteration()} disabled={iterating || !actionText.trim()} className="self-end">
|
||||
<Send size={14} className="mr-1" />
|
||||
{iterating ? t("session.sending") : t("session.send")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{error && !lastFailedAction && <p className="text-xs text-red-400">{error}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar: character sheet + triggers */}
|
||||
<aside className="w-72 border-l border-ink-800 bg-ink-950/50 hidden lg:flex flex-col">
|
||||
<div className="p-3 border-b border-ink-800">
|
||||
<div className="flex items-center gap-2 text-xs font-medium text-ink-300 mb-2">
|
||||
<User size={12} />
|
||||
{t("session.character")}
|
||||
</div>
|
||||
<CharacterSheet state={world.state} />
|
||||
</div>
|
||||
<div className="p-3 flex-1 overflow-y-auto">
|
||||
<div className="flex items-center gap-2 text-xs font-medium text-ink-300 mb-2">
|
||||
<Zap size={12} />
|
||||
{t("session.triggers_panel")}
|
||||
</div>
|
||||
{triggers.length === 0 ? (
|
||||
<p className="text-xs text-ink-500">—</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{triggers.map((tr) => (
|
||||
<div key={tr.id} className="text-xs p-2 rounded bg-ink-900 border border-ink-800">
|
||||
<div className="text-ink-300">{tr.description}</div>
|
||||
<div className="text-ink-500 mt-1">⏱ {tr.fire_at}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<GlossaryModal open={glossaryOpen} onClose={() => setGlossaryOpen(false)} entries={glossary} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageBubble({ message }: { message: Message }) {
|
||||
if (message.kind === "player_action") {
|
||||
return (
|
||||
<div className="flex justify-end fade-in">
|
||||
<div className="max-w-[80%] bg-accent-500/20 border border-accent-500/40 rounded-xl p-3">
|
||||
<div className="text-xs text-accent-300 mb-1">Игрок</div>
|
||||
<div className="text-sm text-ink-100 whitespace-pre-wrap">{message.content}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (message.kind === "narrative_step") {
|
||||
const content = message.content.startsWith("[Событие]")
|
||||
? message.content
|
||||
: message.content;
|
||||
return (
|
||||
<div className="fade-in">
|
||||
<div className="bg-ink-900/70 border border-ink-800 rounded-xl p-4">
|
||||
<div className="prose-rpg text-sm">
|
||||
<ReactMarkdown>{content}</ReactMarkdown>
|
||||
</div>
|
||||
{message.payload?.options?.length > 0 && (
|
||||
<div className="mt-3 pt-3 border-t border-ink-800 flex flex-wrap gap-2">
|
||||
{message.payload.options.map((opt: string, i: number) => (
|
||||
<span key={i} className="text-xs px-2 py-1 rounded bg-ink-800 text-ink-300">
|
||||
{opt}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (message.kind === "summary") {
|
||||
return (
|
||||
<div className="text-xs text-ink-500 italic text-center px-4 py-2 border-y border-ink-800/50">
|
||||
📜 {message.content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -1,266 +1,14 @@
|
||||
import { FormEvent, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { worldsApi } from "@/api";
|
||||
import type { WorldBuilderReply } from "@/types";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Input, Textarea } from "@/components/ui/Input";
|
||||
import { Card, CardBody, CardHeader } from "@/components/ui/Card";
|
||||
import { Send, Check, Sparkles } from "lucide-react";
|
||||
|
||||
interface BuilderMessage {
|
||||
role: "user" | "assistant";
|
||||
text: string;
|
||||
proposed?: any;
|
||||
is_final?: boolean;
|
||||
followups?: string[];
|
||||
}
|
||||
import { WorldBuilder } from "@/components/worlds/WorldBuilder";
|
||||
|
||||
export function WorldBuilderPage() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation() as any;
|
||||
const presetId = location.state?.presetId as string | undefined;
|
||||
const fromScratch = location.state?.fromScratch as boolean | undefined;
|
||||
|
||||
const [worldName, setWorldName] = useState("");
|
||||
const [language, setLanguage] = useState(i18n.language === "ru" ? "ru" : "en");
|
||||
const [settingBrief, setSettingBrief] = useState("");
|
||||
const [characterBrief, setCharacterBrief] = useState("");
|
||||
const [rulesBrief, setRulesBrief] = useState("");
|
||||
const [notes, setNotes] = useState("");
|
||||
const [started, setStarted] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [committing, setCommitting] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [sessionId, setSessionId] = useState<string | null>(null);
|
||||
const [messages, setMessages] = useState<BuilderMessage[]>([]);
|
||||
const [currentReply, setCurrentReply] = useState<WorldBuilderReply | null>(null);
|
||||
const [userInput, setUserInput] = useState("");
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
}, [messages, currentReply]);
|
||||
|
||||
const startBuilder = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!worldName.trim()) return;
|
||||
setError("");
|
||||
setLoading(true);
|
||||
try {
|
||||
const reply = await worldsApi.builderStart({
|
||||
world_name: worldName,
|
||||
language,
|
||||
preset_id: presetId,
|
||||
setting_brief: settingBrief,
|
||||
character_brief: characterBrief,
|
||||
rules_brief: rulesBrief,
|
||||
notes,
|
||||
});
|
||||
setSessionId(reply.session_id);
|
||||
setMessages([{ role: "assistant", text: reply.ai_message, proposed: reply.proposed_definition, is_final: reply.is_final, followups: reply.followup_questions }]);
|
||||
setCurrentReply(reply);
|
||||
setStarted(true);
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || err.message || t("errors.unknown"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const sendMessage = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!userInput.trim() || !sessionId) return;
|
||||
setError("");
|
||||
const msg = userInput.trim();
|
||||
setUserInput("");
|
||||
setMessages((prev) => [...prev, { role: "user", text: msg }]);
|
||||
setLoading(true);
|
||||
try {
|
||||
const reply = await worldsApi.builderContinue(sessionId, msg);
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
role: "assistant",
|
||||
text: reply.ai_message,
|
||||
proposed: reply.proposed_definition,
|
||||
is_final: reply.is_final,
|
||||
followups: reply.followup_questions,
|
||||
},
|
||||
]);
|
||||
setCurrentReply(reply);
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || err.message || t("errors.unknown"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const commitWorld = async () => {
|
||||
if (!sessionId) return;
|
||||
setError("");
|
||||
setCommitting(true);
|
||||
try {
|
||||
const world = await worldsApi.builderCommit(sessionId, worldName);
|
||||
navigate(`/worlds/${world.id}/edit`);
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || err.message || t("errors.unknown"));
|
||||
} finally {
|
||||
setCommitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!started) {
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto px-4 py-8">
|
||||
<h1 className="text-2xl font-serif text-ink-100 mb-6">{t("worlds.builder_title")}</h1>
|
||||
<Card>
|
||||
<CardHeader title={t("worlds.builder_title")} subtitle={t("worlds.builder_desc")} />
|
||||
<CardBody>
|
||||
<form onSubmit={startBuilder} className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<div className="md:col-span-2">
|
||||
<Input
|
||||
label={t("worlds.name")}
|
||||
value={worldName}
|
||||
onChange={(e) => setWorldName(e.target.value)}
|
||||
required
|
||||
placeholder="Тёмное Королевство"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-ink-300 mb-1">{t("worlds.language")}</label>
|
||||
<select
|
||||
value={language}
|
||||
onChange={(e) => setLanguage(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-lg bg-ink-900 border border-ink-700 text-ink-100"
|
||||
>
|
||||
<option value="ru">Русский</option>
|
||||
<option value="en">English</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<Textarea
|
||||
label={t("worlds.setting_brief")}
|
||||
value={settingBrief}
|
||||
onChange={(e) => setSettingBrief(e.target.value)}
|
||||
placeholder={t("worlds.setting_brief_ph")}
|
||||
rows={3}
|
||||
/>
|
||||
<Textarea
|
||||
label={t("worlds.character_brief")}
|
||||
value={characterBrief}
|
||||
onChange={(e) => setCharacterBrief(e.target.value)}
|
||||
placeholder={t("worlds.character_brief_ph")}
|
||||
rows={2}
|
||||
/>
|
||||
<Textarea
|
||||
label={t("worlds.rules_brief")}
|
||||
value={rulesBrief}
|
||||
onChange={(e) => setRulesBrief(e.target.value)}
|
||||
placeholder={t("worlds.rules_brief_ph")}
|
||||
rows={2}
|
||||
/>
|
||||
<Textarea
|
||||
label={t("worlds.notes")}
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
placeholder={t("worlds.notes_ph")}
|
||||
rows={2}
|
||||
/>
|
||||
{error && <p className="text-sm text-red-400">{error}</p>}
|
||||
<Button type="submit" disabled={loading}>
|
||||
<Sparkles size={14} className="mr-1" />
|
||||
{loading ? t("common.loading") : t("worlds.start_builder")}
|
||||
</Button>
|
||||
</form>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-4 py-6 h-[calc(100vh-3.5rem)] flex flex-col">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h1 className="text-xl font-serif text-ink-100">{t("worlds.builder_title")} — {worldName}</h1>
|
||||
<Button size="sm" variant="ghost" onClick={() => navigate("/dashboard")}>
|
||||
{t("worlds.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card className="flex-1 flex flex-col min-h-0">
|
||||
<div ref={scrollRef} className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||
{messages.map((m, i) => (
|
||||
<div key={i} className={`flex ${m.role === "user" ? "justify-end" : "justify-start"}`}>
|
||||
<div
|
||||
className={`max-w-[80%] rounded-xl p-3 ${
|
||||
m.role === "user"
|
||||
? "bg-accent-500/20 border border-accent-500/40 text-ink-100"
|
||||
: "bg-ink-800 border border-ink-700 text-ink-100"
|
||||
}`}
|
||||
>
|
||||
<div className="text-xs text-ink-400 mb-1">
|
||||
{m.role === "user" ? "Вы" : "ИИ"}
|
||||
</div>
|
||||
<div className="whitespace-pre-wrap text-sm">{m.text}</div>
|
||||
{m.followups && m.followups.length > 0 && (
|
||||
<ul className="mt-2 text-xs text-ink-300 list-disc list-inside">
|
||||
{m.followups.map((q, qi) => (
|
||||
<li key={qi}>{q}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{m.proposed && (
|
||||
<details className="mt-2 text-xs">
|
||||
<summary className="cursor-pointer text-accent-400">
|
||||
Предложенное определение мира
|
||||
</summary>
|
||||
<pre className="mt-1 p-2 bg-ink-900 rounded text-[10px] overflow-x-auto">
|
||||
{JSON.stringify(m.proposed, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{loading && (
|
||||
<div className="flex justify-start">
|
||||
<div className="bg-ink-800 border border-ink-700 rounded-xl p-3 text-ink-400 text-sm pulse-soft">
|
||||
{t("common.loading")}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <div className="px-4 py-2 text-sm text-red-400 border-t border-ink-800">{error}</div>}
|
||||
|
||||
<div className="p-3 border-t border-ink-800 flex gap-2">
|
||||
<form onSubmit={sendMessage} className="flex-1 flex gap-2">
|
||||
<Input
|
||||
value={userInput}
|
||||
onChange={(e) => setUserInput(e.target.value)}
|
||||
placeholder={t("worlds.builder_message_ph")}
|
||||
disabled={loading}
|
||||
/>
|
||||
<Button type="submit" disabled={loading || !userInput.trim()}>
|
||||
<Send size={14} />
|
||||
</Button>
|
||||
</form>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={commitWorld}
|
||||
disabled={committing || !currentReply?.proposed_definition}
|
||||
title={!currentReply?.proposed_definition ? "ИИ ещё не предложил мир" : ""}
|
||||
>
|
||||
<Check size={14} className="mr-1" />
|
||||
{committing ? t("worlds.accepting") : t("worlds.accept")}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
<div className="mx-auto max-w-7xl space-y-4 p-4">
|
||||
<header>
|
||||
<h1 className="text-xl font-semibold text-fg">{t("builder.title")}</h1>
|
||||
</header>
|
||||
<WorldBuilder />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { presetsApi } from "@/api";
|
||||
import type { Preset } from "@/types";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Card, CardBody, CardHeader } from "@/components/ui/Card";
|
||||
import { Sparkles, FileText } from "lucide-react";
|
||||
|
||||
export function WorldCreatePage() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [presets, setPresets] = useState<Preset[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [mode, setMode] = useState<"preset" | "scratch" | null>(null);
|
||||
const [selectedPreset, setSelectedPreset] = useState<Preset | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const p = await presetsApi.list(i18n.language);
|
||||
setPresets(p);
|
||||
} catch (e) {
|
||||
// ignore
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, [i18n.language]);
|
||||
|
||||
const startBuilder = () => {
|
||||
// Pass state via location state to WorldBuilderPage
|
||||
navigate("/worlds/builder", {
|
||||
state: {
|
||||
presetId: mode === "preset" ? selectedPreset?.id : undefined,
|
||||
fromScratch: mode === "scratch",
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto px-4 py-8">
|
||||
<h1 className="text-2xl font-serif text-ink-100 mb-6">{t("worlds.preset_choice")}</h1>
|
||||
|
||||
{!mode && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<button onClick={() => setMode("preset")} className="text-left">
|
||||
<Card className="hover:border-accent-500 transition-colors cursor-pointer h-full">
|
||||
<CardBody>
|
||||
<Sparkles className="text-accent-500 mb-3" size={24} />
|
||||
<h3 className="font-semibold text-ink-100 mb-2">{t("worlds.use_preset")}</h3>
|
||||
<p className="text-xs text-ink-400">
|
||||
{t("worlds.preset_choice")} — Fantasy, ...
|
||||
</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</button>
|
||||
<button onClick={() => setMode("scratch")} className="text-left">
|
||||
<Card className="hover:border-accent-500 transition-colors cursor-pointer h-full">
|
||||
<CardBody>
|
||||
<FileText className="text-accent-500 mb-3" size={24} />
|
||||
<h3 className="font-semibold text-ink-100 mb-2">{t("worlds.from_scratch")}</h3>
|
||||
<p className="text-xs text-ink-400">{t("worlds.builder_desc")}</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode === "preset" && (
|
||||
<Card>
|
||||
<CardHeader
|
||||
title={t("worlds.use_preset")}
|
||||
action={
|
||||
<Button size="sm" variant="ghost" onClick={() => setMode(null)}>
|
||||
{t("worlds.cancel")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<CardBody>
|
||||
{loading ? (
|
||||
<p className="text-ink-400">{t("common.loading")}</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{presets.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => setSelectedPreset(p)}
|
||||
className={`w-full text-left p-3 rounded-lg border transition-colors ${
|
||||
selectedPreset?.id === p.id
|
||||
? "border-accent-500 bg-accent-500/10"
|
||||
: "border-ink-700 hover:border-ink-600 bg-ink-900"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-medium text-ink-100">{p.title}</div>
|
||||
<div className="text-xs text-ink-400 mt-0.5">{p.description}</div>
|
||||
</div>
|
||||
<div className="flex gap-2 text-xs text-ink-500">
|
||||
<span>{p.language.toUpperCase()}</span>
|
||||
{p.is_builtin && (
|
||||
<span className="px-1.5 py-0.5 bg-accent-500/20 text-accent-400 rounded">
|
||||
built-in
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end gap-2 mt-4">
|
||||
<Button variant="ghost" onClick={() => setSelectedPreset(null)}>
|
||||
{t("worlds.cancel")}
|
||||
</Button>
|
||||
<Button disabled={!selectedPreset} onClick={startBuilder}>
|
||||
{t("worlds.start_builder")}
|
||||
</Button>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{mode === "scratch" && (
|
||||
<Card>
|
||||
<CardHeader
|
||||
title={t("worlds.from_scratch")}
|
||||
action={
|
||||
<Button size="sm" variant="ghost" onClick={() => setMode(null)}>
|
||||
{t("worlds.cancel")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<CardBody>
|
||||
<p className="text-sm text-ink-400 mb-4">{t("worlds.builder_desc")}</p>
|
||||
<Button onClick={startBuilder}>{t("worlds.start_builder")}</Button>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,305 +1,73 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import axios from "axios";
|
||||
import { worldsApi, sessionsApi } from "@/api";
|
||||
import { useAuthStore } from "@/store/auth";
|
||||
import type { World } from "@/types";
|
||||
import { useWorldsStore } from "@/stores/worldsStore";
|
||||
import { useToastStore } from "@/stores/toastStore";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Input, Textarea } from "@/components/ui/Input";
|
||||
import { Card, CardBody, CardHeader } from "@/components/ui/Card";
|
||||
import { Play, Save, RotateCcw, Send, Sparkles, Loader2 } from "lucide-react";
|
||||
|
||||
interface EditorChatMessage {
|
||||
role: "user" | "assistant";
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface ChatReply {
|
||||
ai_message: string;
|
||||
definition: Record<string, any> | null;
|
||||
changed: boolean;
|
||||
}
|
||||
import { Spinner } from "@/components/ui/Spinner";
|
||||
import { WorldEditor } from "@/components/worlds/WorldEditor";
|
||||
|
||||
export function WorldEditPage() {
|
||||
const { t } = useTranslation();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { token } = useAuthStore();
|
||||
|
||||
const [world, setWorld] = useState<World | null>(null);
|
||||
const [name, setName] = useState("");
|
||||
const [definitionText, setDefinitionText] = useState("");
|
||||
const [stateText, setStateText] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
// AI chat state
|
||||
const [chatMessages, setChatMessages] = useState<EditorChatMessage[]>([]);
|
||||
const [chatInput, setChatInput] = useState("");
|
||||
const [chatLoading, setChatLoading] = useState(false);
|
||||
const [pendingDefinition, setPendingDefinition] = useState<Record<string, any> | null>(null);
|
||||
const chatScrollRef = useRef<HTMLDivElement>(null);
|
||||
const world = useWorldsStore((s) => s.currentWorld);
|
||||
const loading = useWorldsStore((s) => s.currentLoading);
|
||||
const error = useWorldsStore((s) => s.currentError);
|
||||
const fetchWorld = useWorldsStore((s) => s.fetchWorld);
|
||||
const setCurrentWorld = useWorldsStore((s) => s.setCurrentWorld);
|
||||
const pushToast = useToastStore((s) => s.push);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (!id) return;
|
||||
try {
|
||||
const w = await worldsApi.get(id);
|
||||
setWorld(w);
|
||||
setName(w.name);
|
||||
setDefinitionText(JSON.stringify(w.definition, null, 2));
|
||||
setStateText(JSON.stringify(w.state, null, 2));
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || t("errors.unknown"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
chatScrollRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [chatMessages, chatLoading]);
|
||||
|
||||
const save = async () => {
|
||||
if (!id || !world) return;
|
||||
setError("");
|
||||
setSaving(true);
|
||||
try {
|
||||
const definition = JSON.parse(definitionText);
|
||||
const state = JSON.parse(stateText);
|
||||
const updated = await worldsApi.update(id, {
|
||||
name,
|
||||
definition,
|
||||
state,
|
||||
current_time: world.current_time,
|
||||
status: world.status === "draft" ? "ready" : world.status,
|
||||
});
|
||||
setWorld(updated);
|
||||
// Reset chat (definition changed -> stale context)
|
||||
setChatMessages([]);
|
||||
setPendingDefinition(null);
|
||||
} catch (err: any) {
|
||||
setError(err.message || err.response?.data?.detail || t("errors.unknown"));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const startSession = async () => {
|
||||
if (!id) return;
|
||||
try {
|
||||
const session = await sessionsApi.create(id);
|
||||
navigate(`/sessions/${session.id}`);
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || t("errors.unknown"));
|
||||
}
|
||||
};
|
||||
void fetchWorld(id).catch(() => {
|
||||
pushToast("error", t("worlds.not_found"));
|
||||
});
|
||||
return () => setCurrentWorld(null);
|
||||
}, [id, fetchWorld, setCurrentWorld, pushToast, t]);
|
||||
|
||||
const sendChatMessage = async () => {
|
||||
if (!id || !chatInput.trim() || chatLoading) return;
|
||||
const msg = chatInput.trim();
|
||||
setChatInput("");
|
||||
setChatMessages((prev) => [...prev, { role: "user", text: msg }]);
|
||||
setChatLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const { data } = await axios.post<ChatReply>(
|
||||
`/api/worlds/${id}/chat`,
|
||||
{ message: msg },
|
||||
{ headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` } }
|
||||
);
|
||||
setChatMessages((prev) => [...prev, { role: "assistant", text: data.ai_message }]);
|
||||
if (data.definition) {
|
||||
setPendingDefinition(data.definition);
|
||||
setDefinitionText(JSON.stringify(data.definition, null, 2));
|
||||
}
|
||||
} catch (err: any) {
|
||||
const detail = err.response?.data?.detail || err.message || t("errors.unknown");
|
||||
setChatMessages((prev) => [...prev, { role: "assistant", text: `⚠️ ${detail}` }]);
|
||||
} finally {
|
||||
setChatLoading(false);
|
||||
}
|
||||
};
|
||||
if (!id) {
|
||||
return <p className="p-4 text-sm text-err">{t("worlds.not_found")}</p>;
|
||||
}
|
||||
|
||||
const resetChat = async () => {
|
||||
if (!id) return;
|
||||
try {
|
||||
await axios.post(`/api/worlds/${id}/chat/reset`, {}, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
setChatMessages([]);
|
||||
setPendingDefinition(null);
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || t("errors.unknown"));
|
||||
}
|
||||
};
|
||||
if (loading && !world) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 p-8 text-sm text-fg-muted">
|
||||
<Spinner /> {t("common.loading")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const applyPendingDefinition = () => {
|
||||
if (!pendingDefinition) return;
|
||||
setDefinitionText(JSON.stringify(pendingDefinition, null, 2));
|
||||
setPendingDefinition(null);
|
||||
};
|
||||
if (error && !world) {
|
||||
return (
|
||||
<div className="p-4">
|
||||
<p className="text-sm text-err">{t("worlds.not_found")}: {error}</p>
|
||||
<Button className="mt-3" variant="secondary" onClick={() => navigate("/worlds")}>
|
||||
{t("common.back")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) return <div className="p-8 text-center text-ink-400">{t("common.loading")}</div>;
|
||||
if (!world) return <div className="p-8 text-center text-red-400">{error || t("common.not_found")}</div>;
|
||||
if (!world) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 py-6">
|
||||
<div className="flex items-center justify-between mb-4 gap-2">
|
||||
<h1 className="text-xl font-serif text-ink-100 flex-1 min-w-0 truncate">
|
||||
{t("worlds.edit")}: {world.name}
|
||||
</h1>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="ghost" onClick={() => navigate("/dashboard")}>
|
||||
{t("session.back")}
|
||||
</Button>
|
||||
<Button onClick={startSession} disabled={world.status === "draft"}>
|
||||
<Play size={14} className="mr-1" />
|
||||
{t("worlds.start")}
|
||||
</Button>
|
||||
<div className="mx-auto max-w-7xl space-y-4 p-4">
|
||||
<header className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-fg">{t("editor.title")}</h1>
|
||||
<p className="text-sm text-fg-muted">{world.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-red-400 mb-4">{error}</p>}
|
||||
|
||||
{/* World name row */}
|
||||
<Card className="mb-4">
|
||||
<CardHeader title={t("worlds.name")} />
|
||||
<CardBody>
|
||||
<div className="flex gap-2 items-center">
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button onClick={save} disabled={saving || !name.trim()}>
|
||||
{saving ? <Loader2 size={14} className="animate-spin mr-1" /> : <Save size={14} className="mr-1" />}
|
||||
{saving ? t("common.loading") : t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
{/* AI chat panel (1 col) */}
|
||||
<Card className="lg:col-span-1 flex flex-col">
|
||||
<CardHeader
|
||||
title={
|
||||
<span className="flex items-center gap-2">
|
||||
<Sparkles size={14} className="text-accent-400" />
|
||||
{t("worlds.editor_chat_title")}
|
||||
</span> as any
|
||||
}
|
||||
subtitle={t("worlds.editor_chat_desc")}
|
||||
/>
|
||||
<CardBody className="flex-1 flex flex-col min-h-0">
|
||||
<div className="flex-1 overflow-y-auto space-y-3 mb-3 max-h-[55vh]">
|
||||
{chatMessages.length === 0 && (
|
||||
<div className="text-xs text-ink-500 italic text-center py-6">
|
||||
{t("worlds.editor_chat_empty")}
|
||||
</div>
|
||||
)}
|
||||
{chatMessages.map((m, i) => (
|
||||
<div key={i} className={`flex ${m.role === "user" ? "justify-end" : "justify-start"}`}>
|
||||
<div
|
||||
className={`max-w-[90%] rounded-xl p-2.5 text-xs whitespace-pre-wrap ${
|
||||
m.role === "user"
|
||||
? "bg-accent-500/20 border border-accent-500/40 text-ink-100"
|
||||
: "bg-ink-800 border border-ink-700 text-ink-100"
|
||||
}`}
|
||||
>
|
||||
<div className="text-[10px] text-ink-400 mb-1">
|
||||
{m.role === "user" ? t("worlds.editor_you") : "AI"}
|
||||
</div>
|
||||
{m.text}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{chatLoading && (
|
||||
<div className="flex justify-start">
|
||||
<div className="bg-ink-800 border border-ink-700 rounded-xl p-2.5 text-ink-400 text-xs pulse-soft flex items-center gap-2">
|
||||
<Loader2 size={12} className="animate-spin" />
|
||||
{t("common.loading")}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div ref={chatScrollRef} />
|
||||
</div>
|
||||
|
||||
{pendingDefinition && (
|
||||
<div className="mb-3 p-2 rounded-lg bg-accent-500/10 border border-accent-500/40 text-xs text-accent-200 flex items-center justify-between gap-2">
|
||||
<span>{t("worlds.editor_pending_defn")}</span>
|
||||
<Button size="sm" variant="secondary" onClick={applyPendingDefinition}>
|
||||
{t("worlds.editor_apply")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Textarea
|
||||
value={chatInput}
|
||||
onChange={(e) => setChatInput(e.target.value)}
|
||||
placeholder={t("worlds.editor_chat_ph")}
|
||||
disabled={chatLoading}
|
||||
rows={2}
|
||||
className="min-h-[60px] flex-1"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
sendChatMessage();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Button size="sm" onClick={sendChatMessage} disabled={chatLoading || !chatInput.trim()}>
|
||||
<Send size={14} />
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={resetChat} disabled={chatLoading} title={t("worlds.editor_reset")}>
|
||||
<RotateCcw size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* JSON editors (2 cols) */}
|
||||
<div className="lg:col-span-2 grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Card>
|
||||
<CardHeader title="World definition (JSON)" subtitle="setting, rules, schema, plot_rails, initial_state" />
|
||||
<CardBody>
|
||||
<textarea
|
||||
value={definitionText}
|
||||
onChange={(e) => setDefinitionText(e.target.value)}
|
||||
className="w-full h-[55vh] px-3 py-2 rounded bg-ink-950 border border-ink-700 text-ink-100 font-mono text-xs"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</CardBody>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader title="Live world state (JSON)" subtitle="current player/NPC/inventory/time" />
|
||||
<CardBody>
|
||||
<textarea
|
||||
value={stateText}
|
||||
onChange={(e) => setStateText(e.target.value)}
|
||||
className="w-full h-[55vh] px-3 py-2 rounded bg-ink-950 border border-ink-700 text-ink-100 font-mono text-xs"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<Button variant="ghost" onClick={() => navigate("/dashboard")}>
|
||||
{t("worlds.cancel")}
|
||||
<Button variant="secondary" onClick={() => navigate(`/worlds/${world.id}/play`)}>
|
||||
{t("worlds.play")}
|
||||
</Button>
|
||||
<Button onClick={save} disabled={saving}>
|
||||
{saving ? t("common.loading") : t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
<WorldEditor
|
||||
world={world}
|
||||
onWorldUpdated={(w) => setCurrentWorld(w)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
100
frontend/src/pages/WorldsListPage.tsx
Normal file
100
frontend/src/pages/WorldsListPage.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useWorldsStore } from "@/stores/worldsStore";
|
||||
import { useToastStore } from "@/stores/toastStore";
|
||||
import type { WorldListItem } from "@/types";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { Spinner } from "@/components/ui/Spinner";
|
||||
import { WorldCard } from "@/components/worlds/WorldCard";
|
||||
import { Modal } from "@/components/ui/Modal";
|
||||
|
||||
export function WorldsListPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const list = useWorldsStore((s) => s.list);
|
||||
const loading = useWorldsStore((s) => s.loading);
|
||||
const error = useWorldsStore((s) => s.error);
|
||||
const fetchList = useWorldsStore((s) => s.fetchList);
|
||||
const removeWorld = useWorldsStore((s) => s.removeWorld);
|
||||
const pushToast = useToastStore((s) => s.push);
|
||||
|
||||
const [toDelete, setToDelete] = useState<WorldListItem | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
void fetchList();
|
||||
}, [fetchList]);
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!toDelete) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
await removeWorld(toDelete.id);
|
||||
pushToast("success", t("worlds.deleted"));
|
||||
setToDelete(null);
|
||||
} catch (err) {
|
||||
pushToast("error", err instanceof Error ? err.message : t("worlds.delete_failed"));
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl space-y-4 p-4">
|
||||
<header className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-semibold text-fg">{t("worlds.title")}</h1>
|
||||
<Button onClick={() => navigate("/worlds/new")}>{t("worlds.create_new")}</Button>
|
||||
</header>
|
||||
|
||||
{loading && list.length === 0 ? (
|
||||
<div className="flex items-center gap-2 text-sm text-fg-muted">
|
||||
<Spinner size="sm" /> {t("common.loading")}
|
||||
</div>
|
||||
) : error ? (
|
||||
<Card>
|
||||
<p className="text-sm text-err">{t("worlds.load_failed")}: {error}</p>
|
||||
<Button className="mt-2" variant="secondary" onClick={() => void fetchList()}>
|
||||
{t("common.retry")}
|
||||
</Button>
|
||||
</Card>
|
||||
) : list.length === 0 ? (
|
||||
<Card>
|
||||
<p className="text-sm text-fg-muted">{t("worlds.empty")}</p>
|
||||
<Button className="mt-3" onClick={() => navigate("/worlds/new")}>
|
||||
{t("worlds.create_new")}
|
||||
</Button>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{list.map((w) => (
|
||||
<WorldCard key={w.id} world={w} onDelete={setToDelete} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
open={!!toDelete}
|
||||
onClose={() => setToDelete(null)}
|
||||
title={t("worlds.delete")}
|
||||
size="sm"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={() => setToDelete(null)}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button variant="danger" loading={deleting} onClick={confirmDelete}>
|
||||
{t("common.delete")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p className="text-sm text-fg">{t("worlds.delete_confirm")}</p>
|
||||
{toDelete && (
|
||||
<p className="mt-2 text-sm font-semibold text-fg">{toDelete.name}</p>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user