Persist prayer data to a mounted Docker volume via Express API
Replaces localStorage with a JSON-file backend (server.js). Prayers are stored in /data/prayers.json, which is mounted as a named Docker volume so data survives container restarts and rebuilds. React fetches from /api/prayers. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DReRqHxSYuVfCzTzQGD9hZ
This commit is contained in:
6 files changed
+130
-54
No files matched your search
+11
-4
@@ -5,7 +5,14 @@ RUN npm ci
|
|||||||
COPY . .
|
COPY . .
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
FROM nginx:alpine
|
FROM node:20-alpine
|
||||||
COPY --from=build /app/build /usr/share/nginx/html
|
WORKDIR /app
|
||||||
EXPOSE 80
|
COPY package*.json ./
|
||||||
CMD ["nginx", "-g", "daemon off;"]
|
RUN npm ci --omit=dev
|
||||||
|
COPY server.js ./
|
||||||
|
COPY --from=build /app/build ./build
|
||||||
|
|
||||||
|
ENV DATA_DIR=/data
|
||||||
|
VOLUME ["/data"]
|
||||||
|
EXPOSE 4000
|
||||||
|
CMD ["node", "server.js"]
|
||||||
+6
-1
@@ -2,4 +2,9 @@ services:
|
|||||||
prayer:
|
prayer:
|
||||||
build: .
|
build: .
|
||||||
ports:
|
ports:
|
||||||
- "3000:80"
|
- "3000:4000"
|
||||||
|
volumes:
|
||||||
|
- prayer-data:/data
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
prayer-data:
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const cors = require('cors');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data');
|
||||||
|
const DATA_FILE = path.join(DATA_DIR, 'prayers.json');
|
||||||
|
const PORT = process.env.PORT || 4000;
|
||||||
|
|
||||||
|
app.use(cors());
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(express.static(path.join(__dirname, 'build')));
|
||||||
|
|
||||||
|
function readPrayers() {
|
||||||
|
if (!fs.existsSync(DATA_FILE)) return [];
|
||||||
|
try {
|
||||||
|
return JSON.parse(fs.readFileSync(DATA_FILE, 'utf8'));
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writePrayers(prayers) {
|
||||||
|
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||||
|
fs.writeFileSync(DATA_FILE, JSON.stringify(prayers, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
app.get('/api/prayers', (req, res) => {
|
||||||
|
res.json(readPrayers());
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/prayers', (req, res) => {
|
||||||
|
const prayers = readPrayers();
|
||||||
|
const prayer = req.body;
|
||||||
|
prayers.unshift(prayer);
|
||||||
|
writePrayers(prayers);
|
||||||
|
res.status(201).json(prayer);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/api/prayers/:id', (req, res) => {
|
||||||
|
const prayers = readPrayers();
|
||||||
|
const idx = prayers.findIndex(p => p.id === req.params.id);
|
||||||
|
if (idx === -1) return res.status(404).json({ error: 'Not found' });
|
||||||
|
prayers[idx] = { ...prayers[idx], ...req.body };
|
||||||
|
writePrayers(prayers);
|
||||||
|
res.json(prayers[idx]);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/prayers/:id', (req, res) => {
|
||||||
|
const prayers = readPrayers();
|
||||||
|
const filtered = prayers.filter(p => p.id !== req.params.id);
|
||||||
|
writePrayers(filtered);
|
||||||
|
res.status(204).end();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Serve React for all other routes
|
||||||
|
app.get('*', (req, res) => {
|
||||||
|
res.sendFile(path.join(__dirname, 'build', 'index.html'));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.listen(PORT, () => console.log(`Server running on port ${PORT}`));
|
||||||
@@ -234,6 +234,13 @@ body {
|
|||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
padding: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
.empty {
|
.empty {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ type Filter = 'active' | 'answered' | 'all';
|
|||||||
export default function App() {
|
export default function App() {
|
||||||
const {
|
const {
|
||||||
prayers,
|
prayers,
|
||||||
|
loading,
|
||||||
addPrayer,
|
addPrayer,
|
||||||
updateAnswer,
|
updateAnswer,
|
||||||
markAnswered,
|
markAnswered,
|
||||||
@@ -111,6 +112,8 @@ export default function App() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{loading && <p className="loading">Loading prayers…</p>}
|
||||||
|
|
||||||
<ul className="prayer-list">
|
<ul className="prayer-list">
|
||||||
{filtered.length === 0 && (
|
{filtered.length === 0 && (
|
||||||
<li className="empty">
|
<li className="empty">
|
||||||
|
|||||||
+41
-49
@@ -1,23 +1,20 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { Prayer } from './types';
|
import { Prayer } from './types';
|
||||||
|
|
||||||
const STORAGE_KEY = 'prayer-journal';
|
const API = '/api/prayers';
|
||||||
|
|
||||||
export function usePrayers() {
|
export function usePrayers() {
|
||||||
const [prayers, setPrayers] = useState<Prayer[]>(() => {
|
const [prayers, setPrayers] = useState<Prayer[]>([]);
|
||||||
try {
|
const [loading, setLoading] = useState(true);
|
||||||
const stored = localStorage.getItem(STORAGE_KEY);
|
|
||||||
return stored ? JSON.parse(stored) : [];
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(prayers));
|
fetch(API)
|
||||||
}, [prayers]);
|
.then(r => r.json())
|
||||||
|
.then(setPrayers)
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
function addPrayer(request: string, tags: string[]) {
|
const addPrayer = useCallback(async (request: string, tags: string[]) => {
|
||||||
const prayer: Prayer = {
|
const prayer: Prayer = {
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
request,
|
request,
|
||||||
@@ -26,51 +23,46 @@ export function usePrayers() {
|
|||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
tags,
|
tags,
|
||||||
};
|
};
|
||||||
setPrayers(prev => [prayer, ...prev]);
|
const saved = await fetch(API, {
|
||||||
}
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(prayer),
|
||||||
|
}).then(r => r.json());
|
||||||
|
setPrayers(prev => [saved, ...prev]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
function updateAnswer(id: string, answer: string) {
|
const patch = useCallback(async (id: string, updates: Partial<Prayer>) => {
|
||||||
setPrayers(prev =>
|
const updated = await fetch(`${API}/${id}`, {
|
||||||
prev.map(p => p.id === id ? { ...p, answer } : p)
|
method: 'PUT',
|
||||||
);
|
headers: { 'Content-Type': 'application/json' },
|
||||||
}
|
body: JSON.stringify(updates),
|
||||||
|
}).then(r => r.json());
|
||||||
|
setPrayers(prev => prev.map(p => p.id === id ? updated : p));
|
||||||
|
}, []);
|
||||||
|
|
||||||
function markAnswered(id: string) {
|
const updateAnswer = useCallback((id: string, answer: string) =>
|
||||||
setPrayers(prev =>
|
patch(id, { answer }), [patch]);
|
||||||
prev.map(p =>
|
|
||||||
p.id === id
|
|
||||||
? { ...p, status: 'answered', answeredAt: new Date().toISOString() }
|
|
||||||
: p
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function markActive(id: string) {
|
const markAnswered = useCallback((id: string) =>
|
||||||
setPrayers(prev =>
|
patch(id, { status: 'answered', answeredAt: new Date().toISOString() }), [patch]);
|
||||||
prev.map(p =>
|
|
||||||
p.id === id ? { ...p, status: 'active', answeredAt: undefined } : p
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function archivePrayer(id: string) {
|
const markActive = useCallback((id: string) =>
|
||||||
setPrayers(prev =>
|
patch(id, { status: 'active', answeredAt: undefined }), [patch]);
|
||||||
prev.map(p => p.id === id ? { ...p, status: 'archived' } : p)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function deletePrayer(id: string) {
|
const archivePrayer = useCallback((id: string) =>
|
||||||
|
patch(id, { status: 'archived' }), [patch]);
|
||||||
|
|
||||||
|
const updateRequest = useCallback((id: string, request: string) =>
|
||||||
|
patch(id, { request }), [patch]);
|
||||||
|
|
||||||
|
const deletePrayer = useCallback(async (id: string) => {
|
||||||
|
await fetch(`${API}/${id}`, { method: 'DELETE' });
|
||||||
setPrayers(prev => prev.filter(p => p.id !== id));
|
setPrayers(prev => prev.filter(p => p.id !== id));
|
||||||
}
|
}, []);
|
||||||
|
|
||||||
function updateRequest(id: string, request: string) {
|
|
||||||
setPrayers(prev =>
|
|
||||||
prev.map(p => p.id === id ? { ...p, request } : p)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
prayers,
|
prayers,
|
||||||
|
loading,
|
||||||
addPrayer,
|
addPrayer,
|
||||||
updateAnswer,
|
updateAnswer,
|
||||||
markAnswered,
|
markAnswered,
|
||||||
|
|||||||
Reference in new issue
Block a user