Initial commit: InkFlow — EPUB vers livre audio local (MLX/Kokoro)
This commit is contained in:
40
frontend/dist/assets/index-CMUl6Yfl.js
vendored
Normal file
40
frontend/dist/assets/index-CMUl6Yfl.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
frontend/dist/assets/index-DlPmWkkU.css
vendored
Normal file
1
frontend/dist/assets/index-DlPmWkkU.css
vendored
Normal file
File diff suppressed because one or more lines are too long
13
frontend/dist/index.html
vendored
Normal file
13
frontend/dist/index.html
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>InkFlow — EPUB → Livre audio</title>
|
||||
<script type="module" crossorigin src="/assets/index-CMUl6Yfl.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DlPmWkkU.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
12
frontend/index.html
Normal file
12
frontend/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>InkFlow — EPUB → Livre audio</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
2767
frontend/package-lock.json
generated
Normal file
2767
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
frontend/package.json
Normal file
22
frontend/package.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "inkflow-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"vite": "^6.0.7"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
245
frontend/src/AnalysisEditor.jsx
Normal file
245
frontend/src/AnalysisEditor.jsx
Normal file
@@ -0,0 +1,245 @@
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { api } from "./api.js";
|
||||
import { Spinner } from "./ui.jsx";
|
||||
|
||||
const NARRATOR = "narrateur";
|
||||
let _seq = 0;
|
||||
const nextId = () => ++_seq;
|
||||
|
||||
export default function AnalysisEditor({ slug, book, state }) {
|
||||
// Chapitres analysés (intersection ordre du livre x analyzed_chapters).
|
||||
const analyzed = useMemo(() => {
|
||||
const set = new Set(state.analyzed_chapters || []);
|
||||
return book.chapters.filter((c) => set.has(c.index));
|
||||
}, [book, state.analyzed_chapters]);
|
||||
|
||||
const [index, setIndex] = useState(() => analyzed[0]?.index ?? null);
|
||||
const [analysis, setAnalysis] = useState(null); // { index, title, segments:[{_id,type,text,speaker}] }
|
||||
const [names, setNames] = useState([]); // noms de personnages pour la datalist
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
// Derniere selection de texte dans une replique (pour "marquer comme incise").
|
||||
const [sel, setSel] = useState({ id: null, start: 0, end: 0 });
|
||||
|
||||
// Filtres d'affichage (n'altèrent pas la sauvegarde).
|
||||
const [query, setQuery] = useState("");
|
||||
const [typeFilter, setTypeFilter] = useState("all");
|
||||
const [speakerFilter, setSpeakerFilter] = useState("all");
|
||||
|
||||
// Si la liste des chapitres analysés change et que l'index courant disparaît.
|
||||
useEffect(() => {
|
||||
if (index == null || !analyzed.some((c) => c.index === index)) {
|
||||
setIndex(analyzed[0]?.index ?? null);
|
||||
}
|
||||
}, [analyzed]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Noms des personnages du casting (une fois).
|
||||
useEffect(() => {
|
||||
api.getCast(slug)
|
||||
.then((d) => setNames((d.cast?.characters || []).map((c) => c.name)))
|
||||
.catch(() => setNames([]));
|
||||
}, [slug]);
|
||||
|
||||
// Chargement de l'analyse du chapitre sélectionné.
|
||||
useEffect(() => {
|
||||
if (index == null) { setAnalysis(null); return; }
|
||||
setLoading(true);
|
||||
setSaved(false);
|
||||
api.getChapter(slug, index).then((d) => {
|
||||
if (d.analysis) {
|
||||
setAnalysis({
|
||||
index: d.analysis.index,
|
||||
title: d.analysis.title,
|
||||
segments: (d.analysis.segments || []).map((s) => ({ ...s, _id: nextId() })),
|
||||
});
|
||||
} else {
|
||||
setAnalysis({ index, title: d.chapter?.title || "", segments: null });
|
||||
}
|
||||
}).finally(() => setLoading(false));
|
||||
}, [slug, index]);
|
||||
|
||||
const speakerOptions = useMemo(() => {
|
||||
const set = new Set([NARRATOR, ...names]);
|
||||
(analysis?.segments || []).forEach((s) => s.speaker && set.add(s.speaker));
|
||||
return [...set];
|
||||
}, [names, analysis]);
|
||||
|
||||
if (!analyzed.length)
|
||||
return <p className="text-ink-muted">Lancez d'abord l'<b>Analyse</b> sur un chapitre.</p>;
|
||||
|
||||
const touch = (segments) => { setAnalysis((a) => ({ ...a, segments })); setSaved(false); };
|
||||
|
||||
const setSeg = (id, patch) =>
|
||||
touch(analysis.segments.map((s) => {
|
||||
if (s._id !== id) return s;
|
||||
const next = { ...s, ...patch };
|
||||
if (next.type === "narration") { next.speaker = NARRATOR; next.incises = []; }
|
||||
// Edition du texte : on ecarte les incises devenues hors-bornes.
|
||||
if (patch.text !== undefined) {
|
||||
const len = next.text.length;
|
||||
next.incises = (next.incises || []).filter(
|
||||
(inc) => inc.start < inc.end && inc.end <= len);
|
||||
}
|
||||
return next;
|
||||
}));
|
||||
|
||||
// Marque la portion [start,end) d'une replique comme incise (voix narrateur).
|
||||
const addIncise = (id, start, end) =>
|
||||
touch(analysis.segments.map((s) => {
|
||||
if (s._id !== id) return s;
|
||||
const incises = [...(s.incises || []), { start, end }]
|
||||
.sort((a, b) => a.start - b.start)
|
||||
.filter((inc, i, arr) => i === 0 || inc.start >= arr[i - 1].end);
|
||||
return { ...s, incises };
|
||||
}));
|
||||
|
||||
const removeIncise = (id, i) =>
|
||||
touch(analysis.segments.map((s) =>
|
||||
s._id !== id ? s : { ...s, incises: (s.incises || []).filter((_, k) => k !== i) }));
|
||||
|
||||
const removeSeg = (id) => touch(analysis.segments.filter((s) => s._id !== id));
|
||||
|
||||
const insertAfter = (id) => {
|
||||
const segs = analysis.segments;
|
||||
const pos = id == null ? segs.length : segs.findIndex((s) => s._id === id) + 1;
|
||||
const next = [...segs];
|
||||
next.splice(pos, 0, { _id: nextId(), type: "narration", text: "", speaker: NARRATOR });
|
||||
touch(next);
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
const payload = {
|
||||
index: analysis.index,
|
||||
title: analysis.title,
|
||||
segments: analysis.segments.map(({ _id, ...s }) => s),
|
||||
};
|
||||
await api.putAnalysis(slug, analysis.index, payload);
|
||||
setSaved(true);
|
||||
};
|
||||
|
||||
const segments = analysis?.segments;
|
||||
const visible = (segments || []).filter((s) => {
|
||||
if (typeFilter !== "all" && s.type !== typeFilter) return false;
|
||||
if (speakerFilter !== "all" && s.speaker !== speakerFilter) return false;
|
||||
if (query && !s.text.toLowerCase().includes(query.toLowerCase())) return false;
|
||||
return true;
|
||||
});
|
||||
const dialogueCount = (segments || []).filter((s) => s.type === "dialogue").length;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<datalist id="speaker-list">
|
||||
{speakerOptions.map((n) => <option key={n} value={n} />)}
|
||||
</datalist>
|
||||
|
||||
{/* Barre de contrôle */}
|
||||
<div className="card flex flex-wrap items-center gap-3 p-3">
|
||||
<label className="text-sm text-ink-muted">Chapitre</label>
|
||||
<select className="input" value={index ?? ""}
|
||||
onChange={(e) => setIndex(Number(e.target.value))}>
|
||||
{analyzed.map((c) => (
|
||||
<option key={c.index} value={c.index}>{c.index} — {c.title}</option>
|
||||
))}
|
||||
</select>
|
||||
{segments && (
|
||||
<span className="text-xs text-ink-muted">
|
||||
{segments.length} segments · {dialogueCount} dialogues
|
||||
</span>
|
||||
)}
|
||||
<button className="btn-primary ml-auto" disabled={!segments} onClick={save}>
|
||||
{saved ? "✓ enregistré" : "Enregistrer"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading && <p className="text-ink-muted"><Spinner /> chargement de l'analyse…</p>}
|
||||
|
||||
{!loading && segments === null && (
|
||||
<p className="text-ink-muted">Ce chapitre n'a pas encore d'analyse. Lancez l'<b>Analyse</b>.</p>
|
||||
)}
|
||||
|
||||
{!loading && segments && (
|
||||
<>
|
||||
{/* Filtres d'affichage */}
|
||||
<div className="card flex flex-wrap items-center gap-3 p-3">
|
||||
<input className="input flex-1 min-w-[12rem]" placeholder="Rechercher dans le texte…"
|
||||
value={query} onChange={(e) => setQuery(e.target.value)} />
|
||||
<select className="input" value={typeFilter} onChange={(e) => setTypeFilter(e.target.value)}>
|
||||
<option value="all">tous types</option>
|
||||
<option value="narration">narration</option>
|
||||
<option value="dialogue">dialogue</option>
|
||||
</select>
|
||||
<select className="input" value={speakerFilter} onChange={(e) => setSpeakerFilter(e.target.value)}>
|
||||
<option value="all">tous locuteurs</option>
|
||||
{speakerOptions.map((n) => <option key={n} value={n}>{n}</option>)}
|
||||
</select>
|
||||
{visible.length !== segments.length && (
|
||||
<span className="text-xs text-ink-muted">{visible.length} affichés</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="card divide-y divide-ink-edge">
|
||||
{visible.map((s) => {
|
||||
const canMark = s.type === "dialogue"
|
||||
&& sel.id === s._id && sel.end > sel.start;
|
||||
const incises = s.incises || [];
|
||||
return (
|
||||
<div key={s._id} className="px-4 py-2.5">
|
||||
<div className="flex items-start gap-3">
|
||||
<select className="input w-28 shrink-0" value={s.type}
|
||||
onChange={(e) => setSeg(s._id, { type: e.target.value })}>
|
||||
<option value="narration">narration</option>
|
||||
<option value="dialogue">dialogue</option>
|
||||
</select>
|
||||
<textarea className="input flex-1 min-h-[2.5rem] resize-y font-serif text-sm"
|
||||
rows={Math.min(6, Math.ceil((s.text.length || 1) / 80))}
|
||||
value={s.text}
|
||||
onSelect={(e) => s.type === "dialogue" && setSel({
|
||||
id: s._id, start: e.target.selectionStart, end: e.target.selectionEnd })}
|
||||
onChange={(e) => setSeg(s._id, { text: e.target.value })} />
|
||||
<input className="input w-40 shrink-0" list="speaker-list"
|
||||
placeholder="locuteur"
|
||||
value={s.speaker} disabled={s.type === "narration"}
|
||||
onChange={(e) => setSeg(s._id, { speaker: e.target.value })} />
|
||||
<div className="flex shrink-0 gap-1">
|
||||
<button className="btn-ghost" title="Insérer après"
|
||||
onClick={() => insertAfter(s._id)}>+</button>
|
||||
<button className="btn-ghost" title="Supprimer"
|
||||
onClick={() => removeSeg(s._id)}>✕</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Incises : portions lues par le narrateur dans la réplique */}
|
||||
{s.type === "dialogue" && (incises.length > 0 || canMark) && (
|
||||
<div className="mt-1.5 ml-[7.75rem] flex flex-wrap items-center gap-1.5">
|
||||
<span className="text-[11px] uppercase tracking-wide text-ink-muted">incises</span>
|
||||
{incises.map((inc, i) => (
|
||||
<span key={i}
|
||||
className="inline-flex items-center gap-1 rounded bg-ink-edge/40 px-1.5 py-0.5 text-xs"
|
||||
title="Lu par la voix du narrateur">
|
||||
<span className="text-ink-muted">🎙</span>
|
||||
<span className="font-serif">{s.text.slice(inc.start, inc.end)}</span>
|
||||
<button className="text-ink-muted hover:text-ink"
|
||||
title="Retirer l'incise"
|
||||
onClick={() => removeIncise(s._id, i)}>✕</button>
|
||||
</span>
|
||||
))}
|
||||
{canMark && (
|
||||
<button className="btn-ghost text-xs"
|
||||
onClick={() => { addIncise(s._id, sel.start, sel.end);
|
||||
setSel({ id: null, start: 0, end: 0 }); }}>
|
||||
+ marquer la sélection
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
); })}
|
||||
<div className="px-4 py-2.5">
|
||||
<button className="btn-ghost" onClick={() => insertAfter(null)}>+ ajouter un segment</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
44
frontend/src/App.jsx
Normal file
44
frontend/src/App.jsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import React, { useState } from "react";
|
||||
import Library from "./Library.jsx";
|
||||
import BookView from "./BookView.jsx";
|
||||
import Settings from "./Settings.jsx";
|
||||
|
||||
export default function App() {
|
||||
// Permet d'ouvrir un livre directement via #slug (deep-link).
|
||||
const [slug, setSlug] = useState(
|
||||
() => (location.hash ? decodeURIComponent(location.hash.slice(1)) : null)
|
||||
);
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
|
||||
const goHome = () => { setShowSettings(false); setSlug(null); };
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-ink-bg text-ink-text">
|
||||
<header className="border-b border-ink-edge">
|
||||
<div className="mx-auto flex max-w-6xl items-center gap-3 px-6 py-4">
|
||||
<button onClick={goHome} className="flex items-center gap-2">
|
||||
<span className="text-2xl">🖋️</span>
|
||||
<span className="font-serif text-xl tracking-wide">
|
||||
Ink<span className="text-ink-accent">Flow</span>
|
||||
</span>
|
||||
</button>
|
||||
<span className="ml-2 hidden text-sm text-ink-muted sm:inline">
|
||||
EPUB → livre audio · local · MLX
|
||||
</span>
|
||||
<button onClick={() => setShowSettings(true)} title="Réglages techniques"
|
||||
className="ml-auto text-xl text-ink-muted hover:text-ink-text">⚙</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="mx-auto max-w-6xl px-6 py-8">
|
||||
{showSettings ? (
|
||||
<Settings onBack={goHome} />
|
||||
) : slug ? (
|
||||
<BookView slug={slug} onBack={() => setSlug(null)} />
|
||||
) : (
|
||||
<Library onOpen={setSlug} />
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
99
frontend/src/BookView.jsx
Normal file
99
frontend/src/BookView.jsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { api, subscribeState } from "./api.js";
|
||||
import { StatusChip, ProgressBar, Spinner } from "./ui.jsx";
|
||||
import Chapters from "./Chapters.jsx";
|
||||
import AnalysisEditor from "./AnalysisEditor.jsx";
|
||||
import CastEditor from "./CastEditor.jsx";
|
||||
import PronunciationEditor from "./PronunciationEditor.jsx";
|
||||
|
||||
const STAGES = [
|
||||
{ key: "analyze", label: "Analyse", action: (s) => api.analyze(s), hint: "Découpe le texte, détecte les locuteurs et le casting." },
|
||||
{ key: "cast", label: "Casting", action: (s) => api.castAuto(s), hint: "Attribue une voix à chaque personnage." },
|
||||
{ key: "pronounce", label: "Prononciations", action: (s) => api.pronounce(s), hint: "Repère les mots à risque de mauvaise prononciation." },
|
||||
];
|
||||
|
||||
export default function BookView({ slug, onBack }) {
|
||||
const [data, setData] = useState(null);
|
||||
const [state, setState] = useState(null);
|
||||
const [tab, setTab] = useState("chapters");
|
||||
|
||||
useEffect(() => {
|
||||
api.getBook(slug).then((d) => { setData(d); setState(d.state); });
|
||||
const unsub = subscribeState(slug, setState);
|
||||
return unsub;
|
||||
}, [slug]);
|
||||
|
||||
if (!data) return <p className="text-ink-muted"><Spinner /> chargement…</p>;
|
||||
const { book } = data;
|
||||
const st = state || data.state;
|
||||
const busy = !!st.active_stage;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<button onClick={onBack} className="text-sm text-ink-muted hover:text-ink-text">← Bibliothèque</button>
|
||||
|
||||
<div className="flex gap-5">
|
||||
{book.cover_file && (
|
||||
<img src={api.coverUrl(slug)} alt="" className="h-44 rounded-md border border-ink-edge object-cover" />
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<h1 className="font-serif text-2xl">{book.title}</h1>
|
||||
<p className="text-ink-muted">{book.author}</p>
|
||||
<p className="mt-1 text-sm text-ink-muted">{book.chapters.filter((c) => c.render).length} chapitres à narrer</p>
|
||||
|
||||
{busy && (
|
||||
<div className="mt-4 max-w-md space-y-1">
|
||||
<div className="flex justify-between text-xs text-ink-accent">
|
||||
<span>{st.active_detail || st.active_stage}</span>
|
||||
<span>{Math.round((st.active_progress || 0) * 100)}%</span>
|
||||
</div>
|
||||
<ProgressBar value={st.active_progress} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pipeline */}
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
{STAGES.map((stage) => {
|
||||
const status = st.stages?.[stage.key] || "pending";
|
||||
return (
|
||||
<div key={stage.key} className="card p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">{stage.label}</span>
|
||||
<StatusChip status={status} />
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-ink-muted">{stage.hint}</p>
|
||||
<button className="btn-ghost mt-3" disabled={busy}
|
||||
onClick={() => stage.action(slug)}>
|
||||
{status === "done" ? "Relancer" : "Lancer"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Onglets */}
|
||||
<div className="flex gap-1 border-b border-ink-edge">
|
||||
{[
|
||||
["chapters", "Chapitres"],
|
||||
["analysis", "Analyse"],
|
||||
["cast", "Casting"],
|
||||
["pron", "Prononciation"],
|
||||
].map(([key, label]) => (
|
||||
<button key={key} onClick={() => setTab(key)}
|
||||
className={`px-4 py-2 text-sm ${tab === key
|
||||
? "border-b-2 border-ink-accent text-ink-text"
|
||||
: "text-ink-muted hover:text-ink-text"}`}>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === "chapters" && <Chapters slug={slug} book={book} state={st} busy={busy} />}
|
||||
{tab === "analysis" && <AnalysisEditor slug={slug} book={book} state={st} />}
|
||||
{tab === "cast" && <CastEditor slug={slug} busy={busy} />}
|
||||
{tab === "pron" && <PronunciationEditor slug={slug} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
119
frontend/src/CastEditor.jsx
Normal file
119
frontend/src/CastEditor.jsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { api } from "./api.js";
|
||||
import { Spinner } from "./ui.jsx";
|
||||
|
||||
function VoiceSelect({ voices, value, onChange }) {
|
||||
return (
|
||||
<select className="input" value={value || ""} onChange={(e) => onChange(e.target.value)}>
|
||||
<option value="">— aucune —</option>
|
||||
{voices.map((v) => (
|
||||
<option key={v.id} value={v.id}>
|
||||
{v.label || v.id} ({v.gender === "male" ? "H" : v.gender === "female" ? "F" : "?"})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CastEditor({ slug, busy }) {
|
||||
const [cast, setCast] = useState(null);
|
||||
const [voices, setVoices] = useState([]);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [playing, setPlaying] = useState(null);
|
||||
const [msg, setMsg] = useState(null);
|
||||
const dedupPending = React.useRef(false);
|
||||
|
||||
const reload = () =>
|
||||
api.getCast(slug).then((d) => { setCast(d.cast); setVoices(d.voicebank.entries); });
|
||||
|
||||
useEffect(() => { reload(); }, [slug]);
|
||||
// Recharge le casting quand un job de fond (dédup / casting chapitre) se termine.
|
||||
useEffect(() => {
|
||||
if (busy) return;
|
||||
reload().then(() => {
|
||||
if (dedupPending.current) {
|
||||
dedupPending.current = false;
|
||||
api.getCast(slug).then((d) =>
|
||||
setMsg(`✓ déduplication terminée — ${d.cast.characters.length} personnages`));
|
||||
}
|
||||
});
|
||||
}, [busy]);
|
||||
|
||||
const dedup = async () => {
|
||||
setMsg(null);
|
||||
try {
|
||||
dedupPending.current = true;
|
||||
await api.castDedup(slug);
|
||||
setMsg("Déduplication lancée…");
|
||||
} catch (e) {
|
||||
dedupPending.current = false;
|
||||
setMsg("Échec : " + e + " (le serveur backend est-il à jour ? redémarre-le)");
|
||||
}
|
||||
};
|
||||
|
||||
if (!cast) return <p className="text-ink-muted"><Spinner /> chargement du casting…</p>;
|
||||
if (!cast.characters.length)
|
||||
return <p className="text-ink-muted">Lancez d'abord l'<b>Analyse</b> puis le <b>Casting</b>.</p>;
|
||||
|
||||
const update = (patch) => { setCast({ ...cast, ...patch }); setSaved(false); };
|
||||
const setChar = (name, voiceId) =>
|
||||
update({ characters: cast.characters.map((c) => c.name === name ? { ...c, voice_id: voiceId } : c) });
|
||||
|
||||
const preview = async (voiceId) => {
|
||||
if (!voiceId) return;
|
||||
setPlaying(voiceId);
|
||||
try {
|
||||
const url = await api.previewVoice(voiceId, "Bonjour, voici un aperçu de cette voix.");
|
||||
const a = new Audio(url);
|
||||
a.onended = () => setPlaying(null);
|
||||
a.play();
|
||||
} catch { setPlaying(null); }
|
||||
};
|
||||
|
||||
const save = async () => { await api.putCast(slug, cast); setSaved(true); };
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="card flex items-center gap-3 p-3">
|
||||
<span className="text-sm text-ink-muted">Narrateur</span>
|
||||
<VoiceSelect voices={voices} value={cast.narrator_voice_id}
|
||||
onChange={(v) => update({ narrator_voice_id: v })} />
|
||||
<button className="btn-ghost" onClick={() => preview(cast.narrator_voice_id)}>
|
||||
{playing === cast.narrator_voice_id ? "♪" : "▶"} écouter
|
||||
</button>
|
||||
<button className="btn-ghost ml-auto" disabled={busy}
|
||||
title="Fusionne les variantes d'un même personnage (Holden / James Holden / James)"
|
||||
onClick={dedup}>
|
||||
{busy ? "…" : "Dédupliquer"}
|
||||
</button>
|
||||
<button className="btn-primary" onClick={save}>
|
||||
{saved ? "✓ enregistré" : "Enregistrer"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{msg && <p className="px-1 text-sm text-ink-muted">{msg}</p>}
|
||||
|
||||
<div className="card divide-y divide-ink-edge">
|
||||
{cast.characters.map((c) => (
|
||||
<div key={c.name} className="flex items-center gap-3 px-4 py-2.5">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="truncate font-serif text-sm">{c.name}</p>
|
||||
{c.aliases?.length > 0 && (
|
||||
<p className="truncate text-xs text-ink-muted">alias : {c.aliases.join(", ")}</p>
|
||||
)}
|
||||
{c.description && <p className="truncate text-xs text-ink-muted">{c.description}</p>}
|
||||
</div>
|
||||
<span className="chip bg-ink-edge text-ink-muted">
|
||||
{c.gender === "male" ? "homme" : c.gender === "female" ? "femme" : "?"}
|
||||
</span>
|
||||
<VoiceSelect voices={voices} value={c.voice_id}
|
||||
onChange={(v) => setChar(c.name, v)} />
|
||||
<button className="btn-ghost" onClick={() => preview(c.voice_id)}>
|
||||
{playing === c.voice_id ? "♪" : "▶"}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
98
frontend/src/Chapters.jsx
Normal file
98
frontend/src/Chapters.jsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { api } from "./api.js";
|
||||
import { StatusChip, ProgressBar } from "./ui.jsx";
|
||||
|
||||
export default function Chapters({ slug, book, state, busy }) {
|
||||
const chapters = book.chapters.filter((c) => c.render);
|
||||
const [backend, setBackend] = useState("kokoro");
|
||||
const [mono, setMono] = useState(false);
|
||||
const [selected, setSelected] = useState(() => new Set());
|
||||
|
||||
// Initialise le moteur sur le backend par defaut des reglages.
|
||||
useEffect(() => {
|
||||
api.getSettings().then((s) => s?.default_backend && setBackend(s.default_backend)).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const toggle = (idx) => {
|
||||
const next = new Set(selected);
|
||||
next.has(idx) ? next.delete(idx) : next.add(idx);
|
||||
setSelected(next);
|
||||
};
|
||||
|
||||
const renderChapters = (indexes) => {
|
||||
if (!indexes.length) return;
|
||||
api.render(slug, indexes, backend, mono);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="card flex flex-wrap items-center gap-3 p-3">
|
||||
<label className="text-sm text-ink-muted">Moteur</label>
|
||||
<select className="input" value={backend} onChange={(e) => setBackend(e.target.value)}>
|
||||
<option value="kokoro">Kokoro (rapide)</option>
|
||||
<option value="qwen3">Qwen3 (qualité + clonage)</option>
|
||||
</select>
|
||||
<label className="flex items-center gap-2 text-sm text-ink-muted">
|
||||
<input type="checkbox" checked={mono} onChange={(e) => setMono(e.target.checked)} />
|
||||
mono-narrateur
|
||||
</label>
|
||||
<div className="ml-auto flex gap-2">
|
||||
<button className="btn-ghost" disabled={busy || !selected.size}
|
||||
onClick={() => renderChapters([...selected])}>
|
||||
Rendre la sélection ({selected.size})
|
||||
</button>
|
||||
<button className="btn-primary" disabled={busy}
|
||||
onClick={() => renderChapters(chapters.map((c) => c.index))}>
|
||||
Rendre tout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card divide-y divide-ink-edge">
|
||||
{chapters.map((c) => {
|
||||
const rs = state.render?.[c.index] || state.render?.[String(c.index)] || {};
|
||||
const analyzed = (state.analyzed_chapters || []).includes(c.index);
|
||||
return (
|
||||
<div key={c.index} className="flex items-center gap-3 px-4 py-2.5">
|
||||
<input type="checkbox" checked={selected.has(c.index)}
|
||||
onChange={() => toggle(c.index)} />
|
||||
<div className="w-9 text-center text-xs text-ink-muted">{c.index}</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="truncate font-serif text-sm">{c.title}</p>
|
||||
<div className="mt-0.5 flex items-center gap-2 text-xs text-ink-muted">
|
||||
<span>{c.word_count} mots</span>
|
||||
{c.pov && <span className="chip bg-ink-edge text-ink-muted">{c.pov}</span>}
|
||||
{analyzed && <span className="text-emerald-400">analysé</span>}
|
||||
</div>
|
||||
{rs.status === "running" && (
|
||||
<div className="mt-1.5 max-w-xs"><ProgressBar value={rs.progress} /></div>
|
||||
)}
|
||||
</div>
|
||||
{rs.status && <StatusChip status={rs.status} />}
|
||||
{rs.mp3 && (
|
||||
<>
|
||||
<audio controls src={api.audioUrl(slug, c.index)} className="h-8" />
|
||||
<a className="btn-ghost" href={api.audioUrl(slug, c.index)} download>↓</a>
|
||||
</>
|
||||
)}
|
||||
{!busy && (
|
||||
<>
|
||||
<button className="btn-ghost" title={analyzed ? "Ré-analyser ce chapitre" : "Analyser ce chapitre"}
|
||||
onClick={() => api.analyze(slug, [c.index])}>
|
||||
{analyzed ? "Ré-analyser" : "Analyser"}
|
||||
</button>
|
||||
<button className="btn-ghost" title="Ré-analyser le casting de ce chapitre (sans re-segmenter)"
|
||||
onClick={() => api.castAnalyze(slug, [c.index])}>
|
||||
Casting
|
||||
</button>
|
||||
<button className="btn-ghost" title="Rendre ce chapitre"
|
||||
onClick={() => renderChapters([c.index])}>▶</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
80
frontend/src/Library.jsx
Normal file
80
frontend/src/Library.jsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { api } from "./api.js";
|
||||
import { Spinner } from "./ui.jsx";
|
||||
|
||||
export default function Library({ onOpen }) {
|
||||
const [books, setBooks] = useState(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const fileRef = useRef();
|
||||
|
||||
const refresh = () => api.listBooks().then(setBooks).catch((e) => setError(String(e)));
|
||||
useEffect(() => { refresh(); }, []);
|
||||
|
||||
const upload = async (file) => {
|
||||
if (!file) return;
|
||||
setUploading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const { slug } = await api.uploadBook(file);
|
||||
await refresh();
|
||||
onOpen(slug);
|
||||
} catch (e) {
|
||||
setError("Échec de l'import : " + e);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<section
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => { e.preventDefault(); upload(e.dataTransfer.files[0]); }}
|
||||
className="card flex flex-col items-center justify-center gap-3 border-dashed py-12 text-center"
|
||||
>
|
||||
<div className="text-4xl">📖</div>
|
||||
<p className="font-serif text-lg">Déposez un fichier EPUB</p>
|
||||
<p className="text-sm text-ink-muted">ou</p>
|
||||
<button className="btn-primary" disabled={uploading}
|
||||
onClick={() => fileRef.current?.click()}>
|
||||
{uploading ? <Spinner /> : null}
|
||||
{uploading ? "Import en cours…" : "Choisir un fichier"}
|
||||
</button>
|
||||
<input ref={fileRef} type="file" accept=".epub" className="hidden"
|
||||
onChange={(e) => upload(e.target.files[0])} />
|
||||
</section>
|
||||
|
||||
{error && <p className="text-sm text-red-400">{error}</p>}
|
||||
|
||||
<section>
|
||||
<h2 className="mb-3 font-serif text-lg text-ink-muted">Bibliothèque</h2>
|
||||
{books === null ? (
|
||||
<p className="text-ink-muted"><Spinner /> chargement…</p>
|
||||
) : books.length === 0 ? (
|
||||
<p className="text-ink-muted">Aucun livre pour l'instant.</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
|
||||
{books.map((b) => (
|
||||
<button key={b.slug} onClick={() => onOpen(b.slug)}
|
||||
className="card group overflow-hidden text-left transition-transform hover:-translate-y-1">
|
||||
<div className="aspect-[2/3] w-full bg-ink-edge">
|
||||
{b.cover && (
|
||||
<img src={b.cover} alt="" className="h-full w-full object-cover" />
|
||||
)}
|
||||
</div>
|
||||
<div className="p-3">
|
||||
<p className="line-clamp-2 font-serif text-sm">{b.title}</p>
|
||||
<p className="mt-1 text-xs text-ink-muted">{b.author}</p>
|
||||
<p className="mt-2 text-xs text-ink-accent">
|
||||
{b.rendered}/{b.chapters} chapitres rendus
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
59
frontend/src/PronunciationEditor.jsx
Normal file
59
frontend/src/PronunciationEditor.jsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { api } from "./api.js";
|
||||
import { Spinner } from "./ui.jsx";
|
||||
|
||||
export default function PronunciationEditor({ slug }) {
|
||||
const [entries, setEntries] = useState(null);
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
api.getPron(slug).then((d) => setEntries(d.entries || []));
|
||||
}, [slug]);
|
||||
|
||||
if (entries === null) return <p className="text-ink-muted"><Spinner /> chargement…</p>;
|
||||
|
||||
const dirty = () => setSaved(false);
|
||||
const setRow = (i, patch) => {
|
||||
setEntries(entries.map((e, j) => (j === i ? { ...e, ...patch } : e)));
|
||||
dirty();
|
||||
};
|
||||
const add = () => { setEntries([...entries, { term: "", replacement: "", enabled: true }]); dirty(); };
|
||||
const remove = (i) => { setEntries(entries.filter((_, j) => j !== i)); dirty(); };
|
||||
const save = async () => {
|
||||
await api.putPron(slug, { entries: entries.filter((e) => e.term) });
|
||||
setSaved(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<p className="text-sm text-ink-muted">
|
||||
Corrigez la graphie des mots mal prononcés. La colonne « prononciation » remplace le terme avant la synthèse.
|
||||
</p>
|
||||
<button className="btn-ghost ml-auto" onClick={add}>+ ajouter</button>
|
||||
<button className="btn-primary" onClick={save}>{saved ? "✓ enregistré" : "Enregistrer"}</button>
|
||||
</div>
|
||||
|
||||
{entries.length === 0 ? (
|
||||
<p className="text-ink-muted">Aucune entrée. Lancez l'étape <b>Prononciations</b> ou ajoutez-en.</p>
|
||||
) : (
|
||||
<div className="card divide-y divide-ink-edge">
|
||||
<div className="grid grid-cols-[1fr_1fr_auto_auto] gap-3 px-4 py-2 text-xs uppercase text-ink-muted">
|
||||
<span>Terme</span><span>Prononciation</span><span>Actif</span><span></span>
|
||||
</div>
|
||||
{entries.map((e, i) => (
|
||||
<div key={i} className="grid grid-cols-[1fr_1fr_auto_auto] items-center gap-3 px-4 py-2">
|
||||
<input className="input" value={e.term}
|
||||
onChange={(ev) => setRow(i, { term: ev.target.value })} />
|
||||
<input className="input" value={e.replacement}
|
||||
onChange={(ev) => setRow(i, { replacement: ev.target.value })} />
|
||||
<input type="checkbox" checked={e.enabled !== false}
|
||||
onChange={(ev) => setRow(i, { enabled: ev.target.checked })} />
|
||||
<button className="text-ink-muted hover:text-red-400" onClick={() => remove(i)}>✕</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
142
frontend/src/Settings.jsx
Normal file
142
frontend/src/Settings.jsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { api } from "./api.js";
|
||||
import { Spinner } from "./ui.jsx";
|
||||
|
||||
// Description declarative des champs, groupes par section.
|
||||
const SECTIONS = [
|
||||
{
|
||||
title: "Modèles (identifiants MLX / HuggingFace)",
|
||||
hint: "Changer un identifiant recharge un autre modèle (peut déclencher un téléchargement au prochain usage).",
|
||||
fields: [
|
||||
{ key: "gemma_model", label: "Gemma (analyse)", type: "text" },
|
||||
{ key: "qwen3_model", label: "Qwen3-TTS (rendu)", type: "text" },
|
||||
{ key: "kokoro_model", label: "Kokoro (preview)", type: "text" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Génération Gemma",
|
||||
hint: "Paramètres d'échantillonnage de l'analyse (locuteurs, personnages, prononciations).",
|
||||
fields: [
|
||||
{ key: "gemma_temperature", label: "Température", type: "number", step: 0.05, min: 0, max: 2 },
|
||||
{ key: "gemma_max_tokens", label: "Max tokens", type: "number", step: 1, min: 64, max: 8192 },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Prompts système (analyse)",
|
||||
hint: "Instructions envoyées à Gemma avant chaque tâche. Le modèle doit répondre en JSON.",
|
||||
fields: [
|
||||
{ key: "prompt_speakers", label: "Attribution des locuteurs", type: "textarea" },
|
||||
{ key: "prompt_characters", label: "Extraction des personnages", type: "textarea" },
|
||||
{ key: "prompt_pronunciation", label: "Mots à risque (prononciation)", type: "textarea" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Casting (déduplication)",
|
||||
hint: "Le rapprochement des variantes de noms (Holden / James Holden / James) est heuristique et sûr. La passe Gemma ajoute les variantes non évidentes (diminutifs, titres) mais, avec un petit modèle local, produit des fusions erronées.",
|
||||
fields: [
|
||||
{ key: "dedup_use_gemma", label: "Affiner la déduplication avec Gemma (moins sûr)", type: "checkbox" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "TTS (voix par défaut)",
|
||||
hint: "Backend et voix utilisés par défaut pour le rendu et les replis.",
|
||||
fields: [
|
||||
{ key: "default_backend", label: "Backend par défaut", type: "select",
|
||||
options: [["kokoro", "Kokoro (rapide)"], ["qwen3", "Qwen3 (qualité + clonage)"]] },
|
||||
{ key: "language", label: "Langue (Qwen3)", type: "text" },
|
||||
{ key: "kokoro_lang_code", label: "Code langue Kokoro", type: "text" },
|
||||
{ key: "kokoro_default_voice", label: "Voix Kokoro par défaut", type: "text" },
|
||||
{ key: "qwen3_default_voice", label: "Voix Qwen3 par défaut", type: "text" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Audio (encodage final)",
|
||||
hint: "Appliqué à la concaténation et à l'export MP3.",
|
||||
fields: [
|
||||
{ key: "target_sample_rate", label: "Sample rate (Hz)", type: "number", step: 1000, min: 8000, max: 48000 },
|
||||
{ key: "mp3_bitrate", label: "Bitrate MP3", type: "text" },
|
||||
{ key: "target_dbfs", label: "Normalisation (dBFS)", type: "number", step: 0.5, min: -40, max: 0 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function Field({ field, value, onChange }) {
|
||||
const common = "input w-full";
|
||||
if (field.type === "checkbox")
|
||||
return <input type="checkbox" className="h-4 w-4"
|
||||
checked={!!value} onChange={(e) => onChange(e.target.checked)} />;
|
||||
if (field.type === "textarea")
|
||||
return <textarea className={`${common} min-h-[5rem] resize-y text-sm`} rows={4}
|
||||
value={value ?? ""} onChange={(e) => onChange(e.target.value)} />;
|
||||
if (field.type === "select")
|
||||
return <select className={common} value={value ?? ""} onChange={(e) => onChange(e.target.value)}>
|
||||
{field.options.map(([v, lbl]) => <option key={v} value={v}>{lbl}</option>)}
|
||||
</select>;
|
||||
if (field.type === "number")
|
||||
return <input className={common} type="number"
|
||||
step={field.step} min={field.min} max={field.max}
|
||||
value={value ?? ""} onChange={(e) => onChange(e.target.value === "" ? "" : Number(e.target.value))} />;
|
||||
return <input className={common} type="text"
|
||||
value={value ?? ""} onChange={(e) => onChange(e.target.value)} />;
|
||||
}
|
||||
|
||||
export default function Settings({ onBack }) {
|
||||
const [settings, setSettings] = useState(null);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
api.getSettings().then(setSettings).catch((e) => setError(String(e)));
|
||||
}, []);
|
||||
|
||||
if (error) return <p className="text-sm text-red-400">{error}</p>;
|
||||
if (!settings) return <p className="text-ink-muted"><Spinner /> chargement des réglages…</p>;
|
||||
|
||||
const set = (key, val) => { setSettings({ ...settings, [key]: val }); setSaved(false); };
|
||||
|
||||
const save = async () => {
|
||||
setError(null);
|
||||
try { await api.putSettings(settings); setSaved(true); }
|
||||
catch (e) { setError("Échec de l'enregistrement : " + e); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={onBack} className="text-sm text-ink-muted hover:text-ink-text">← Bibliothèque</button>
|
||||
<h1 className="font-serif text-2xl">Réglages techniques</h1>
|
||||
<button className="btn-primary ml-auto" onClick={save}>
|
||||
{saved ? "✓ enregistré" : "Enregistrer"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-ink-muted">
|
||||
Réglages globaux appliqués à toute l'app. Les changements de modèle prennent effet au
|
||||
prochain lancement d'analyse ou de rendu.
|
||||
</p>
|
||||
|
||||
{SECTIONS.map((sec) => (
|
||||
<section key={sec.title} className="card p-4 space-y-3">
|
||||
<div>
|
||||
<h2 className="font-medium">{sec.title}</h2>
|
||||
{sec.hint && <p className="text-xs text-ink-muted">{sec.hint}</p>}
|
||||
</div>
|
||||
<div className="grid gap-3">
|
||||
{sec.fields.map((f) => (
|
||||
<label key={f.key} className="grid gap-1">
|
||||
<span className="text-sm text-ink-muted">{f.label}</span>
|
||||
<Field field={f} value={settings[f.key]} onChange={(v) => set(f.key, v)} />
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button className="btn-primary" onClick={save}>
|
||||
{saved ? "✓ enregistré" : "Enregistrer"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
64
frontend/src/api.js
Normal file
64
frontend/src/api.js
Normal file
@@ -0,0 +1,64 @@
|
||||
// Client API InkFlow : wrappers fetch + abonnement WebSocket a l'etat.
|
||||
|
||||
async function j(url, opts) {
|
||||
const res = await fetch(url, opts);
|
||||
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
|
||||
const ct = res.headers.get("content-type") || "";
|
||||
return ct.includes("application/json") ? res.json() : res;
|
||||
}
|
||||
|
||||
const json = (method, body) => ({
|
||||
method,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
|
||||
export const api = {
|
||||
listBooks: () => j("/api/books"),
|
||||
uploadBook: (file) => {
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
return j("/api/books", { method: "POST", body: fd });
|
||||
},
|
||||
getBook: (slug) => j(`/api/books/${slug}`),
|
||||
getChapter: (slug, idx) => j(`/api/books/${slug}/chapters/${idx}`),
|
||||
putAnalysis: (slug, idx, analysis) =>
|
||||
j(`/api/books/${slug}/chapters/${idx}/analysis`, json("PUT", analysis)),
|
||||
analyze: (slug, chapters) => j(`/api/books/${slug}/analyze`, json("POST", { chapters })),
|
||||
pronounce: (slug) => j(`/api/books/${slug}/pronounce`, json("POST")),
|
||||
castAuto: (slug) => j(`/api/books/${slug}/cast/auto`, json("POST")),
|
||||
castAnalyze: (slug, chapters) =>
|
||||
j(`/api/books/${slug}/cast/analyze`, json("POST", { chapters })),
|
||||
castDedup: (slug) => j(`/api/books/${slug}/cast/dedup`, json("POST")),
|
||||
render: (slug, chapters, backend, mono) =>
|
||||
j(`/api/books/${slug}/render`, json("POST", { chapters, backend, mono })),
|
||||
getCast: (slug) => j(`/api/books/${slug}/cast`),
|
||||
putCast: (slug, cast) => j(`/api/books/${slug}/cast`, json("PUT", cast)),
|
||||
getPron: (slug) => j(`/api/books/${slug}/pronunciation`),
|
||||
putPron: (slug, pron) => j(`/api/books/${slug}/pronunciation`, json("PUT", pron)),
|
||||
getSettings: () => j("/api/settings"),
|
||||
putSettings: (settings) => j("/api/settings", json("PUT", settings)),
|
||||
audioUrl: (slug, idx) => `/api/books/${slug}/audio/${idx}`,
|
||||
coverUrl: (slug) => `/api/books/${slug}/cover`,
|
||||
previewVoice: async (voiceId, text) => {
|
||||
const res = await fetch("/api/voicebank/preview", json("POST", { voice_id: voiceId, text }));
|
||||
if (!res.ok) throw new Error("preview");
|
||||
return URL.createObjectURL(await res.blob());
|
||||
},
|
||||
};
|
||||
|
||||
// Abonnement temps reel a l'etat d'un livre. Reconnecte automatiquement.
|
||||
export function subscribeState(slug, onState) {
|
||||
let ws, closed = false;
|
||||
const connect = () => {
|
||||
const proto = location.protocol === "https:" ? "wss" : "ws";
|
||||
ws = new WebSocket(`${proto}://${location.host}/ws/${slug}`);
|
||||
ws.onmessage = (e) => {
|
||||
const msg = JSON.parse(e.data);
|
||||
if (msg.type === "state") onState(msg.state);
|
||||
};
|
||||
ws.onclose = () => { if (!closed) setTimeout(connect, 1500); };
|
||||
};
|
||||
connect();
|
||||
return () => { closed = true; ws && ws.close(); };
|
||||
}
|
||||
37
frontend/src/index.css
Normal file
37
frontend/src/index.css
Normal file
@@ -0,0 +1,37 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: #14110f;
|
||||
color: #ede4d8;
|
||||
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.btn {
|
||||
@apply inline-flex items-center gap-2 rounded-md px-3 py-1.5 text-sm font-medium
|
||||
transition-colors disabled:opacity-40 disabled:cursor-not-allowed;
|
||||
}
|
||||
.btn-primary {
|
||||
@apply btn bg-ink-accent text-ink-bg hover:bg-ink-accent2;
|
||||
}
|
||||
.btn-ghost {
|
||||
@apply btn border border-ink-edge text-ink-text hover:bg-ink-edge;
|
||||
}
|
||||
.card {
|
||||
@apply rounded-lg border border-ink-edge bg-ink-panel;
|
||||
}
|
||||
.chip {
|
||||
@apply inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium;
|
||||
}
|
||||
.input {
|
||||
@apply rounded-md border border-ink-edge bg-ink-bg px-2 py-1 text-sm
|
||||
text-ink-text outline-none focus:border-ink-accent;
|
||||
}
|
||||
}
|
||||
6
frontend/src/main.jsx
Normal file
6
frontend/src/main.jsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import App from "./App.jsx";
|
||||
import "./index.css";
|
||||
|
||||
createRoot(document.getElementById("root")).render(<App />);
|
||||
35
frontend/src/ui.jsx
Normal file
35
frontend/src/ui.jsx
Normal file
@@ -0,0 +1,35 @@
|
||||
// Petits widgets partages.
|
||||
import React from "react";
|
||||
|
||||
const STATUS_STYLE = {
|
||||
done: "bg-emerald-900/50 text-emerald-300",
|
||||
running: "bg-ink-accent/20 text-ink-accent",
|
||||
error: "bg-red-900/50 text-red-300",
|
||||
pending: "bg-ink-edge text-ink-muted",
|
||||
};
|
||||
const STATUS_LABEL = { done: "terminé", running: "en cours", error: "erreur", pending: "en attente" };
|
||||
|
||||
export function StatusChip({ status }) {
|
||||
return (
|
||||
<span className={`chip ${STATUS_STYLE[status] || STATUS_STYLE.pending}`}>
|
||||
{STATUS_LABEL[status] || status}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProgressBar({ value }) {
|
||||
return (
|
||||
<div className="h-1.5 w-full overflow-hidden rounded-full bg-ink-edge">
|
||||
<div
|
||||
className="h-full bg-ink-accent transition-all duration-300"
|
||||
style={{ width: `${Math.round((value || 0) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Spinner() {
|
||||
return (
|
||||
<span className="inline-block h-3.5 w-3.5 animate-spin rounded-full border-2 border-ink-accent border-t-transparent" />
|
||||
);
|
||||
}
|
||||
23
frontend/tailwind.config.js
Normal file
23
frontend/tailwind.config.js
Normal file
@@ -0,0 +1,23 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ["./index.html", "./src/**/*.{js,jsx}"],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
ink: {
|
||||
bg: "#14110f",
|
||||
panel: "#1d1916",
|
||||
edge: "#2c2622",
|
||||
muted: "#9a8c7d",
|
||||
text: "#ede4d8",
|
||||
accent: "#d9a441",
|
||||
accent2: "#b9763f",
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
serif: ["Georgia", "Cambria", "serif"],
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
14
frontend/vite.config.js
Normal file
14
frontend/vite.config.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
// En dev, l'UI tourne sur 5173 et proxifie l'API/WS vers le backend (8000).
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
"/api": { target: "http://127.0.0.1:8000", changeOrigin: true },
|
||||
"/ws": { target: "ws://127.0.0.1:8000", ws: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user