362 lines
13 KiB
TypeScript
362 lines
13 KiB
TypeScript
import React, { useRef, useState } from "react";
|
|
import { useNavigate } from "react-router-dom";
|
|
import { Badge, Button, Modal, toast } from "@pubscript/ui";
|
|
import {
|
|
importManuscriptFile,
|
|
SUPPORTED_IMPORT_EXTENSIONS,
|
|
type ImportedManuscript,
|
|
} from "@pubscript/exporters";
|
|
import { useProjectStore } from "../../store/project-store.js";
|
|
|
|
export interface ImportDialogProps {
|
|
open: boolean;
|
|
onClose: () => void;
|
|
defaultMode?: "new-project" | "append-chapters";
|
|
}
|
|
|
|
export function ImportDialog({
|
|
open,
|
|
onClose,
|
|
defaultMode = "new-project",
|
|
}: ImportDialogProps) {
|
|
const navigate = useNavigate();
|
|
const currentProject = useProjectStore((s) => s.project);
|
|
const importManuscript = useProjectStore((s) => s.importManuscript);
|
|
|
|
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
|
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
|
const [rawBytes, setRawBytes] = useState<Uint8Array | null>(null);
|
|
const [parsed, setParsed] = useState<ImportedManuscript | null>(null);
|
|
const [mode, setMode] = useState<"new-project" | "append-chapters">(defaultMode);
|
|
const [customTitle, setCustomTitle] = useState("");
|
|
const [splitChapters, setSplitChapters] = useState(true);
|
|
const [parsing, setParsing] = useState(false);
|
|
const [importing, setImporting] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const runParse = async (fileName: string, bytes: Uint8Array, split: boolean) => {
|
|
setParsing(true);
|
|
setError(null);
|
|
try {
|
|
const res = await importManuscriptFile(fileName, bytes, {
|
|
splitChapters: split,
|
|
});
|
|
setParsed(res);
|
|
setCustomTitle((prev) => prev || res.title);
|
|
} catch (err) {
|
|
setParsed(null);
|
|
setError(err instanceof Error ? err.message : "Failed to parse manuscript file.");
|
|
} finally {
|
|
setParsing(false);
|
|
}
|
|
};
|
|
|
|
const handleFileChange = async (file: File | null) => {
|
|
if (!file) return;
|
|
setSelectedFile(file);
|
|
setCustomTitle("");
|
|
const buf = await file.arrayBuffer();
|
|
const bytes = new Uint8Array(buf);
|
|
setRawBytes(bytes);
|
|
await runParse(file.name, bytes, splitChapters);
|
|
};
|
|
|
|
const handleToggleSplit = async (nextSplit: boolean) => {
|
|
setSplitChapters(nextSplit);
|
|
if (selectedFile && rawBytes) {
|
|
await runParse(selectedFile.name, rawBytes, nextSplit);
|
|
}
|
|
};
|
|
|
|
const handleConfirmImport = async () => {
|
|
if (!parsed) return;
|
|
setImporting(true);
|
|
setError(null);
|
|
try {
|
|
await importManuscript(parsed, {
|
|
mode: currentProject ? mode : "new-project",
|
|
title: customTitle.trim() || parsed.title,
|
|
});
|
|
toast.success(
|
|
mode === "append-chapters" && currentProject
|
|
? `Imported ${parsed.chapters.length} chapter(s) (${parsed.totalWords.toLocaleString()} words) into "${currentProject.title}"`
|
|
: `Created project "${customTitle.trim() || parsed.title}" (${parsed.chapters.length} chapter(s), ${parsed.totalWords.toLocaleString()} words)`,
|
|
);
|
|
setSelectedFile(null);
|
|
setRawBytes(null);
|
|
setParsed(null);
|
|
setCustomTitle("");
|
|
onClose();
|
|
navigate("/");
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : "Failed to import manuscript.");
|
|
} finally {
|
|
setImporting(false);
|
|
}
|
|
};
|
|
|
|
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
|
|
e.preventDefault();
|
|
const f = e.dataTransfer.files?.[0];
|
|
if (f) void handleFileChange(f);
|
|
};
|
|
|
|
return (
|
|
<Modal
|
|
open={open}
|
|
title="Import Manuscript from File"
|
|
onClose={onClose}
|
|
footer={
|
|
<>
|
|
<Button variant="ghost" onClick={onClose} disabled={importing}>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
variant="primary"
|
|
onClick={() => void handleConfirmImport()}
|
|
disabled={!parsed || parsing || importing}
|
|
>
|
|
{importing
|
|
? "Converting & Saving…"
|
|
: mode === "append-chapters" && currentProject
|
|
? `Import ${parsed?.chapters.length ?? 0} Chapter(s)`
|
|
: "Import as New Project"}
|
|
</Button>
|
|
</>
|
|
}
|
|
>
|
|
<div style={{ display: "flex", flexDirection: "column", gap: 14 }}>
|
|
<div
|
|
onDragOver={(e) => e.preventDefault()}
|
|
onDrop={handleDrop}
|
|
onClick={() => fileInputRef.current?.click()}
|
|
style={{
|
|
border: "2px dashed var(--ps-border)",
|
|
borderRadius: 10,
|
|
padding: "20px 16px",
|
|
textAlign: "center",
|
|
background: "var(--ps-bg)",
|
|
cursor: "pointer",
|
|
}}
|
|
>
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept={SUPPORTED_IMPORT_EXTENSIONS.join(",")}
|
|
style={{ display: "none" }}
|
|
onChange={(e) => void handleFileChange(e.target.files?.[0] ?? null)}
|
|
/>
|
|
<div style={{ fontSize: 22, marginBottom: 6 }}>📥</div>
|
|
<div style={{ fontWeight: 600, fontSize: 13.5, marginBottom: 4 }}>
|
|
{selectedFile
|
|
? `${selectedFile.name} (${(selectedFile.size / 1024).toFixed(1)} KB)`
|
|
: "Click to choose a file or drag & drop here"}
|
|
</div>
|
|
<div style={{ fontSize: 11.5, color: "var(--ps-text-muted)" }}>
|
|
Compatible formats: <strong>.docx</strong> (Word / OnlyOffice), <strong>.odt</strong>{" "}
|
|
(OpenOffice / LibreOffice / OnlyOffice), <strong>.epub</strong>, <strong>.md</strong>,{" "}
|
|
<strong>.txt</strong>, <strong>.html</strong>, <strong>.rtf</strong>,{" "}
|
|
<strong>.json</strong>
|
|
</div>
|
|
</div>
|
|
|
|
{error && (
|
|
<div
|
|
role="alert"
|
|
style={{
|
|
padding: "10px 12px",
|
|
borderRadius: 8,
|
|
border: "1px solid var(--ps-danger)",
|
|
background: "color-mix(in srgb, var(--ps-danger) 14%, transparent)",
|
|
color: "var(--ps-danger)",
|
|
fontSize: 12.5,
|
|
}}
|
|
>
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
{parsing && (
|
|
<div style={{ fontSize: 12.5, color: "var(--ps-text-muted)", textAlign: "center" }}>
|
|
Running PubScript format conversion pipeline…
|
|
</div>
|
|
)}
|
|
|
|
{parsed && !parsing && (
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
gap: 12,
|
|
padding: 14,
|
|
borderRadius: 10,
|
|
border: "1px solid var(--ps-border)",
|
|
background: "var(--ps-surface)",
|
|
}}
|
|
>
|
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", flexWrap: "wrap", gap: 8 }}>
|
|
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
|
<Badge variant="accent">{parsed.formatLabel}</Badge>
|
|
<span style={{ fontSize: 12, color: "var(--ps-text-muted)" }}>
|
|
{parsed.chapters.length} chapter(s) · {parsed.totalWords.toLocaleString()} words
|
|
</span>
|
|
</div>
|
|
{parsed.author && (
|
|
<span style={{ fontSize: 11.5, color: "var(--ps-text-muted)" }}>
|
|
Author: {parsed.author}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{currentProject && (
|
|
<div className="ps-field">
|
|
<label className="ps-field-label">Import Destination</label>
|
|
<div style={{ display: "flex", gap: 8 }}>
|
|
<button
|
|
type="button"
|
|
onClick={() => setMode("new-project")}
|
|
style={{
|
|
flex: 1,
|
|
padding: "8px 10px",
|
|
borderRadius: 8,
|
|
border:
|
|
mode === "new-project"
|
|
? "2px solid var(--ps-accent)"
|
|
: "1px solid var(--ps-border)",
|
|
background:
|
|
mode === "new-project"
|
|
? "color-mix(in srgb, var(--ps-accent) 14%, var(--ps-bg))"
|
|
: "var(--ps-bg)",
|
|
color: "var(--ps-text)",
|
|
fontSize: 12.5,
|
|
fontWeight: mode === "new-project" ? 600 : 500,
|
|
cursor: "pointer",
|
|
}}
|
|
>
|
|
✨ Create New Project
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setMode("append-chapters")}
|
|
style={{
|
|
flex: 1,
|
|
padding: "8px 10px",
|
|
borderRadius: 8,
|
|
border:
|
|
mode === "append-chapters"
|
|
? "2px solid var(--ps-accent)"
|
|
: "1px solid var(--ps-border)",
|
|
background:
|
|
mode === "append-chapters"
|
|
? "color-mix(in srgb, var(--ps-accent) 14%, var(--ps-bg))"
|
|
: "var(--ps-bg)",
|
|
color: "var(--ps-text)",
|
|
fontSize: 12.5,
|
|
fontWeight: mode === "append-chapters" ? 600 : 500,
|
|
cursor: "pointer",
|
|
}}
|
|
>
|
|
📖 Add Chapters to “{currentProject.title}”
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{mode === "new-project" && (
|
|
<div className="ps-field">
|
|
<label className="ps-field-label" htmlFor="ps-import-title">
|
|
Project Title
|
|
</label>
|
|
<input
|
|
id="ps-import-title"
|
|
type="text"
|
|
className="ps-input"
|
|
value={customTitle}
|
|
onChange={(e) => setCustomTitle(e.target.value)}
|
|
placeholder="Manuscript Title"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{parsed.format !== "epub" && parsed.format !== "json" && (
|
|
<label
|
|
style={{
|
|
display: "inline-flex",
|
|
alignItems: "center",
|
|
gap: 8,
|
|
fontSize: 12.5,
|
|
cursor: "pointer",
|
|
}}
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={splitChapters}
|
|
onChange={(e) => void handleToggleSplit(e.target.checked)}
|
|
/>
|
|
<span>
|
|
Automatically split into chapters on headings (<code>H1</code> /{" "}
|
|
<code>Chapter N</code>)
|
|
</span>
|
|
</label>
|
|
)}
|
|
|
|
<div>
|
|
<div
|
|
style={{
|
|
fontSize: 11.5,
|
|
fontWeight: 600,
|
|
color: "var(--ps-text-muted)",
|
|
marginBottom: 6,
|
|
}}
|
|
>
|
|
Converted Chapters Preview ({parsed.chapters.length})
|
|
</div>
|
|
<div
|
|
style={{
|
|
maxHeight: 160,
|
|
overflowY: "auto",
|
|
border: "1px solid var(--ps-border)",
|
|
borderRadius: 8,
|
|
background: "var(--ps-bg)",
|
|
padding: 6,
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
gap: 4,
|
|
}}
|
|
>
|
|
{parsed.chapters.map((ch, idx) => (
|
|
<div
|
|
key={idx}
|
|
style={{
|
|
display: "flex",
|
|
justifyContent: "space-between",
|
|
alignItems: "center",
|
|
padding: "5px 8px",
|
|
borderRadius: 6,
|
|
fontSize: 12.5,
|
|
background: "var(--ps-surface)",
|
|
}}
|
|
>
|
|
<span style={{ fontWeight: 500 }}>
|
|
{idx + 1}. {ch.title}
|
|
</span>
|
|
<span
|
|
style={{
|
|
fontFamily: "var(--ps-font-mono)",
|
|
fontSize: 11.5,
|
|
color: "var(--ps-text-muted)",
|
|
}}
|
|
>
|
|
{ch.wordCount.toLocaleString()} words
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</Modal>
|
|
);
|
|
}
|