🎉 init: 小龙的工作空间
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -0,0 +1,7 @@
|
||||
node_modules/
|
||||
dist/
|
||||
data/
|
||||
.env
|
||||
*.db
|
||||
*.db-journal
|
||||
*.db-wal
|
||||
@@ -0,0 +1,90 @@
|
||||
# BookShelf — Backend API
|
||||
|
||||
> 一款支持多端同步、Markdown 编辑和标签管理的笔记应用。
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Runtime**: Node.js
|
||||
- **Framework**: Fastify 5
|
||||
- **Language**: TypeScript
|
||||
- **Database**: SQLite (better-sqlite3)
|
||||
- **Auth**: JWT + bcrypt
|
||||
|
||||
## Getting Started
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Development (hot reload)
|
||||
npm run dev
|
||||
|
||||
# Build
|
||||
npm run build
|
||||
|
||||
# Production start
|
||||
npm run start
|
||||
|
||||
# Run tests
|
||||
npm test
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── index.ts # Server entry point
|
||||
├── db/
|
||||
│ ├── schema.ts # SQLite schema
|
||||
│ └── client.ts # Database client
|
||||
├── routes/
|
||||
│ ├── auth.ts # Auth routes (register/login/me)
|
||||
│ └── *.ts # CRUD routes
|
||||
├── services/
|
||||
│ └── *.ts # Business logic
|
||||
├── middleware/
|
||||
│ └── auth.ts # JWT middleware
|
||||
├── types/
|
||||
│ └── index.ts # TypeScript types
|
||||
└── __tests__/
|
||||
└── *.test.ts # Tests
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Auth
|
||||
- `POST /api/auth/register` — Register
|
||||
- `POST /api/auth/login` — Login
|
||||
- `GET /api/auth/me` — Current user (auth required)
|
||||
|
||||
### Resources
|
||||
#### users
|
||||
- `GET /api/users` — List all
|
||||
- `GET /api/users/:id` — Get by ID
|
||||
- `POST /api/users` — Create
|
||||
- `PUT /api/users/:id` — Update
|
||||
- `DELETE /api/users/:id` — Delete
|
||||
|
||||
#### notes
|
||||
- `GET /api/notes` — List all
|
||||
- `GET /api/notes/:id` — Get by ID
|
||||
- `POST /api/notes` — Create
|
||||
- `PUT /api/notes/:id` — Update
|
||||
- `DELETE /api/notes/:id` — Delete
|
||||
|
||||
#### tags
|
||||
- `GET /api/tags` — List all
|
||||
- `GET /api/tags/:id` — Get by ID
|
||||
- `POST /api/tags` — Create
|
||||
- `PUT /api/tags/:id` — Update
|
||||
- `DELETE /api/tags/:id` — Delete
|
||||
|
||||
#### note_tags
|
||||
- `GET /api/note_tags` — List all
|
||||
- `GET /api/note_tags/:id` — Get by ID
|
||||
- `POST /api/note_tags` — Create
|
||||
- `PUT /api/note_tags/:id` — Update
|
||||
- `DELETE /api/note_tags/:id` — Delete
|
||||
|
||||
### System
|
||||
- `GET /api/health` — Health check
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "bookshelf",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"test": "node --import tsx --test src/__tests__/*.test.ts",
|
||||
"test:watch": "node --import tsx --test --watch src/__tests__/*.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"fastify": "^5.0.0",
|
||||
"@fastify/cors": "^10.0.0",
|
||||
"@fastify/jwt": "^9.0.0",
|
||||
"sql.js": "^1.12.0",
|
||||
"bcrypt": "^5.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/bcrypt": "^5.0.0",
|
||||
"typescript": "^5.6.0",
|
||||
"tsx": "^4.0.0",
|
||||
"pino-pretty": "^11.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
// Auth routes test
|
||||
import { describe, it, before, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { buildApp } from "../index.js";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
|
||||
let app: FastifyInstance;
|
||||
let token: string;
|
||||
|
||||
before(async () => {
|
||||
process.env.JWT_SECRET = "test-secret";
|
||||
process.env.DATABASE_URL = ":memory:";
|
||||
app = await buildApp();
|
||||
await app.ready();
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
describe("POST /api/auth/register", () => {
|
||||
it("registers a new user", async () => {
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/auth/register",
|
||||
payload: { username: "testuser", password: "password123" },
|
||||
});
|
||||
assert.equal(res.statusCode, 201);
|
||||
const body = res.json();
|
||||
assert.ok(body.token);
|
||||
assert.equal(body.user.username, "testuser");
|
||||
token = body.token;
|
||||
});
|
||||
|
||||
it("rejects duplicate username", async () => {
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/auth/register",
|
||||
payload: { username: "testuser", password: "password123" },
|
||||
});
|
||||
assert.equal(res.statusCode, 409);
|
||||
});
|
||||
|
||||
it("rejects short password", async () => {
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/auth/register",
|
||||
payload: { username: "user2", password: "123" },
|
||||
});
|
||||
assert.equal(res.statusCode, 400);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/auth/login", () => {
|
||||
it("logs in with correct credentials", async () => {
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/auth/login",
|
||||
payload: { username: "testuser", password: "password123" },
|
||||
});
|
||||
assert.equal(res.statusCode, 200);
|
||||
const body = res.json();
|
||||
assert.ok(body.token);
|
||||
token = body.token;
|
||||
});
|
||||
|
||||
it("rejects wrong password", async () => {
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/auth/login",
|
||||
payload: { username: "testuser", password: "wrongpassword" },
|
||||
});
|
||||
assert.equal(res.statusCode, 401);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/auth/me", () => {
|
||||
it("returns current user with valid token", async () => {
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/auth/me",
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
});
|
||||
assert.equal(res.statusCode, 200);
|
||||
const body = res.json();
|
||||
assert.equal(body.data.username, "testuser");
|
||||
});
|
||||
|
||||
it("rejects without token", async () => {
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/auth/me",
|
||||
});
|
||||
assert.equal(res.statusCode, 401);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
// NoteTag CRUD test
|
||||
import { describe, it, before, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { buildApp } from "../index.js";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
|
||||
let app: FastifyInstance;
|
||||
let token: string;
|
||||
let createdId: string;
|
||||
|
||||
before(async () => {
|
||||
process.env.JWT_SECRET = "test-secret";
|
||||
process.env.DATABASE_URL = ":memory:";
|
||||
app = await buildApp();
|
||||
await app.ready();
|
||||
|
||||
// Register and login to get a token
|
||||
const regRes = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/auth/register",
|
||||
payload: { username: `test_${Date.now()}`, password: "password123" },
|
||||
});
|
||||
token = regRes.json().token;
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
describe("GET /api/note_tags", () => {
|
||||
it("returns empty list", async () => {
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/note_tags",
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
});
|
||||
assert.equal(res.statusCode, 200);
|
||||
const body = res.json();
|
||||
assert.ok(Array.isArray(body.data));
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/note_tags", () => {
|
||||
it("creates a note_tag", async () => {
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/note_tags",
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: {
|
||||
"noteId": "00000000-0000-0000-0000-000000000001",
|
||||
"tagId": "00000000-0000-0000-0000-000000000001",
|
||||
"PRIMARY KEY (noteId, tagId)": "sample-primary key (noteid, tagid)"
|
||||
},
|
||||
});
|
||||
assert.equal(res.statusCode, 201);
|
||||
const body = res.json();
|
||||
assert.ok(body.data.id);
|
||||
createdId = body.data.id;
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/note_tags/:id", () => {
|
||||
it("returns the created note_tag", async () => {
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/note_tags/${createdId}`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
});
|
||||
assert.equal(res.statusCode, 200);
|
||||
const body = res.json();
|
||||
assert.equal(body.data.id, createdId);
|
||||
});
|
||||
|
||||
it("returns 404 for nonexistent id", async () => {
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/note_tags/nonexistent`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
});
|
||||
assert.equal(res.statusCode, 404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUT /api/note_tags/:id", () => {
|
||||
it("updates the note_tag", async () => {
|
||||
const res = await app.inject({
|
||||
method: "PUT",
|
||||
url: `/api/note_tags/${createdId}`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: {
|
||||
"noteId": "00000000-0000-0000-0000-000000000001",
|
||||
"tagId": "00000000-0000-0000-0000-000000000001",
|
||||
"PRIMARY KEY (noteId, tagId)": "sample-primary key (noteid, tagid)"
|
||||
},
|
||||
});
|
||||
assert.equal(res.statusCode, 200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DELETE /api/note_tags/:id", () => {
|
||||
it("deletes the note_tag", async () => {
|
||||
const res = await app.inject({
|
||||
method: "DELETE",
|
||||
url: `/api/note_tags/${createdId}`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
});
|
||||
assert.equal(res.statusCode, 204);
|
||||
});
|
||||
|
||||
it("returns 404 after delete", async () => {
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/note_tags/${createdId}`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
});
|
||||
assert.equal(res.statusCode, 404);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
// Note CRUD test
|
||||
import { describe, it, before, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { buildApp } from "../index.js";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
|
||||
let app: FastifyInstance;
|
||||
let token: string;
|
||||
let createdId: string;
|
||||
|
||||
before(async () => {
|
||||
process.env.JWT_SECRET = "test-secret";
|
||||
process.env.DATABASE_URL = ":memory:";
|
||||
app = await buildApp();
|
||||
await app.ready();
|
||||
|
||||
// Register and login to get a token
|
||||
const regRes = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/auth/register",
|
||||
payload: { username: `test_${Date.now()}`, password: "password123" },
|
||||
});
|
||||
token = regRes.json().token;
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
describe("GET /api/notes", () => {
|
||||
it("returns empty list", async () => {
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/notes",
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
});
|
||||
assert.equal(res.statusCode, 200);
|
||||
const body = res.json();
|
||||
assert.ok(Array.isArray(body.data));
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/notes", () => {
|
||||
it("creates a note", async () => {
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/notes",
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: {
|
||||
"userId": "00000000-0000-0000-0000-000000000001",
|
||||
"title": "sample-title",
|
||||
"content": "sample-content",
|
||||
"isMarkdown": true,
|
||||
"folderId": "00000000-0000-0000-0000-000000000001"
|
||||
},
|
||||
});
|
||||
assert.equal(res.statusCode, 201);
|
||||
const body = res.json();
|
||||
assert.ok(body.data.id);
|
||||
createdId = body.data.id;
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/notes/:id", () => {
|
||||
it("returns the created note", async () => {
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/notes/${createdId}`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
});
|
||||
assert.equal(res.statusCode, 200);
|
||||
const body = res.json();
|
||||
assert.equal(body.data.id, createdId);
|
||||
});
|
||||
|
||||
it("returns 404 for nonexistent id", async () => {
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/notes/nonexistent`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
});
|
||||
assert.equal(res.statusCode, 404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUT /api/notes/:id", () => {
|
||||
it("updates the note", async () => {
|
||||
const res = await app.inject({
|
||||
method: "PUT",
|
||||
url: `/api/notes/${createdId}`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: {
|
||||
"userId": "00000000-0000-0000-0000-000000000001",
|
||||
"title": "sample-title",
|
||||
"content": "sample-content",
|
||||
"isMarkdown": true,
|
||||
"folderId": "00000000-0000-0000-0000-000000000001"
|
||||
},
|
||||
});
|
||||
assert.equal(res.statusCode, 200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DELETE /api/notes/:id", () => {
|
||||
it("deletes the note", async () => {
|
||||
const res = await app.inject({
|
||||
method: "DELETE",
|
||||
url: `/api/notes/${createdId}`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
});
|
||||
assert.equal(res.statusCode, 204);
|
||||
});
|
||||
|
||||
it("returns 404 after delete", async () => {
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/notes/${createdId}`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
});
|
||||
assert.equal(res.statusCode, 404);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
// Tag CRUD test
|
||||
import { describe, it, before, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { buildApp } from "../index.js";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
|
||||
let app: FastifyInstance;
|
||||
let token: string;
|
||||
let createdId: string;
|
||||
|
||||
before(async () => {
|
||||
process.env.JWT_SECRET = "test-secret";
|
||||
process.env.DATABASE_URL = ":memory:";
|
||||
app = await buildApp();
|
||||
await app.ready();
|
||||
|
||||
// Register and login to get a token
|
||||
const regRes = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/auth/register",
|
||||
payload: { username: `test_${Date.now()}`, password: "password123" },
|
||||
});
|
||||
token = regRes.json().token;
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
describe("GET /api/tags", () => {
|
||||
it("returns empty list", async () => {
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/tags",
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
});
|
||||
assert.equal(res.statusCode, 200);
|
||||
const body = res.json();
|
||||
assert.ok(Array.isArray(body.data));
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/tags", () => {
|
||||
it("creates a tag", async () => {
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/tags",
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: {
|
||||
"name": "sample-name",
|
||||
"color": "sample-color"
|
||||
},
|
||||
});
|
||||
assert.equal(res.statusCode, 201);
|
||||
const body = res.json();
|
||||
assert.ok(body.data.id);
|
||||
createdId = body.data.id;
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/tags/:id", () => {
|
||||
it("returns the created tag", async () => {
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/tags/${createdId}`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
});
|
||||
assert.equal(res.statusCode, 200);
|
||||
const body = res.json();
|
||||
assert.equal(body.data.id, createdId);
|
||||
});
|
||||
|
||||
it("returns 404 for nonexistent id", async () => {
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/tags/nonexistent`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
});
|
||||
assert.equal(res.statusCode, 404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUT /api/tags/:id", () => {
|
||||
it("updates the tag", async () => {
|
||||
const res = await app.inject({
|
||||
method: "PUT",
|
||||
url: `/api/tags/${createdId}`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: {
|
||||
"name": "sample-name",
|
||||
"color": "sample-color"
|
||||
},
|
||||
});
|
||||
assert.equal(res.statusCode, 200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DELETE /api/tags/:id", () => {
|
||||
it("deletes the tag", async () => {
|
||||
const res = await app.inject({
|
||||
method: "DELETE",
|
||||
url: `/api/tags/${createdId}`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
});
|
||||
assert.equal(res.statusCode, 204);
|
||||
});
|
||||
|
||||
it("returns 404 after delete", async () => {
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/tags/${createdId}`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
});
|
||||
assert.equal(res.statusCode, 404);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
// User CRUD test
|
||||
import { describe, it, before, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { buildApp } from "../index.js";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
|
||||
let app: FastifyInstance;
|
||||
let token: string;
|
||||
let createdId: string;
|
||||
|
||||
before(async () => {
|
||||
process.env.JWT_SECRET = "test-secret";
|
||||
process.env.DATABASE_URL = ":memory:";
|
||||
app = await buildApp();
|
||||
await app.ready();
|
||||
|
||||
// Register and login to get a token
|
||||
const regRes = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/auth/register",
|
||||
payload: { username: `test_${Date.now()}`, password: "password123" },
|
||||
});
|
||||
token = regRes.json().token;
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
describe("GET /api/users", () => {
|
||||
it("returns empty list", async () => {
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/users",
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
});
|
||||
assert.equal(res.statusCode, 200);
|
||||
const body = res.json();
|
||||
assert.ok(Array.isArray(body.data));
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/users", () => {
|
||||
it("creates a user", async () => {
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/users",
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: {
|
||||
"phone": "sample-phone",
|
||||
"nickname": "sample-nickname",
|
||||
"avatarUrl": "sample-avatarurl"
|
||||
},
|
||||
});
|
||||
assert.equal(res.statusCode, 201);
|
||||
const body = res.json();
|
||||
assert.ok(body.data.id);
|
||||
createdId = body.data.id;
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/users/:id", () => {
|
||||
it("returns the created user", async () => {
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/users/${createdId}`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
});
|
||||
assert.equal(res.statusCode, 200);
|
||||
const body = res.json();
|
||||
assert.equal(body.data.id, createdId);
|
||||
});
|
||||
|
||||
it("returns 404 for nonexistent id", async () => {
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/users/nonexistent`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
});
|
||||
assert.equal(res.statusCode, 404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUT /api/users/:id", () => {
|
||||
it("updates the user", async () => {
|
||||
const res = await app.inject({
|
||||
method: "PUT",
|
||||
url: `/api/users/${createdId}`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: {
|
||||
"phone": "sample-phone",
|
||||
"nickname": "sample-nickname",
|
||||
"avatarUrl": "sample-avatarurl"
|
||||
},
|
||||
});
|
||||
assert.equal(res.statusCode, 200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DELETE /api/users/:id", () => {
|
||||
it("deletes the user", async () => {
|
||||
const res = await app.inject({
|
||||
method: "DELETE",
|
||||
url: `/api/users/${createdId}`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
});
|
||||
assert.equal(res.statusCode, 204);
|
||||
});
|
||||
|
||||
it("returns 404 after delete", async () => {
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/users/${createdId}`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
});
|
||||
assert.equal(res.statusCode, 404);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
// SQLite database client (sql.js — pure WASM, no native deps)
|
||||
import initSqlJs, { type Database, type BindParams } from "sql.js";
|
||||
import { createTables } from "./schema.js";
|
||||
|
||||
let db: Database | null = null;
|
||||
let initPromise: Promise<Database> | null = null;
|
||||
|
||||
/** Initialize the database (call once at startup). */
|
||||
export async function initDb(dbPath?: string): Promise<Database> {
|
||||
if (db) return db;
|
||||
if (initPromise) return initPromise;
|
||||
|
||||
initPromise = (async () => {
|
||||
const SQL = await initSqlJs();
|
||||
const path = dbPath || process.env.DATABASE_URL || ":memory:";
|
||||
|
||||
// Try to load existing database from file
|
||||
let buffer: ArrayLike<number> | undefined;
|
||||
if (path !== ":memory:") {
|
||||
try {
|
||||
const fs = await import("node:fs/promises");
|
||||
const data = await fs.readFile(path);
|
||||
buffer = new Uint8Array(data);
|
||||
} catch {
|
||||
// File doesn't exist yet — start fresh
|
||||
}
|
||||
}
|
||||
|
||||
db = new SQL.Database(buffer);
|
||||
db.run("PRAGMA foreign_keys = ON");
|
||||
createTables(db);
|
||||
return db;
|
||||
})();
|
||||
|
||||
return initPromise;
|
||||
}
|
||||
|
||||
/** Get the initialized database (must call initDb first). */
|
||||
export function getDb(): Database {
|
||||
if (!db) throw new Error("Database not initialized. Call initDb() first.");
|
||||
return db;
|
||||
}
|
||||
|
||||
/** Save database to disk. */
|
||||
export async function saveDb(dbPath?: string): Promise<void> {
|
||||
if (!db) return;
|
||||
const path = dbPath || process.env.DATABASE_URL || "./data/app.db";
|
||||
if (path === ":memory:") return;
|
||||
const fs = await import("node:fs/promises");
|
||||
const { dirname } = await import("node:path");
|
||||
await fs.mkdir(dirname(path), { recursive: true });
|
||||
const data = db.export();
|
||||
await fs.writeFile(path, Buffer.from(data));
|
||||
}
|
||||
|
||||
export async function closeDb(): Promise<void> {
|
||||
if (db) {
|
||||
await saveDb();
|
||||
db.close();
|
||||
db = null;
|
||||
initPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper: run a query and return all rows as objects
|
||||
export function queryAll<T = Record<string, unknown>>(sql: string, params: BindParams = []): T[] {
|
||||
const d = getDb();
|
||||
const stmt = d.prepare(sql);
|
||||
if (params) stmt.bind(params);
|
||||
const results: T[] = [];
|
||||
while (stmt.step()) {
|
||||
const row = stmt.getAsObject();
|
||||
results.push(row as unknown as T);
|
||||
}
|
||||
stmt.free();
|
||||
return results;
|
||||
}
|
||||
|
||||
// Helper: run a query and return the first row
|
||||
export function queryOne<T = Record<string, unknown>>(sql: string, params: BindParams = []): T | undefined {
|
||||
const rows = queryAll<T>(sql, params);
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
// Helper: run a mutation and return { changes, lastInsertRowid }
|
||||
export function execute(sql: string, params: BindParams = []): { changes: number; lastInsertRowid: number } {
|
||||
const d = getDb();
|
||||
d.run(sql, params);
|
||||
return {
|
||||
changes: d.getRowsModified(),
|
||||
lastInsertRowid: 0,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// Auto-generated SQLite schema
|
||||
import type { Database } from "sql.js";
|
||||
|
||||
export function createTables(db: Database): void {
|
||||
const statements = [
|
||||
"CREATE TABLE IF NOT EXISTS users (\n id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),\n phone TEXT UNIQUE,\n nickname TEXT,\n avatar_url TEXT,\n created_at TEXT,\n updated_at TEXT\n);",
|
||||
"idx_users_phone ON users(phone)",
|
||||
"CREATE TABLE IF NOT EXISTS notes (\n id TEXT PRIMARY KEY,\n user_id TEXT REFERENCES users(id),\n title TEXT,\n content TEXT,\n is_markdown INTEGER DEFAULT false,\n folder_id TEXT REFERENCES folders(id),\n created_at TEXT,\n updated_at TEXT\n);",
|
||||
"idx_notes_user ON notes(user_id)",
|
||||
"idx_notes_folder ON notes(folder_id)",
|
||||
"idx_notes_fts ON notes USING GIN (to_tsvector('simple', title || ' ' || content))",
|
||||
"CREATE TABLE IF NOT EXISTS tags (\n id TEXT PRIMARY KEY,\n name TEXT NOT NULL UNIQUE,\n color TEXT\n);",
|
||||
"CREATE TABLE IF NOT EXISTS note_tags (\n note_id TEXT REFERENCES notes(id),\n tag_id TEXT REFERENCES tags(id)\n);"
|
||||
];
|
||||
for (const sql of statements) {
|
||||
const trimmed = sql.trim();
|
||||
if (trimmed) db.run(trimmed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
// BookShelf — Fastify Backend Server
|
||||
import Fastify from "fastify";
|
||||
import cors from "@fastify/cors";
|
||||
import fjwt from "@fastify/jwt";
|
||||
import { initDb, closeDb } from "./db/client.js";
|
||||
import { authRoutes } from "./routes/auth.js";
|
||||
import { usersRoutes } from "./routes/users.js";
|
||||
import { notesRoutes } from "./routes/notes.js";
|
||||
import { tagsRoutes } from "./routes/tags.js";
|
||||
import { note_tagsRoutes } from "./routes/note_tags.js";
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || "change-me-in-production-63d10ee5";
|
||||
|
||||
export async function buildApp() {
|
||||
const app = Fastify({
|
||||
logger: {
|
||||
level: process.env.LOG_LEVEL || "info",
|
||||
transport: process.env.NODE_ENV !== "production"
|
||||
? { target: "pino-pretty", options: { colorize: true } }
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
// Init database
|
||||
await initDb();
|
||||
|
||||
// Plugins
|
||||
await app.register(cors, {
|
||||
origin: process.env.CORS_ORIGIN || "*",
|
||||
methods: ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"],
|
||||
});
|
||||
|
||||
await app.register(fjwt, { secret: JWT_SECRET });
|
||||
|
||||
// Routes
|
||||
await app.register(authRoutes, { prefix: "/api/auth" });
|
||||
await app.register(usersRoutes, { prefix: "/api/users" });
|
||||
await app.register(notesRoutes, { prefix: "/api/notes" });
|
||||
await app.register(tagsRoutes, { prefix: "/api/tags" });
|
||||
await app.register(note_tagsRoutes, { prefix: "/api/note_tags" });
|
||||
|
||||
// Health check
|
||||
app.get("/api/health", async () => ({ status: "ok", timestamp: new Date().toISOString() }));
|
||||
|
||||
// Graceful shutdown
|
||||
app.addHook("onClose", async () => {
|
||||
closeDb();
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
// Start server if called directly
|
||||
const port = parseInt(process.env.PORT || "3001", 10);
|
||||
const host = process.env.HOST || "0.0.0.0";
|
||||
|
||||
async function main() {
|
||||
const app = await buildApp();
|
||||
try {
|
||||
await app.listen({ port, host });
|
||||
} catch (err) {
|
||||
app.log.error(err);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,41 @@
|
||||
// JWT Authentication Middleware
|
||||
import type { FastifyRequest, FastifyReply } from "fastify";
|
||||
import type { JwtPayload } from "../types/index.js";
|
||||
|
||||
/**
|
||||
* Verify JWT token and attach user to request.
|
||||
*/
|
||||
export async function authenticate(request: FastifyRequest, reply: FastifyReply): Promise<void> {
|
||||
try {
|
||||
await request.jwtVerify();
|
||||
} catch (err) {
|
||||
reply.status(401).send({ error: "Unauthorized", message: "Invalid or expired token", statusCode: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
/** Helper to get typed user from request (after authenticate). */
|
||||
export function getUser(request: FastifyRequest): JwtPayload {
|
||||
return request.user as unknown as JwtPayload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Require admin role.
|
||||
* Must be used after authenticate.
|
||||
*/
|
||||
export async function requireAdmin(request: FastifyRequest, reply: FastifyReply): Promise<void> {
|
||||
const user = request.user as unknown as JwtPayload | undefined;
|
||||
if (!user || user.role !== "admin") {
|
||||
reply.status(403).send({ error: "Forbidden", message: "Admin access required", statusCode: 403 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional auth: attach user if token present, but don't fail if missing.
|
||||
*/
|
||||
export async function optionalAuth(request: FastifyRequest): Promise<void> {
|
||||
try {
|
||||
await request.jwtVerify();
|
||||
} catch {
|
||||
// No token or invalid — continue without user
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
// Authentication routes
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import bcrypt from "bcrypt";
|
||||
import { queryOne, execute } from "../db/client.js";
|
||||
import { authenticate } from "../middleware/auth.js";
|
||||
import type { User, RegisterInput, LoginInput, AuthResponse } from "../types/index.js";
|
||||
|
||||
const SALT_ROUNDS = 10;
|
||||
|
||||
export async function authRoutes(app: FastifyInstance): Promise<void> {
|
||||
// POST /api/auth/register
|
||||
app.post<{ Body: RegisterInput }>("/register", async (request, reply) => {
|
||||
const { username, password, nickname } = request.body;
|
||||
|
||||
if (!username || !password) {
|
||||
return reply.status(400).send({
|
||||
error: "Bad Request",
|
||||
message: "Username and password are required",
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
return reply.status(400).send({
|
||||
error: "Bad Request",
|
||||
message: "Password must be at least 6 characters",
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const existing = queryOne("SELECT id FROM users WHERE username = ?", [username]);
|
||||
if (existing) {
|
||||
return reply.status(409).send({
|
||||
error: "Conflict",
|
||||
message: "Username already exists",
|
||||
statusCode: 409,
|
||||
});
|
||||
}
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
const passwordHash = await bcrypt.hash(password, SALT_ROUNDS);
|
||||
const now = new Date().toISOString();
|
||||
|
||||
execute(
|
||||
"INSERT INTO users (id, username, password_hash, nickname, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
[id, username, passwordHash, nickname || username, now, now]
|
||||
);
|
||||
|
||||
const user = queryOne<User>(
|
||||
"SELECT id, username, nickname, role, created_at, updated_at FROM users WHERE id = ?",
|
||||
[id]
|
||||
);
|
||||
if (!user) {
|
||||
return reply.status(500).send({ error: "Internal Error", message: "Failed to create user", statusCode: 500 });
|
||||
}
|
||||
|
||||
const token = app.jwt.sign({ userId: id, username, role: user.role });
|
||||
|
||||
return reply.status(201).send({ token, user } satisfies AuthResponse);
|
||||
});
|
||||
|
||||
// POST /api/auth/login
|
||||
app.post<{ Body: LoginInput }>("/login", async (request, reply) => {
|
||||
const { username, password } = request.body;
|
||||
|
||||
if (!username || !password) {
|
||||
return reply.status(400).send({
|
||||
error: "Bad Request",
|
||||
message: "Username and password are required",
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const user = queryOne<User & { password_hash: string }>(
|
||||
"SELECT * FROM users WHERE username = ?",
|
||||
[username]
|
||||
);
|
||||
|
||||
if (!user) {
|
||||
return reply.status(401).send({
|
||||
error: "Unauthorized",
|
||||
message: "Invalid username or password",
|
||||
statusCode: 401,
|
||||
});
|
||||
}
|
||||
|
||||
const valid = await bcrypt.compare(password, user.password_hash);
|
||||
if (!valid) {
|
||||
return reply.status(401).send({
|
||||
error: "Unauthorized",
|
||||
message: "Invalid username or password",
|
||||
statusCode: 401,
|
||||
});
|
||||
}
|
||||
|
||||
const token = app.jwt.sign({ userId: user.id, username: user.username, role: user.role });
|
||||
|
||||
const { password_hash, ...safeUser } = user;
|
||||
|
||||
return { token, user: safeUser } satisfies AuthResponse;
|
||||
});
|
||||
|
||||
// GET /api/auth/me — current user info
|
||||
app.get("/me", { onRequest: [authenticate] }, async (request, reply) => {
|
||||
const jwtUser = request.user as unknown as { userId: string };
|
||||
const user = queryOne<User>(
|
||||
"SELECT id, username, nickname, role, created_at, updated_at FROM users WHERE id = ?",
|
||||
[jwtUser.userId]
|
||||
);
|
||||
|
||||
if (!user) {
|
||||
return reply.status(404).send({
|
||||
error: "Not Found",
|
||||
message: "User not found",
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
|
||||
return { data: user };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// Auto-generated NoteTag routes
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { authenticate } from "../middleware/auth.js";
|
||||
import { NoteTagService } from "../services/note_tag.js";
|
||||
import type { CreateNoteTagInput, UpdateNoteTagInput } from "../types/index.js";
|
||||
|
||||
const service = new NoteTagService();
|
||||
|
||||
export async function note_tagsRoutes(app: FastifyInstance): Promise<void> {
|
||||
// All routes require authentication
|
||||
app.addHook("onRequest", authenticate);
|
||||
|
||||
// GET /api/note_tags — list
|
||||
app.get("/", async (request, reply) => {
|
||||
const items = service.list();
|
||||
return { data: items };
|
||||
});
|
||||
|
||||
// GET /api/note_tags/:id — get by id
|
||||
app.get<{ Params: { id: string } }>("/:id", async (request, reply) => {
|
||||
const item = service.getById(request.params.id);
|
||||
if (!item) {
|
||||
return reply.status(404).send({ error: "Not Found", message: "NoteTag not found", statusCode: 404 });
|
||||
}
|
||||
return { data: item };
|
||||
});
|
||||
|
||||
// POST /api/note_tags — create
|
||||
app.post<{ Body: CreateNoteTagInput }>("/", async (request, reply) => {
|
||||
const item = service.create(request.body);
|
||||
return reply.status(201).send({ data: item });
|
||||
});
|
||||
|
||||
// PUT /api/note_tags/:id — update
|
||||
app.put<{ Params: { id: string }; Body: UpdateNoteTagInput }>("/:id", async (request, reply) => {
|
||||
const item = service.update(request.params.id, request.body);
|
||||
if (!item) {
|
||||
return reply.status(404).send({ error: "Not Found", message: "NoteTag not found", statusCode: 404 });
|
||||
}
|
||||
return { data: item };
|
||||
});
|
||||
|
||||
// DELETE /api/note_tags/:id — delete
|
||||
app.delete<{ Params: { id: string } }>("/:id", async (request, reply) => {
|
||||
const deleted = service.delete(request.params.id);
|
||||
if (!deleted) {
|
||||
return reply.status(404).send({ error: "Not Found", message: "NoteTag not found", statusCode: 404 });
|
||||
}
|
||||
return reply.status(204).send();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// Auto-generated Note routes
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { authenticate } from "../middleware/auth.js";
|
||||
import { NoteService } from "../services/note.js";
|
||||
import type { CreateNoteInput, UpdateNoteInput } from "../types/index.js";
|
||||
|
||||
const service = new NoteService();
|
||||
|
||||
export async function notesRoutes(app: FastifyInstance): Promise<void> {
|
||||
// All routes require authentication
|
||||
app.addHook("onRequest", authenticate);
|
||||
|
||||
// GET /api/notes — list
|
||||
app.get("/", async (request, reply) => {
|
||||
const items = service.list();
|
||||
return { data: items };
|
||||
});
|
||||
|
||||
// GET /api/notes/:id — get by id
|
||||
app.get<{ Params: { id: string } }>("/:id", async (request, reply) => {
|
||||
const item = service.getById(request.params.id);
|
||||
if (!item) {
|
||||
return reply.status(404).send({ error: "Not Found", message: "Note not found", statusCode: 404 });
|
||||
}
|
||||
return { data: item };
|
||||
});
|
||||
|
||||
// POST /api/notes — create
|
||||
app.post<{ Body: CreateNoteInput }>("/", async (request, reply) => {
|
||||
const item = service.create(request.body);
|
||||
return reply.status(201).send({ data: item });
|
||||
});
|
||||
|
||||
// PUT /api/notes/:id — update
|
||||
app.put<{ Params: { id: string }; Body: UpdateNoteInput }>("/:id", async (request, reply) => {
|
||||
const item = service.update(request.params.id, request.body);
|
||||
if (!item) {
|
||||
return reply.status(404).send({ error: "Not Found", message: "Note not found", statusCode: 404 });
|
||||
}
|
||||
return { data: item };
|
||||
});
|
||||
|
||||
// DELETE /api/notes/:id — delete
|
||||
app.delete<{ Params: { id: string } }>("/:id", async (request, reply) => {
|
||||
const deleted = service.delete(request.params.id);
|
||||
if (!deleted) {
|
||||
return reply.status(404).send({ error: "Not Found", message: "Note not found", statusCode: 404 });
|
||||
}
|
||||
return reply.status(204).send();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// Auto-generated Tag routes
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { authenticate } from "../middleware/auth.js";
|
||||
import { TagService } from "../services/tag.js";
|
||||
import type { CreateTagInput, UpdateTagInput } from "../types/index.js";
|
||||
|
||||
const service = new TagService();
|
||||
|
||||
export async function tagsRoutes(app: FastifyInstance): Promise<void> {
|
||||
// All routes require authentication
|
||||
app.addHook("onRequest", authenticate);
|
||||
|
||||
// GET /api/tags — list
|
||||
app.get("/", async (request, reply) => {
|
||||
const items = service.list();
|
||||
return { data: items };
|
||||
});
|
||||
|
||||
// GET /api/tags/:id — get by id
|
||||
app.get<{ Params: { id: string } }>("/:id", async (request, reply) => {
|
||||
const item = service.getById(request.params.id);
|
||||
if (!item) {
|
||||
return reply.status(404).send({ error: "Not Found", message: "Tag not found", statusCode: 404 });
|
||||
}
|
||||
return { data: item };
|
||||
});
|
||||
|
||||
// POST /api/tags — create
|
||||
app.post<{ Body: CreateTagInput }>("/", async (request, reply) => {
|
||||
const item = service.create(request.body);
|
||||
return reply.status(201).send({ data: item });
|
||||
});
|
||||
|
||||
// PUT /api/tags/:id — update
|
||||
app.put<{ Params: { id: string }; Body: UpdateTagInput }>("/:id", async (request, reply) => {
|
||||
const item = service.update(request.params.id, request.body);
|
||||
if (!item) {
|
||||
return reply.status(404).send({ error: "Not Found", message: "Tag not found", statusCode: 404 });
|
||||
}
|
||||
return { data: item };
|
||||
});
|
||||
|
||||
// DELETE /api/tags/:id — delete
|
||||
app.delete<{ Params: { id: string } }>("/:id", async (request, reply) => {
|
||||
const deleted = service.delete(request.params.id);
|
||||
if (!deleted) {
|
||||
return reply.status(404).send({ error: "Not Found", message: "Tag not found", statusCode: 404 });
|
||||
}
|
||||
return reply.status(204).send();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// Auto-generated User routes
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { authenticate } from "../middleware/auth.js";
|
||||
import { UserService } from "../services/user.js";
|
||||
import type { CreateUserInput, UpdateUserInput } from "../types/index.js";
|
||||
|
||||
const service = new UserService();
|
||||
|
||||
export async function usersRoutes(app: FastifyInstance): Promise<void> {
|
||||
// All routes require authentication
|
||||
app.addHook("onRequest", authenticate);
|
||||
|
||||
// GET /api/users — list
|
||||
app.get("/", async (request, reply) => {
|
||||
const items = service.list();
|
||||
return { data: items };
|
||||
});
|
||||
|
||||
// GET /api/users/:id — get by id
|
||||
app.get<{ Params: { id: string } }>("/:id", async (request, reply) => {
|
||||
const item = service.getById(request.params.id);
|
||||
if (!item) {
|
||||
return reply.status(404).send({ error: "Not Found", message: "User not found", statusCode: 404 });
|
||||
}
|
||||
return { data: item };
|
||||
});
|
||||
|
||||
// POST /api/users — create
|
||||
app.post<{ Body: CreateUserInput }>("/", async (request, reply) => {
|
||||
const item = service.create(request.body);
|
||||
return reply.status(201).send({ data: item });
|
||||
});
|
||||
|
||||
// PUT /api/users/:id — update
|
||||
app.put<{ Params: { id: string }; Body: UpdateUserInput }>("/:id", async (request, reply) => {
|
||||
const item = service.update(request.params.id, request.body);
|
||||
if (!item) {
|
||||
return reply.status(404).send({ error: "Not Found", message: "User not found", statusCode: 404 });
|
||||
}
|
||||
return { data: item };
|
||||
});
|
||||
|
||||
// DELETE /api/users/:id — delete
|
||||
app.delete<{ Params: { id: string } }>("/:id", async (request, reply) => {
|
||||
const deleted = service.delete(request.params.id);
|
||||
if (!deleted) {
|
||||
return reply.status(404).send({ error: "Not Found", message: "User not found", statusCode: 404 });
|
||||
}
|
||||
return reply.status(204).send();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// Auto-generated Note service
|
||||
import { queryAll, queryOne, execute } from "../db/client.js";
|
||||
import type { Note, CreateNoteInput, UpdateNoteInput } from "../types/index.js";
|
||||
|
||||
export class NoteService {
|
||||
/** List all notes */
|
||||
list(): Note[] {
|
||||
return queryAll<Note>("SELECT * FROM notes ORDER BY created_at DESC");
|
||||
}
|
||||
|
||||
/** Get by ID */
|
||||
getById(id: string): Note | undefined {
|
||||
return queryOne<Note>("SELECT * FROM notes WHERE id = ?", [id]);
|
||||
}
|
||||
|
||||
/** Create */
|
||||
create(input: CreateNoteInput): Note {
|
||||
const id = crypto.randomUUID();
|
||||
const now = new Date().toISOString();
|
||||
const hasCreatedAt = true;
|
||||
const hasUpdatedAt = true;
|
||||
const cols = ["id", "user_id", "title", "content", "is_markdown", "folder_id", "created_at", "updated_at"];
|
||||
const placeholders = cols.map(() => "?").join(", ");
|
||||
const values = [id, input.userId ?? null, input.title ?? null, input.content ?? null, input.isMarkdown ?? null, input.folderId ?? null, now, now];
|
||||
|
||||
execute(`INSERT INTO notes (${cols.join(", ")}) VALUES (${placeholders})`, values);
|
||||
return this.getById(id)!;
|
||||
}
|
||||
|
||||
/** Update */
|
||||
update(id: string, input: UpdateNoteInput): Note | undefined {
|
||||
const existing = this.getById(id);
|
||||
if (!existing) return undefined;
|
||||
|
||||
const sets: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
if (input.userId !== undefined) { sets.push("user_id = ?"); values.push(input.userId); }
|
||||
if (input.title !== undefined) { sets.push("title = ?"); values.push(input.title); }
|
||||
if (input.content !== undefined) { sets.push("content = ?"); values.push(input.content); }
|
||||
if (input.isMarkdown !== undefined) { sets.push("is_markdown = ?"); values.push(input.isMarkdown); }
|
||||
if (input.folderId !== undefined) { sets.push("folder_id = ?"); values.push(input.folderId); }
|
||||
|
||||
if (sets.length === 0) return existing;
|
||||
|
||||
sets.push("updated_at = ?");
|
||||
values.push(new Date().toISOString());
|
||||
|
||||
values.push(id);
|
||||
execute(`UPDATE notes SET ${sets.join(", ")} WHERE id = ?`, values);
|
||||
return this.getById(id)!;
|
||||
}
|
||||
|
||||
/** Delete */
|
||||
delete(id: string): boolean {
|
||||
const result = execute("DELETE FROM notes WHERE id = ?", [id]);
|
||||
return result.changes > 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// Auto-generated NoteTag service
|
||||
import { queryAll, queryOne, execute } from "../db/client.js";
|
||||
import type { NoteTag, CreateNoteTagInput, UpdateNoteTagInput } from "../types/index.js";
|
||||
|
||||
export class NoteTagService {
|
||||
/** List all note_tags */
|
||||
list(): NoteTag[] {
|
||||
return queryAll<NoteTag>("SELECT * FROM note_tags ORDER BY created_at DESC");
|
||||
}
|
||||
|
||||
/** Get by ID */
|
||||
getById(id: string): NoteTag | undefined {
|
||||
return queryOne<NoteTag>("SELECT * FROM note_tags WHERE id = ?", [id]);
|
||||
}
|
||||
|
||||
/** Create */
|
||||
create(input: CreateNoteTagInput): NoteTag {
|
||||
const id = crypto.randomUUID();
|
||||
const now = new Date().toISOString();
|
||||
const hasCreatedAt = false;
|
||||
const hasUpdatedAt = false;
|
||||
const cols = ["id", "note_id", "tag_id"];
|
||||
const placeholders = cols.map(() => "?").join(", ");
|
||||
const values = [id, input.noteId ?? null, input.tagId ?? null];
|
||||
|
||||
execute(`INSERT INTO note_tags (${cols.join(", ")}) VALUES (${placeholders})`, values);
|
||||
return this.getById(id)!;
|
||||
}
|
||||
|
||||
/** Update */
|
||||
update(id: string, input: UpdateNoteTagInput): NoteTag | undefined {
|
||||
const existing = this.getById(id);
|
||||
if (!existing) return undefined;
|
||||
|
||||
const sets: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
if (input.noteId !== undefined) { sets.push("note_id = ?"); values.push(input.noteId); }
|
||||
if (input.tagId !== undefined) { sets.push("tag_id = ?"); values.push(input.tagId); }
|
||||
|
||||
if (sets.length === 0) return existing;
|
||||
|
||||
|
||||
|
||||
values.push(id);
|
||||
execute(`UPDATE note_tags SET ${sets.join(", ")} WHERE id = ?`, values);
|
||||
return this.getById(id)!;
|
||||
}
|
||||
|
||||
/** Delete */
|
||||
delete(id: string): boolean {
|
||||
const result = execute("DELETE FROM note_tags WHERE id = ?", [id]);
|
||||
return result.changes > 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// Auto-generated Tag service
|
||||
import { queryAll, queryOne, execute } from "../db/client.js";
|
||||
import type { Tag, CreateTagInput, UpdateTagInput } from "../types/index.js";
|
||||
|
||||
export class TagService {
|
||||
/** List all tags */
|
||||
list(): Tag[] {
|
||||
return queryAll<Tag>("SELECT * FROM tags ORDER BY created_at DESC");
|
||||
}
|
||||
|
||||
/** Get by ID */
|
||||
getById(id: string): Tag | undefined {
|
||||
return queryOne<Tag>("SELECT * FROM tags WHERE id = ?", [id]);
|
||||
}
|
||||
|
||||
/** Create */
|
||||
create(input: CreateTagInput): Tag {
|
||||
const id = crypto.randomUUID();
|
||||
const now = new Date().toISOString();
|
||||
const hasCreatedAt = false;
|
||||
const hasUpdatedAt = false;
|
||||
const cols = ["id", "name", "color"];
|
||||
const placeholders = cols.map(() => "?").join(", ");
|
||||
const values = [id, input.name ?? null, input.color ?? null];
|
||||
|
||||
execute(`INSERT INTO tags (${cols.join(", ")}) VALUES (${placeholders})`, values);
|
||||
return this.getById(id)!;
|
||||
}
|
||||
|
||||
/** Update */
|
||||
update(id: string, input: UpdateTagInput): Tag | undefined {
|
||||
const existing = this.getById(id);
|
||||
if (!existing) return undefined;
|
||||
|
||||
const sets: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
if (input.name !== undefined) { sets.push("name = ?"); values.push(input.name); }
|
||||
if (input.color !== undefined) { sets.push("color = ?"); values.push(input.color); }
|
||||
|
||||
if (sets.length === 0) return existing;
|
||||
|
||||
|
||||
|
||||
values.push(id);
|
||||
execute(`UPDATE tags SET ${sets.join(", ")} WHERE id = ?`, values);
|
||||
return this.getById(id)!;
|
||||
}
|
||||
|
||||
/** Delete */
|
||||
delete(id: string): boolean {
|
||||
const result = execute("DELETE FROM tags WHERE id = ?", [id]);
|
||||
return result.changes > 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// Auto-generated User service
|
||||
import { queryAll, queryOne, execute } from "../db/client.js";
|
||||
import type { User, CreateUserInput, UpdateUserInput } from "../types/index.js";
|
||||
|
||||
export class UserService {
|
||||
/** List all users */
|
||||
list(): User[] {
|
||||
return queryAll<User>("SELECT * FROM users ORDER BY created_at DESC");
|
||||
}
|
||||
|
||||
/** Get by ID */
|
||||
getById(id: string): User | undefined {
|
||||
return queryOne<User>("SELECT * FROM users WHERE id = ?", [id]);
|
||||
}
|
||||
|
||||
/** Create */
|
||||
create(input: CreateUserInput): User {
|
||||
const id = crypto.randomUUID();
|
||||
const now = new Date().toISOString();
|
||||
const hasCreatedAt = true;
|
||||
const hasUpdatedAt = true;
|
||||
const cols = ["id", "phone", "nickname", "avatar_url", "created_at", "updated_at"];
|
||||
const placeholders = cols.map(() => "?").join(", ");
|
||||
const values = [id, input.phone ?? null, input.nickname ?? null, input.avatarUrl ?? null, now, now];
|
||||
|
||||
execute(`INSERT INTO users (${cols.join(", ")}) VALUES (${placeholders})`, values);
|
||||
return this.getById(id)!;
|
||||
}
|
||||
|
||||
/** Update */
|
||||
update(id: string, input: UpdateUserInput): User | undefined {
|
||||
const existing = this.getById(id);
|
||||
if (!existing) return undefined;
|
||||
|
||||
const sets: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
if (input.phone !== undefined) { sets.push("phone = ?"); values.push(input.phone); }
|
||||
if (input.nickname !== undefined) { sets.push("nickname = ?"); values.push(input.nickname); }
|
||||
if (input.avatarUrl !== undefined) { sets.push("avatar_url = ?"); values.push(input.avatarUrl); }
|
||||
|
||||
if (sets.length === 0) return existing;
|
||||
|
||||
sets.push("updated_at = ?");
|
||||
values.push(new Date().toISOString());
|
||||
|
||||
values.push(id);
|
||||
execute(`UPDATE users SET ${sets.join(", ")} WHERE id = ?`, values);
|
||||
return this.getById(id)!;
|
||||
}
|
||||
|
||||
/** Delete */
|
||||
delete(id: string): boolean {
|
||||
const result = execute("DELETE FROM users WHERE id = ?", [id]);
|
||||
return result.changes > 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
// Augment Fastify request with JWT user
|
||||
import type { JwtPayload } from "./index.js";
|
||||
|
||||
declare module "fastify" {
|
||||
interface FastifyRequest {
|
||||
user?: JwtPayload;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
// Auto-generated types
|
||||
|
||||
// ─── Base ───────────────────────────────────────────
|
||||
export interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
nickname?: string;
|
||||
role: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface Note {
|
||||
id: string;
|
||||
userId?: string;
|
||||
title?: string;
|
||||
content?: string;
|
||||
isMarkdown?: boolean;
|
||||
folderId?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface Tag {
|
||||
id: string;
|
||||
name: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export interface NoteTag {
|
||||
noteId?: string;
|
||||
tagId?: string;
|
||||
}
|
||||
|
||||
export interface CreateUserInput {
|
||||
phone?: string;
|
||||
nickname?: string;
|
||||
avatarUrl?: string;
|
||||
}
|
||||
|
||||
export interface CreateNoteInput {
|
||||
userId?: string;
|
||||
title?: string;
|
||||
content?: string;
|
||||
isMarkdown?: boolean;
|
||||
folderId?: string;
|
||||
}
|
||||
|
||||
export interface CreateTagInput {
|
||||
name: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export interface CreateNoteTagInput {
|
||||
noteId?: string;
|
||||
tagId?: string;
|
||||
}
|
||||
|
||||
export interface UpdateUserInput {
|
||||
phone?: string;
|
||||
nickname?: string;
|
||||
avatarUrl?: string;
|
||||
}
|
||||
|
||||
export interface UpdateNoteInput {
|
||||
userId?: string;
|
||||
title?: string;
|
||||
content?: string;
|
||||
isMarkdown?: boolean;
|
||||
folderId?: string;
|
||||
}
|
||||
|
||||
export interface UpdateTagInput {
|
||||
name?: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export interface UpdateNoteTagInput {
|
||||
noteId?: string;
|
||||
tagId?: string;
|
||||
}
|
||||
|
||||
// ─── Auth ───────────────────────────────────────────
|
||||
export interface LoginInput {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface RegisterInput {
|
||||
username: string;
|
||||
password: string;
|
||||
nickname?: string;
|
||||
}
|
||||
|
||||
export interface AuthResponse {
|
||||
token: string;
|
||||
user: User;
|
||||
}
|
||||
|
||||
// ─── API ────────────────────────────────────────────
|
||||
export interface ApiResponse<T> {
|
||||
data: T;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
data: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export interface ErrorResponse {
|
||||
error: string;
|
||||
message: string;
|
||||
statusCode: number;
|
||||
}
|
||||
|
||||
// ─── JWT ────────────────────────────────────────────
|
||||
export interface JwtPayload {
|
||||
userId: string;
|
||||
username: string;
|
||||
role: string;
|
||||
iat?: number;
|
||||
exp?: number;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// Type declarations for sql.js (no native types package available)
|
||||
declare module "sql.js" {
|
||||
export interface Database {
|
||||
run(sql: string, params?: BindParams): void;
|
||||
exec(sql: string): void;
|
||||
prepare(sql: string): Statement;
|
||||
export(): Uint8Array;
|
||||
close(): void;
|
||||
getRowsModified(): number;
|
||||
}
|
||||
|
||||
export interface Statement {
|
||||
bind(params?: BindParams): boolean;
|
||||
step(): boolean;
|
||||
getAsObject<T = Record<string, unknown>>(): T;
|
||||
getColumnNames(): string[];
|
||||
free(): boolean;
|
||||
}
|
||||
|
||||
export type BindParams = unknown[] | Record<string, unknown>;
|
||||
|
||||
export interface SqlJsStatic {
|
||||
Database: new (data?: ArrayLike<number>) => Database;
|
||||
}
|
||||
|
||||
export default function initSqlJs(config?: Record<string, unknown>): Promise<SqlJsStatic>;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": [
|
||||
"ES2022"
|
||||
],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": [
|
||||
"src/**/*"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist",
|
||||
"src/__tests__"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
# bookshelf — Desktop
|
||||
|
||||
> Electron desktop application wrapping the bookshelf fullstack web project.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Web Fullstack Project
|
||||
×
|
||||
Desktop Shell (Electron)
|
||||
=
|
||||
Desktop Application
|
||||
```
|
||||
|
||||
- **Main Process** — Window management, menu, tray, IPC, auto-update
|
||||
- **Preload** — Secure bridge between main and renderer
|
||||
- **Renderer** — Your existing web frontend (Next.js)
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Start dev mode (web + api + electron concurrently)
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Dev mode:
|
||||
- Web frontend: `http://localhost:3000`
|
||||
- API backend: `http://localhost:3001`
|
||||
- Electron loads the web URL directly
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
# Build for current platform
|
||||
npm run build
|
||||
|
||||
# Platform-specific builds
|
||||
npm run build:electron:mac
|
||||
npm run build:electron:win
|
||||
npm run build:electron:linux
|
||||
```
|
||||
|
||||
Output: `release/` directory with platform installers.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
electron/
|
||||
├── main.ts — Main process (BrowserWindow, Menu, lifecycle)
|
||||
├── preload.ts — Secure IPC bridge (contextBridge)
|
||||
├── ipc.ts — IPC handlers (dialogs, store, notifications)
|
||||
├── tray.ts — System tray
|
||||
├── updater.ts — Auto-update (electron-updater)
|
||||
└── utils.ts — Shared utilities
|
||||
```
|
||||
|
||||
## IPC API (available in renderer)
|
||||
|
||||
```typescript
|
||||
window.electronAPI.getAppVersion()
|
||||
window.electronAPI.getPlatform()
|
||||
window.electronAPI.minimizeWindow()
|
||||
window.electronAPI.maximizeWindow()
|
||||
window.electronAPI.closeWindow()
|
||||
window.electronAPI.openFile(options?)
|
||||
window.electronAPI.saveFile(options?)
|
||||
window.electronAPI.showNotification(title, body)
|
||||
window.electronAPI.openExternal(url)
|
||||
window.electronAPI.storeGet(key)
|
||||
window.electronAPI.storeSet(key, value)
|
||||
window.electronAPI.checkForUpdates()
|
||||
window.electronAPI.downloadUpdate()
|
||||
window.electronAPI.quitAndInstall()
|
||||
```
|
||||
|
||||
## Auto Update
|
||||
|
||||
Configured via `electron-builder.yml`. Uses `electron-updater` with generic provider.
|
||||
Set your release URL in the `publish` section.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 66 B |
@@ -0,0 +1,100 @@
|
||||
# electron-builder.yml — Auto-generated by SF-06
|
||||
|
||||
appId: com.bookshelf.desktop
|
||||
productName: bookshelf
|
||||
copyright: "Copyright © 2026 bookshelf"
|
||||
|
||||
# ─── Directories ─────────────────────────────────
|
||||
directories:
|
||||
output: release
|
||||
buildResources: assets
|
||||
|
||||
# ─── Files ───────────────────────────────────────
|
||||
files:
|
||||
- dist/electron/**/*
|
||||
- "!node_modules/**/*"
|
||||
- "**/*.node"
|
||||
|
||||
# ─── Extra Resources ─────────────────────────────
|
||||
extraResources:
|
||||
- from: "../web/out"
|
||||
to: "web"
|
||||
filter:
|
||||
- "**/*"
|
||||
- from: "../api/dist"
|
||||
to: "api"
|
||||
filter:
|
||||
- "**/*"
|
||||
- from: "../api/data"
|
||||
to: "data"
|
||||
filter:
|
||||
- "**/*"
|
||||
|
||||
# ─── macOS ───────────────────────────────────────
|
||||
mac:
|
||||
category: public.app-category.productivity
|
||||
icon: assets/icon.png
|
||||
target:
|
||||
- target: dmg
|
||||
arch:
|
||||
- x64
|
||||
- arm64
|
||||
- target: zip
|
||||
arch:
|
||||
- x64
|
||||
- arm64
|
||||
darkModeSupport: true
|
||||
hardenedRuntime: true
|
||||
gatekeeperAssess: false
|
||||
entitlements: entitlements.mac.plist
|
||||
entitlementsInherit: entitlements.mac.plist
|
||||
|
||||
# ─── Windows ─────────────────────────────────────
|
||||
win:
|
||||
icon: assets/icon.png
|
||||
target:
|
||||
- target: nsis
|
||||
arch:
|
||||
- x64
|
||||
- target: portable
|
||||
arch:
|
||||
- x64
|
||||
publisherName: bookshelf
|
||||
|
||||
nsis:
|
||||
oneClick: false
|
||||
perMachine: false
|
||||
allowToChangeInstallationDirectory: true
|
||||
installerIcon: assets/icon.ico
|
||||
uninstallerIcon: assets/icon.ico
|
||||
installerHeaderIcon: assets/icon.ico
|
||||
createDesktopShortcut: true
|
||||
createStartMenuShortcut: true
|
||||
shortcutName: bookshelf
|
||||
|
||||
# ─── Linux ───────────────────────────────────────
|
||||
linux:
|
||||
icon: assets/icon.png
|
||||
category: Utility
|
||||
target:
|
||||
- target: AppImage
|
||||
arch:
|
||||
- x64
|
||||
- target: deb
|
||||
arch:
|
||||
- x64
|
||||
- target: rpm
|
||||
arch:
|
||||
- x64
|
||||
desktop:
|
||||
Name: bookshelf
|
||||
Comment: bookshelf Desktop Application
|
||||
Terminal: false
|
||||
Type: Application
|
||||
Categories: Utility;
|
||||
|
||||
# ─── Auto Update ─────────────────────────────────
|
||||
publish:
|
||||
provider: generic
|
||||
url: https://releases.example.com/bookshelf/
|
||||
channel: latest
|
||||
@@ -0,0 +1,77 @@
|
||||
import { ipcMain, app, dialog, shell, BrowserWindow, Notification } from "electron";
|
||||
import Store from "electron-store";
|
||||
|
||||
const store = new Store() as any;
|
||||
|
||||
export function setupIPC(mainWindow: BrowserWindow): void {
|
||||
// ── App Info ──
|
||||
ipcMain.handle("app:version", () => app.getVersion());
|
||||
ipcMain.handle("app:platform", () => process.platform);
|
||||
ipcMain.handle("app:isDev", () => !app.isPackaged);
|
||||
|
||||
// ── Window Controls ──
|
||||
ipcMain.on("window:minimize", () => {
|
||||
mainWindow?.minimize();
|
||||
});
|
||||
|
||||
ipcMain.on("window:maximize", () => {
|
||||
if (mainWindow?.isMaximized()) {
|
||||
mainWindow.unmaximize();
|
||||
} else {
|
||||
mainWindow?.maximize();
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.on("window:close", () => {
|
||||
mainWindow?.close();
|
||||
});
|
||||
|
||||
ipcMain.handle("window:isMaximized", () => {
|
||||
return mainWindow?.isMaximized() ?? false;
|
||||
});
|
||||
|
||||
// ── File Dialogs ──
|
||||
ipcMain.handle("dialog:openFile", async (_event, options?) => {
|
||||
const result = await dialog.showOpenDialog(mainWindow, {
|
||||
properties: ["openFile"],
|
||||
...options,
|
||||
});
|
||||
return result.canceled ? undefined : result.filePaths;
|
||||
});
|
||||
|
||||
ipcMain.handle("dialog:saveFile", async (_event, options?) => {
|
||||
const result = await dialog.showSaveDialog(mainWindow, {
|
||||
...options,
|
||||
});
|
||||
return result.canceled ? undefined : result.filePath;
|
||||
});
|
||||
|
||||
// ── Notifications ──
|
||||
ipcMain.on("notification:show", (_event, { title, body }) => {
|
||||
if (Notification.isSupported()) {
|
||||
new Notification({ title, body }).show();
|
||||
}
|
||||
});
|
||||
|
||||
// ── Shell ──
|
||||
ipcMain.on("shell:openExternal", (_event, url: string) => {
|
||||
shell.openExternal(url);
|
||||
});
|
||||
|
||||
ipcMain.on("shell:openPath", (_event, path: string) => {
|
||||
shell.openPath(path);
|
||||
});
|
||||
|
||||
// ── Persistent Store ──
|
||||
ipcMain.handle("store:get", (_event, key: string) => {
|
||||
return store.get(key);
|
||||
});
|
||||
|
||||
ipcMain.handle("store:set", (_event, key: string, value: unknown) => {
|
||||
store.set(key, value);
|
||||
});
|
||||
|
||||
ipcMain.handle("store:delete", (_event, key: string) => {
|
||||
store.delete(key);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
import { app, BrowserWindow, Menu, nativeImage, ipcMain } from "electron";
|
||||
import { join } from "path";
|
||||
import { existsSync } from "fs";
|
||||
import { setupTray } from "./tray";
|
||||
import { setupIPC } from "./ipc";
|
||||
import { setupUpdater } from "./updater";
|
||||
import { checkSingleInstance, getWebUrl, getApiPath, isDev } from "./utils";
|
||||
|
||||
// ─── Constants ───────────────────────────────────
|
||||
const WEB_PORT = 3000;
|
||||
const API_PORT = 3001;
|
||||
const APP_NAME = "bookshelf";
|
||||
|
||||
// ─── Single Instance Lock ────────────────────────
|
||||
if (!checkSingleInstance()) {
|
||||
app.quit();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// ─── State ───────────────────────────────────────
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
let apiProcess: ReturnType<typeof import("child_process").spawn> | null = null;
|
||||
|
||||
// ─── Create Window ───────────────────────────────
|
||||
function createWindow(): BrowserWindow {
|
||||
const preloadPath = join(__dirname, "preload.js");
|
||||
|
||||
const win = new BrowserWindow({
|
||||
title: APP_NAME,
|
||||
width: 1400,
|
||||
height: 900,
|
||||
minWidth: 800,
|
||||
minHeight: 600,
|
||||
show: false,
|
||||
icon: getIconPath(),
|
||||
webPreferences: {
|
||||
preload: preloadPath,
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: false,
|
||||
},
|
||||
titleBarStyle: process.platform === "darwin" ? "hiddenInset" : "default",
|
||||
trafficLightPosition: { x: 16, y: 16 },
|
||||
});
|
||||
|
||||
// ── Load content ──
|
||||
const url = getWebUrl(isDev(), WEB_PORT);
|
||||
if (url.startsWith("http")) {
|
||||
win.loadURL(url);
|
||||
} else {
|
||||
win.loadFile(url);
|
||||
}
|
||||
|
||||
// ── Show when ready ──
|
||||
win.once("ready-to-show", () => {
|
||||
win.show();
|
||||
if (isDev()) {
|
||||
win.webContents.openDevTools({ mode: "detach" });
|
||||
}
|
||||
});
|
||||
|
||||
// ── External links → default browser ──
|
||||
win.webContents.setWindowOpenHandler(({ url }) => {
|
||||
if (url.startsWith("http")) {
|
||||
require("electron").shell.openExternal(url);
|
||||
}
|
||||
return { action: "deny" };
|
||||
});
|
||||
|
||||
// ── Prevent navigation away ──
|
||||
win.webContents.on("will-navigate", (event, url) => {
|
||||
const allowed = isDev()
|
||||
? url.startsWith("http://localhost:3000")
|
||||
: url.startsWith("file://");
|
||||
if (!allowed) event.preventDefault();
|
||||
});
|
||||
|
||||
return win;
|
||||
}
|
||||
|
||||
// ─── Icon Path ───────────────────────────────────
|
||||
function getIconPath(): string {
|
||||
const candidates = [
|
||||
join(__dirname, "..", "assets", "icon.png"),
|
||||
join(__dirname, "..", "assets", "icon.icns"),
|
||||
join(__dirname, "..", "assets", "icon.ico"),
|
||||
];
|
||||
for (const p of candidates) {
|
||||
if (existsSync(p)) return p;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
// ─── API Server (Production) ─────────────────────
|
||||
function startApiServer(): void {
|
||||
if (isDev()) return;
|
||||
|
||||
const apiPath = getApiPath();
|
||||
if (!apiPath || !existsSync(apiPath)) {
|
||||
console.warn("[main] API server not found at", apiPath);
|
||||
return;
|
||||
}
|
||||
|
||||
const { spawn } = require("child_process");
|
||||
apiProcess = spawn("node", [apiPath], {
|
||||
env: {
|
||||
...process.env,
|
||||
NODE_ENV: "production",
|
||||
PORT: String(API_PORT),
|
||||
HOST: "127.0.0.1",
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
apiProcess!.stdout?.on("data", (data: Buffer) => {
|
||||
console.log("[api]", data.toString().trim());
|
||||
});
|
||||
apiProcess!.stderr?.on("data", (data: Buffer) => {
|
||||
console.error("[api]", data.toString().trim());
|
||||
});
|
||||
apiProcess!.on("exit", (code: number) => {
|
||||
console.warn("[main] API server exited with code", code);
|
||||
apiProcess = null;
|
||||
});
|
||||
}
|
||||
|
||||
function stopApiServer(): void {
|
||||
if (apiProcess) {
|
||||
apiProcess.kill("SIGTERM");
|
||||
apiProcess = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Menu ────────────────────────────────────────
|
||||
function buildMenu(): void {
|
||||
const isMac = process.platform === "darwin";
|
||||
|
||||
const template: Electron.MenuItemConstructorOptions[] = [
|
||||
...(isMac
|
||||
? [{
|
||||
label: APP_NAME,
|
||||
submenu: [
|
||||
{ role: "about" as const },
|
||||
{ type: "separator" as const },
|
||||
{ role: "services" as const },
|
||||
{ type: "separator" as const },
|
||||
{ role: "hide" as const },
|
||||
{ role: "hideOthers" as const },
|
||||
{ role: "unhide" as const },
|
||||
{ type: "separator" as const },
|
||||
{ role: "quit" as const },
|
||||
],
|
||||
}]
|
||||
: []),
|
||||
{
|
||||
label: "Edit",
|
||||
submenu: [
|
||||
{ role: "undo" },
|
||||
{ role: "redo" },
|
||||
{ type: "separator" },
|
||||
{ role: "cut" },
|
||||
{ role: "copy" },
|
||||
{ role: "paste" },
|
||||
{ role: "selectAll" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "View",
|
||||
submenu: [
|
||||
{ role: "reload" },
|
||||
{ role: "forceReload" },
|
||||
{ role: "toggleDevTools" },
|
||||
{ type: "separator" },
|
||||
{ role: "resetZoom" },
|
||||
{ role: "zoomIn" },
|
||||
{ role: "zoomOut" },
|
||||
{ type: "separator" },
|
||||
{ role: "togglefullscreen" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Window",
|
||||
submenu: [
|
||||
{ role: "minimize" },
|
||||
{ role: "zoom" },
|
||||
...(isMac
|
||||
? [
|
||||
{ type: "separator" as const },
|
||||
{ role: "front" as const },
|
||||
]
|
||||
: [{ role: "close" as const }]),
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
Menu.setApplicationMenu(Menu.buildFromTemplate(template));
|
||||
}
|
||||
|
||||
// ─── App Lifecycle ───────────────────────────────
|
||||
app.whenReady().then(() => {
|
||||
startApiServer();
|
||||
mainWindow = createWindow();
|
||||
buildMenu();
|
||||
if (mainWindow) {
|
||||
setupTray(mainWindow, APP_NAME);
|
||||
setupIPC(mainWindow);
|
||||
}
|
||||
setupUpdater(APP_NAME);
|
||||
|
||||
app.on("activate", () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
mainWindow = createWindow();
|
||||
if (mainWindow) {
|
||||
setupTray(mainWindow, APP_NAME);
|
||||
setupIPC(mainWindow);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
app.on("window-all-closed", () => {
|
||||
stopApiServer();
|
||||
if (process.platform !== "darwin") {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
|
||||
app.on("before-quit", () => {
|
||||
stopApiServer();
|
||||
});
|
||||
|
||||
app.on("gpu-process-crashed" as any, () => {
|
||||
console.warn("[main] GPU process crashed — continuing");
|
||||
});
|
||||
|
||||
process.on("exit", () => {
|
||||
stopApiServer();
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import { contextBridge, ipcRenderer } from "electron";
|
||||
|
||||
// ─── Expose safe API to renderer ─────────────────
|
||||
contextBridge.exposeInMainWorld("electronAPI", {
|
||||
// ── App Info ──
|
||||
getAppVersion: () => ipcRenderer.invoke("app:version"),
|
||||
getPlatform: () => ipcRenderer.invoke("app:platform"),
|
||||
getIsDev: () => ipcRenderer.invoke("app:isDev"),
|
||||
|
||||
// ── Window Controls ──
|
||||
minimizeWindow: () => ipcRenderer.send("window:minimize"),
|
||||
maximizeWindow: () => ipcRenderer.send("window:maximize"),
|
||||
closeWindow: () => ipcRenderer.send("window:close"),
|
||||
isMaximized: () => ipcRenderer.invoke("window:isMaximized"),
|
||||
|
||||
// ── File Dialogs ──
|
||||
openFile: (options?: Electron.OpenDialogOptions) =>
|
||||
ipcRenderer.invoke("dialog:openFile", options),
|
||||
saveFile: (options?: Electron.SaveDialogOptions) =>
|
||||
ipcRenderer.invoke("dialog:saveFile", options),
|
||||
|
||||
// ── Notifications ──
|
||||
showNotification: (title: string, body: string) =>
|
||||
ipcRenderer.send("notification:show", { title, body }),
|
||||
|
||||
// ── Shell ──
|
||||
openExternal: (url: string) => ipcRenderer.send("shell:openExternal", url),
|
||||
openPath: (path: string) => ipcRenderer.send("shell:openPath", path),
|
||||
|
||||
// ── Store ──
|
||||
storeGet: (key: string) => ipcRenderer.invoke("store:get", key),
|
||||
storeSet: (key: string, value: unknown) =>
|
||||
ipcRenderer.invoke("store:set", key, value),
|
||||
storeDelete: (key: string) => ipcRenderer.invoke("store:delete", key),
|
||||
|
||||
// ── Updates ──
|
||||
checkForUpdates: () => ipcRenderer.invoke("updater:check"),
|
||||
downloadUpdate: () => ipcRenderer.invoke("updater:download"),
|
||||
quitAndInstall: () => ipcRenderer.send("updater:quitAndInstall"),
|
||||
|
||||
// ── Update Events ──
|
||||
onUpdateAvailable: (callback: (info: unknown) => void) => {
|
||||
ipcRenderer.on("updater:available", (_event, info) => callback(info));
|
||||
},
|
||||
onUpdateDownloaded: (callback: (info: unknown) => void) => {
|
||||
ipcRenderer.on("updater:downloaded", (_event, info) => callback(info));
|
||||
},
|
||||
onUpdateProgress: (callback: (progress: unknown) => void) => {
|
||||
ipcRenderer.on("updater:progress", (_event, progress) => callback(progress));
|
||||
},
|
||||
onUpdateError: (callback: (error: string) => void) => {
|
||||
ipcRenderer.on("updater:error", (_event, error) => callback(error));
|
||||
},
|
||||
|
||||
// ── Remove listener ──
|
||||
removeAllListeners: (channel: string) => {
|
||||
ipcRenderer.removeAllListeners(channel);
|
||||
},
|
||||
});
|
||||
|
||||
// ─── Type declarations for renderer ──────────────
|
||||
export interface ElectronAPI {
|
||||
getAppVersion: () => Promise<string>;
|
||||
getPlatform: () => Promise<string>;
|
||||
getIsDev: () => Promise<boolean>;
|
||||
minimizeWindow: () => void;
|
||||
maximizeWindow: () => void;
|
||||
closeWindow: () => void;
|
||||
isMaximized: () => Promise<boolean>;
|
||||
openFile: (options?: Electron.OpenDialogOptions) => Promise<string[] | undefined>;
|
||||
saveFile: (options?: Electron.SaveDialogOptions) => Promise<string | undefined>;
|
||||
showNotification: (title: string, body: string) => void;
|
||||
openExternal: (url: string) => void;
|
||||
openPath: (path: string) => void;
|
||||
storeGet: (key: string) => Promise<unknown>;
|
||||
storeSet: (key: string, value: unknown) => Promise<void>;
|
||||
storeDelete: (key: string) => Promise<void>;
|
||||
checkForUpdates: () => Promise<void>;
|
||||
downloadUpdate: () => Promise<void>;
|
||||
quitAndInstall: () => void;
|
||||
onUpdateAvailable: (callback: (info: unknown) => void) => void;
|
||||
onUpdateDownloaded: (callback: (info: unknown) => void) => void;
|
||||
onUpdateProgress: (callback: (progress: unknown) => void) => void;
|
||||
onUpdateError: (callback: (error: string) => void) => void;
|
||||
removeAllListeners: (channel: string) => void;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
electronAPI: ElectronAPI;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Tray, Menu, nativeImage, BrowserWindow, app } from "electron";
|
||||
import { join } from "path";
|
||||
import { existsSync } from "fs";
|
||||
|
||||
let tray: Tray | null = null;
|
||||
|
||||
export function setupTray(mainWindow: BrowserWindow, appName: string): void {
|
||||
const iconPath = getTrayIcon();
|
||||
if (!iconPath) {
|
||||
console.warn("[tray] No icon found, skipping tray setup");
|
||||
return;
|
||||
}
|
||||
|
||||
const icon = nativeImage.createFromPath(iconPath);
|
||||
tray = new Tray(icon.resize({ width: 16, height: 16 }));
|
||||
tray.setToolTip(appName);
|
||||
|
||||
const contextMenu = Menu.buildFromTemplate([
|
||||
{
|
||||
label: "Show " + appName,
|
||||
click: () => {
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
},
|
||||
},
|
||||
{ type: "separator" },
|
||||
{
|
||||
label: "Quit",
|
||||
click: () => {
|
||||
app.quit();
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
tray.setContextMenu(contextMenu);
|
||||
|
||||
tray.on("click", () => {
|
||||
if (mainWindow.isVisible()) {
|
||||
mainWindow.focus();
|
||||
} else {
|
||||
mainWindow.show();
|
||||
}
|
||||
});
|
||||
|
||||
tray.on("double-click", () => {
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
});
|
||||
}
|
||||
|
||||
function getTrayIcon(): string | null {
|
||||
const candidates = [
|
||||
join(__dirname, "..", "assets", "tray-icon.png"),
|
||||
join(__dirname, "..", "assets", "icon.png"),
|
||||
join(__dirname, "..", "assets", "icon.ico"),
|
||||
];
|
||||
for (const p of candidates) {
|
||||
if (existsSync(p)) return p;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { autoUpdater } from "electron-updater";
|
||||
import { ipcMain, BrowserWindow } from "electron";
|
||||
|
||||
let updateAvailable = false;
|
||||
let updateDownloaded = false;
|
||||
|
||||
export function setupUpdater(appName: string): void {
|
||||
autoUpdater.autoDownload = false;
|
||||
autoUpdater.autoInstallOnAppQuit = true;
|
||||
autoUpdater.logger = {
|
||||
info: (msg) => console.log("[updater]", msg),
|
||||
warn: (msg) => console.warn("[updater]", msg),
|
||||
error: (msg) => console.error("[updater]", msg),
|
||||
debug: (msg) => console.debug("[updater]", msg),
|
||||
};
|
||||
|
||||
autoUpdater.on("checking-for-update", () => {
|
||||
console.log("[updater] Checking for updates...");
|
||||
});
|
||||
|
||||
autoUpdater.on("update-available", (info) => {
|
||||
updateAvailable = true;
|
||||
console.log("[updater] Update available:", info.version);
|
||||
broadcast("updater:available", info);
|
||||
});
|
||||
|
||||
autoUpdater.on("update-not-available", (info) => {
|
||||
console.log("[updater] Up to date:", info.version);
|
||||
});
|
||||
|
||||
autoUpdater.on("download-progress", (progress) => {
|
||||
broadcast("updater:progress", {
|
||||
percent: progress.percent,
|
||||
bytesPerSecond: progress.bytesPerSecond,
|
||||
transferred: progress.transferred,
|
||||
total: progress.total,
|
||||
});
|
||||
});
|
||||
|
||||
autoUpdater.on("update-downloaded", (info) => {
|
||||
updateDownloaded = true;
|
||||
console.log("[updater] Update downloaded:", info.version);
|
||||
broadcast("updater:downloaded", info);
|
||||
});
|
||||
|
||||
autoUpdater.on("error", (err) => {
|
||||
console.error("[updater] Error:", err.message);
|
||||
broadcast("updater:error", err.message);
|
||||
});
|
||||
|
||||
ipcMain.handle("updater:check", async () => {
|
||||
try {
|
||||
const result = await autoUpdater.checkForUpdates();
|
||||
return result?.updateInfo ?? null;
|
||||
} catch (err) {
|
||||
console.error("[updater] Check failed:", err);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("updater:download", async () => {
|
||||
if (!updateAvailable) return;
|
||||
try {
|
||||
await autoUpdater.downloadUpdate();
|
||||
} catch (err) {
|
||||
console.error("[updater] Download failed:", err);
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.on("updater:quitAndInstall", () => {
|
||||
if (updateDownloaded) {
|
||||
autoUpdater.quitAndInstall(false, true);
|
||||
}
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
autoUpdater.checkForUpdates().catch(() => {});
|
||||
}, 10_000);
|
||||
}
|
||||
|
||||
function broadcast(channel: string, data: unknown): void {
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
if (!win.isDestroyed()) {
|
||||
win.webContents.send(channel, data);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { app } from "electron";
|
||||
import { join } from "path";
|
||||
import { existsSync } from "fs";
|
||||
|
||||
// ─── Single Instance Lock ────────────────────────
|
||||
export function checkSingleInstance(): boolean {
|
||||
const gotLock = app.requestSingleInstanceLock();
|
||||
if (!gotLock) {
|
||||
app.quit();
|
||||
return false;
|
||||
}
|
||||
|
||||
app.on("second-instance", (_event, _argv, _cwd) => {
|
||||
const windows = require("electron").BrowserWindow.getAllWindows();
|
||||
if (windows.length > 0) {
|
||||
const win = windows[0];
|
||||
if (win.isMinimized()) win.restore();
|
||||
win.focus();
|
||||
}
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// ─── Dev mode detection ──────────────────────────
|
||||
export function isDev(): boolean {
|
||||
return !app.isPackaged;
|
||||
}
|
||||
|
||||
// ─── Web URL ─────────────────────────────────────
|
||||
export function getWebUrl(dev: boolean, port: number): string {
|
||||
if (dev) {
|
||||
return "http://localhost:" + port;
|
||||
}
|
||||
|
||||
const appPath = app.isPackaged
|
||||
? join(process.resourcesPath, "web")
|
||||
: join(__dirname, "..", "..", "web", "out");
|
||||
|
||||
const staticIndex = join(appPath, "index.html");
|
||||
if (existsSync(staticIndex)) {
|
||||
return staticIndex;
|
||||
}
|
||||
|
||||
const standalone = join(appPath, ".next", "standalone", "server.js");
|
||||
if (existsSync(standalone)) {
|
||||
return "http://localhost:3000";
|
||||
}
|
||||
|
||||
const fallback = join(appPath, "index.html");
|
||||
if (existsSync(fallback)) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
console.warn("[utils] No web build found at", appPath);
|
||||
return "http://localhost:3000";
|
||||
}
|
||||
|
||||
// ─── API Path ────────────────────────────────────
|
||||
export function getApiPath(): string | null {
|
||||
if (app.isPackaged) {
|
||||
const packed = join(process.resourcesPath, "api", "dist", "index.js");
|
||||
if (existsSync(packed)) return packed;
|
||||
const packedSrc = join(process.resourcesPath, "api", "src", "index.js");
|
||||
if (existsSync(packedSrc)) return packedSrc;
|
||||
}
|
||||
|
||||
const devPath = join(__dirname, "..", "..", "api", "dist", "index.js");
|
||||
if (existsSync(devPath)) return devPath;
|
||||
|
||||
const devSrc = join(__dirname, "..", "..", "api", "src", "index.js");
|
||||
if (existsSync(devSrc)) return devSrc;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── Get API port from env ───────────────────────
|
||||
export function getApiPort(): number {
|
||||
return 3001;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.server</key>
|
||||
<true/>
|
||||
<key>com.apple.security.files.user-selected.read-write</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "bookshelf-desktop",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "bookshelf — Desktop Application powered by Electron",
|
||||
"main": "dist/electron/main.js",
|
||||
"scripts": {
|
||||
"dev": "concurrently \"npm run dev:web\" \"npm run dev:api\" \"npm run dev:electron\"",
|
||||
"dev:electron": "tsc && electron .",
|
||||
"dev:web": "cd ../web && npm run dev",
|
||||
"dev:api": "cd ../api && npm run dev",
|
||||
"build": "npm run build:web && npm run build:api && npm run build:electron",
|
||||
"build:web": "cd ../web && npm run build",
|
||||
"build:api": "cd ../api && npm run build",
|
||||
"build:electron": "tsc && electron-builder --config electron-builder.yml",
|
||||
"build:electron:win": "tsc && electron-builder --win --config electron-builder.yml",
|
||||
"build:electron:mac": "tsc && electron-builder --mac --config electron-builder.yml",
|
||||
"build:electron:linux": "tsc && electron-builder --linux --config electron-builder.yml",
|
||||
"pack": "tsc && electron-builder --dir --config electron-builder.yml",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint electron/"
|
||||
},
|
||||
"dependencies": {
|
||||
"electron-updater": "^6.3.9",
|
||||
"electron-store": "^10.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"electron": "^35.0.0",
|
||||
"electron-builder": "^25.1.8",
|
||||
"concurrently": "^9.1.0",
|
||||
"typescript": "^5.7.0",
|
||||
"@types/node": "^22.10.0"
|
||||
},
|
||||
"build": {
|
||||
"productName": "bookshelf",
|
||||
"appId": "com.bookshelf.desktop",
|
||||
"directories": {
|
||||
"output": "release"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node",
|
||||
"lib": [
|
||||
"ES2022"
|
||||
],
|
||||
"outDir": "dist",
|
||||
"rootDir": "electron",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": false,
|
||||
"sourceMap": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"types": [
|
||||
"node"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"electron/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist",
|
||||
"release"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
body {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
import { Sidebar } from "@/components/layout/Sidebar";
|
||||
import { BottomNav } from "@/components/layout/BottomNav";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "BookShelf",
|
||||
description: "一款支持多端同步、Markdown 编辑和标签管理的笔记应用。",
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="zh-CN">
|
||||
<body className="bg-gray-50 min-h-screen">
|
||||
<div className="flex">
|
||||
{/* Desktop Sidebar */}
|
||||
<Sidebar items={[{"name":"首页","href":"/home","icon":"🏠"},{"name":"统计仪表盘","href":"/统计仪表盘","icon":"📋"},{"name":"搜索和筛选","href":"/搜索和筛选","icon":"📅"},{"name":"书籍库","href":"/书籍库","icon":"✏️"},{"name":"阅读状态","href":"/阅读状态","icon":"📸"}] } projectName="BookShelf" />
|
||||
{/* Main Content */}
|
||||
<main className="flex-1 lg:pl-64 pb-20 lg:pb-0">
|
||||
<div className="max-w-2xl mx-auto px-4 py-6">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
{/* Mobile Bottom Nav */}
|
||||
<BottomNav items={[{"name":"首页","href":"/home","icon":"🏠"},{"name":"统计仪表盘","href":"/统计仪表盘","icon":"📋"},{"name":"搜索和筛选","href":"/搜索和筛选","icon":"📅"},{"name":"书籍库","href":"/书籍库","icon":"✏️"}] } />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import Link from "next/link";
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Hero Section */}
|
||||
<section className="bg-gradient-to-br from-blue-500 to-blue-700 rounded-2xl p-6 text-white">
|
||||
<h1 className="text-2xl font-bold">BookShelf</h1>
|
||||
<p className="mt-2 text-blue-100 text-sm">应用首页</p>
|
||||
</section>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<section>
|
||||
<h2 className="text-lg font-semibold text-gray-800 mb-3">快捷入口</h2>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Link key="0" href="/home" className="bg-white rounded-xl border border-gray-100 p-4 text-center hover:shadow-md transition-shadow">
|
||||
<span className="text-2xl block mb-1">📋</span>
|
||||
<span className="text-sm text-gray-600">书籍库</span>
|
||||
</Link>
|
||||
<Link key="1" href="/home" className="bg-white rounded-xl border border-gray-100 p-4 text-center hover:shadow-md transition-shadow">
|
||||
<span className="text-2xl block mb-1">📅</span>
|
||||
<span className="text-sm text-gray-600">阅读状态</span>
|
||||
</Link>
|
||||
<Link key="2" href="/home" className="bg-white rounded-xl border border-gray-100 p-4 text-center hover:shadow-md transition-shadow">
|
||||
<span className="text-2xl block mb-1">📝</span>
|
||||
<span className="text-sm text-gray-600">阅读进度</span>
|
||||
</Link>
|
||||
<Link key="3" href="/home" className="bg-white rounded-xl border border-gray-100 p-4 text-center hover:shadow-md transition-shadow">
|
||||
<span className="text-2xl block mb-1">📸</span>
|
||||
<span className="text-sm text-gray-600">笔记和标注</span>
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Recent Activity / Stats */}
|
||||
<section>
|
||||
<h2 className="text-lg font-semibold text-gray-800 mb-3">今日概览</h2>
|
||||
<div className="bg-white rounded-xl border border-gray-100 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">欢迎使用 BookShelf</p>
|
||||
<p className="text-xs text-gray-400 mt-1">开始探索功能吧</p>
|
||||
</div>
|
||||
<span className="text-3xl">👋</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { EmptyState } from "@/components/ui/EmptyState";
|
||||
import { use书籍库 } from "@/hooks/use书籍库";
|
||||
|
||||
export default function PagePage() {
|
||||
const { data, loading, error, refetch } = use书籍库();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-800">书籍库</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">添加书籍(书名</p>
|
||||
</div>
|
||||
<Button onClick={() => setOpen(true)} variant="primary" size="sm">新增</Button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<Card className="text-center py-8">
|
||||
<p className="text-red-500 text-sm">{error}</p>
|
||||
<Button onClick={refetch} variant="secondary" size="sm" className="mt-2">重试</Button>
|
||||
</Card>
|
||||
) : data.length === 0 ? (
|
||||
<EmptyState
|
||||
icon="📭"
|
||||
title="暂无数据"
|
||||
description="还没有任何记录,点击上方按钮开始"
|
||||
action={<Button onClick={() => setOpen(true)} variant="primary" size="sm">创建第一条</Button>}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{data.map((item: any) => (
|
||||
<Card key={item.id}>
|
||||
<h3 className="font-medium text-gray-800">{item.title || item.name || `Item ${item.id.slice(0, 8)}`}</h3>
|
||||
<p className="text-xs text-gray-400 mt-1">{item.createdAt || ""}</p>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { EmptyState } from "@/components/ui/EmptyState";
|
||||
|
||||
|
||||
export default function PagePage() {
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-800">书评</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">读完后写书评,打分(</p>
|
||||
</div>
|
||||
<Button onClick={() => setOpen(true)} variant="primary" size="sm">新增</Button>
|
||||
</div>
|
||||
|
||||
{<Card>
|
||||
<p className="text-gray-500 text-sm">书评 页面内容</p>
|
||||
<p className="text-xs text-gray-400 mt-1">路由: /书评</p>
|
||||
</Card>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { EmptyState } from "@/components/ui/EmptyState";
|
||||
|
||||
|
||||
export default function PagePage() {
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-800">搜索和筛选</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">按状态/分类/作者/评分筛选。技术要求</p>
|
||||
</div>
|
||||
<Button onClick={() => setOpen(true)} variant="primary" size="sm">新增</Button>
|
||||
</div>
|
||||
|
||||
{<Card>
|
||||
<p className="text-gray-500 text-sm">搜索和筛选 页面内容</p>
|
||||
<p className="text-xs text-gray-400 mt-1">路由: /搜索和筛选</p>
|
||||
</Card>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { EmptyState } from "@/components/ui/EmptyState";
|
||||
import { use笔记和标注 } from "@/hooks/use笔记和标注";
|
||||
|
||||
export default function PagePage() {
|
||||
const { data, loading, error, refetch } = use笔记和标注();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-800">笔记和标注</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">对某一页写笔记,支持高亮段落摘抄。</p>
|
||||
</div>
|
||||
<Button onClick={() => setOpen(true)} variant="primary" size="sm">新增</Button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<Card className="text-center py-8">
|
||||
<p className="text-red-500 text-sm">{error}</p>
|
||||
<Button onClick={refetch} variant="secondary" size="sm" className="mt-2">重试</Button>
|
||||
</Card>
|
||||
) : data.length === 0 ? (
|
||||
<EmptyState
|
||||
icon="📭"
|
||||
title="暂无数据"
|
||||
description="还没有任何记录,点击上方按钮开始"
|
||||
action={<Button onClick={() => setOpen(true)} variant="primary" size="sm">创建第一条</Button>}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{data.map((item: any) => (
|
||||
<Card key={item.id}>
|
||||
<h3 className="font-medium text-gray-800">{item.title || item.name || `Item ${item.id.slice(0, 8)}`}</h3>
|
||||
<p className="text-xs text-gray-400 mt-1">{item.createdAt || ""}</p>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { EmptyState } from "@/components/ui/EmptyState";
|
||||
|
||||
|
||||
export default function PagePage() {
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-800">统计仪表盘</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">年度阅读量</p>
|
||||
</div>
|
||||
<Button onClick={() => setOpen(true)} variant="primary" size="sm">新增</Button>
|
||||
</div>
|
||||
|
||||
{<Card>
|
||||
<p className="text-gray-500 text-sm">统计仪表盘 页面内容</p>
|
||||
<p className="text-xs text-gray-400 mt-1">路由: /统计仪表盘</p>
|
||||
</Card>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { EmptyState } from "@/components/ui/EmptyState";
|
||||
import { use阅读状态 } from "@/hooks/use阅读状态";
|
||||
|
||||
export default function PagePage() {
|
||||
const { data, loading, error, refetch } = use阅读状态();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-800">阅读状态</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">想读/在读/已读,记录开始日期和完成日期。</p>
|
||||
</div>
|
||||
<Button onClick={() => setOpen(true)} variant="primary" size="sm">新增</Button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<Card className="text-center py-8">
|
||||
<p className="text-red-500 text-sm">{error}</p>
|
||||
<Button onClick={refetch} variant="secondary" size="sm" className="mt-2">重试</Button>
|
||||
</Card>
|
||||
) : data.length === 0 ? (
|
||||
<EmptyState
|
||||
icon="📭"
|
||||
title="暂无数据"
|
||||
description="还没有任何记录,点击上方按钮开始"
|
||||
action={<Button onClick={() => setOpen(true)} variant="primary" size="sm">创建第一条</Button>}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{data.map((item: any) => (
|
||||
<Card key={item.id}>
|
||||
<h3 className="font-medium text-gray-800">{item.title || item.name || `Item ${item.id.slice(0, 8)}`}</h3>
|
||||
<p className="text-xs text-gray-400 mt-1">{item.createdAt || ""}</p>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
import { Button } from "@/components/ui/Button";
|
||||
import { EmptyState } from "@/components/ui/EmptyState";
|
||||
import { use阅读进度 } from "@/hooks/use阅读进度";
|
||||
|
||||
export default function PagePage() {
|
||||
const { data, loading, error, refetch } = use阅读进度();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-800">阅读进度</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">更新当前页数,自动计算完成百分比。</p>
|
||||
</div>
|
||||
<Button onClick={() => setOpen(true)} variant="primary" size="sm">新增</Button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<Card className="text-center py-8">
|
||||
<p className="text-red-500 text-sm">{error}</p>
|
||||
<Button onClick={refetch} variant="secondary" size="sm" className="mt-2">重试</Button>
|
||||
</Card>
|
||||
) : data.length === 0 ? (
|
||||
<EmptyState
|
||||
icon="📭"
|
||||
title="暂无数据"
|
||||
description="还没有任何记录,点击上方按钮开始"
|
||||
action={<Button onClick={() => setOpen(true)} variant="primary" size="sm">创建第一条</Button>}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{data.map((item: any) => (
|
||||
<Card key={item.id}>
|
||||
<h3 className="font-medium text-gray-800">{item.title || item.name || `Item ${item.id.slice(0, 8)}`}</h3>
|
||||
<p className="text-xs text-gray-400 mt-1">{item.createdAt || ""}</p>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
interface NavItem {
|
||||
name: string;
|
||||
href: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export function BottomNav({ items }: { items: NavItem[] }) {
|
||||
const pathname = usePathname();
|
||||
|
||||
if (!items.length) return null;
|
||||
|
||||
return (
|
||||
<nav className="lg:hidden fixed bottom-0 inset-x-0 bg-white border-t border-gray-100 z-40">
|
||||
<div className="flex items-center justify-around h-16">
|
||||
{items.map((item) => {
|
||||
const active = pathname === item.href;
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`flex flex-col items-center gap-0.5 px-3 py-1 ${
|
||||
active ? "text-blue-600" : "text-gray-400"
|
||||
}`}
|
||||
>
|
||||
<span className="text-xl">{item.icon}</span>
|
||||
<span className="text-[10px]">{item.name}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
interface NavItem {
|
||||
name: string;
|
||||
href: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
interface SidebarProps {
|
||||
items: NavItem[];
|
||||
projectName: string;
|
||||
}
|
||||
|
||||
export function Sidebar({ items, projectName }: SidebarProps) {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<aside className="hidden lg:flex lg:flex-col lg:w-64 lg:fixed lg:inset-y-0 bg-white border-r border-gray-100">
|
||||
<div className="px-6 py-5 border-b border-gray-50">
|
||||
<h1 className="text-lg font-bold text-gray-800">{projectName}</h1>
|
||||
</div>
|
||||
<nav className="flex-1 px-3 py-4 space-y-1 overflow-y-auto">
|
||||
{items.map((item) => {
|
||||
const active = pathname === item.href;
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm transition-colors ${
|
||||
active ? "bg-blue-50 text-blue-700 font-medium" : "text-gray-600 hover:bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
<span className="text-lg">{item.icon}</span>
|
||||
<span>{item.name}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { Note } from "@/types";
|
||||
import { Card } from "@/components/ui/Card";
|
||||
|
||||
interface NoteCardProps {
|
||||
note: Note;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export function NoteCard({ note, onClick }: NoteCardProps) {
|
||||
return (
|
||||
<Card onClick={onClick}>
|
||||
<h3 className="font-medium text-gray-800 truncate">{note.title || "无标题"}</h3>
|
||||
<p className="text-sm text-gray-500 mt-1 line-clamp-2">{note.content?.slice(0, 120) || "空笔记"}</p>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
{note.isMarkdown && <span className="text-xs px-1.5 py-0.5 bg-purple-100 text-purple-600 rounded">MD</span>}
|
||||
<span className="text-xs text-gray-400">{new Date(note.updatedAt).toLocaleDateString("zh-CN")}</span>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import { type ButtonHTMLAttributes } from "react";
|
||||
|
||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: "primary" | "secondary" | "danger" | "ghost";
|
||||
size?: "sm" | "md" | "lg";
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export function Button({ variant = "primary", size = "md", loading, children, className = "", disabled, ...props }: ButtonProps) {
|
||||
const base = "inline-flex items-center justify-center font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed";
|
||||
const variants = {
|
||||
primary: "bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500",
|
||||
secondary: "bg-gray-100 text-gray-700 hover:bg-gray-200 focus:ring-gray-400",
|
||||
danger: "bg-red-600 text-white hover:bg-red-700 focus:ring-red-500",
|
||||
ghost: "text-gray-600 hover:bg-gray-100 focus:ring-gray-400",
|
||||
};
|
||||
const sizes = {
|
||||
sm: "px-3 py-1.5 text-sm",
|
||||
md: "px-4 py-2 text-sm",
|
||||
lg: "px-6 py-3 text-base",
|
||||
};
|
||||
|
||||
return (
|
||||
<button className={`${base} ${variants[variant]} ${sizes[size]} ${className}`} disabled={disabled || loading} {...props}>
|
||||
{loading && <svg className="animate-spin -ml-1 mr-2 h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" /><path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" /></svg>}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
interface CardProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export function Card({ children, className = "", onClick }: CardProps) {
|
||||
return (
|
||||
<div
|
||||
className={`bg-white rounded-xl shadow-sm border border-gray-100 p-4 ${onClick ? "cursor-pointer hover:shadow-md transition-shadow" : ""} ${className}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
interface EmptyStateProps {
|
||||
icon?: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: ReactNode;
|
||||
}
|
||||
|
||||
export function EmptyState({ icon = "📭", title, description, action }: EmptyStateProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<span className="text-5xl mb-4">{icon}</span>
|
||||
<h3 className="text-lg font-medium text-gray-700 mb-1">{title}</h3>
|
||||
{description && <p className="text-sm text-gray-500 mb-4">{description}</p>}
|
||||
{action}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { type InputHTMLAttributes } from "react";
|
||||
|
||||
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
label?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function Input({ label, error, className = "", id, ...props }: InputProps) {
|
||||
return (
|
||||
<div className="w-full">
|
||||
{label && <label htmlFor={id} className="block text-sm font-medium text-gray-700 mb-1">{label}</label>}
|
||||
<input
|
||||
id={id}
|
||||
className={`w-full px-3 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent ${error ? "border-red-300" : "border-gray-300"} ${className}`}
|
||||
{...props}
|
||||
/>
|
||||
{error && <p className="mt-1 text-xs text-red-500">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, type ReactNode } from "react";
|
||||
|
||||
interface ModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function Modal({ open, onClose, title, children }: ModalProps) {
|
||||
useEffect(() => {
|
||||
if (open) document.body.style.overflow = "hidden";
|
||||
else document.body.style.overflow = "";
|
||||
return () => { document.body.style.overflow = ""; };
|
||||
}, [open]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="absolute inset-0 bg-black/40" onClick={onClose} />
|
||||
<div className="relative bg-white rounded-2xl shadow-xl max-w-lg w-full mx-4 p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold">{title}</h2>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-xl leading-none">×</button>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
|
||||
// Generic data type for auth
|
||||
type Auth = Record<string, unknown>;
|
||||
|
||||
/**
|
||||
* Fetch and manage auth data.
|
||||
*/
|
||||
export function useAuth() {
|
||||
const [data, setData] = useState<Auth[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const res = await fetch(`/api/auth`);
|
||||
const json = await res.json();
|
||||
setData(Array.isArray(json) ? json : json?.data || []);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to load data");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
return { data, loading, error, refetch: fetchData };
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
export function useDebounce<T>(value: T, delay: number = 300): T {
|
||||
const [debounced, setDebounced] = useState(value);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebounced(value), delay);
|
||||
return () => clearTimeout(timer);
|
||||
}, [value, delay]);
|
||||
|
||||
return debounced;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
|
||||
interface UseFormOptions<T> {
|
||||
initialValues: T;
|
||||
onSubmit: (values: T) => Promise<void>;
|
||||
}
|
||||
|
||||
export function useForm<T extends Record<string, unknown>>({ initialValues, onSubmit }: UseFormOptions<T>) {
|
||||
const [values, setValues] = useState<T>(initialValues);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const setField = useCallback(<K extends keyof T>(field: K, value: T[K]) => {
|
||||
setValues(prev => ({ ...prev, [field]: value }));
|
||||
}, []);
|
||||
|
||||
const handleSubmit = useCallback(async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
await onSubmit(values);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Submit failed");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}, [values, onSubmit]);
|
||||
|
||||
return { values, setField, submitting, error, handleSubmit };
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
|
||||
// Generic data type for 阅读进度
|
||||
type Item = Record<string, unknown>;
|
||||
|
||||
/**
|
||||
* Fetch and manage 阅读进度 data.
|
||||
*/
|
||||
export function useItem() {
|
||||
const [data, setData] = useState<Item[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const res = await fetch(`/api/阅读进度`);
|
||||
const json = await res.json();
|
||||
setData(Array.isArray(json) ? json : json?.data || []);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to load data");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
return { data, loading, error, refetch: fetchData };
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "bookshelf",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "^15.0.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"typescript": "^5.6.0",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"postcss": "^8.4.0",
|
||||
"autoprefixer": "^10.4.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
// Auto-generated API client for BookShelf
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001/api";
|
||||
|
||||
interface RequestOptions extends RequestInit {
|
||||
params?: Record<string, string | number>;
|
||||
}
|
||||
|
||||
async function request<T>(endpoint: string, options: RequestOptions = {}): Promise<T> {
|
||||
const { params, ...init } = options;
|
||||
let url = `${API_BASE}${endpoint}`;
|
||||
|
||||
if (params) {
|
||||
const searchParams = new URLSearchParams();
|
||||
Object.entries(params).forEach(([k, v]) => searchParams.set(k, String(v)));
|
||||
url += `?${searchParams.toString()}`;
|
||||
}
|
||||
|
||||
const res = await fetch(url, {
|
||||
...init,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...init.headers,
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ message: res.statusText }));
|
||||
throw new Error(err.message || `API Error: ${res.status}`);
|
||||
}
|
||||
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T>(url: string, params?: Record<string, string | number>) =>
|
||||
request<T>(url, { method: "GET", params }),
|
||||
|
||||
post: <T>(url: string, data?: unknown) =>
|
||||
request<T>(url, { method: "POST", body: JSON.stringify(data) }),
|
||||
|
||||
put: <T>(url: string, data?: unknown) =>
|
||||
request<T>(url, { method: "PUT", body: JSON.stringify(data) }),
|
||||
|
||||
delete: <T>(url: string) =>
|
||||
request<T>(url, { method: "DELETE" }),
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
// Auto-generated auth service
|
||||
import { api } from "./api";
|
||||
|
||||
export function postlogin(data: Record<string, unknown>) {
|
||||
return api.post("/api/auth", data);
|
||||
}
|
||||
|
||||
export function getme() {
|
||||
return api.get("/api/auth");
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// Auto-generated 书籍库 service
|
||||
import { api } from "./api";
|
||||
|
||||
export function get() {
|
||||
return api.get("/api/书籍库");
|
||||
}
|
||||
|
||||
export function post(data: Record<string, unknown>) {
|
||||
return api.post("/api/书籍库", data);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// Auto-generated 笔记和标注 service
|
||||
import { api } from "./api";
|
||||
|
||||
export function get() {
|
||||
return api.get("/api/笔记和标注");
|
||||
}
|
||||
|
||||
export function post(data: Record<string, unknown>) {
|
||||
return api.post("/api/笔记和标注", data);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// Auto-generated 阅读状态 service
|
||||
import { api } from "./api";
|
||||
|
||||
export function get() {
|
||||
return api.get("/api/阅读状态");
|
||||
}
|
||||
|
||||
export function post(data: Record<string, unknown>) {
|
||||
return api.post("/api/阅读状态", data);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// Auto-generated 阅读进度 service
|
||||
import { api } from "./api";
|
||||
|
||||
export function get() {
|
||||
return api.get("/api/阅读进度");
|
||||
}
|
||||
|
||||
export function post(data: Record<string, unknown>) {
|
||||
return api.post("/api/阅读进度", data);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { Config } from "tailwindcss";
|
||||
|
||||
const config: Config = {
|
||||
content: ["./app/**/*.{js,ts,jsx,tsx,mdx}", "./components/**/*.{js,ts,jsx,tsx,mdx}", "./hooks/**/*.{js,ts,jsx,tsx,mdx}", "./services/**/*.{js,ts,jsx,tsx,mdx}"],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
export default config;
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// Auto-generated types for BookShelf
|
||||
|
||||
// ─── Base ───────────────────────────────────────────
|
||||
export interface User {
|
||||
id: string;
|
||||
phone: string;
|
||||
nickname: string;
|
||||
avatarUrl: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface Note {
|
||||
id: string;
|
||||
userId: string;
|
||||
title: string;
|
||||
content: string;
|
||||
isMarkdown: boolean;
|
||||
folderId: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface Tag {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
// ─── API Responses ──────────────────────────────────
|
||||
export interface ApiResponse<T> {
|
||||
data: T;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> extends ApiResponse<T[]> {
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
export interface ApiError {
|
||||
code: string;
|
||||
message: string;
|
||||
details?: Record<string, string[]>;
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,33 @@
|
||||
# bookshelf — Release Package
|
||||
|
||||
> Version: 0.1.0 | Build: #1
|
||||
> Generated: 2026-06-05T10:19:15.026Z
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
release/
|
||||
├── version.json — Version metadata
|
||||
├── build-info.json — Build environment info
|
||||
├── README.md — This file
|
||||
├── manifests/
|
||||
│ └── manifest.json — Release manifest with file hashes
|
||||
├── checksums/
|
||||
│ └── checksums.txt — SHA256 checksums for verification
|
||||
├── release-notes/
|
||||
│ └── release-notes.md — Human-readable release notes
|
||||
├── windows/ — Windows installers
|
||||
├── macos/ — macOS disk images
|
||||
└── linux/ — Linux packages
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
# Verify checksums
|
||||
cd release/checksums
|
||||
shasum -a 256 -c checksums.txt
|
||||
```
|
||||
|
||||
---
|
||||
*Generated by Release Builder Agent — SF-07*
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"nodeVersion": "v26.0.0",
|
||||
"generatorVersion": "1.0.0",
|
||||
"buildTime": "2026-06-05T10:19:15.026Z",
|
||||
"domain": "unknown",
|
||||
"entityCount": 5,
|
||||
"routeCount": 1,
|
||||
"screenCount": 7,
|
||||
"platforms": [
|
||||
"windows",
|
||||
"macos",
|
||||
"linux"
|
||||
],
|
||||
"project": "bookshelf",
|
||||
"version": "0.1.0"
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
2f0a062fef294d84784e049d67cd2d82c405574a2a3fdeee53186dd66f2a2c3b windows/bookshelf-setup-0.1.0.exe
|
||||
cbd5a847c9baa9145519c8b71ed909acec5aff72d4cacd39cb852306f99ea53b windows/bookshelf-0.1.0-portable.exe
|
||||
647033f5354dcfc6765049116226e1c83be4629b69603e3713282b582e7eca9f macos/bookshelf-0.1.0.dmg
|
||||
bd40f41f088bee8366631b5ba2f3310200727b1d4fdff9bc4ebfd5850aa2b7b2 macos/bookshelf-0.1.0-arm64.dmg
|
||||
5309f4c06d660d02a236d3888cba01cc0c9d3687189cf00a62a432184e7ad0d1 linux/bookshelf-0.1.0.AppImage
|
||||
07c8c69cbd2843a24010dcd1a717e38bfdc85af2b98702ab798b93a4f412b5ee linux/bookshelf_0.1.0_amd64.deb
|
||||
@@ -0,0 +1,3 @@
|
||||
[PLACEHOLDER] bookshelf v0.1.0 Linux AppImage
|
||||
This is a placeholder file for the Linux AppImage.
|
||||
Generated: 2026-06-05T10:19:15.026Z
|
||||
@@ -0,0 +1,3 @@
|
||||
[PLACEHOLDER] bookshelf v0.1.0 Linux Debian Package
|
||||
This is a placeholder file for the Linux .deb package.
|
||||
Generated: 2026-06-05T10:19:15.026Z
|
||||
@@ -0,0 +1,3 @@
|
||||
[PLACEHOLDER] bookshelf v0.1.0 macOS ARM64 Disk Image
|
||||
This is a placeholder file for the macOS ARM64 DMG.
|
||||
Generated: 2026-06-05T10:19:15.026Z
|
||||
@@ -0,0 +1,3 @@
|
||||
[PLACEHOLDER] bookshelf v0.1.0 macOS Disk Image
|
||||
This is a placeholder file for the macOS DMG installer.
|
||||
Generated: 2026-06-05T10:19:15.026Z
|
||||
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"project": "bookshelf",
|
||||
"version": "0.1.0",
|
||||
"buildNumber": 1,
|
||||
"generatedAt": "2026-06-05T10:19:15.026Z",
|
||||
"generator": "release-builder-agent",
|
||||
"generatorVersion": "1.0.0",
|
||||
"platforms": [
|
||||
"windows",
|
||||
"macos",
|
||||
"linux"
|
||||
],
|
||||
"files": [
|
||||
{
|
||||
"path": "windows/bookshelf-setup-0.1.0.exe",
|
||||
"size": 144,
|
||||
"sha256": "2f0a062fef294d84784e049d67cd2d82c405574a2a3fdeee53186dd66f2a2c3b"
|
||||
},
|
||||
{
|
||||
"path": "windows/bookshelf-0.1.0-portable.exe",
|
||||
"size": 148,
|
||||
"sha256": "cbd5a847c9baa9145519c8b71ed909acec5aff72d4cacd39cb852306f99ea53b"
|
||||
},
|
||||
{
|
||||
"path": "macos/bookshelf-0.1.0.dmg",
|
||||
"size": 140,
|
||||
"sha256": "647033f5354dcfc6765049116226e1c83be4629b69603e3713282b582e7eca9f"
|
||||
},
|
||||
{
|
||||
"path": "macos/bookshelf-0.1.0-arm64.dmg",
|
||||
"size": 142,
|
||||
"sha256": "bd40f41f088bee8366631b5ba2f3310200727b1d4fdff9bc4ebfd5850aa2b7b2"
|
||||
},
|
||||
{
|
||||
"path": "linux/bookshelf-0.1.0.AppImage",
|
||||
"size": 133,
|
||||
"sha256": "5309f4c06d660d02a236d3888cba01cc0c9d3687189cf00a62a432184e7ad0d1"
|
||||
},
|
||||
{
|
||||
"path": "linux/bookshelf_0.1.0_amd64.deb",
|
||||
"size": 143,
|
||||
"sha256": "07c8c69cbd2843a24010dcd1a717e38bfdc85af2b98702ab798b93a4f412b5ee"
|
||||
}
|
||||
],
|
||||
"totalFiles": 6,
|
||||
"totalSize": 850
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
# bookshelf v0.1.0
|
||||
|
||||
> Release Builder Agent — SF-07
|
||||
> Generated: 2026-06-05 10:19:15 UTC
|
||||
|
||||
## Version
|
||||
|
||||
- **Name:** bookshelf
|
||||
- **Version:** 0.1.0
|
||||
- **Build:** #1
|
||||
|
||||
## Features
|
||||
|
||||
- auth
|
||||
- note_tags
|
||||
- notes
|
||||
- tags
|
||||
- users
|
||||
|
||||
## Build Info
|
||||
|
||||
- **Build Time:** 2026-06-05T10:19:15.026Z
|
||||
- **Generator:** release-builder-agent v1.0.0
|
||||
- **Node Version:** v26.0.0
|
||||
|
||||
## Platforms
|
||||
|
||||
- ✅ windows
|
||||
- ✅ macos
|
||||
- ✅ linux
|
||||
|
||||
## Installation
|
||||
|
||||
### Windows
|
||||
```
|
||||
Download the .exe installer from release/windows/
|
||||
```
|
||||
|
||||
### macOS
|
||||
```
|
||||
Download the .dmg from release/macos/
|
||||
```
|
||||
|
||||
### Linux
|
||||
```
|
||||
Download the .AppImage from release/linux/
|
||||
```
|
||||
|
||||
## Checksums
|
||||
|
||||
See `checksums/checksums.txt` for SHA256 verification.
|
||||
|
||||
---
|
||||
*Generated by Release Builder Agent — SF-07*
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "bookshelf",
|
||||
"version": "0.1.0",
|
||||
"buildNumber": 1,
|
||||
"platforms": [
|
||||
"windows",
|
||||
"macos",
|
||||
"linux"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
[PLACEHOLDER] bookshelf v0.1.0 Windows Portable
|
||||
This is a placeholder file for the Windows portable executable.
|
||||
Generated: 2026-06-05T10:19:15.026Z
|
||||
@@ -0,0 +1,3 @@
|
||||
[PLACEHOLDER] bookshelf v0.1.0 Windows Installer
|
||||
This is a placeholder file for the Windows NSIS installer.
|
||||
Generated: 2026-06-05T10:19:15.025Z
|
||||
Reference in New Issue
Block a user