Просмотр исходного кода

feat(studio): language isolation — first-time selector, genre/platform filtering

First-time language selector:
- Shows when inkos.json has no explicit `language` field
- Full-screen choice: 中文创作 / English Writing
- POSTs to /api/project/language, then proceeds to dashboard
- Cinematic layout: large serif logo, generous cards, hover effects

Genre filtering by language:
- API /api/genres now includes `language` field from genre profile
- BookCreate filters genres by project language (zh→Chinese genres only)
- Custom (project) genres always shown regardless of language

Platform filtering:
- Chinese: 番茄小说, 起点中文网, 飞卢, 其他
- English: Royal Road, Kindle Unlimited, Scribble Hub, Other

BookCreate redesign:
- Genre selection as button grid (not dropdown)
- Platform as button row
- Removed manual language toggle (inherits from project)
- Default word count: 3000 (zh) / 2000 (en)
Ma 7 месяцев назад
Родитель
Сommit
6cc2f93f15

+ 25 - 4
packages/studio/src/App.tsx

@@ -32,8 +32,9 @@ export function App() {
   const sse = useSSE();
   const { theme, setTheme } = useTheme();
   const { t } = useI18n();
-  const { data: project } = useApi<{ language: string }>("/project");
-  const [languageSet, setLanguageSet] = useState<boolean | null>(null);
+  const { data: project, refetch: refetchProject } = useApi<{ language: string; languageExplicit: boolean }>("/project");
+  const [showLanguageSelector, setShowLanguageSelector] = useState(false);
+  const [ready, setReady] = useState(false);
 
   const isDark = theme === "dark";
 
@@ -41,9 +42,13 @@ export function App() {
     document.documentElement.classList.toggle("dark", isDark);
   }, [isDark]);
 
+  // Check if language needs to be set (first-time flow)
   useEffect(() => {
     if (project) {
-      setLanguageSet(true);
+      if (!project.languageExplicit) {
+        setShowLanguageSelector(true);
+      }
+      setReady(true);
     }
   }, [project]);
 
