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\"",
|
"dev": "concurrently -k \"npm run build:watch\" \"wait-on dist/main.js && npm run electron\"",
|
||||||
"start": "npm run build && npm run electron",
|
"start": "npm run build && npm run electron",
|
||||||
"lint": "tsc --noEmit",
|
"lint": "tsc --noEmit",
|
||||||
|
"test": "node tests/run-project-test.mjs",
|
||||||
"package": "electron-builder"
|
"package": "electron-builder"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"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 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 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() {
|
function createWindow() {
|
||||||
mainWindow = new BrowserWindow({
|
mainWindow = new BrowserWindow({
|
||||||
|
|
@ -16,7 +41,6 @@ function createWindow() {
|
||||||
preload: path.join(__dirname, "preload.js"),
|
preload: path.join(__dirname, "preload.js"),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
mainWindow.loadFile(path.join(__dirname, "index.html"));
|
mainWindow.loadFile(path.join(__dirname, "index.html"));
|
||||||
mainWindow.webContents.on("did-finish-load", () =>
|
mainWindow.webContents.on("did-finish-load", () =>
|
||||||
console.log("[folio] renderer loaded")
|
console.log("[folio] renderer loaded")
|
||||||
|
|
@ -24,6 +48,12 @@ function createWindow() {
|
||||||
}
|
}
|
||||||
|
|
||||||
app.whenReady().then(() => {
|
app.whenReady().then(() => {
|
||||||
|
buildMenu(
|
||||||
|
getWindow,
|
||||||
|
getState,
|
||||||
|
() => getWindow()?.webContents.send("folio:new-book-requested"),
|
||||||
|
() => getWindow()?.webContents.send("folio:open-book-requested")
|
||||||
|
);
|
||||||
createWindow();
|
createWindow();
|
||||||
app.on("activate", () => {
|
app.on("activate", () => {
|
||||||
if (BrowserWindow.getAllWindows().length === 0) createWindow();
|
if (BrowserWindow.getAllWindows().length === 0) createWindow();
|
||||||
|
|
@ -33,3 +63,74 @@ app.whenReady().then(() => {
|
||||||
app.on("window-all-closed", () => {
|
app.on("window-all-closed", () => {
|
||||||
if (process.platform !== "darwin") app.quit();
|
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", () => {
|
import { contextBridge, ipcRenderer } from "electron";
|
||||||
console.log("[folio] dom ready");
|
|
||||||
|
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;
|
font-size: 1rem;
|
||||||
margin: 0 0 6px;
|
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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root">
|
<div id="root">
|
||||||
<aside class="sidebar" id="sidebar">
|
<aside class="sidebar">
|
||||||
<div class="sidebar-header">
|
<div class="sidebar-header">
|
||||||
<span class="sidebar-title">Chapters</span>
|
<span class="sidebar-title">Chapters</span>
|
||||||
</div>
|
</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>
|
<ul class="chapter-list" id="chapterList"></ul>
|
||||||
<div class="sidebar-footer">
|
<div class="sidebar-footer">
|
||||||
<button class="button button-ghost" id="newChapterBtn">
|
<button class="button button-ghost" id="newChapterBtn">+ New Chapter</button>
|
||||||
+ New Chapter
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
<main class="editor" id="editor">
|
<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>
|
<p>Select a chapter to begin writing.</p>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</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());
|
type FolioAPI = {
|
||||||
root.addEventListener("drop", (e) => e.preventDefault());
|
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", () => {
|
declare global {
|
||||||
alert("Chapter creation coming soon.");
|
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