diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..313be35 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,35 @@ +# ── Stage 1: Build the Vite frontend ──────────────────────────────────────── +FROM node:20-alpine AS builder + +WORKDIR /app +COPY package*.json ./ +RUN npm ci + +COPY . . +RUN npm run build + +# ── Stage 2: Production image ──────────────────────────────────────────────── +FROM node:20-alpine + +WORKDIR /app + +# Copy package files and install production deps only +COPY package*.json ./ +RUN npm ci --omit=dev + +# Copy built frontend and server source +COPY --from=builder /app/dist ./dist +COPY server/ ./server/ + +# SQLite data directory — mount a volume here for persistence +# e.g. docker run -v /your/host/path:/app/server/data ... +RUN mkdir -p /app/server/data + +EXPOSE 3001 + +ENV NODE_ENV=production +ENV PORT=3001 +# Override DATA_DIR if you mount the volume elsewhere +ENV DATA_DIR=/app/server/data + +CMD ["node", "server/index.js"] \ No newline at end of file diff --git a/package.json b/package.json index fd1e17e..9deca7c 100644 --- a/package.json +++ b/package.json @@ -5,14 +5,20 @@ "type": "module", "scripts": { "dev": "vite", + "dev:server": "node server/index.js", + "dev:full": "concurrently \"npm run dev:server\" \"npm run dev\"", "build": "vite build", "preview": "vite preview", + "start": "NODE_ENV=production node server/index.js", "test": "vitest", "test:run": "vitest run", "coverage": "vitest run --coverage" }, "dependencies": { + "better-sqlite3": "^9.4.3", + "concurrently": "^8.2.2", "docx": "^9.7.0", + "express": "^4.19.2", "react": "^19.0.0", "react-dom": "^19.0.0" }, @@ -29,4 +35,4 @@ "vite": "^5.4.1", "vitest": "^4.1.7" } -} +} \ No newline at end of file diff --git a/restart.sh b/restart.sh index e644332..c5fe558 100755 --- a/restart.sh +++ b/restart.sh @@ -2,24 +2,24 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PID_FILE="$ROOT_DIR/.vite.pid" -stop_server() { - if [ -f "$PID_FILE" ]; then - OLD_PID=$(cat "$PID_FILE") - if kill -0 "$OLD_PID" 2>/dev/null; then - echo "Stopping existing Vite server (PID $OLD_PID)..." - kill "$OLD_PID" +stop_pid_file() { + local pid_file="$1" + local label="$2" + if [ -f "$pid_file" ]; then + local old_pid + old_pid=$(cat "$pid_file") + if kill -0 "$old_pid" 2>/dev/null; then + echo "Stopping $label (PID $old_pid)..." + kill "$old_pid" sleep 1 fi - rm -f "$PID_FILE" + rm -f "$pid_file" fi } -start_server() { - echo "Restarting Vite server using setup.sh..." - exec "$ROOT_DIR/setup.sh" -} +stop_pid_file "$ROOT_DIR/.api.pid" "API server" +stop_pid_file "$ROOT_DIR/.vite.pid" "Vite server" -stop_server -start_server +echo "Restarting via setup.sh..." +exec "$ROOT_DIR/setup.sh" \ No newline at end of file diff --git a/server/db.js b/server/db.js new file mode 100644 index 0000000..6cf0e9e --- /dev/null +++ b/server/db.js @@ -0,0 +1,100 @@ +import Database from 'better-sqlite3'; +import { mkdirSync } from 'fs'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const DATA_DIR = process.env.DATA_DIR || join(__dirname, 'data'); +const DB_PATH = join(DATA_DIR, 'projects.db'); + +let db; + +// --------------------------------------------------------------------------- +// Init — create tables if they don't exist +// --------------------------------------------------------------------------- +export function initDb() { + mkdirSync(DATA_DIR, { recursive: true }); + db = new Database(DB_PATH); + + // Enable WAL for better concurrent read performance + db.pragma('journal_mode = WAL'); + + db.exec(` + CREATE TABLE IF NOT EXISTS projects ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + last_edited INTEGER NOT NULL, + chapter_summary TEXT, + data TEXT NOT NULL + ); + `); + + console.log(`SQLite database ready at ${DB_PATH}`); +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function buildSummary(project) { + if (!Array.isArray(project.chapters)) return ''; + return project.chapters.map((ch) => `${ch.book} ${ch.chapter}`).join(', '); +} + +// --------------------------------------------------------------------------- +// Queries +// --------------------------------------------------------------------------- + +/** + * Returns all project summaries (id, title, lastEdited, chapterSummary). + * Does NOT return full project data to keep the response small. + */ +export function getAllProjects() { + const rows = db.prepare(` + SELECT id, title, last_edited AS lastEdited, chapter_summary AS chapterSummary + FROM projects + ORDER BY last_edited DESC + `).all(); + return rows; +} + +/** + * Returns a single full project by id, or null if not found. + */ +export function getProject(id) { + const row = db.prepare('SELECT data FROM projects WHERE id = ?').get(id); + if (!row) return null; + try { + return JSON.parse(row.data); + } catch { + return null; + } +} + +/** + * Insert or replace a project. Returns the summary. + */ +export function upsertProject(project) { + const lastEdited = project.lastEdited ?? Date.now(); + const chapterSummary = buildSummary(project); + const updated = { ...project, lastEdited }; + + db.prepare(` + INSERT INTO projects (id, title, last_edited, chapter_summary, data) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + title = excluded.title, + last_edited = excluded.last_edited, + chapter_summary = excluded.chapter_summary, + data = excluded.data + `).run(project.id, project.title, lastEdited, chapterSummary, JSON.stringify(updated)); + + return { id: project.id, title: project.title, lastEdited, chapterSummary }; +} + +/** + * Delete a project by id. No-op if not found. + */ +export function deleteProject(id) { + db.prepare('DELETE FROM projects WHERE id = ?').run(id); +} \ No newline at end of file diff --git a/server/index.js b/server/index.js new file mode 100644 index 0000000..8d6c002 --- /dev/null +++ b/server/index.js @@ -0,0 +1,102 @@ +import express from 'express'; +import { fileURLToPath } from 'url'; +import { dirname, join } from 'path'; +import { initDb, getAllProjects, getProject, upsertProject, deleteProject } from './db.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const app = express(); +const PORT = process.env.PORT || 3001; + +app.use(express.json({ limit: '10mb' })); + +// --------------------------------------------------------------------------- +// Health check +// --------------------------------------------------------------------------- +app.get('/api/health', (_req, res) => { + res.json({ ok: true }); +}); + +// --------------------------------------------------------------------------- +// GET /api/projects — list all project summaries (no full data) +// --------------------------------------------------------------------------- +app.get('/api/projects', (_req, res) => { + try { + const projects = getAllProjects(); + res.json(projects); + } catch (err) { + console.error('GET /api/projects error:', err); + res.status(500).json({ error: 'Failed to list projects.' }); + } +}); + +// --------------------------------------------------------------------------- +// GET /api/projects/:id — fetch a single full project +// --------------------------------------------------------------------------- +app.get('/api/projects/:id', (req, res) => { + try { + const project = getProject(req.params.id); + if (!project) return res.status(404).json({ error: 'Project not found.' }); + res.json(project); + } catch (err) { + console.error('GET /api/projects/:id error:', err); + res.status(500).json({ error: 'Failed to load project.' }); + } +}); + +// --------------------------------------------------------------------------- +// PUT /api/projects/:id — create or update a project +// --------------------------------------------------------------------------- +app.put('/api/projects/:id', (req, res) => { + try { + const body = req.body; + if (!body || typeof body !== 'object') { + return res.status(400).json({ error: 'Invalid JSON body.' }); + } + if (!body.id || !body.title || !Array.isArray(body.chapters)) { + return res.status(400).json({ error: 'Missing required fields: id, title, chapters.' }); + } + if (body.id !== req.params.id) { + return res.status(400).json({ error: 'URL id does not match body id.' }); + } + const saved = upsertProject(body); + res.json(saved); + } catch (err) { + console.error('PUT /api/projects/:id error:', err); + res.status(500).json({ error: 'Failed to save project.' }); + } +}); + +// --------------------------------------------------------------------------- +// DELETE /api/projects/:id — remove a project +// --------------------------------------------------------------------------- +app.delete('/api/projects/:id', (req, res) => { + try { + deleteProject(req.params.id); + res.json({ ok: true }); + } catch (err) { + console.error('DELETE /api/projects/:id error:', err); + res.status(500).json({ error: 'Failed to delete project.' }); + } +}); + +// --------------------------------------------------------------------------- +// Serve Vite production build (when NODE_ENV=production) +// --------------------------------------------------------------------------- +if (process.env.NODE_ENV === 'production') { + const distPath = join(__dirname, '..', 'dist'); + app.use(express.static(distPath)); + app.get('*', (_req, res) => { + res.sendFile(join(distPath, 'index.html')); + }); +} + +// --------------------------------------------------------------------------- +// Start +// --------------------------------------------------------------------------- +initDb(); +app.listen(PORT, () => { + console.log(`Bible Study API running on http://localhost:${PORT}`); + if (process.env.NODE_ENV === 'production') { + console.log('Serving Vite build from /dist'); + } +}); \ No newline at end of file diff --git a/setup.sh b/setup.sh index e75f356..43bb05a 100755 --- a/setup.sh +++ b/setup.sh @@ -1,13 +1,20 @@ #!/usr/bin/env bash set -euo pipefail -PREFERRED_PORT=5173 -MAX_PORT=5200 +PREFERRED_VITE_PORT=5173 +MAX_VITE_PORT=5200 +API_PORT=3001 -# Find an available port starting from PREFERRED_PORT +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +VITE_PID_FILE="$ROOT_DIR/.vite.pid" +API_PID_FILE="$ROOT_DIR/.api.pid" +VITE_LOG_FILE="$ROOT_DIR/.vite.log" +API_LOG_FILE="$ROOT_DIR/.api.log" + +# ── Find an available Vite port ─────────────────────────────────────────────── find_port() { - local port=$PREFERRED_PORT - while [ $port -le $MAX_PORT ]; do + local port=$PREFERRED_VITE_PORT + while [ $port -le $MAX_VITE_PORT ]; do if ! lsof -i TCP:"$port" -sTCP:LISTEN -t >/dev/null 2>&1 && \ ! ss -tlnH "sport = :$port" 2>/dev/null | grep -q .; then echo "$port" @@ -18,6 +25,22 @@ find_port() { echo "" } +# ── Stop a process by PID file ──────────────────────────────────────────────── +stop_pid_file() { + local pid_file="$1" + local label="$2" + if [ -f "$pid_file" ]; then + local old_pid + old_pid=$(cat "$pid_file") + if kill -0 "$old_pid" 2>/dev/null; then + echo "Stopping previous $label (PID $old_pid)..." + kill "$old_pid" + sleep 1 + fi + rm -f "$pid_file" + fi +} + echo "=== Bible Study App Setup ===" echo "" @@ -45,44 +68,48 @@ echo "" echo "Installing dependencies..." npm install -# ── Find a free port ───────────────────────────────────────────────────────── +# ── Find a free Vite port ───────────────────────────────────────────────────── echo "" -PORT=$(find_port) -if [ -z "$PORT" ]; then - echo "ERROR: No free port found between $PREFERRED_PORT and $MAX_PORT." +VITE_PORT=$(find_port) +if [ -z "$VITE_PORT" ]; then + echo "ERROR: No free port found between $PREFERRED_VITE_PORT and $MAX_VITE_PORT." exit 1 fi -if [ "$PORT" -ne "$PREFERRED_PORT" ]; then - echo "Port $PREFERRED_PORT is in use. Using port $PORT instead." +if [ "$VITE_PORT" -ne "$PREFERRED_VITE_PORT" ]; then + echo "Port $PREFERRED_VITE_PORT is in use. Using port $VITE_PORT instead." else - echo "Port $PORT is available." + echo "Port $VITE_PORT is available for Vite." fi -# ── Launch ──────────────────────────────────────────────────────────────────── -PID_FILE="$(dirname "$0")/.vite.pid" -LOG_FILE="$(dirname "$0")/.vite.log" +# ── Stop any previously running instances ───────────────────────────────────── +stop_pid_file "$API_PID_FILE" "API server" +stop_pid_file "$VITE_PID_FILE" "Vite server" -# Stop any previously started instance -if [ -f "$PID_FILE" ]; then - OLD_PID=$(cat "$PID_FILE") - if kill -0 "$OLD_PID" 2>/dev/null; then - echo "Stopping previous server (PID $OLD_PID)..." - kill "$OLD_PID" - sleep 1 - fi - rm -f "$PID_FILE" -fi +# ── Start Express API server ────────────────────────────────────────────────── +echo "" +echo "Starting API server on http://localhost:$API_PORT" +echo "API logs: $API_LOG_FILE" + +nohup node "$ROOT_DIR/server/index.js" > "$API_LOG_FILE" 2>&1 & +echo $! > "$API_PID_FILE" +echo "API server PID: $(cat "$API_PID_FILE")" + +# Give the API a moment to initialise before Vite starts +sleep 1 + +# ── Start Vite dev server ───────────────────────────────────────────────────── +echo "" +echo "Starting Vite dev server on http://localhost:$VITE_PORT" +echo "Vite logs: $VITE_LOG_FILE" + +nohup npx vite --port "$VITE_PORT" --host > "$VITE_LOG_FILE" 2>&1 & +echo $! > "$VITE_PID_FILE" +echo "Vite server PID: $(cat "$VITE_PID_FILE")" echo "" -echo "Starting dev server in the background on http://localhost:$PORT" -echo "Logs: $LOG_FILE" +echo "Both servers are running." echo "" - -nohup npx vite --port "$PORT" --host > "$LOG_FILE" 2>&1 & -echo $! > "$PID_FILE" - -echo "Server PID: $(cat "$PID_FILE")" -echo "" -echo "To stop the server: kill \$(cat .vite.pid)" -echo "To view logs: tail -f .vite.log" +echo "To stop both: kill \$(cat .api.pid) \$(cat .vite.pid)" +echo "To view API logs: tail -f .api.log" +echo "To view Vite logs: tail -f .vite.log" \ No newline at end of file diff --git a/src/App.jsx b/src/App.jsx index ed66f67..73b5edf 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -11,6 +11,13 @@ import { WidthType, } from 'docx'; +import { + saveRemoteProject, + deleteRemoteProject, + listRemoteProjects, + loadRemoteProject, +} from './syncService.js'; + const bookOptions = [ { name: 'Matthew', abbrev: 'MAT' }, { name: 'Mark', abbrev: 'MRK' }, @@ -512,13 +519,24 @@ const App = () => { const [errorMessage, setErrorMessage] = useState(''); const [statusMessage, setStatusMessage] = useState(''); const saveTimerRef = useRef(null); - + const [syncStatus, setSyncStatus] = useState(''); // '' | 'syncing' | 'synced' | 'error' + const [remoteOnlyProjects, setRemoteOnlyProjects] = useState([]); // projects on server not in localStorage + // --------------------------------------------------------------------------- // Startup: migrate old keys and load index // --------------------------------------------------------------------------- useEffect(() => { migrateOldStorageKeys(); - setProjectIndex(loadProjectIndex()); + const localIndex = loadProjectIndex(); + setProjectIndex(localIndex); + + // Check server for any projects not present locally (cross-device restore) + listRemoteProjects().then((result) => { + if (!result.ok) return; + const localIds = new Set(localIndex.map((e) => e.id)); + const missing = result.data.filter((e) => !localIds.has(e.id)); + if (missing.length > 0) setRemoteOnlyProjects(missing); + }); }, []); useEffect(() => { @@ -555,14 +573,21 @@ const App = () => { }, [project?.selectedChunkId]); // Autosave - useEffect(() => { +useEffect(() => { if (!project) return; if (saveTimerRef.current) window.clearTimeout(saveTimerRef.current); - saveTimerRef.current = window.setTimeout(() => { + saveTimerRef.current = window.setTimeout(async () => { + // 1. Always save locally first saveProjectToStorage(project); setProjectIndex(loadProjectIndex()); setSaveStatus('Saved'); window.setTimeout(() => setSaveStatus(''), 1400); + + // 2. Then sync to server (non-blocking — failures are silent) + setSyncStatus('syncing'); + const result = await saveRemoteProject(project); + setSyncStatus(result.ok ? 'synced' : 'error'); + window.setTimeout(() => setSyncStatus(''), 2500); }, 1000); return () => { if (saveTimerRef.current) window.clearTimeout(saveTimerRef.current); @@ -1145,12 +1170,24 @@ const App = () => { setCurrentPage('setup'); }; - const deleteProject = (id) => { +const deleteProject = (id) => { if (!window.confirm('Delete this project? This cannot be undone.')) return; deleteProjectFromStorage(id); setProjectIndex(loadProjectIndex()); + deleteRemoteProject(id); // fire-and-forget }; +const restoreRemoteProject = async (id) => { + const result = await loadRemoteProject(id); + if (!result.ok) { + alert('Could not restore project from server.'); + return; + } + saveProjectToStorage(result.data); + setProjectIndex(loadProjectIndex()); + setRemoteOnlyProjects((prev) => prev.filter((e) => e.id !== id)); + }; + const goHome = () => { setProjectIndex(loadProjectIndex()); setCurrentPage('home'); @@ -1212,16 +1249,18 @@ const App = () => { )} -
- {loadingChapter ? ( - Loading… - ) : saveStatus ? ( - {saveStatus} - ) : ( -   - )} -
- +
+ {loadingChapter ? ( + Loading… + ) : saveStatus ? ( + {saveStatus} + ) : ( +   + )} + {syncStatus === 'syncing' &&
Syncing…
} + {syncStatus === 'synced' &&
Synced ✓
} + {syncStatus === 'error' &&
Sync failed (saved locally)
} +
); // --------------------------------------------------------------------------- @@ -1246,6 +1285,25 @@ const App = () => {
+ {remoteOnlyProjects.length > 0 && ( +
+

+ 📥 {remoteOnlyProjects.length} project{remoteOnlyProjects.length > 1 ? 's' : ''} found on the server that aren't saved locally: +

+
+ {remoteOnlyProjects.map((entry) => ( + + ))} +
+
+ )} {projectIndex.length === 0 ? (

No projects yet

diff --git a/src/syncService.js b/src/syncService.js new file mode 100644 index 0000000..8cd7ff8 --- /dev/null +++ b/src/syncService.js @@ -0,0 +1,78 @@ +/** + * syncService.js + * + * Thin wrapper around the /api/projects endpoints. + * All functions are fire-and-forget friendly: they never throw — they + * return { ok: true, data } or { ok: false, error }. + * + * The caller decides whether to surface the error to the user. + */ + +const BASE = '/api'; + +async function request(method, path, body) { + try { + const opts = { + method, + headers: { 'Content-Type': 'application/json' }, + }; + if (body !== undefined) opts.body = JSON.stringify(body); + const res = await fetch(`${BASE}${path}`, opts); + const data = await res.json().catch(() => null); + if (!res.ok) { + return { ok: false, error: data?.error ?? `HTTP ${res.status}` }; + } + return { ok: true, data }; + } catch (err) { + return { ok: false, error: err?.message ?? 'Network error' }; + } +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * List all remote project summaries. + * Returns { ok, data: Array<{ id, title, lastEdited, chapterSummary }> } + */ +export async function listRemoteProjects() { + return request('GET', '/projects'); +} + +/** + * Fetch a single full project by id. + * Returns { ok, data: project } + */ +export async function loadRemoteProject(id) { + return request('GET', `/projects/${id}`); +} + +/** + * Save (create or update) a project on the server. + * Returns { ok, data: summary } + */ +export async function saveRemoteProject(project) { + return request('PUT', `/projects/${project.id}`, project); +} + +/** + * Delete a project from the server. + * Returns { ok } + */ +export async function deleteRemoteProject(id) { + return request('DELETE', `/projects/${id}`); +} + +/** + * Check whether the server is reachable. + * Returns true / false. + */ +export async function isServerReachable() { + try { + const res = await fetch(`${BASE}/health`, { method: 'GET' }); + return res.ok; + } catch { + return false; + } +} \ No newline at end of file diff --git a/vite.config.js b/vite.config.js index 5d5a071..e89189a 100644 --- a/vite.config.js +++ b/vite.config.js @@ -6,6 +6,13 @@ export default defineConfig({ server: { host: true, allowedHosts: ['study.necloud.us', 'localhost', 'study.versebyversewithnate.us'], + // Proxy /api calls to the Express backend during development + proxy: { + '/api': { + target: 'http://localhost:3001', + changeOrigin: true, + }, + }, }, test: { environment: 'jsdom', @@ -17,4 +24,4 @@ export default defineConfig({ include: ['src/**/*.{js,jsx}'], }, }, -}); +}); \ No newline at end of file