@@ -65,10 +70,26 @@ export function App() {
       ? `book:${(route as { bookId: string }).bookId}`
       : route.page;
 
-  if (languageSet === null) {
+  if (!ready) {
     return <div className="min-h-screen bg-background" />;
   }
 
+  if (showLanguageSelector) {
+    return (
+      <LanguageSelector
+        onSelect={async (lang) => {
+          await fetch("/api/project/language", {
+            method: "POST",
+            headers: { "Content-Type": "application/json" },
+            body: JSON.stringify({ language: lang }),
+          });
+          setShowLanguageSelector(false);
+          refetchProject();
+        }}
+      />
+    );
+  }
+
   return (
     <div className="h-screen bg-background text-foreground flex overflow-hidden">
       <Sidebar nav={nav} activePage={activePage} t={t} />

+ 18 - 3
packages/studio/src/api/server.ts

@@ -93,8 +93,18 @@ export function createStudioServer(config: ProjectConfig, root: string) {
   // --- Genres ---
 
   app.get("/api/genres", async (c) => {
-    const { listAvailableGenres } = await import("@actalk/inkos-core");
-    const genres = await listAvailableGenres(root);
+    const { listAvailableGenres, readGenreProfile } = await import("@actalk/inkos-core");
+    const rawGenres = await listAvailableGenres(root);
+    const genres = await Promise.all(
+      rawGenres.map(async (g) => {
+        try {
+          const { profile } = await readGenreProfile(root, g.id);
+          return { ...g, language: profile.language ?? "zh" };
+        } catch {
+          return { ...g, language: "zh" };
+        }
+      }),
+    );
     return c.json({ genres });
   });
 
@@ -297,10 +307,15 @@ export function createStudioServer(config: ProjectConfig, root: string) {
 
   // --- Project info ---
 
-  app.get("/api/project", (c) => {
+  app.get("/api/project", async (c) => {
+    // Check if language was explicitly set in inkos.json (not just the schema default)
+    const raw = JSON.parse(await readFile(join(root, "inkos.json"), "utf-8"));
+    const languageExplicit = "language" in raw && raw.language !== "";
+
     return c.json({
       name: config.name,
       language: config.language,
+      languageExplicit,
       model: config.llm.model,
       provider: config.llm.provider,
       baseUrl: config.llm.baseUrl,

+ 81 - 44
packages/studio/src/pages/BookCreate.tsx

@@ -13,26 +13,50 @@ interface GenreInfo {
   readonly id: string;
   readonly name: string;
   readonly source: "project" | "builtin";
+  readonly language: "zh" | "en";
 }
 
+const PLATFORMS_ZH = [
+  { value: "tomato", label: "番茄小说" },
+  { value: "qidian", label: "起点中文网" },
+  { value: "feilu", label: "飞卢" },
+  { value: "other", label: "其他" },
+];
+
+const PLATFORMS_EN = [
+  { value: "other", label: "Royal Road" },
+  { value: "other", label: "Kindle Unlimited" },
+  { value: "other", label: "Scribble Hub" },
+  { value: "other", label: "Other" },
+];
+
 export function BookCreate({ nav, theme, t }: { nav: Nav; theme: Theme; t: TFunction }) {
   const c = useColors(theme);
   const { data: genreData } = useApi<{ genres: ReadonlyArray<GenreInfo> }>("/genres");
+  const { data: project } = useApi<{ language: string }>("/project");
+
+  const projectLang = (project?.language ?? "zh") as "zh" | "en";
 
   const [title, setTitle] = useState("");
   const [genre, setGenre] = useState("");
-  const [language, setLanguage] = useState<"zh" | "en">("zh");
-  const [chapterWords, setChapterWords] = useState("3000");
+  const [platform, setPlatform] = useState("");
+  const [chapterWords, setChapterWords] = useState(projectLang === "en" ? "2000" : "3000");
   const [targetChapters, setTargetChapters] = useState("200");
   const [creating, setCreating] = useState(false);
   const [error, setError] = useState<string | null>(null);
 
-  const genres = genreData?.genres ?? [];
+  // Filter genres by project language + custom genres (always show)
+  const allGenres = genreData?.genres ?? [];
+  const genres = allGenres.filter((g) => g.language === projectLang || g.source === "project");
+  const platforms = projectLang === "en" ? PLATFORMS_EN : PLATFORMS_ZH;
 
-  // Auto-select first genre when data loads
+  // Auto-select first matching genre
   if (genres.length > 0 && !genre) {
     setGenre(genres[0]!.id);
   }
+  if (platforms.length > 0 && !platform) {
+    setPlatform(platforms[0]!.value);
+  }
 
   const handleCreate = async () => {
     if (!title.trim()) {
@@ -49,7 +73,8 @@ export function BookCreate({ nav, theme, t }: { nav: Nav; theme: Theme; t: TFunc
       const result = await postApi<{ bookId: string }>("/books/create", {
         title: title.trim(),
         genre,
-        language,
+        language: projectLang,
+        platform,
         chapterWordCount: parseInt(chapterWords, 10),
         targetChapters: parseInt(targetChapters, 10),
       });
@@ -62,83 +87,95 @@ export function BookCreate({ nav, theme, t }: { nav: Nav; theme: Theme; t: TFunc
   };
 
   return (
-    <div className="max-w-lg mx-auto space-y-6">
-      <div className={`flex items-center gap-2 text-sm ${c.muted}`}>
+    <div className="max-w-lg mx-auto space-y-8">
+      <div className="flex items-center gap-2 text-sm text-muted-foreground">
         <button onClick={nav.toDashboard} className={c.link}>{t("bread.books")}</button>
-        <span>/</span>
-        <span className={c.subtle}>{t("bread.newBook")}</span>
+        <span className="text-border">/</span>
+        <span>{t("bread.newBook")}</span>
       </div>
 
-      <h1 className="text-2xl font-semibold">{t("create.title")}</h1>
+      <h1 className="font-serif text-3xl">{t("create.title")}</h1>
 
       {error && (
-        <div className={`border ${c.error} rounded-lg px-4 py-3 text-sm`}>
+        <div className={`border ${c.error} rounded-md px-4 py-3`}>
           {error}
         </div>
       )}
 
-      <div className="space-y-4">
+      <div className="space-y-5">
+        {/* Title */}
         <div>
-          <label className={`block text-sm ${c.subtle} mb-1`}>{t("create.bookTitle")}</label>
+          <label className="block text-sm text-muted-foreground mb-2">{t("create.bookTitle")}</label>
           <input
             type="text"
             value={title}
             onChange={(e) => setTitle(e.target.value)}
-            className={`w-full ${c.input} rounded-md px-3 py-2 focus:outline-none`}
+            className={`w-full ${c.input} rounded-md px-4 py-3 focus:outline-none text-base`}
             placeholder={t("create.placeholder")}
           />
         </div>
 
+        {/* Genre — filtered by language */}
         <div>
-          <label className={`block text-sm ${c.subtle} mb-1`}>{t("create.language")}</label>
-          <div className="flex gap-2">
-            <button
-              onClick={() => setLanguage("zh")}
-              className={`px-4 py-2 rounded-md text-sm ${language === "zh" ? c.btnPrimary : c.btnSecondary}`}
-            >
-              Chinese
-            </button>
-            <button
-              onClick={() => setLanguage("en")}
-              className={`px-4 py-2 rounded-md text-sm ${language === "en" ? c.btnPrimary : c.btnSecondary}`}
-            >
-              English
-            </button>
+          <label className="block text-sm text-muted-foreground mb-2">{t("create.genre")}</label>
+          <div className="grid grid-cols-3 gap-2">
+            {genres.map((g) => (
+              <button
+                key={g.id}
+                onClick={() => setGenre(g.id)}
+                className={`px-3 py-2.5 rounded-md text-sm text-left transition-all ${
+                  genre === g.id
+                    ? "bg-primary/15 text-primary border border-primary/30 font-medium"
+                    : "bg-secondary text-secondary-foreground border border-transparent hover:border-border"
+                }`}
+              >
+                {g.name}
+                {g.source === "project" && <span className="text-xs text-muted-foreground ml-1">✦</span>}
+              </button>
+            ))}
           </div>
         </div>
 
+        {/* Platform — filtered by language */}
         <div>
-          <label className={`block text-sm ${c.subtle} mb-1`}>{t("create.genre")}</label>
-          <select
-            value={genre}
-            onChange={(e) => setGenre(e.target.value)}
-            className={`w-full ${c.input} rounded-md px-3 py-2.5 focus:outline-none appearance-none cursor-pointer`}
-          >
-            {genres.map((g) => (
-              <option key={g.id} value={g.id}>
-                {g.name} ({g.id}){g.source === "project" ? " [custom]" : ""}
-              </option>
+          <label className="block text-sm text-muted-foreground mb-2">
+            {projectLang === "zh" ? "平台" : "Platform"}
+          </label>
+          <div className="flex gap-2">
+            {platforms.map((p, i) => (
+              <button
+                key={`${p.value}-${i}`}
+                onClick={() => setPlatform(p.value)}
+                className={`px-3 py-2 rounded-md text-sm transition-all ${
+                  platform === p.value && platforms.indexOf(platforms.find((x) => x.value === platform && x.label === p.label)!) === i
+                    ? "bg-primary/15 text-primary border border-primary/30"
+                    : "bg-secondary text-secondary-foreground border border-transparent hover:border-border"
+                }`}
+              >
+                {p.label}
+              </button>
             ))}
-          </select>
+          </div>
         </div>
 
+        {/* Word count + chapters */}
         <div className="grid grid-cols-2 gap-4">
           <div>
-            <label className={`block text-sm ${c.subtle} mb-1`}>{t("create.wordsPerChapter")}</label>
+            <label className="block text-sm text-muted-foreground mb-2">{t("create.wordsPerChapter")}</label>
             <input
               type="number"
               value={chapterWords}
               onChange={(e) => setChapterWords(e.target.value)}
-              className={`w-full ${c.input} rounded-md px-3 py-2 focus:outline-none`}
+              className={`w-full ${c.input} rounded-md px-4 py-3 focus:outline-none`}
             />
           </div>
           <div>
-            <label className={`block text-sm ${c.subtle} mb-1`}>{t("create.targetChapters")}</label>
+            <label className="block text-sm text-muted-foreground mb-2">{t("create.targetChapters")}</label>
             <input
               type="number"
               value={targetChapters}
               onChange={(e) => setTargetChapters(e.target.value)}
-              className={`w-full ${c.input} rounded-md px-3 py-2 focus:outline-none`}
+              className={`w-full ${c.input} rounded-md px-4 py-3 focus:outline-none`}
             />
           </div>
         </div>
@@ -147,7 +184,7 @@ export function BookCreate({ nav, theme, t }: { nav: Nav; theme: Theme; t: TFunc
       <button
         onClick={handleCreate}
         disabled={creating || !title.trim()}
-        className={`w-full px-4 py-2.5 ${c.btnPrimary} rounded-md transition-colors disabled:opacity-50 font-medium`}
+        className={`w-full px-4 py-3 ${c.btnPrimary} rounded-md disabled:opacity-50 font-medium text-base`}
       >
         {creating ? t("create.creating") : t("create.submit")}
       </button>

+ 64 - 39
packages/studio/src/pages/LanguageSelector.tsx

@@ -1,48 +1,73 @@
+import { useState } from "react";
+
 export function LanguageSelector({ onSelect }: { onSelect: (lang: "zh" | "en") => void }) {
+  const [hovering, setHovering] = useState<"zh" | "en" | null>(null);
+  const [selected, setSelected] = useState<"zh" | "en" | null>(null);
+
+  const handleSelect = (lang: "zh" | "en") => {
+    setSelected(lang);
+    // Brief pause for the selection animation before transitioning
+    setTimeout(() => onSelect(lang), 400);
+  };
+
   return (
-    <div className="min-h-screen bg-background flex items-center justify-center">
-      <div className="text-center space-y-12">
-        {/* Logo */}
-        <div>
-          <div className="flex items-baseline justify-center gap-1 mb-3">
-            <span className="font-serif text-5xl italic text-primary">Ink</span>
-            <span className="text-4xl font-semibold tracking-tight">OS</span>
-          </div>
-          <div className="text-sm text-muted-foreground/50">Studio</div>
+    <div className="min-h-screen bg-background flex flex-col items-center justify-center px-8">
+      {/* Logo — cinematic scale */}
+      <div className="mb-16 text-center">
+        <div className="flex items-baseline justify-center gap-1.5 mb-4">
+          <span className="font-serif text-6xl italic text-primary">Ink</span>
+          <span className="text-5xl font-semibold tracking-tight text-foreground">OS</span>
         </div>
+        <div className="text-base text-muted-foreground tracking-widest uppercase">Studio</div>
+      </div>
 
-        {/* Language cards */}
-        <div className="flex gap-6">
-          <button
-            onClick={() => onSelect("zh")}
-            className="group w-72 border border-border rounded-lg p-10 hover:border-primary/50 transition-all duration-300 text-left"
-          >
-            <div className="font-serif text-2xl mb-2">中文创作</div>
-            <div className="text-sm text-muted-foreground leading-relaxed">
-              玄幻 · 仙侠 · 都市 · 恐怖
-            </div>
-            <div className="text-xs text-muted-foreground/40 mt-4">
-              番茄小说 · 起点中文网
-            </div>
-          </button>
+      {/* Language cards — generous, distinct, immersive */}
+      <div className="flex gap-8 mb-16">
+        <button
+          onClick={() => handleSelect("zh")}
+          onMouseEnter={() => setHovering("zh")}
+          onMouseLeave={() => setHovering(null)}
+          className={`group w-80 border rounded-lg p-10 text-left transition-all duration-300 ${
+            selected === "zh"
+              ? "border-primary bg-primary/10 scale-[1.02]"
+              : hovering === "zh"
+                ? "border-primary/50 bg-card"
+                : "border-border bg-card/50"
+          }`}
+        >
+          <div className="font-serif text-3xl mb-4 text-foreground">中文创作</div>
+          <div className="text-base text-foreground/70 leading-relaxed mb-6">
+            玄幻 · 仙侠 · 都市 · 恐怖 · 通用
+          </div>
+          <div className="text-sm text-muted-foreground">
+            番茄小说 · 起点中文网 · 飞卢
+          </div>
+        </button>
 
-          <button
-            onClick={() => onSelect("en")}
-            className="group w-72 border border-border rounded-lg p-10 hover:border-primary/50 transition-all duration-300 text-left"
-          >
-            <div className="font-serif text-2xl mb-2 italic">English Writing</div>
-            <div className="text-sm text-muted-foreground leading-relaxed">
-              LitRPG · Progression · Romantasy · Sci-Fi
-            </div>
-            <div className="text-xs text-muted-foreground/40 mt-4">
-              Royal Road · Kindle Unlimited
-            </div>
-          </button>
-        </div>
+        <button
+          onClick={() => handleSelect("en")}
+          onMouseEnter={() => setHovering("en")}
+          onMouseLeave={() => setHovering(null)}
+          className={`group w-80 border rounded-lg p-10 text-left transition-all duration-300 ${
+            selected === "en"
+              ? "border-primary bg-primary/10 scale-[1.02]"
+              : hovering === "en"
+                ? "border-primary/50 bg-card"
+                : "border-border bg-card/50"
+          }`}
+        >
+          <div className="font-serif text-3xl italic mb-4 text-foreground">English Writing</div>
+          <div className="text-base text-foreground/70 leading-relaxed mb-6">
+            LitRPG · Progression · Romantasy · Sci-Fi · Isekai
+          </div>
+          <div className="text-sm text-muted-foreground">
+            Royal Road · Kindle Unlimited · Scribble Hub
+          </div>
+        </button>
+      </div>
 
-        <div className="text-[11px] text-muted-foreground/30">
-          This can be changed later in Settings
-        </div>
+      <div className="text-sm text-muted-foreground">
+        可在设置中更改 · Can be changed in Settings
       </div>
     </div>
   );