diff --git a/src/components/CoverArt.tsx b/src/components/CoverArt.tsx new file mode 100644 index 0000000..fdea0c6 --- /dev/null +++ b/src/components/CoverArt.tsx @@ -0,0 +1,23 @@ +import { type Book, trimRatio } from "../model/book"; + +export function CoverArt({ book }: { book: Book }) { + const { cover, metadata, settings } = book; + const ratio = String(trimRatio(settings.trim)); + + return ( +
+ {cover.kind === "image" && cover.image ? ( + {metadata.title + ) : ( +
+
+

{metadata.title || "Untitled"}

+ + {metadata.subtitle &&

{metadata.subtitle}

} +
+ {metadata.author &&

{metadata.author}

} +
+ )} +
+ ); +} diff --git a/src/components/CoverView.tsx b/src/components/CoverView.tsx new file mode 100644 index 0000000..ad28f12 --- /dev/null +++ b/src/components/CoverView.tsx @@ -0,0 +1,92 @@ +import { useRef } from "react"; +import { COVER_PALETTES } from "../model/book"; +import { useBook } from "../store/useBook"; +import { CoverArt } from "./CoverArt"; +import { Icon } from "./Icon"; + +function readImage(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = reject; + reader.readAsDataURL(file); + }); +} + +export function CoverView() { + const book = useBook((s) => s.book); + const setCover = useBook((s) => s.setCover); + const fileRef = useRef(null); + + if (!book) return null; + const cover = book.cover; + + const upload = async (file: File) => { + const image = await readImage(file); + setCover({ kind: "image", image }); + }; + + return ( +
+
+ +
+ +
+ {cover.kind === "image" ? ( + <> +
+ + +
+

Your artwork fills the cover as-is.

+ + ) : ( + <> +
+ {COVER_PALETTES.map((p) => ( + + ))} + + +
+
+ +
+

Title, subtitle & author are set in Book setup.

+ + )} + { + const file = e.target.files?.[0]; + if (file) upload(file); + e.currentTarget.value = ""; + }} + /> +
+
+ ); +} diff --git a/src/components/Dock.tsx b/src/components/Dock.tsx index 6ece4f3..60c4a59 100644 --- a/src/components/Dock.tsx +++ b/src/components/Dock.tsx @@ -1,11 +1,12 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { generateHTML } from "@tiptap/core"; -import { useBook } from "../store/useBook"; +import { COVER_ID, useBook } from "../store/useBook"; import type { TrimSize } from "../model/book"; import { editorExtensions } from "../editor/extensions"; -import { chapterToPdfInputs } from "../export/typst"; +import { chapterToPdfInputs, coverToPdfInputs } from "../export/typst"; import { compilePdf, isDesktop } from "../ipc"; import { PdfPreview } from "./PdfPreview"; +import { CoverArt } from "./CoverArt"; const TRIMS: { value: TrimSize; label: string }[] = [ { value: "6x9", label: "6 × 9 in" }, @@ -47,11 +48,12 @@ function PdfDock() { useEffect(() => { if (!book) return; + const coverActive = activeChapterId === COVER_ID; const idx = Math.max(0, book.chapters.findIndex((c) => c.id === activeChapterId)); clearTimeout(timer.current); setBusy(true); timer.current = setTimeout(() => { - const { source, images } = chapterToPdfInputs(book, idx); + const { source, images } = coverActive ? coverToPdfInputs(book) : chapterToPdfInputs(book, idx); compilePdf(source, images) .then((bytes) => { setPdf(bytes); @@ -86,12 +88,32 @@ function PdfDock() { function HtmlDock() { const book = useBook((s) => s.book); const activeChapterId = useBook((s) => s.activeChapterId); + const coverActive = activeChapterId === COVER_ID; const chapters = book?.chapters ?? []; const idx = chapters.findIndex((c) => c.id === activeChapterId); const chapter = chapters[idx] ?? chapters[0]; const html = useMemo(() => (chapter ? generateHTML(chapter.content, editorExtensions) : ""), [chapter]); - if (!book || !chapter) return null; + if (!book) return null; + + if (coverActive) { + return ( +
+
+ Preview +
+ Cover + +
+
+
+ +
+
+ ); + } + + if (!chapter) return null; return (
diff --git a/src/components/EditorView.tsx b/src/components/EditorView.tsx index b5173be..17708f9 100644 --- a/src/components/EditorView.tsx +++ b/src/components/EditorView.tsx @@ -4,9 +4,10 @@ import { Sidebar } from "./Sidebar"; import { Dock } from "./Dock"; import { Icon } from "./Icon"; import { Settings } from "./Settings"; +import { CoverView } from "./CoverView"; import { Editor } from "../editor/Editor"; import { FloatingToolbar } from "../editor/FloatingToolbar"; -import { useBook } from "../store/useBook"; +import { COVER_ID, useBook } from "../store/useBook"; import { useTheme } from "../store/useTheme"; import type { Book } from "../model/book"; import { saveBook } from "../library"; @@ -76,6 +77,7 @@ export function EditorView() { }; if (!book) return null; + const coverActive = activeChapterId === COVER_ID; const idx = book.chapters.findIndex((c) => c.id === activeChapterId); const chapter = book.chapters[idx] ?? book.chapters[0]; @@ -121,25 +123,31 @@ export function EditorView() {
-
-
-
Chapter {idx + 1}
- setChapterTitle(chapter.id, e.target.value)} - /> -
- setChapterContent(chapter.id, content)} - onReady={setEditor} - /> -
- + {coverActive ? ( + + ) : ( + <> +
+
+
Chapter {idx + 1}
+ setChapterTitle(chapter.id, e.target.value)} + /> +
+ setChapterContent(chapter.id, content)} + onReady={setEditor} + /> +
+ + + )}
{dock && }
diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 9bb44aa..a4ccf00 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -1,4 +1,5 @@ -import { useBook } from "../store/useBook"; +import { useState } from "react"; +import { COVER_ID, useBook } from "../store/useBook"; import { Icon } from "./Icon"; export function Sidebar() { @@ -6,14 +7,43 @@ export function Sidebar() { const activeChapterId = useBook((s) => s.activeChapterId); const setActiveChapter = useBook((s) => s.setActiveChapter); const addChapter = useBook((s) => s.addChapter); + const moveChapter = useBook((s) => s.moveChapter); const closeBook = useBook((s) => s.closeBook); + const [dragIndex, setDragIndex] = useState(null); + const [dropIndex, setDropIndex] = useState(null); + + const onDragOver = (e: React.DragEvent, i: number) => { + if (dragIndex === null) return; + e.preventDefault(); + const rect = e.currentTarget.getBoundingClientRect(); + const after = e.clientY > rect.top + rect.height / 2; + setDropIndex(after ? i + 1 : i); + }; + + const onDrop = () => { + if (dragIndex !== null && dropIndex !== null) { + const to = dragIndex < dropIndex ? dropIndex - 1 : dropIndex; + moveChapter(dragIndex, to); + } + setDragIndex(null); + setDropIndex(null); + }; + return (