Stage 2: local project creation — folder dialog, folio.json, recents, menu, book open/new/reveal, welcome UI
This commit is contained in:
parent
6fac0d013b
commit
2862c433bd
10 changed files with 710 additions and 14 deletions
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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<BookResult | ErrResult> {
|
||||
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;
|
||||
});
|
||||
|
|
|
|||
71
src/main/menu.ts
Normal file
71
src/main/menu.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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)),
|
||||
});
|
||||
|
|
|
|||
130
src/main/project.ts
Normal file
130
src/main/project.ts
Normal file
|
|
@ -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<string, ChapterEntry>;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
18
src/main/recents.ts
Normal file
18
src/main/recents.ts
Normal file
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,19 +8,36 @@
|
|||
</head>
|
||||
<body>
|
||||
<div id="root">
|
||||
<aside class="sidebar" id="sidebar">
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<span class="sidebar-title">Chapters</span>
|
||||
</div>
|
||||
<div class="project-bar hidden" id="projectBar">
|
||||
<span class="book-title" id="bookTitle"></span>
|
||||
<span class="book-path" id="bookPath" title=""></span>
|
||||
<button class="icon-button" id="revealBtn" title="Reveal in folder">📁</button>
|
||||
</div>
|
||||
<ul class="chapter-list" id="chapterList"></ul>
|
||||
<div class="sidebar-footer">
|
||||
<button class="button button-ghost" id="newChapterBtn">
|
||||
+ New Chapter
|
||||
</button>
|
||||
<button class="button button-ghost" id="newChapterBtn">+ New Chapter</button>
|
||||
</div>
|
||||
</aside>
|
||||
<main class="editor" id="editor">
|
||||
<div class="editor-placeholder">
|
||||
<div class="welcome" id="welcome">
|
||||
<h1 class="app-name">Folio</h1>
|
||||
<p class="app-tagline">Your stories, in folders you choose.</p>
|
||||
<div class="welcome-actions">
|
||||
<button class="button" id="welcomeNewBook">New Book</button>
|
||||
<button class="button button-ghost" id="welcomeOpenBook">Open Book</button>
|
||||
</div>
|
||||
<div class="recents-block">
|
||||
<div class="recents-title">Recent Projects</div>
|
||||
<ul class="recent-list" id="recentList">
|
||||
<li class="muted">No recent books.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="editor-placeholder hidden" id="editorPlaceholder">
|
||||
<p>Select a chapter to begin writing.</p>
|
||||
</div>
|
||||
</main>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,137 @@
|
|||
const root = document.getElementById("root")!;
|
||||
type BookResultMsg = { path: string; title: string } | { canceled: true } | { error: string } | null;
|
||||
export {};
|
||||
|
||||
root.addEventListener("dragover", (e) => e.preventDefault());
|
||||
root.addEventListener("drop", (e) => e.preventDefault());
|
||||
type FolioAPI = {
|
||||
newBook: () => Promise<BookResultMsg>;
|
||||
openBook: () => Promise<BookResultMsg>;
|
||||
openBookPath: (p: string) => Promise<BookResultMsg>;
|
||||
revealInFolder: (p: string) => Promise<boolean>;
|
||||
getRecents: () => Promise<any[]>;
|
||||
closeBook: () => Promise<boolean>;
|
||||
onBookOpened: (cb: (data: { path: string; title: string }) => void) => void;
|
||||
onBookClosed: (cb: () => void) => void;
|
||||
onNewBookRequested: (cb: () => void) => void;
|
||||
onOpenBookRequested: (cb: () => void) => void;
|
||||
onOpenRecent: (cb: (p: string) => void) => void;
|
||||
onRevealRequested: (cb: (p: string) => void) => void;
|
||||
};
|
||||
|
||||
document.getElementById("newChapterBtn")!.addEventListener("click", () => {
|
||||
alert("Chapter creation coming soon.");
|
||||
declare global {
|
||||
interface Window {
|
||||
folio: FolioAPI;
|
||||
}
|
||||
}
|
||||
|
||||
const $ = (id: string) => document.getElementById(id) as HTMLElement;
|
||||
|
||||
const welcome = $("welcome");
|
||||
const placeholder = $("editorPlaceholder");
|
||||
const projectBar = $("projectBar");
|
||||
const bookTitle = $("bookTitle");
|
||||
const bookPath = $("bookPath");
|
||||
const revealBtn = $("revealBtn") as HTMLButtonElement;
|
||||
const newChapterBtn = $("newChapterBtn") as HTMLButtonElement;
|
||||
const recentList = $("recentList");
|
||||
|
||||
let currentBook: { path: string; title: string } | null = null;
|
||||
|
||||
function showBook() {
|
||||
welcome.classList.add("hidden");
|
||||
placeholder.classList.remove("hidden");
|
||||
projectBar.classList.remove("hidden");
|
||||
}
|
||||
|
||||
function showWelcome() {
|
||||
welcome.classList.remove("hidden");
|
||||
placeholder.classList.add("hidden");
|
||||
projectBar.classList.add("hidden");
|
||||
}
|
||||
|
||||
function setCurrentBook(book: { path: string; title: string }) {
|
||||
currentBook = book;
|
||||
bookTitle.textContent = book.title;
|
||||
bookPath.textContent = book.path;
|
||||
bookPath.title = book.path;
|
||||
showBook();
|
||||
void refreshRecents();
|
||||
}
|
||||
|
||||
function clearBook() {
|
||||
currentBook = null;
|
||||
showWelcome();
|
||||
}
|
||||
|
||||
function handleResult(res: BookResultMsg) {
|
||||
if (!res) return;
|
||||
if ("canceled" in res) return;
|
||||
if ("error" in res) {
|
||||
alert(res.error);
|
||||
return;
|
||||
}
|
||||
setCurrentBook({ path: res.path, title: res.title });
|
||||
}
|
||||
|
||||
async function startNewBook() {
|
||||
const res = await window.folio.newBook();
|
||||
handleResult(res);
|
||||
}
|
||||
|
||||
async function startOpenBook() {
|
||||
const res = await window.folio.openBook();
|
||||
handleResult(res);
|
||||
}
|
||||
|
||||
async function openPath(p: string) {
|
||||
const res = await window.folio.openBookPath(p);
|
||||
handleResult(res);
|
||||
}
|
||||
|
||||
function renderRecents(recents: any[]) {
|
||||
recentList.innerHTML = "";
|
||||
if (!recents.length) {
|
||||
const li = document.createElement("li");
|
||||
li.className = "muted";
|
||||
li.textContent = "No recent books.";
|
||||
recentList.appendChild(li);
|
||||
return;
|
||||
}
|
||||
for (const r of recents as { title: string; path: string }[]) {
|
||||
const li = document.createElement("li");
|
||||
li.textContent = r.title || r.path;
|
||||
li.title = r.path;
|
||||
li.addEventListener("mousedown", (e) => {
|
||||
e.preventDefault(); // prevent sidebar blur/focus issues
|
||||
void openPath(r.path);
|
||||
});
|
||||
recentList.appendChild(li);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshRecents() {
|
||||
const recents = await window.folio.getRecents();
|
||||
renderRecents(recents);
|
||||
}
|
||||
|
||||
function setupButtons() {
|
||||
$("welcomeNewBook").addEventListener("click", () => void startNewBook());
|
||||
$("welcomeOpenBook").addEventListener("click", () => void startOpenBook());
|
||||
newChapterBtn.addEventListener("click", () => alert("Creating chapters is part of the next stage."));
|
||||
revealBtn.addEventListener("click", () => {
|
||||
if (currentBook) void window.folio.revealInFolder(currentBook.path);
|
||||
});
|
||||
}
|
||||
|
||||
function setupListeners() {
|
||||
window.folio.onBookOpened((data) => setCurrentBook({ path: data.path, title: data.title }));
|
||||
window.folio.onBookClosed(clearBook);
|
||||
window.folio.onNewBookRequested(() => void startNewBook());
|
||||
window.folio.onOpenBookRequested(() => void startOpenBook());
|
||||
window.folio.onOpenRecent((p) => void openPath(p));
|
||||
window.folio.onRevealRequested((p) => void window.folio.revealInFolder(p));
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
setupButtons();
|
||||
setupListeners();
|
||||
void refreshRecents();
|
||||
});
|
||||
|
|
|
|||
85
tests/run-project-test.mjs
Normal file
85
tests/run-project-test.mjs
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
// Standalone test for the pure project/recents logic.
|
||||
// Builds src/main/project.ts with esbuild (no Electron needed) and asserts behavior.
|
||||
// Run with: node tests/run-project-test.mjs
|
||||
import esbuild from "esbuild";
|
||||
import fs from "fs";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import url from "url";
|
||||
|
||||
const out = path.join(os.tmpdir(), `folio-project-${process.pid}.mjs`);
|
||||
await esbuild.build({
|
||||
entryPoints: [path.resolve("src/main/project.ts")],
|
||||
bundle: true,
|
||||
platform: "node",
|
||||
format: "esm",
|
||||
outfile: out,
|
||||
write: true,
|
||||
logLevel: "silent",
|
||||
});
|
||||
|
||||
const mod = await import(url.pathToFileURL(out).href);
|
||||
const {
|
||||
APP_ID,
|
||||
META_FILE,
|
||||
CHAPTERS_DIR,
|
||||
createBook,
|
||||
loadBook,
|
||||
saveMeta,
|
||||
hasBook,
|
||||
listRecents,
|
||||
addRecent,
|
||||
makeChapterId,
|
||||
chapterFileName,
|
||||
} = mod;
|
||||
|
||||
let failures = 0;
|
||||
function ok(cond, msg) {
|
||||
if (cond) console.log(" ok -", msg);
|
||||
else { failures++; console.error("FAIL -", msg); }
|
||||
}
|
||||
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "folio-test-"));
|
||||
|
||||
const meta = createBook(dir, "My Novel");
|
||||
ok(meta.app === APP_ID, "book app id is folio");
|
||||
ok(meta.title === "My Novel", "book title set");
|
||||
ok(meta.chapterOrder.length === 0, "new book has empty chapter order");
|
||||
ok(fs.existsSync(path.join(dir, CHAPTERS_DIR)), "chapters/ dir created");
|
||||
ok(fs.existsSync(path.join(dir, META_FILE)), "folio.json created");
|
||||
|
||||
const loaded = loadBook(dir);
|
||||
ok(loaded.title === "My Novel", "load returns title");
|
||||
ok(hasBook(dir) === true, "hasBook true for created book");
|
||||
ok(hasBook("/tmp") === false, "hasBook false for non-book dir");
|
||||
|
||||
let threw = false;
|
||||
try { loadBook("/tmp"); } catch { threw = true; }
|
||||
ok(threw, "loadBook throws on non-book");
|
||||
|
||||
const m = loadBook(dir);
|
||||
m.chapterOrder.push("x");
|
||||
m.chapters["x"] = { id: "x", title: "Intro", file: "chapters/x.md" };
|
||||
saveMeta(dir, m);
|
||||
const m2 = loadBook(dir);
|
||||
ok(m2.chapterOrder[0] === "x", "chapter order persisted");
|
||||
ok(m2.chapters["x"].title === "Intro", "chapter entry persisted");
|
||||
|
||||
const rf = path.join(dir, "recents.json");
|
||||
const r1 = addRecent(rf, { path: dir, title: "My Novel", lastOpened: "2026-01-01T00:00:00Z" });
|
||||
ok(r1.length === 1, "first recent added");
|
||||
ok(listRecents(rf).length === 1, "recent read back");
|
||||
const r2 = addRecent(rf, { path: "/other", title: "Other", lastOpened: "2026-02-02T00:00:00Z" });
|
||||
ok(r2.length === 2 && r2[0].path === "/other", "second recent added to top");
|
||||
const r3 = addRecent(rf, { path: dir, title: "My Novel", lastOpened: "2026-03-03T00:00:00Z" });
|
||||
ok(r3.length === 2 && r3[0].path === dir, "duplicate path deduped + moved to top");
|
||||
|
||||
const id = makeChapterId("Chapter One");
|
||||
ok(id.startsWith("chapter-one-"), "chapter id slugified: " + id);
|
||||
ok(chapterFileName(id) === `${id}.md`, "filename is <id>.md");
|
||||
|
||||
fs.rmSync(out, { force: true });
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
|
||||
console.log("\nProject tests: " + (failures === 0 ? "ALL PASSED" : failures + " FAILED"));
|
||||
process.exit(failures === 0 ? 0 : 1);
|
||||
Loading…
Add table
Add a link
Reference in a new issue