From 73ed2e2cf167dabe64fbb90084448efb80aad5f8 Mon Sep 17 00:00:00 2001 From: Claudio Schaad Date: Wed, 26 Aug 2026 20:07:40 +0200 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01NvmYFQSMqs2N3q2zUDNTVn --- .env.example | 20 + .eslintrc.json | 22 + .gitignore | 6 + Dockerfile | 27 + README.md | 34 +- client/index.html | 13 + client/package.json | 26 + client/public/icons/icon-192.png | Bin 0 -> 413 bytes client/public/icons/icon-512.png | Bin 0 -> 1496 bytes client/scripts/gen-icons.py | 32 + client/src/App.tsx | 29 + client/src/api/auth.ts | 17 + client/src/api/books.ts | 6 + client/src/api/client.ts | 20 + client/src/api/notes.ts | 9 + client/src/main.tsx | 22 + client/src/pages/LoginPage.tsx | 51 + client/src/pages/NoteFormPage.tsx | 97 + client/src/styles.css | 121 + client/src/vite-env.d.ts | 1 + client/tsconfig.json | 16 + client/vite.config.ts | 42 + docker-compose.yml | 17 + package-lock.json | 9264 +++++++++++++++++++++ package.json | 26 + server/package.json | 28 + server/src/auth/authRoutes.ts | 39 + server/src/auth/requireAuth.ts | 9 + server/src/auth/session.ts | 21 + server/src/config.ts | 23 + server/src/env.ts | 7 + server/src/index.ts | 31 + server/src/nextcloud/auth.ts | 8 + server/src/nextcloud/bookCache.ts | 99 + server/src/nextcloud/collectivesClient.ts | 63 + server/src/nextcloud/noteFormatter.ts | 9 + server/src/nextcloud/webdavClient.ts | 51 + server/src/routes/books.ts | 14 + server/src/routes/notes.ts | 27 + server/tsconfig.json | 16 + shared/package.json | 18 + shared/src/index.ts | 1 + shared/src/schemas.ts | 19 + shared/tsconfig.json | 16 + 44 files changed, 10416 insertions(+), 1 deletion(-) create mode 100644 .env.example create mode 100644 .eslintrc.json create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 client/index.html create mode 100644 client/package.json create mode 100644 client/public/icons/icon-192.png create mode 100644 client/public/icons/icon-512.png create mode 100644 client/scripts/gen-icons.py create mode 100644 client/src/App.tsx create mode 100644 client/src/api/auth.ts create mode 100644 client/src/api/books.ts create mode 100644 client/src/api/client.ts create mode 100644 client/src/api/notes.ts create mode 100644 client/src/main.tsx create mode 100644 client/src/pages/LoginPage.tsx create mode 100644 client/src/pages/NoteFormPage.tsx create mode 100644 client/src/styles.css create mode 100644 client/src/vite-env.d.ts create mode 100644 client/tsconfig.json create mode 100644 client/vite.config.ts create mode 100644 docker-compose.yml create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 server/package.json create mode 100644 server/src/auth/authRoutes.ts create mode 100644 server/src/auth/requireAuth.ts create mode 100644 server/src/auth/session.ts create mode 100644 server/src/config.ts create mode 100644 server/src/env.ts create mode 100644 server/src/index.ts create mode 100644 server/src/nextcloud/auth.ts create mode 100644 server/src/nextcloud/bookCache.ts create mode 100644 server/src/nextcloud/collectivesClient.ts create mode 100644 server/src/nextcloud/noteFormatter.ts create mode 100644 server/src/nextcloud/webdavClient.ts create mode 100644 server/src/routes/books.ts create mode 100644 server/src/routes/notes.ts create mode 100644 server/tsconfig.json create mode 100644 shared/package.json create mode 100644 shared/src/index.ts create mode 100644 shared/src/schemas.ts create mode 100644 shared/tsconfig.json diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..377e8b7 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..38f049b --- /dev/null +++ b/.eslintrc.json @@ -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": "^_" }] + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..21b5d68 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +dist/ +.env +*.local +.DS_Store +npm-debug.log* diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0b29e21 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md index 9a7c2bf..ab434d3 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,35 @@ # Notes -Note taking app with sync to a wiki / md \ No newline at end of file +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). diff --git a/client/index.html b/client/index.html new file mode 100644 index 0000000..4088862 --- /dev/null +++ b/client/index.html @@ -0,0 +1,13 @@ + + + + + + + Reading Notes + + +
+ + + diff --git a/client/package.json b/client/package.json new file mode 100644 index 0000000..50cc9de --- /dev/null +++ b/client/package.json @@ -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" + } +} diff --git a/client/public/icons/icon-192.png b/client/public/icons/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..c793da3b88740ca958edf13769fdd72bdb42cf27 GIT binary patch literal 413 zcmeAS@N?(olHy`uVBq!ia0vp^2SAvE2}s`E_d9@rficC?#WAGf*4s;tybKH+M>Z5U zxwIrRakrFD){2<+s&TtVL!*HJ+aZSp1)dflOM-(rQGtgAC;((NLZl=(z*1~XAgPwd z10bnH1+XrJB3kH&TSBJ&c=huvVM6gGHV@!802)dMuQEw<$>f^UoPNXr1fH&bF6*2U FngDpWVI", 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") diff --git a/client/src/App.tsx b/client/src/App.tsx new file mode 100644 index 0000000..2da7ef9 --- /dev/null +++ b/client/src/App.tsx @@ -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 ( +
+

