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 0000000..c793da3 Binary files /dev/null and b/client/public/icons/icon-192.png differ diff --git a/client/public/icons/icon-512.png b/client/public/icons/icon-512.png new file mode 100644 index 0000000..0ae297d Binary files /dev/null and b/client/public/icons/icon-512.png differ diff --git a/client/scripts/gen-icons.py b/client/scripts/gen-icons.py new file mode 100644 index 0000000..6e68080 --- /dev/null +++ b/client/scripts/gen-icons.py @@ -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") 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

+ +
+ +