From 2862c433bd520c5f0656f75858f018d1c1060b5a Mon Sep 17 00:00:00 2001 From: avi Date: Sun, 16 Aug 2026 14:11:51 -0500 Subject: [PATCH] =?UTF-8?q?Stage=202:=20local=20project=20creation=20?= =?UTF-8?q?=E2=80=94=20folder=20dialog,=20folio.json,=20recents,=20menu,?= =?UTF-8?q?=20book=20open/new/reveal,=20welcome=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 1 + src/main/index.ts | 105 +++++++++++++++++++++++++++- src/main/menu.ts | 71 +++++++++++++++++++ src/main/preload.ts | 23 +++++- src/main/project.ts | 130 ++++++++++++++++++++++++++++++++++ src/main/recents.ts | 18 +++++ src/renderer/index.css | 125 +++++++++++++++++++++++++++++++++ src/renderer/index.html | 27 +++++-- src/renderer/index.ts | 139 +++++++++++++++++++++++++++++++++++-- tests/run-project-test.mjs | 85 +++++++++++++++++++++++ 10 files changed, 710 insertions(+), 14 deletions(-) create mode 100644 src/main/menu.ts create mode 100644 src/main/project.ts create mode 100644 src/main/recents.ts create mode 100644 tests/run-project-test.mjs diff --git a/package.json b/package.json index 98dc4fc..feff2ed 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "dev": "concurrently -k \"npm run build:watch\" \"wait-on dist/main.js && npm run electron\"", "start": "npm run build && npm run electron", "lint": "tsc --noEmit", + "test": "node tests/run-project-test.mjs", "package": "electron-builder" }, "devDependencies": { diff --git a/src/main/index.ts b/src/main/index.ts index a850712..c52d8ae 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,7 +1,32 @@ -import { app, BrowserWindow } from "electron"; +import { app, BrowserWindow, dialog, ipcMain, shell } from "electron"; +import type { OpenDialogOptions } from "electron"; import path from "path"; +import { + BookResult, + ErrResult, + createBook, + hasBook, + loadBook, + Recent, +} from "./project.js"; +import { listRecents, addRecent } from "./recents.js"; +import { buildMenu } from "./menu.js"; let mainWindow: BrowserWindow | null = null; +let bookPath: string | null = null; + +function getWindow(): BrowserWindow | null { + return mainWindow; +} + +function showOpenDialog(options: OpenDialogOptions) { + const win = getWindow(); + return win ? dialog.showOpenDialog(win, options) : dialog.showOpenDialog(options); +} + +function getState() { + return { bookPath }; +} function createWindow() { mainWindow = new BrowserWindow({ @@ -16,7 +41,6 @@ function createWindow() { preload: path.join(__dirname, "preload.js"), }, }); - mainWindow.loadFile(path.join(__dirname, "index.html")); mainWindow.webContents.on("did-finish-load", () => console.log("[folio] renderer loaded") @@ -24,6 +48,12 @@ function createWindow() { } app.whenReady().then(() => { + buildMenu( + getWindow, + getState, + () => getWindow()?.webContents.send("folio:new-book-requested"), + () => getWindow()?.webContents.send("folio:open-book-requested") + ); createWindow(); app.on("activate", () => { if (BrowserWindow.getAllWindows().length === 0) createWindow(); @@ -33,3 +63,74 @@ app.whenReady().then(() => { app.on("window-all-closed", () => { if (process.platform !== "darwin") app.quit(); }); + +function refreshMenu() { + buildMenu( + getWindow, + getState, + () => getWindow()?.webContents.send("folio:new-book-requested"), + () => getWindow()?.webContents.send("folio:open-book-requested") + ); +} + +async function openBookAt(p: string): Promise { + try { + const meta = loadBook(p); + addRecent({ path: p, title: meta.title, lastOpened: new Date().toISOString() }); + bookPath = p; + mainWindow?.setTitle(`${meta.title} — Folio`); + refreshMenu(); + mainWindow?.webContents.send("folio:bookOpened", { path: p, title: meta.title }); + return { path: p, title: meta.title }; + } catch (e) { + return { error: (e as Error).message }; + } +} + +ipcMain.handle("folio:newBook", async () => { + const { canceled, filePaths } = await showOpenDialog({ + properties: ["createDirectory", "openDirectory"], + title: "Choose where to save your new book", + }); + if (canceled || !filePaths.length) return { canceled: true } as const; + const p = filePaths[0]; + if (hasBook(p)) return { error: "A Folio book already exists at this location." }; + const title = path.basename(p) || "Untitled"; + const meta = createBook(p, title); + addRecent({ path: p, title: meta.title, lastOpened: new Date().toISOString() }); + bookPath = p; + mainWindow?.setTitle(`${meta.title} — Folio`); + refreshMenu(); + getWindow()?.webContents.send("folio:bookOpened", { path: p, title: meta.title }); + return { path: p, title: meta.title }; +}); + +ipcMain.handle("folio:openBook", async () => { + const { canceled, filePaths } = await showOpenDialog({ + properties: ["openDirectory"], + title: "Open a Folio book", + }); + if (canceled || !filePaths.length) return { canceled: true } as const; + const p = filePaths[0]; + if (!hasBook(p)) { + return { error: "No Folio book (folio.json) found at this location." }; + } + return openBookAt(p); +}); + +ipcMain.handle("folio:openPath", async (_e, p: string) => openBookAt(p)); + +ipcMain.handle("folio:reveal", async (_e, p: string) => { + if (p) await shell.openPath(p); + return true; +}); + +ipcMain.handle("folio:getRecents", (): Recent[] => listRecents()); + +ipcMain.handle("folio:closeBook", () => { + bookPath = null; + mainWindow?.setTitle("Folio"); + refreshMenu(); + getWindow()?.webContents.send("folio:bookClosed"); + return true; +}); diff --git a/src/main/menu.ts b/src/main/menu.ts new file mode 100644 index 0000000..dc6a377 --- /dev/null +++ b/src/main/menu.ts @@ -0,0 +1,71 @@ +import path from "path"; +import { app, Menu, BrowserWindow } from "electron"; +import { listRecents } from "./recents.js"; +import type { Recent } from "./recents.js"; + +type MenuItem = Electron.MenuItemConstructorOptions; +type GetState = () => { bookPath: string | null }; +type GetWindow = () => BrowserWindow | null; + +export function buildMenu( + getWindow: GetWindow, + getState: GetState, + onRequestNew: () => void, + onRequestOpen: () => void +) { + const isMac = process.platform === "darwin"; + const recents = listRecents(); + + const recentItems: MenuItem[] = recents.length + ? recents.map((r: Recent) => ({ + label: r.title.length ? `${r.title} — ${path.dirname(r.path)}` : path.basename(r.path), + tooltip: r.path, + click: () => getWindow()?.webContents.send("folio:open-recent", r.path), + })) + : [{ label: "(no recent books)", enabled: false }]; + + const fileMenu: MenuItem = { + label: "File", + submenu: [ + { label: "New Book", accelerator: "CmdOrCtrl+N", click: onRequestNew }, + { label: "Open Book", accelerator: "CmdOrCtrl+O", click: onRequestOpen }, + { type: "separator" }, + { label: "Open Recent", submenu: recentItems }, + { type: "separator" }, + { + label: "Reveal in Folder", + accelerator: "CmdOrCtrl+R", + enabled: !!getState().bookPath, + click: () => { + const p = getState().bookPath; + if (p) getWindow()?.webContents.send("folio:reveal-requested", p); + }, + }, + { type: "separator" }, + { + label: "Close", + accelerator: isMac ? "Cmd+W" : "Ctrl+W", + click: () => getWindow()?.close(), + }, + { label: "Quit", accelerator: "CmdOrCtrl+Q", click: () => app.quit() }, + ], + }; + + const editMenu: MenuItem = { + label: "Edit", + submenu: [ + { role: "undo" }, + { role: "redo" }, + { type: "separator" }, + { role: "cut" }, + { role: "copy" }, + { role: "paste" }, + { type: "separator" }, + { role: "selectAll" }, + ], + }; + + const menu = Menu.buildFromTemplate([fileMenu, editMenu]); + Menu.setApplicationMenu(menu); + return menu; +} diff --git a/src/main/preload.ts b/src/main/preload.ts index 19997db..2b86138 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -1,3 +1,22 @@ -window.addEventListener("DOMContentLoaded", () => { - console.log("[folio] dom ready"); +import { contextBridge, ipcRenderer } from "electron"; + +contextBridge.exposeInMainWorld("folio", { + newBook: () => ipcRenderer.invoke("folio:newBook"), + openBook: () => ipcRenderer.invoke("folio:openBook"), + openBookPath: (p: string) => ipcRenderer.invoke("folio:openPath", p), + revealInFolder: (p: string) => ipcRenderer.invoke("folio:reveal", p), + getRecents: () => ipcRenderer.invoke("folio:getRecents"), + closeBook: () => ipcRenderer.invoke("folio:closeBook"), + onBookOpened: (cb: (data: { path: string; title: string }) => void) => + ipcRenderer.on("folio:bookOpened", (_e, data) => cb(data)), + onBookClosed: (cb: () => void) => + ipcRenderer.on("folio:bookClosed", () => cb()), + onNewBookRequested: (cb: () => void) => + ipcRenderer.on("folio:new-book-requested", () => cb()), + onOpenBookRequested: (cb: () => void) => + ipcRenderer.on("folio:open-book-requested", () => cb()), + onOpenRecent: (cb: (p: string) => void) => + ipcRenderer.on("folio:open-recent", (_e, p) => cb(p)), + onRevealRequested: (cb: (p: string) => void) => + ipcRenderer.on("folio:reveal-requested", (_e, p) => cb(p)), }); diff --git a/src/main/project.ts b/src/main/project.ts new file mode 100644 index 0000000..d864b42 --- /dev/null +++ b/src/main/project.ts @@ -0,0 +1,130 @@ +import fs from "fs"; +import path from "path"; + +export interface ChapterEntry { + id: string; + title: string; + file: string; +} + +export interface BookMeta { + app: string; + version: number; + title: string; + chapterOrder: string[]; + chapters: Record; +} + +export interface Recent { + path: string; + title: string; + lastOpened: string; +} + +export interface BookResult { + path: string; + title: string; +} + +export interface ErrResult { + error: string; +} + +export const APP_ID = "folio"; +export const META_FILE = "folio.json"; +export const CHAPTERS_DIR = "chapters"; +export const CURRENT_VERSION = 1; + +export function slugify(s: string): string { + return s + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") || "chapter"; +} + +export function makeChapterId(title: string): string { + const rand = Math.random().toString(36).slice(2, 6); + return `${slugify(title) || "chapter"}-${rand}`; +} + +export function chapterFileName(id: string): string { + return `${id}.md`; +} + +export function defaultMeta(title: string): BookMeta { + return { + app: APP_ID, + version: CURRENT_VERSION, + title, + chapterOrder: [], + chapters: {}, + }; +} + +export function metaPath(bookPath: string): string { + return path.join(bookPath, META_FILE); +} + +export function chaptersDir(bookPath: string): string { + return path.join(bookPath, CHAPTERS_DIR); +} + +export function hasBook(bookPath: string): boolean { + try { + return fs.statSync(metaPath(bookPath)).isFile(); + } catch { + return false; + } +} + +export function createBook(bookPath: string, title: string): BookMeta { + fs.mkdirSync(bookPath, { recursive: true }); + fs.mkdirSync(chaptersDir(bookPath), { recursive: true }); + const meta = defaultMeta(title); + saveMeta(bookPath, meta); + return meta; +} + +export function loadBook(bookPath: string): BookMeta { + const raw = fs.readFileSync(metaPath(bookPath), "utf-8"); + const data = JSON.parse(raw) as BookMeta; + if (data.app !== APP_ID) { + throw new Error( + `Not a Folio book (no valid folio.json): ${bookPath}` + ); + } + // tolerate older/simpler metadata + if (!Array.isArray(data.chapterOrder)) data.chapterOrder = []; + if (!data.chapters) data.chapters = {}; + return data; +} + +export function saveMeta(bookPath: string, meta: BookMeta): void { + fs.writeFileSync( + metaPath(bookPath), + JSON.stringify(meta, null, 2), + "utf-8" + ); +} + +function ensureParent(filePath: string): void { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); +} + +export function listRecents(filePath: string): Recent[] { + try { + const raw = fs.readFileSync(filePath, "utf-8"); + const arr = JSON.parse(raw); + return Array.isArray(arr) ? (arr as Recent[]) : []; + } catch { + return []; + } +} + +export function addRecent(filePath: string, recent: Recent): Recent[] { + const recents = listRecents(filePath).filter((r) => r.path !== recent.path); + recents.unshift(recent); + ensureParent(filePath); + fs.writeFileSync(filePath, JSON.stringify(recents, null, 2), "utf-8"); + return recents; +} diff --git a/src/main/recents.ts b/src/main/recents.ts new file mode 100644 index 0000000..fef6914 --- /dev/null +++ b/src/main/recents.ts @@ -0,0 +1,18 @@ +import path from "path"; +import { app } from "electron"; +import { APP_ID, listRecents as _listRecents, addRecent as _addRecent } from "./project.js"; +import type { Recent } from "./project.js"; + +export type { Recent }; + +export function recentsFilePath(): string { + return path.join(app.getPath("userData"), `${APP_ID}-recents.json`); +} + +export function listRecents(): Recent[] { + return _listRecents(recentsFilePath()); +} + +export function addRecent(recent: Recent): Recent[] { + return _addRecent(recentsFilePath(), recent); +} diff --git a/src/renderer/index.css b/src/renderer/index.css index 34cf1ee..cec2790 100644 --- a/src/renderer/index.css +++ b/src/renderer/index.css @@ -158,3 +158,128 @@ html, body { font-size: 1rem; margin: 0 0 6px; } + +/* ---- project bar ---- */ +.project-bar { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + background: var(--color-panel); + border-radius: var(--radius); + margin-bottom: var(--gap); +} + +.book-title { + font-size: 0.9rem; + font-weight: 600; + color: var(--color-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.book-path { + flex: 1; + font-size: 0.7rem; + color: var(--color-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.icon-button { + border: 1px solid var(--color-border); + background: transparent; + border-radius: var(--radius); + padding: 4px 8px; + cursor: pointer; + font-size: 0.9rem; + line-height: 1; + transition: background 0.12s ease, border-color 0.12s ease; +} + +.icon-button:hover { + background: var(--color-accent); + color: #fff; + border-color: var(--color-accent); +} + +/* ---- welcome ---- */ +.welcome { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + max-width: 520px; + margin: 0 auto; + padding: var(--gap); +} + +.app-name { + font-size: 2rem; + font-weight: 700; + margin: 0 0 4px; + color: var(--color-text); +} + +.app-tagline { + font-size: 0.95rem; + color: var(--color-muted); + margin: 0 0 var(--gap); +} + +.welcome-actions { + display: flex; + gap: 12px; + margin-bottom: var(--gap); +} + +.recents-block { + width: 100%; + text-align: left; +} + +.recents-title { + font-size: 0.72rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--color-muted); + margin-bottom: 8px; +} + +.recent-list { + list-style: none; + padding: 0; + margin: 0; + max-height: 200px; + overflow-y: auto; +} + +.recent-list li { + padding: 6px 8px; + border-radius: var(--radius); + cursor: pointer; + font-size: 0.85rem; + color: var(--color-text); + transition: background 0.12s ease, color 0.12s ease; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.recent-list li:hover { + background: var(--color-accent); + color: #fff; +} + +.muted { + color: var(--color-muted); +} + +.hidden { + display: none !important; +} diff --git a/src/renderer/index.html b/src/renderer/index.html index 5bf92af..ac33b82 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -8,19 +8,36 @@
-