Loading…

+
+ ); + } + + if (!sessionQuery.data?.authenticated) { + return ( + queryClient.invalidateQueries({ queryKey: ["session"] })} /> + ); + } + + return ( + queryClient.invalidateQueries({ queryKey: ["session"] })} + /> + ); +} diff --git a/client/src/api/auth.ts b/client/src/api/auth.ts new file mode 100644 index 0000000..e938e35 --- /dev/null +++ b/client/src/api/auth.ts @@ -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"); +} diff --git a/client/src/api/books.ts b/client/src/api/books.ts new file mode 100644 index 0000000..f657a1d --- /dev/null +++ b/client/src/api/books.ts @@ -0,0 +1,6 @@ +import type { Book } from "@notes/shared"; +import { apiFetch } from "./client"; + +export function fetchBooks() { + return apiFetch("/api/books"); +} diff --git a/client/src/api/client.ts b/client/src/api/client.ts new file mode 100644 index 0000000..fbc85c4 --- /dev/null +++ b/client/src/api/client.ts @@ -0,0 +1,20 @@ +export async function apiFetch(path: string, init?: RequestInit): Promise { + 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; +} diff --git a/client/src/api/notes.ts b/client/src/api/notes.ts new file mode 100644 index 0000000..918e57a --- /dev/null +++ b/client/src/api/notes.ts @@ -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) + }); +} diff --git a/client/src/main.tsx b/client/src/main.tsx new file mode 100644 index 0000000..cdcb501 --- /dev/null +++ b/client/src/main.tsx @@ -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( + + + + + +); diff --git a/client/src/pages/LoginPage.tsx b/client/src/pages/LoginPage.tsx new file mode 100644 index 0000000..965ff21 --- /dev/null +++ b/client/src/pages/LoginPage.tsx @@ -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 ( +
+
+

Reading Notes

+ + + {loginMutation.isError && ( +

{(loginMutation.error as Error).message}

+ )} + +
+
+ ); +} diff --git a/client/src/pages/NoteFormPage.tsx b/client/src/pages/NoteFormPage.tsx new file mode 100644 index 0000000..f8d8e73 --- /dev/null +++ b/client/src/pages/NoteFormPage.tsx @@ -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 ( +
+
+
+

New note

+ +
+ +