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