Files
PubScript/apps/web/src/features/import/ImportDialog.tsx
T

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 &ldquo;{currentProject.title}&rdquo;
</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>
);
}