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