This commit is contained in:
Mikan
2026-06-19 19:14:27 +03:00
parent 2167493887
commit 32575e217e
23 changed files with 1191 additions and 92 deletions

View File

@@ -1,5 +1,7 @@
import { useEffect } from "react";
import { Routes, Route, Navigate } from "react-router-dom";
import { useAuthStore } from "@/store/auth";
import { useUiStore } from "@/store/ui";
import { Navbar } from "@/components/ui/Navbar";
import { HomePage } from "@/pages/HomePage";
import { LoginPage } from "@/pages/LoginPage";
@@ -26,6 +28,15 @@ function AdminRoute({ children }: { children: JSX.Element }) {
}
export default function App() {
// Load public UI settings (logo URL, etc.) once on app boot. These are
// unauthenticated and cached by the api layer, so subsequent navigations
// do not re-fetch. The admin panel calls load(true) after saving to
// pick up a new logo URL without a full page reload.
const loadUi = useUiStore((s) => s.load);
useEffect(() => {
loadUi();
}, [loadUi]);
return (
<div className="min-h-screen flex flex-col">
<Navbar />

View File

@@ -229,4 +229,41 @@ export const miscApi = {
},
};
// Public UI settings — no auth required. Used on login/register/home pages
// to render branding (logo, eventually theme). Caches the result in-process
// so multiple components can call getPublicSettings() without re-fetching.
export type PublicUiSettings = {
logo_url?: string;
};
let _publicSettingsCache: PublicUiSettings | null = null;
let _publicSettingsPromise: Promise<PublicUiSettings> | null = null;
export const uiApi = {
/** Fetch public UI settings (logo URL, etc.). Cached after first call. */
getPublicSettings: async (force = false): Promise<PublicUiSettings> => {
if (_publicSettingsCache && !force) return _publicSettingsCache;
if (_publicSettingsPromise && !force) return _publicSettingsPromise;
_publicSettingsPromise = (async () => {
try {
const { data } = await api.get("/settings/public");
_publicSettingsCache = {
logo_url: data["ui.logo_url"] || "/logo.png",
};
} catch {
_publicSettingsCache = { logo_url: "/logo.png" };
} finally {
_publicSettingsPromise = null;
}
return _publicSettingsCache;
})();
return _publicSettingsPromise;
},
/** Reset the in-memory cache. Call after admin saves new ui.logo_url. */
resetCache: () => {
_publicSettingsCache = null;
_publicSettingsPromise = null;
},
};
export const SSE_ENDPOINT = "/api/sessions";

View File

@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
import { Link, useNavigate } from "react-router-dom";
import { LogOut, Shield, Globe, BookOpen } from "lucide-react";
import { useAuthStore } from "@/store/auth";
import { useUiStore } from "@/store/ui";
import { Button } from "./Button";
import { cn } from "./cn";
@@ -11,6 +12,7 @@ export function Navbar() {
const { user, logout, isAdmin } = useAuthStore();
const navigate = useNavigate();
const [langOpen, setLangOpen] = useState(false);
const logoUrl = useUiStore((s) => s.logoUrl);
const handleLogout = () => {
logout();
@@ -26,7 +28,21 @@ export function Navbar() {
<header className="border-b border-ink-800 bg-ink-950/80 backdrop-blur sticky top-0 z-40">
<div className="max-w-7xl mx-auto px-4 py-3 flex items-center justify-between">
<Link to="/" className="flex items-center gap-2 text-ink-100 hover:text-accent-400 transition-colors">
<BookOpen size={20} className="text-accent-500" />
{logoUrl ? (
<img
src={logoUrl}
alt="logo"
className="w-7 h-7 rounded object-contain"
onError={(e) => {
// If the configured logo fails to load, hide the broken image
// so the navbar degrades gracefully. The bundled /logo.png is
// always available as the default fallback.
(e.currentTarget as HTMLImageElement).style.display = "none";
}}
/>
) : (
<BookOpen size={20} className="text-accent-500" />
)}
<span className="font-serif text-lg font-semibold">{t("app.title")}</span>
</Link>

View File

@@ -59,6 +59,14 @@ export const en = {
send: "Send",
accept: "Accept world and create",
accepting: "Creating world...",
editor_chat_title: "AI chat",
editor_chat_desc: "Describe changes — the AI will update the world definition.",
editor_chat_empty: "Nothing yet. Tell the AI what to change.",
editor_chat_ph: "e.g. add a vampire faction in the south...",
editor_you: "You",
editor_pending_defn: "AI proposed a new definition.",
editor_apply: "Apply",
editor_reset: "Reset chat",
status_draft: "Draft",
status_ready: "Ready",
status_active: "Active",
@@ -84,6 +92,7 @@ export const en = {
no_messages: "Start with your first action!",
new_session: "New session",
error_iter: "Iteration failed",
retry: "Retry",
},
admin: {
title: "Admin panel",
@@ -106,6 +115,12 @@ export const en = {
trigger_settings_desc: "Fire when in-world time advances (no polling)",
triggers_enabled: "Enabled",
triggers_enabled_desc: "Triggers fire automatically inside the orchestrator when world time advances past their scheduled fire_at.",
ui_settings: "UI customization",
ui_settings_desc: "Branding shown to all users (logo, favicon).",
ui_logo_url: "Logo URL",
ui_logo_url_hint:
"Path (e.g. /logo.png), full URL (https://.../logo.png), or data: URI. Default /logo.png is the bundled Mikan logo. Used in navbar, home page, and browser tab.",
ui_logo_preview: "Preview",
embedding_settings: "Embeddings (RAG)",
embedding_provider: "Provider",
embedding_provider_hash: "Hash (offline fallback, no semantics)",

View File

@@ -59,6 +59,14 @@ export const ru = {
send: "Отправить",
accept: "Принять мир и создать",
accepting: "Создаём мир...",
editor_chat_title: "Чат с ИИ",
editor_chat_desc: "Опишите изменения — ИИ обновит определение мира.",
editor_chat_empty: "Пока пусто. Напишите, что изменить в мире.",
editor_chat_ph: "Например: добавь фракцию вампиров на юге...",
editor_you: "Вы",
editor_pending_defn: "ИИ предложил новое определение.",
editor_apply: "Применить",
editor_reset: "Сбросить диалог",
status_draft: "Черновик",
status_ready: "Готов",
status_active: "Активен",
@@ -84,6 +92,7 @@ export const ru = {
no_messages: "Начните с первого действия!",
new_session: "Новая сессия",
error_iter: "Ошибка при выполнении итерации",
retry: "Повторить",
},
admin: {
title: "Панель администратора",
@@ -106,6 +115,12 @@ export const ru = {
trigger_settings_desc: "Срабатывают при сдвиге внутриигрового времени (без поллинга)",
triggers_enabled: "Включены",
triggers_enabled_desc: "Триггеры срабатывают автоматически внутри оркестратора, когда время мира проходит запланированное fire_at.",
ui_settings: "Настройки интерфейса",
ui_settings_desc: "Брендинг, видимый всем пользователям (логотип, favicon).",
ui_logo_url: "URL логотипа",
ui_logo_url_hint:
"Путь (напр. /logo.png), полный URL (https://.../logo.png) или data: URI. По умолчанию /logo.png — встроенный логотип Mikan. Используется в навбаре, на главной и во вкладке браузера.",
ui_logo_preview: "Превью",
embedding_settings: "Эмбеддинги (RAG)",
embedding_provider: "Провайдер",
embedding_provider_hash: "Hash (офлайн-фолбэк, без семантики)",

View File

@@ -1,8 +1,9 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { adminApi } from "@/api";
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";
@@ -63,6 +64,12 @@ export function AdminPanelPage() {
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 {
@@ -441,6 +448,40 @@ export function AdminPanelPage() {
</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}>

View File

@@ -1,18 +1,38 @@
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">
<BookOpen className="text-accent-500" size={32} />
<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>

View File

@@ -11,7 +11,7 @@ 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 } from "lucide-react";
import { Send, BookOpen, User, Pencil, Clock, Zap, Loader2, RefreshCw } from "lucide-react";
import ReactMarkdown from "react-markdown";
export function SessionPage() {
@@ -28,6 +28,7 @@ export function SessionPage() {
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[]>([]);
@@ -61,27 +62,105 @@ export function SessionPage() {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages, status]);
const runIteration = async () => {
if (!id || !actionText.trim() || iterating) return;
// 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 = actionText.trim();
setActionText("");
const action = rawAction.trim();
if (overrideAction === undefined) setActionText("");
// Optimistic: show user action immediately
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]);
// 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`, {
@@ -132,6 +211,7 @@ export function SessionPage() {
}
} 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();
@@ -147,6 +227,7 @@ export function SessionPage() {
setIterating(false);
setStatus("");
setError(String(err) || t("session.error_iter"));
setLastFailedAction(action);
throw err; // stop retry
},
});
@@ -237,27 +318,45 @@ export function SessionPage() {
))}
</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 && <p className="text-xs text-red-400">{error}</p>}
{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>

View File

@@ -1,29 +1,54 @@
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
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 { Button } from "@/components/ui/Button";
import { Input, Textarea } from "@/components/ui/Input";
import { Card, CardBody, CardHeader } from "@/components/ui/Card";
import { Play } from "lucide-react";
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;
}
export function WorldEditPage() {
const { t } = useTranslation();
const { id } = useParams<{ id: string }>();
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);
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) {
@@ -34,6 +59,10 @@ export function WorldEditPage() {
})();
}, [id]);
useEffect(() => {
chatScrollRef.current?.scrollIntoView({ behavior: "smooth" });
}, [chatMessages, chatLoading]);
const save = async () => {
if (!id || !world) return;
setError("");
@@ -42,14 +71,18 @@ export function WorldEditPage() {
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 || t("errors.unknown"));
setError(err.message || err.response?.data?.detail || t("errors.unknown"));
} finally {
setSaving(false);
}
@@ -65,13 +98,58 @@ export function WorldEditPage() {
}
};
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);
}
};
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"));
}
};
const applyPendingDefinition = () => {
if (!pendingDefinition) return;
setDefinitionText(JSON.stringify(pendingDefinition, null, 2));
setPendingDefinition(null);
};
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>;
return (
<div className="max-w-5xl mx-auto px-4 py-6">
<div className="flex items-center justify-between mb-4">
<h1 className="text-xl font-serif text-ink-100">
<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">
@@ -87,29 +165,131 @@ export function WorldEditPage() {
{error && <p className="text-sm text-red-400 mb-4">{error}</p>}
<div className="grid grid-cols-1 lg: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-[60vh] 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-[60vh] px-3 py-2 rounded bg-ink-950 border border-ink-700 text-ink-100 font-mono text-xs"
spellCheck={false}
{/* 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">

38
frontend/src/store/ui.ts Normal file
View File

@@ -0,0 +1,38 @@
import { create } from "zustand";
import { uiApi, type PublicUiSettings } from "@/api";
interface UiState {
/** Logo URL (or path) to show in navbar, home page, and favicon. */
logoUrl: string;
/** True while the public UI settings are being fetched for the first time. */
loading: boolean;
/** Loads public UI settings from /api/settings/public (cached in api layer). */
load: (force?: boolean) => Promise<void>;
}
const DEFAULT_LOGO_URL = "/logo.png";
export const useUiStore = create<UiState>((set) => ({
logoUrl: DEFAULT_LOGO_URL,
loading: false,
load: async (force = false) => {
set({ loading: true });
try {
const s: PublicUiSettings = await uiApi.getPublicSettings(force);
const next = s.logo_url || DEFAULT_LOGO_URL;
set({ logoUrl: next, loading: false });
// Dynamically update the document favicon so a custom logo is reflected
// in the browser tab without a page reload.
try {
const existing = document.querySelector<HTMLLinkElement>('link[rel="icon"]');
if (existing && existing.href !== next) {
existing.href = next;
}
} catch {
// ignore — DOM might not be ready during SSR/early hydration
}
} catch {
set({ logoUrl: DEFAULT_LOGO_URL, loading: false });
}
},
}));