Scaffold Reading Notes app: React/Vite PWA client, Express server with Nextcloud Collectives integration
Notes are saved as markdown pages in a Nextcloud collective via the Collectives OCS API (page metadata/creation) and WebDAV (content read/append), following the same npm-workspaces + session-auth architecture as CityTracker. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NvmYFQSMqs2N3q2zUDNTVn
This commit is contained in:
parent
61901071f7
commit
73ed2e2cf1
44 changed files with 10416 additions and 1 deletions
20
.env.example
Normal file
20
.env.example
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
# App login
|
||||||
|
SESSION_SECRET=change-me-to-a-long-random-string
|
||||||
|
ADMIN_USERNAME=admin
|
||||||
|
ADMIN_PASSWORD=change-me
|
||||||
|
SECURE_COOKIES=false
|
||||||
|
|
||||||
|
# Nextcloud connection
|
||||||
|
# Generate an app password in Nextcloud: Settings -> Security -> App passwords
|
||||||
|
NEXTCLOUD_URL=https://cloud.example.com
|
||||||
|
NEXTCLOUD_USERNAME=your-nextcloud-username
|
||||||
|
NEXTCLOUD_APP_PASSWORD=your-app-password
|
||||||
|
|
||||||
|
# Target collective (Nextcloud Collectives app)
|
||||||
|
COLLECTIVE_NAME=Your Collective Name
|
||||||
|
PARENT_PAGE_TITLE=Reading Notes
|
||||||
|
|
||||||
|
# Optional
|
||||||
|
PORT=3000
|
||||||
|
HOST_PORT=3000
|
||||||
|
BOOK_CACHE_TTL_MS=60000
|
||||||
22
.eslintrc.json
Normal file
22
.eslintrc.json
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
{
|
||||||
|
"root": true,
|
||||||
|
"parser": "@typescript-eslint/parser",
|
||||||
|
"plugins": ["@typescript-eslint"],
|
||||||
|
"extends": [
|
||||||
|
"eslint:recommended",
|
||||||
|
"plugin:@typescript-eslint/recommended"
|
||||||
|
],
|
||||||
|
"env": {
|
||||||
|
"es2022": true,
|
||||||
|
"node": true,
|
||||||
|
"browser": true
|
||||||
|
},
|
||||||
|
"parserOptions": {
|
||||||
|
"ecmaVersion": 2022,
|
||||||
|
"sourceType": "module"
|
||||||
|
},
|
||||||
|
"ignorePatterns": ["dist", "node_modules"],
|
||||||
|
"rules": {
|
||||||
|
"@typescript-eslint/no-unused-vars": ["warn", { "argsIgnorePattern": "^_" }]
|
||||||
|
}
|
||||||
|
}
|
||||||
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
|
*.local
|
||||||
|
.DS_Store
|
||||||
|
npm-debug.log*
|
||||||
27
Dockerfile
Normal file
27
Dockerfile
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
FROM node:20-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
COPY shared/package.json shared/package.json
|
||||||
|
COPY server/package.json server/package.json
|
||||||
|
COPY client/package.json client/package.json
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM node:20-alpine AS runtime
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
COPY shared/package.json shared/package.json
|
||||||
|
COPY server/package.json server/package.json
|
||||||
|
RUN npm ci --omit=dev --workspace=@notes/shared --workspace=@notes/server
|
||||||
|
|
||||||
|
COPY --from=build /app/shared/dist shared/dist
|
||||||
|
COPY --from=build /app/server/dist server/dist
|
||||||
|
COPY --from=build /app/client/dist client/dist
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
CMD ["node", "server/dist/index.js"]
|
||||||
34
README.md
34
README.md
|
|
@ -1,3 +1,35 @@
|
||||||
# Notes
|
# Notes
|
||||||
|
|
||||||
Note taking app with sync to a wiki / md
|
A small PWA for jotting down short notes while reading — e.g. an interesting sentence from a book. Each note can optionally be tagged with a book/topic (existing or new) and a page number. Notes are saved as markdown directly into pages of a Nextcloud **Collective**, one page per book/topic, nested under a parent page (default: "Reading Notes"). Untagged notes are appended to that parent page itself.
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
npm workspaces monorepo:
|
||||||
|
|
||||||
|
- `shared/` — TypeScript types & Zod schemas shared by client and server
|
||||||
|
- `server/` — Express API: session auth, and a Nextcloud integration that talks to both the Collectives OCS API (page metadata/creation) and WebDAV (page content read/append)
|
||||||
|
- `client/` — React + Vite PWA
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
1. Copy `.env.example` to `.env` and fill in:
|
||||||
|
- `SESSION_SECRET`, `ADMIN_USERNAME`, `ADMIN_PASSWORD` — the app's own login
|
||||||
|
- `NEXTCLOUD_URL`, `NEXTCLOUD_USERNAME`, `NEXTCLOUD_APP_PASSWORD` — generate an app password under Nextcloud Settings → Security → App passwords
|
||||||
|
- `COLLECTIVE_NAME` — the exact name of an existing collective the user has access to
|
||||||
|
2. Install dependencies: `npm install`
|
||||||
|
3. Run in dev mode: `npm run dev` (client on `http://localhost:5173`, proxies `/api` to the server on port 3000)
|
||||||
|
|
||||||
|
## Build & run
|
||||||
|
|
||||||
|
```
|
||||||
|
npm run build
|
||||||
|
npm run start
|
||||||
|
```
|
||||||
|
|
||||||
|
## Docker
|
||||||
|
|
||||||
|
```
|
||||||
|
docker compose up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Reads secrets from `.env` in the project root (same variables as above).
|
||||||
|
|
|
||||||
13
client/index.html
Normal file
13
client/index.html
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||||
|
<meta name="theme-color" content="#1f6feb" />
|
||||||
|
<title>Reading Notes</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
26
client/package.json
Normal file
26
client/package.json
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
{
|
||||||
|
"name": "@notes/client",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -p tsconfig.json && vite build",
|
||||||
|
"clean": "rimraf dist"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@notes/shared": "*",
|
||||||
|
"@tanstack/react-query": "^5.51.23",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.3.3",
|
||||||
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
|
"rimraf": "^5.0.9",
|
||||||
|
"typescript": "^5.5.4",
|
||||||
|
"vite": "^6.0.0",
|
||||||
|
"vite-plugin-pwa": "^1.3.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
client/public/icons/icon-192.png
Normal file
BIN
client/public/icons/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 413 B |
BIN
client/public/icons/icon-512.png
Normal file
BIN
client/public/icons/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.5 KiB |
32
client/scripts/gen-icons.py
Normal file
32
client/scripts/gen-icons.py
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
import struct
|
||||||
|
import zlib
|
||||||
|
import os
|
||||||
|
|
||||||
|
def make_png(path, size, rgb):
|
||||||
|
width = height = size
|
||||||
|
r, g, b = rgb
|
||||||
|
raw = bytearray()
|
||||||
|
for _ in range(height):
|
||||||
|
raw.append(0) # filter type 0 for each scanline
|
||||||
|
for _ in range(width):
|
||||||
|
raw += bytes([r, g, b])
|
||||||
|
compressed = zlib.compress(bytes(raw), 9)
|
||||||
|
|
||||||
|
def chunk(tag, data):
|
||||||
|
return struct.pack(">I", len(data)) + tag + data + struct.pack(
|
||||||
|
">I", zlib.crc32(tag + data) & 0xFFFFFFFF
|
||||||
|
)
|
||||||
|
|
||||||
|
sig = b"\x89PNG\r\n\x1a\n"
|
||||||
|
ihdr = struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0)
|
||||||
|
png = sig + chunk(b"IHDR", ihdr) + chunk(b"IDAT", compressed) + chunk(b"IEND", b"")
|
||||||
|
|
||||||
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||||
|
with open(path, "wb") as f:
|
||||||
|
f.write(png)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
color = (31, 111, 235) # brand blue
|
||||||
|
make_png("public/icons/icon-192.png", 192, color)
|
||||||
|
make_png("public/icons/icon-512.png", 512, color)
|
||||||
|
print("Icons generated")
|
||||||
29
client/src/App.tsx
Normal file
29
client/src/App.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { getSession } from "./api/auth";
|
||||||
|
import { LoginPage } from "./pages/LoginPage";
|
||||||
|
import { NoteFormPage } from "./pages/NoteFormPage";
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const sessionQuery = useQuery({ queryKey: ["session"], queryFn: getSession });
|
||||||
|
|
||||||
|
if (sessionQuery.isLoading) {
|
||||||
|
return (
|
||||||
|
<main className="centered">
|
||||||
|
<p>Loading…</p>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sessionQuery.data?.authenticated) {
|
||||||
|
return (
|
||||||
|
<LoginPage onLoggedIn={() => queryClient.invalidateQueries({ queryKey: ["session"] })} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NoteFormPage
|
||||||
|
onLoggedOut={() => queryClient.invalidateQueries({ queryKey: ["session"] })}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
17
client/src/api/auth.ts
Normal file
17
client/src/api/auth.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
import type { LoginInput } from "@notes/shared";
|
||||||
|
import { apiFetch } from "./client";
|
||||||
|
|
||||||
|
export function login(input: LoginInput) {
|
||||||
|
return apiFetch<{ ok: boolean }>("/api/login", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(input)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function logout() {
|
||||||
|
return apiFetch<{ ok: boolean }>("/api/logout", { method: "POST" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSession() {
|
||||||
|
return apiFetch<{ authenticated: boolean }>("/api/session");
|
||||||
|
}
|
||||||
6
client/src/api/books.ts
Normal file
6
client/src/api/books.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
import type { Book } from "@notes/shared";
|
||||||
|
import { apiFetch } from "./client";
|
||||||
|
|
||||||
|
export function fetchBooks() {
|
||||||
|
return apiFetch<Book[]>("/api/books");
|
||||||
|
}
|
||||||
20
client/src/api/client.ts
Normal file
20
client/src/api/client.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
export async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
const res = await fetch(path, {
|
||||||
|
...init,
|
||||||
|
credentials: "include",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...init?.headers
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(body.error ?? `Request failed (${res.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.status === 204) {
|
||||||
|
return undefined as T;
|
||||||
|
}
|
||||||
|
return (await res.json()) as T;
|
||||||
|
}
|
||||||
9
client/src/api/notes.ts
Normal file
9
client/src/api/notes.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import type { NoteInput } from "@notes/shared";
|
||||||
|
import { apiFetch } from "./client";
|
||||||
|
|
||||||
|
export function saveNote(input: NoteInput) {
|
||||||
|
return apiFetch<{ ok: boolean }>("/api/notes", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(input)
|
||||||
|
});
|
||||||
|
}
|
||||||
22
client/src/main.tsx
Normal file
22
client/src/main.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
import React from "react";
|
||||||
|
import ReactDOM from "react-dom/client";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { App } from "./App";
|
||||||
|
import "./styles.css";
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
retry: false,
|
||||||
|
refetchOnWindowFocus: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<App />
|
||||||
|
</QueryClientProvider>
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
51
client/src/pages/LoginPage.tsx
Normal file
51
client/src/pages/LoginPage.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
import { useState, type FormEvent } from "react";
|
||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
import { login } from "../api/auth";
|
||||||
|
|
||||||
|
export function LoginPage({ onLoggedIn }: { onLoggedIn: () => void }) {
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
|
||||||
|
const loginMutation = useMutation({
|
||||||
|
mutationFn: login,
|
||||||
|
onSuccess: onLoggedIn
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleSubmit(e: FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
loginMutation.mutate({ username, password });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="centered">
|
||||||
|
<form className="card" onSubmit={handleSubmit}>
|
||||||
|
<h1>Reading Notes</h1>
|
||||||
|
<label>
|
||||||
|
Username
|
||||||
|
<input
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
autoComplete="username"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Password
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
autoComplete="current-password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{loginMutation.isError && (
|
||||||
|
<p className="error">{(loginMutation.error as Error).message}</p>
|
||||||
|
)}
|
||||||
|
<button type="submit" disabled={loginMutation.isPending}>
|
||||||
|
{loginMutation.isPending ? "Signing in…" : "Sign in"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
97
client/src/pages/NoteFormPage.tsx
Normal file
97
client/src/pages/NoteFormPage.tsx
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
import { useState, type FormEvent } from "react";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { fetchBooks } from "../api/books";
|
||||||
|
import { saveNote } from "../api/notes";
|
||||||
|
import { logout } from "../api/auth";
|
||||||
|
|
||||||
|
export function NoteFormPage({ onLoggedOut }: { onLoggedOut: () => void }) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const booksQuery = useQuery({ queryKey: ["books"], queryFn: fetchBooks });
|
||||||
|
|
||||||
|
const [book, setBook] = useState("");
|
||||||
|
const [page, setPage] = useState("");
|
||||||
|
const [text, setText] = useState("");
|
||||||
|
|
||||||
|
const saveNoteMutation = useMutation({
|
||||||
|
mutationFn: saveNote,
|
||||||
|
onSuccess: () => {
|
||||||
|
setText("");
|
||||||
|
setPage("");
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["books"] });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const logoutMutation = useMutation({
|
||||||
|
mutationFn: logout,
|
||||||
|
onSuccess: onLoggedOut
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleSubmit(e: FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
const trimmedBook = book.trim();
|
||||||
|
const trimmedText = text.trim();
|
||||||
|
if (!trimmedText) return;
|
||||||
|
|
||||||
|
saveNoteMutation.mutate({
|
||||||
|
book: trimmedBook || undefined,
|
||||||
|
page: page ? Number(page) : undefined,
|
||||||
|
text: trimmedText
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="centered">
|
||||||
|
<form className="card" onSubmit={handleSubmit}>
|
||||||
|
<div className="header-row">
|
||||||
|
<h1>New note</h1>
|
||||||
|
<button type="button" className="link" onClick={() => logoutMutation.mutate()}>
|
||||||
|
Sign out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Note
|
||||||
|
<textarea
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => setText(e.target.value)}
|
||||||
|
rows={5}
|
||||||
|
placeholder="An interesting sentence…"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Book / topic (optional)
|
||||||
|
<input
|
||||||
|
list="book-options"
|
||||||
|
value={book}
|
||||||
|
onChange={(e) => setBook(e.target.value)}
|
||||||
|
placeholder="Select or type a new one"
|
||||||
|
/>
|
||||||
|
<datalist id="book-options">
|
||||||
|
{booksQuery.data?.map((b) => <option key={b.title} value={b.title} />)}
|
||||||
|
</datalist>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Page (optional)
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={page}
|
||||||
|
onChange={(e) => setPage(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{saveNoteMutation.isError && (
|
||||||
|
<p className="error">{(saveNoteMutation.error as Error).message}</p>
|
||||||
|
)}
|
||||||
|
{saveNoteMutation.isSuccess && <p className="success">Saved.</p>}
|
||||||
|
|
||||||
|
<button type="submit" disabled={saveNoteMutation.isPending}>
|
||||||
|
{saveNoteMutation.isPending ? "Saving…" : "Save note"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
121
client/src/styles.css
Normal file
121
client/src/styles.css
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
:root {
|
||||||
|
color-scheme: light dark;
|
||||||
|
--bg: #f5f5f7;
|
||||||
|
--card-bg: #ffffff;
|
||||||
|
--text: #1a1a1a;
|
||||||
|
--muted: #6b6b6b;
|
||||||
|
--accent: #1f6feb;
|
||||||
|
--border: #d9d9df;
|
||||||
|
--error: #d1242f;
|
||||||
|
--success: #1a7f37;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--bg: #0d1117;
|
||||||
|
--card-bg: #161b22;
|
||||||
|
--text: #e6edf3;
|
||||||
|
--muted: #9198a1;
|
||||||
|
--border: #30363d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.centered {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 420px;
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1.75rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
textarea {
|
||||||
|
font: inherit;
|
||||||
|
color: var(--text);
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.55rem 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0.65rem 1rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.link {
|
||||||
|
background: none;
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 400;
|
||||||
|
padding: 0;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: var(--error);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success {
|
||||||
|
color: var(--success);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
1
client/src/vite-env.d.ts
vendored
Normal file
1
client/src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
/// <reference types="vite/client" />
|
||||||
16
client/tsconfig.json
Normal file
16
client/tsconfig.json
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"resolveJsonModule": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
42
client/vite.config.ts
Normal file
42
client/vite.config.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
import { VitePWA } from "vite-plugin-pwa";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
react(),
|
||||||
|
VitePWA({
|
||||||
|
registerType: "autoUpdate",
|
||||||
|
includeAssets: ["icons/icon-192.png", "icons/icon-512.png"],
|
||||||
|
manifest: {
|
||||||
|
name: "Reading Notes",
|
||||||
|
short_name: "Notes",
|
||||||
|
description: "Jot down short notes while reading, saved straight to Nextcloud.",
|
||||||
|
start_url: "/",
|
||||||
|
display: "standalone",
|
||||||
|
background_color: "#ffffff",
|
||||||
|
theme_color: "#1f6feb",
|
||||||
|
icons: [
|
||||||
|
{
|
||||||
|
src: "icons/icon-192.png",
|
||||||
|
sizes: "192x192",
|
||||||
|
type: "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: "icons/icon-512.png",
|
||||||
|
sizes: "512x512",
|
||||||
|
type: "image/png"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
"/api": {
|
||||||
|
target: "http://localhost:3000",
|
||||||
|
changeOrigin: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
17
docker-compose.yml
Normal file
17
docker-compose.yml
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
services:
|
||||||
|
notes:
|
||||||
|
build: .
|
||||||
|
image: notes:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "${HOST_PORT:-3000}:3000"
|
||||||
|
environment:
|
||||||
|
SESSION_SECRET: ${SESSION_SECRET}
|
||||||
|
ADMIN_USERNAME: ${ADMIN_USERNAME}
|
||||||
|
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
|
||||||
|
SECURE_COOKIES: ${SECURE_COOKIES:-false}
|
||||||
|
NEXTCLOUD_URL: ${NEXTCLOUD_URL}
|
||||||
|
NEXTCLOUD_USERNAME: ${NEXTCLOUD_USERNAME}
|
||||||
|
NEXTCLOUD_APP_PASSWORD: ${NEXTCLOUD_APP_PASSWORD}
|
||||||
|
COLLECTIVE_NAME: ${COLLECTIVE_NAME}
|
||||||
|
PARENT_PAGE_TITLE: ${PARENT_PAGE_TITLE:-Reading Notes}
|
||||||
9264
package-lock.json
generated
Normal file
9264
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
26
package.json
Normal file
26
package.json
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
{
|
||||||
|
"name": "notes",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"workspaces": [
|
||||||
|
"shared",
|
||||||
|
"server",
|
||||||
|
"client"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"dev": "concurrently -n server,client -c blue,green \"npm run dev -w server\" \"npm run dev -w client\"",
|
||||||
|
"build": "npm run build -w shared && npm run build -w client && npm run build -w server",
|
||||||
|
"start": "npm run start -w server",
|
||||||
|
"lint": "eslint . --ext .ts,.tsx",
|
||||||
|
"clean": "npm run clean -w shared && npm run clean -w server && npm run clean -w client"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@typescript-eslint/eslint-plugin": "^7.18.0",
|
||||||
|
"@typescript-eslint/parser": "^7.18.0",
|
||||||
|
"concurrently": "^8.2.2",
|
||||||
|
"eslint": "^8.57.0",
|
||||||
|
"prettier": "^3.3.3",
|
||||||
|
"rimraf": "^5.0.9",
|
||||||
|
"typescript": "^5.5.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
28
server/package.json
Normal file
28
server/package.json
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
{
|
||||||
|
"name": "@notes/server",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tsx watch src/index.ts",
|
||||||
|
"build": "tsc -p tsconfig.json",
|
||||||
|
"start": "node dist/index.js",
|
||||||
|
"clean": "rimraf dist"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@notes/shared": "*",
|
||||||
|
"argon2": "^0.40.3",
|
||||||
|
"dotenv": "^16.4.5",
|
||||||
|
"express": "^4.21.0",
|
||||||
|
"express-session": "^1.18.0",
|
||||||
|
"zod": "^3.23.8"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/express": "^4.17.21",
|
||||||
|
"@types/express-session": "^1.18.0",
|
||||||
|
"@types/node": "^20.14.15",
|
||||||
|
"rimraf": "^5.0.9",
|
||||||
|
"tsx": "^4.19.0",
|
||||||
|
"typescript": "^5.5.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
39
server/src/auth/authRoutes.ts
Normal file
39
server/src/auth/authRoutes.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
import argon2 from "argon2";
|
||||||
|
import { Router } from "express";
|
||||||
|
import { loginInputSchema } from "@notes/shared";
|
||||||
|
import { config } from "../config.js";
|
||||||
|
|
||||||
|
const adminPasswordHashPromise = argon2.hash(config.adminPassword);
|
||||||
|
|
||||||
|
export const authRoutes = Router();
|
||||||
|
|
||||||
|
authRoutes.post("/login", async (req, res) => {
|
||||||
|
const parsed = loginInputSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({ error: "Invalid credentials payload" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { username, password } = parsed.data;
|
||||||
|
const hash = await adminPasswordHashPromise;
|
||||||
|
const usernameMatches = username === config.adminUsername;
|
||||||
|
const passwordMatches = await argon2.verify(hash, password);
|
||||||
|
|
||||||
|
if (!usernameMatches || !passwordMatches) {
|
||||||
|
res.status(401).json({ error: "Invalid username or password" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
req.session.authenticated = true;
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
authRoutes.post("/logout", (req, res) => {
|
||||||
|
req.session.destroy(() => {
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
authRoutes.get("/session", (req, res) => {
|
||||||
|
res.json({ authenticated: Boolean(req.session.authenticated) });
|
||||||
|
});
|
||||||
9
server/src/auth/requireAuth.ts
Normal file
9
server/src/auth/requireAuth.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import type { NextFunction, Request, Response } from "express";
|
||||||
|
|
||||||
|
export function requireAuth(req: Request, res: Response, next: NextFunction): void {
|
||||||
|
if (req.session.authenticated) {
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.status(401).json({ error: "Not authenticated" });
|
||||||
|
}
|
||||||
21
server/src/auth/session.ts
Normal file
21
server/src/auth/session.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import session from "express-session";
|
||||||
|
import { config } from "../config.js";
|
||||||
|
|
||||||
|
declare module "express-session" {
|
||||||
|
interface SessionData {
|
||||||
|
authenticated?: boolean;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sessionMiddleware = session({
|
||||||
|
name: "notes.sid",
|
||||||
|
secret: config.sessionSecret,
|
||||||
|
resave: false,
|
||||||
|
saveUninitialized: false,
|
||||||
|
cookie: {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: "lax",
|
||||||
|
secure: config.secureCookies,
|
||||||
|
maxAge: 30 * 24 * 60 * 60 * 1000
|
||||||
|
}
|
||||||
|
});
|
||||||
23
server/src/config.ts
Normal file
23
server/src/config.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
function required(name: string): string {
|
||||||
|
const value = process.env[name];
|
||||||
|
if (!value) {
|
||||||
|
throw new Error(`Missing required environment variable: ${name}`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
port: Number(process.env.PORT ?? 3000),
|
||||||
|
sessionSecret: required("SESSION_SECRET"),
|
||||||
|
adminUsername: required("ADMIN_USERNAME"),
|
||||||
|
adminPassword: required("ADMIN_PASSWORD"),
|
||||||
|
secureCookies: process.env.SECURE_COOKIES === "true",
|
||||||
|
|
||||||
|
nextcloudUrl: required("NEXTCLOUD_URL").replace(/\/+$/, ""),
|
||||||
|
nextcloudUsername: required("NEXTCLOUD_USERNAME"),
|
||||||
|
nextcloudAppPassword: required("NEXTCLOUD_APP_PASSWORD"),
|
||||||
|
collectiveName: required("COLLECTIVE_NAME"),
|
||||||
|
parentPageTitle: process.env.PARENT_PAGE_TITLE ?? "Reading Notes",
|
||||||
|
|
||||||
|
bookCacheTtlMs: Number(process.env.BOOK_CACHE_TTL_MS ?? 60_000)
|
||||||
|
};
|
||||||
7
server/src/env.ts
Normal file
7
server/src/env.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import dotenv from "dotenv";
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
dotenv.config({ path: path.resolve(__dirname, "../../.env") });
|
||||||
31
server/src/index.ts
Normal file
31
server/src/index.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import "./env.js";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import express from "express";
|
||||||
|
import { config } from "./config.js";
|
||||||
|
import { sessionMiddleware } from "./auth/session.js";
|
||||||
|
import { requireAuth } from "./auth/requireAuth.js";
|
||||||
|
import { authRoutes } from "./auth/authRoutes.js";
|
||||||
|
import { booksRoutes } from "./routes/books.js";
|
||||||
|
import { notesRoutes } from "./routes/notes.js";
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(sessionMiddleware);
|
||||||
|
|
||||||
|
app.use("/api", authRoutes);
|
||||||
|
app.use("/api/books", requireAuth, booksRoutes);
|
||||||
|
app.use("/api/notes", requireAuth, notesRoutes);
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const defaultClientDist = path.resolve(__dirname, "../../client/dist");
|
||||||
|
const clientDist = path.resolve(process.env.CLIENT_DIST_PATH ?? defaultClientDist);
|
||||||
|
app.use(express.static(clientDist));
|
||||||
|
app.get("*", (_req, res) => {
|
||||||
|
res.sendFile(path.join(clientDist, "index.html"));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.listen(config.port, () => {
|
||||||
|
console.log(`Notes server listening on port ${config.port}`);
|
||||||
|
});
|
||||||
8
server/src/nextcloud/auth.ts
Normal file
8
server/src/nextcloud/auth.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
import { config } from "../config.js";
|
||||||
|
|
||||||
|
export function basicAuthHeader(): string {
|
||||||
|
const credentials = Buffer.from(
|
||||||
|
`${config.nextcloudUsername}:${config.nextcloudAppPassword}`
|
||||||
|
).toString("base64");
|
||||||
|
return `Basic ${credentials}`;
|
||||||
|
}
|
||||||
99
server/src/nextcloud/bookCache.ts
Normal file
99
server/src/nextcloud/bookCache.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
import { config } from "../config.js";
|
||||||
|
import {
|
||||||
|
createPage,
|
||||||
|
findRootPage,
|
||||||
|
listCollectives,
|
||||||
|
listPages,
|
||||||
|
type PageInfoDto
|
||||||
|
} from "./collectivesClient.js";
|
||||||
|
|
||||||
|
let collectiveIdPromise: Promise<number> | null = null;
|
||||||
|
let parentPagePromise: Promise<PageInfoDto> | null = null;
|
||||||
|
|
||||||
|
let cachedBooks: Map<string, PageInfoDto> | null = null;
|
||||||
|
let cachedAt = 0;
|
||||||
|
|
||||||
|
function getCollectiveId(): Promise<number> {
|
||||||
|
if (!collectiveIdPromise) {
|
||||||
|
collectiveIdPromise = listCollectives().then((collectives) => {
|
||||||
|
const match = collectives.find((c) => c.name === config.collectiveName);
|
||||||
|
if (!match) {
|
||||||
|
const available = collectives.map((c) => c.name).join(", ");
|
||||||
|
throw new Error(
|
||||||
|
`Collective "${config.collectiveName}" not found. Available collectives: ${available}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return match.id;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return collectiveIdPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getParentPage(): Promise<PageInfoDto> {
|
||||||
|
if (!parentPagePromise) {
|
||||||
|
parentPagePromise = (async () => {
|
||||||
|
const collectiveId = await getCollectiveId();
|
||||||
|
const pages = await listPages(collectiveId);
|
||||||
|
const root = findRootPage(pages);
|
||||||
|
if (!root) {
|
||||||
|
throw new Error(`Could not find root page for collective "${config.collectiveName}"`);
|
||||||
|
}
|
||||||
|
const existing = pages.find(
|
||||||
|
(p) => p.parentId === root.id && p.title === config.parentPageTitle
|
||||||
|
);
|
||||||
|
if (existing) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
return createPage(collectiveId, root.id, config.parentPageTitle);
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
return parentPagePromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshBooks(force = false): Promise<Map<string, PageInfoDto>> {
|
||||||
|
if (!force && cachedBooks && Date.now() - cachedAt < config.bookCacheTtlMs) {
|
||||||
|
return cachedBooks;
|
||||||
|
}
|
||||||
|
|
||||||
|
const collectiveId = await getCollectiveId();
|
||||||
|
const parent = await getParentPage();
|
||||||
|
const pages = await listPages(collectiveId);
|
||||||
|
|
||||||
|
const map = new Map<string, PageInfoDto>();
|
||||||
|
for (const page of pages) {
|
||||||
|
if (page.parentId === parent.id) {
|
||||||
|
map.set(page.title, page);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cachedBooks = map;
|
||||||
|
cachedAt = Date.now();
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listBooks(): Promise<{ title: string }[]> {
|
||||||
|
const map = await refreshBooks();
|
||||||
|
return [...map.keys()].sort((a, b) => a.localeCompare(b)).map((title) => ({ title }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getOrCreateBookPage(title: string): Promise<PageInfoDto> {
|
||||||
|
let map = await refreshBooks();
|
||||||
|
let page = map.get(title);
|
||||||
|
if (page) {
|
||||||
|
return page;
|
||||||
|
}
|
||||||
|
|
||||||
|
map = await refreshBooks(true);
|
||||||
|
page = map.get(title);
|
||||||
|
if (page) {
|
||||||
|
return page;
|
||||||
|
}
|
||||||
|
|
||||||
|
const collectiveId = await getCollectiveId();
|
||||||
|
const parent = await getParentPage();
|
||||||
|
const created = await createPage(collectiveId, parent.id, title);
|
||||||
|
|
||||||
|
map.set(created.title, created);
|
||||||
|
cachedBooks = map;
|
||||||
|
return created;
|
||||||
|
}
|
||||||
63
server/src/nextcloud/collectivesClient.ts
Normal file
63
server/src/nextcloud/collectivesClient.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
import { config } from "../config.js";
|
||||||
|
import { basicAuthHeader } from "./auth.js";
|
||||||
|
|
||||||
|
export interface CollectiveDto {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PageInfoDto {
|
||||||
|
id: number;
|
||||||
|
parentId: number;
|
||||||
|
title: string;
|
||||||
|
fileName: string;
|
||||||
|
filePath: string;
|
||||||
|
collectivePath: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const API_BASE = `${config.nextcloudUrl}/ocs/v2.php/apps/collectives/api/v1.0`;
|
||||||
|
|
||||||
|
async function ocsFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
const res = await fetch(`${API_BASE}${path}`, {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
...init?.headers,
|
||||||
|
"OCS-APIRequest": "true",
|
||||||
|
Accept: "application/json",
|
||||||
|
Authorization: basicAuthHeader()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Collectives API request failed (${res.status} ${res.statusText}): ${path}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = (await res.json()) as { ocs: { data: T } };
|
||||||
|
return body.ocs.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listCollectives(): Promise<CollectiveDto[]> {
|
||||||
|
const data = await ocsFetch<{ collectives: CollectiveDto[] }>("/collectives");
|
||||||
|
return data.collectives;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listPages(collectiveId: number): Promise<PageInfoDto[]> {
|
||||||
|
const data = await ocsFetch<{ pages: PageInfoDto[] }>(`/collectives/${collectiveId}/pages`);
|
||||||
|
return data.pages;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createPage(
|
||||||
|
collectiveId: number,
|
||||||
|
parentId: number,
|
||||||
|
title: string
|
||||||
|
): Promise<PageInfoDto> {
|
||||||
|
const data = await ocsFetch<{ page: PageInfoDto }>(
|
||||||
|
`/collectives/${collectiveId}/pages/${parentId}?title=${encodeURIComponent(title)}`,
|
||||||
|
{ method: "POST" }
|
||||||
|
);
|
||||||
|
return data.page;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findRootPage(pages: PageInfoDto[]): PageInfoDto | undefined {
|
||||||
|
return pages.find((page) => page.parentId === 0);
|
||||||
|
}
|
||||||
9
server/src/nextcloud/noteFormatter.ts
Normal file
9
server/src/nextcloud/noteFormatter.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
export function formatEntry(text: string, page?: number): string {
|
||||||
|
const date = new Date().toISOString().slice(0, 10);
|
||||||
|
const quoted = text
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => `> ${line}`)
|
||||||
|
.join("\n");
|
||||||
|
const meta = page ? `— p. ${page}, ${date}` : `— ${date}`;
|
||||||
|
return `${quoted}\n\n${meta}\n\n---`;
|
||||||
|
}
|
||||||
51
server/src/nextcloud/webdavClient.ts
Normal file
51
server/src/nextcloud/webdavClient.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
import { config } from "../config.js";
|
||||||
|
import { basicAuthHeader } from "./auth.js";
|
||||||
|
import type { PageInfoDto } from "./collectivesClient.js";
|
||||||
|
|
||||||
|
function buildDavUrl(page: PageInfoDto): string {
|
||||||
|
const joined = [page.collectivePath, page.filePath, page.fileName]
|
||||||
|
.filter((part): part is string => Boolean(part))
|
||||||
|
.join("/");
|
||||||
|
const encodedPath = joined
|
||||||
|
.split("/")
|
||||||
|
.filter(Boolean)
|
||||||
|
.map(encodeURIComponent)
|
||||||
|
.join("/");
|
||||||
|
return `${config.nextcloudUrl}/remote.php/dav/files/${encodeURIComponent(config.nextcloudUsername)}/${encodedPath}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readFile(page: PageInfoDto): Promise<string> {
|
||||||
|
const res = await fetch(buildDavUrl(page), {
|
||||||
|
headers: { Authorization: basicAuthHeader() }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status === 404) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`WebDAV read failed (${res.status} ${res.statusText}) for page ${page.id}`);
|
||||||
|
}
|
||||||
|
return await res.text();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeFile(page: PageInfoDto, content: string): Promise<void> {
|
||||||
|
const res = await fetch(buildDavUrl(page), {
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
Authorization: basicAuthHeader(),
|
||||||
|
"Content-Type": "text/markdown"
|
||||||
|
},
|
||||||
|
body: content
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`WebDAV write failed (${res.status} ${res.statusText}) for page ${page.id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function appendEntry(page: PageInfoDto, entryMarkdown: string): Promise<void> {
|
||||||
|
const current = await readFile(page);
|
||||||
|
const trimmed = current.replace(/\s+$/, "");
|
||||||
|
const updated = trimmed.length > 0 ? `${trimmed}\n\n${entryMarkdown}\n` : `${entryMarkdown}\n`;
|
||||||
|
await writeFile(page, updated);
|
||||||
|
}
|
||||||
14
server/src/routes/books.ts
Normal file
14
server/src/routes/books.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { Router } from "express";
|
||||||
|
import { listBooks } from "../nextcloud/bookCache.js";
|
||||||
|
|
||||||
|
export const booksRoutes = Router();
|
||||||
|
|
||||||
|
booksRoutes.get("/", async (_req, res) => {
|
||||||
|
try {
|
||||||
|
const books = await listBooks();
|
||||||
|
res.json(books);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to list books", err);
|
||||||
|
res.status(502).json({ error: "Failed to load books from Nextcloud" });
|
||||||
|
}
|
||||||
|
});
|
||||||
27
server/src/routes/notes.ts
Normal file
27
server/src/routes/notes.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { Router } from "express";
|
||||||
|
import { noteInputSchema } from "@notes/shared";
|
||||||
|
import { getOrCreateBookPage, getParentPage } from "../nextcloud/bookCache.js";
|
||||||
|
import { appendEntry } from "../nextcloud/webdavClient.js";
|
||||||
|
import { formatEntry } from "../nextcloud/noteFormatter.js";
|
||||||
|
|
||||||
|
export const notesRoutes = Router();
|
||||||
|
|
||||||
|
notesRoutes.post("/", async (req, res) => {
|
||||||
|
const parsed = noteInputSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({ error: "Invalid note payload", details: parsed.error.flatten() });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { book, page, text } = parsed.data;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const targetPage = book ? await getOrCreateBookPage(book) : await getParentPage();
|
||||||
|
const entry = formatEntry(text, page);
|
||||||
|
await appendEntry(targetPage, entry);
|
||||||
|
res.status(201).json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to save note", err);
|
||||||
|
res.status(502).json({ error: "Failed to save note to Nextcloud" });
|
||||||
|
}
|
||||||
|
});
|
||||||
16
server/tsconfig.json
Normal file
16
server/tsconfig.json
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
18
shared/package.json
Normal file
18
shared/package.json
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
{
|
||||||
|
"name": "@notes/shared",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"types": "dist/index.d.ts",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc -p tsconfig.json",
|
||||||
|
"clean": "rimraf dist"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"zod": "^3.23.8"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.5.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
shared/src/index.ts
Normal file
1
shared/src/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export * from "./schemas.js";
|
||||||
19
shared/src/schemas.ts
Normal file
19
shared/src/schemas.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const loginInputSchema = z.object({
|
||||||
|
username: z.string().trim().min(1),
|
||||||
|
password: z.string().min(1)
|
||||||
|
});
|
||||||
|
export type LoginInput = z.infer<typeof loginInputSchema>;
|
||||||
|
|
||||||
|
export const noteInputSchema = z.object({
|
||||||
|
book: z.string().trim().min(1).max(200).optional(),
|
||||||
|
page: z.number().int().positive().max(1_000_000).optional(),
|
||||||
|
text: z.string().trim().min(1).max(5000)
|
||||||
|
});
|
||||||
|
export type NoteInput = z.infer<typeof noteInputSchema>;
|
||||||
|
|
||||||
|
export const bookSchema = z.object({
|
||||||
|
title: z.string()
|
||||||
|
});
|
||||||
|
export type Book = z.infer<typeof bookSchema>;
|
||||||
16
shared/tsconfig.json
Normal file
16
shared/tsconfig.json
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"declaration": true,
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue