Stage 1: app skeleton — Electron window, sidebar/editor layout, light/dark CSS, esbuild build, typecheck
This commit is contained in:
commit
6fac0d013b
12 changed files with 2488 additions and 0 deletions
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
node_modules/
|
||||
dist/
|
||||
*.log
|
||||
*.asar
|
||||
releases/
|
||||
.DS_Store
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2026 Folio Contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
25
README.md
Normal file
25
README.md
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
# Folio
|
||||
|
||||
A simple, local, open-source desktop writing app for organizing a book.
|
||||
|
||||
- **Fully offline** — no cloud, no accounts, no telemetry.
|
||||
- **Your folders** — each book lives in a folder you choose; chapters are plain Markdown.
|
||||
- **Drag-and-drop reordering** of chapters.
|
||||
- **Light & dark** themes that follow the system.
|
||||
|
||||
## Developing
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev # starts esbuild watch + Electron
|
||||
```
|
||||
|
||||
## Building the renderer/bundle
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT — see [LICENSE](LICENSE).
|
||||
62
esbuild.config.mjs
Normal file
62
esbuild.config.mjs
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import esbuild from "esbuild";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
const watch = process.argv.includes("--watch");
|
||||
|
||||
const copyAssets = {
|
||||
name: "copy-assets",
|
||||
setup(build) {
|
||||
build.onEnd(() => {
|
||||
fs.mkdirSync("dist", { recursive: true });
|
||||
fs.copyFileSync(
|
||||
path.join("src", "renderer", "index.html"),
|
||||
path.join("dist", "index.html")
|
||||
);
|
||||
fs.copyFileSync(
|
||||
path.join("src", "renderer", "index.css"),
|
||||
path.join("dist", "index.css")
|
||||
);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
const common = {
|
||||
bundle: true,
|
||||
sourcemap: true,
|
||||
logLevel: "info",
|
||||
};
|
||||
|
||||
const main = {
|
||||
entryPoints: ["src/main/index.ts"],
|
||||
platform: "node",
|
||||
external: ["electron"],
|
||||
outfile: "dist/main.js",
|
||||
plugins: [copyAssets],
|
||||
...common,
|
||||
};
|
||||
|
||||
const preload = {
|
||||
entryPoints: ["src/main/preload.ts"],
|
||||
platform: "node",
|
||||
external: ["electron"],
|
||||
outfile: "dist/preload.js",
|
||||
plugins: [copyAssets],
|
||||
...common,
|
||||
};
|
||||
|
||||
const renderer = {
|
||||
entryPoints: ["src/renderer/index.ts"],
|
||||
platform: "browser",
|
||||
outfile: "dist/renderer.js",
|
||||
...common,
|
||||
};
|
||||
|
||||
const builds = [main, preload, renderer];
|
||||
|
||||
if (watch) {
|
||||
const contexts = await Promise.all(builds.map((opts) => esbuild.context(opts)));
|
||||
await Promise.all(contexts.map((ctx) => ctx.watch()));
|
||||
} else {
|
||||
await Promise.all(builds.map((opts) => esbuild.build(opts)));
|
||||
}
|
||||
2095
package-lock.json
generated
Normal file
2095
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
26
package.json
Normal file
26
package.json
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"name": "folio",
|
||||
"version": "0.1.0",
|
||||
"description": "A simple, local, open-source desktop writing app.",
|
||||
"productName": "Folio",
|
||||
"author": "Folio Contributors",
|
||||
"license": "MIT",
|
||||
"main": "dist/main.js",
|
||||
"scripts": {
|
||||
"build": "node esbuild.config.mjs",
|
||||
"build:watch": "node esbuild.config.mjs --watch",
|
||||
"electron": "electron .",
|
||||
"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",
|
||||
"package": "electron-builder"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.5.0",
|
||||
"concurrently": "^9.0.0",
|
||||
"electron": "^32.0.0",
|
||||
"esbuild": "^0.24.0",
|
||||
"typescript": "^5.5.0",
|
||||
"wait-on": "^8.0.0"
|
||||
}
|
||||
}
|
||||
35
src/main/index.ts
Normal file
35
src/main/index.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { app, BrowserWindow } from "electron";
|
||||
import path from "path";
|
||||
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
|
||||
function createWindow() {
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1280,
|
||||
height: 780,
|
||||
minWidth: 960,
|
||||
minHeight: 640,
|
||||
title: "Folio",
|
||||
webPreferences: {
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
preload: path.join(__dirname, "preload.js"),
|
||||
},
|
||||
});
|
||||
|
||||
mainWindow.loadFile(path.join(__dirname, "index.html"));
|
||||
mainWindow.webContents.on("did-finish-load", () =>
|
||||
console.log("[folio] renderer loaded")
|
||||
);
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
createWindow();
|
||||
app.on("activate", () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) createWindow();
|
||||
});
|
||||
});
|
||||
|
||||
app.on("window-all-closed", () => {
|
||||
if (process.platform !== "darwin") app.quit();
|
||||
});
|
||||
3
src/main/preload.ts
Normal file
3
src/main/preload.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
window.addEventListener("DOMContentLoaded", () => {
|
||||
console.log("[folio] dom ready");
|
||||
});
|
||||
160
src/renderer/index.css
Normal file
160
src/renderer/index.css
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
/* System-font stack only — no remote fonts. */
|
||||
:root {
|
||||
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||
"Helvetica Neue", Arial, sans-serif;
|
||||
--font-serif: Georgia, "Times New Roman", serif;
|
||||
--radius: 8px;
|
||||
--gap: 16px;
|
||||
|
||||
/* Light theme */
|
||||
--color-bg: #ffffff;
|
||||
--color-panel: #f7f7f8;
|
||||
--color-border: #e2e2e7;
|
||||
--color-text: #1a1a1d;
|
||||
--color-muted: #6b6b76;
|
||||
--color-accent: #2563eb;
|
||||
--color-accent-hover: #1d4ed8;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--color-bg: #0f0f12;
|
||||
--color-panel: #18181e;
|
||||
--color-border: #2a2a32;
|
||||
--color-text: #e6e6ec;
|
||||
--color-muted: #9a9aa6;
|
||||
--color-accent: #4a94f0;
|
||||
--color-accent-hover: #6ab0ff;
|
||||
}
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
font-family: var(--font-sans);
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
#root {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
flex: 0 0 280px;
|
||||
min-width: 240px;
|
||||
max-width: 360px;
|
||||
background: var(--color-panel);
|
||||
border-right: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: var(--gap);
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding-bottom: var(--gap);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.sidebar-title {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.chapter-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
margin-top: var(--gap);
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.chapter-list li {
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
font-family: var(--font-serif);
|
||||
font-size: 0.95rem;
|
||||
color: var(--color-text);
|
||||
transition: background 0.12s ease, color 0.12s ease;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.chapter-list li:hover {
|
||||
background: var(--color-accent);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.chapter-list li.active {
|
||||
background: var(--color-accent);
|
||||
color: #ffffff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
padding-top: var(--gap);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
padding: 6px 12px;
|
||||
font-size: 0.85rem;
|
||||
font-family: var(--font-sans);
|
||||
cursor: pointer;
|
||||
transition: background 0.12s ease, border-color 0.12s ease;
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
background: var(--color-accent);
|
||||
color: #ffffff;
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.button-ghost {
|
||||
border: 1px dashed var(--color-border);
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.button-ghost:hover {
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
.editor {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--color-bg);
|
||||
padding: var(--gap);
|
||||
}
|
||||
|
||||
.editor-placeholder {
|
||||
max-width: 560px;
|
||||
text-align: center;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.editor-placeholder p {
|
||||
font-size: 1rem;
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
30
src/renderer/index.html
Normal file
30
src/renderer/index.html
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Folio</title>
|
||||
<link rel="stylesheet" href="./index.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root">
|
||||
<aside class="sidebar" id="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<span class="sidebar-title">Chapters</span>
|
||||
</div>
|
||||
<ul class="chapter-list" id="chapterList"></ul>
|
||||
<div class="sidebar-footer">
|
||||
<button class="button button-ghost" id="newChapterBtn">
|
||||
+ New Chapter
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
<main class="editor" id="editor">
|
||||
<div class="editor-placeholder">
|
||||
<p>Select a chapter to begin writing.</p>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<script src="./renderer.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
8
src/renderer/index.ts
Normal file
8
src/renderer/index.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
const root = document.getElementById("root")!;
|
||||
|
||||
root.addEventListener("dragover", (e) => e.preventDefault());
|
||||
root.addEventListener("drop", (e) => e.preventDefault());
|
||||
|
||||
document.getElementById("newChapterBtn")!.addEventListener("click", () => {
|
||||
alert("Chapter creation coming soon.");
|
||||
});
|
||||
17
tsconfig.json
Normal file
17
tsconfig.json
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2022",
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"lib": ["es2022", "dom"],
|
||||
"strict": true,
|
||||
"noImplicitAny": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue