Add DOCX export endpoint and Export button
- GET /api/export generates a Word document with all non-archived prayers - Accepts ?status=active|answered|all and ?tag= query params to match the current filter/tag selection in the UI - Document sections: Active Prayers + Answered Prayers with page break, each prayer showing person, request, answer, dates, and tags - Styled with Palatino Linotype, page numbers in footer - Export button in the toolbar triggers download of dated .docx file Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DReRqHxSYuVfCzTzQGD9hZ
This commit is contained in:
5 files changed
+383
-3
No files matched your search
Generated
+154
@@ -17,6 +17,7 @@
|
|||||||
"@types/react": "^19.2.17",
|
"@types/react": "^19.2.17",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"cors": "^2.8.6",
|
"cors": "^2.8.6",
|
||||||
|
"docx": "^9.7.1",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"react": "^19.2.7",
|
"react": "^19.2.7",
|
||||||
"react-dom": "^19.2.7",
|
"react-dom": "^19.2.7",
|
||||||
@@ -6673,6 +6674,50 @@
|
|||||||
"node": ">=6.0.0"
|
"node": ">=6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/docx": {
|
||||||
|
"version": "9.7.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/docx/-/docx-9.7.1.tgz",
|
||||||
|
"integrity": "sha512-ilXFf9Moz47ABjFpDiA5s1w9lpb4EFSp7+5iiJSbfyYDM+bpZdAgLlSr7fW4aXhVe/E+F6QCv0EvRVFEd5CsWg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "^25.2.3",
|
||||||
|
"hash.js": "^1.1.7",
|
||||||
|
"jszip": "^3.10.1",
|
||||||
|
"nanoid": "^5.1.3",
|
||||||
|
"xml": "^1.0.1",
|
||||||
|
"xml-js": "^1.6.8"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/docx/node_modules/@types/node": {
|
||||||
|
"version": "25.9.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.5.tgz",
|
||||||
|
"integrity": "sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"undici-types": ">=7.24.0 <7.24.7"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/docx/node_modules/nanoid": {
|
||||||
|
"version": "5.1.16",
|
||||||
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.16.tgz",
|
||||||
|
"integrity": "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ai"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"nanoid": "bin/nanoid.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18 || >=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/dom-accessibility-api": {
|
"node_modules/dom-accessibility-api": {
|
||||||
"version": "0.5.16",
|
"version": "0.5.16",
|
||||||
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
||||||
@@ -8852,6 +8897,16 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/hash.js": {
|
||||||
|
"version": "1.1.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz",
|
||||||
|
"integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"inherits": "^2.0.3",
|
||||||
|
"minimalistic-assert": "^1.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/hasown": {
|
"node_modules/hasown": {
|
||||||
"version": "2.0.4",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
|
||||||
@@ -9193,6 +9248,12 @@
|
|||||||
"node": ">= 4"
|
"node": ">= 4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/immediate": {
|
||||||
|
"version": "3.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
|
||||||
|
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/immer": {
|
"node_modules/immer": {
|
||||||
"version": "9.0.21",
|
"version": "9.0.21",
|
||||||
"resolved": "https://registry.npmjs.org/immer/-/immer-9.0.21.tgz",
|
"resolved": "https://registry.npmjs.org/immer/-/immer-9.0.21.tgz",
|
||||||
@@ -11063,6 +11124,54 @@
|
|||||||
"node": ">=4.0"
|
"node": ">=4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jszip": {
|
||||||
|
"version": "3.10.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
|
||||||
|
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
|
||||||
|
"license": "(MIT OR GPL-3.0-or-later)",
|
||||||
|
"dependencies": {
|
||||||
|
"lie": "~3.3.0",
|
||||||
|
"pako": "~1.0.2",
|
||||||
|
"readable-stream": "~2.3.6",
|
||||||
|
"setimmediate": "^1.0.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/jszip/node_modules/isarray": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/jszip/node_modules/readable-stream": {
|
||||||
|
"version": "2.3.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
|
||||||
|
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"core-util-is": "~1.0.0",
|
||||||
|
"inherits": "~2.0.3",
|
||||||
|
"isarray": "~1.0.0",
|
||||||
|
"process-nextick-args": "~2.0.0",
|
||||||
|
"safe-buffer": "~5.1.1",
|
||||||
|
"string_decoder": "~1.1.1",
|
||||||
|
"util-deprecate": "~1.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/jszip/node_modules/safe-buffer": {
|
||||||
|
"version": "5.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||||
|
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/jszip/node_modules/string_decoder": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"safe-buffer": "~5.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/keyv": {
|
"node_modules/keyv": {
|
||||||
"version": "4.5.4",
|
"version": "4.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||||
@@ -11149,6 +11258,15 @@
|
|||||||
"node": ">= 0.8.0"
|
"node": ">= 0.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lie": {
|
||||||
|
"version": "3.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
|
||||||
|
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"immediate": "~3.0.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/lilconfig": {
|
"node_modules/lilconfig": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz",
|
||||||
@@ -12058,6 +12176,12 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pako": {
|
||||||
|
"version": "1.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||||
|
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
||||||
|
"license": "(MIT AND Zlib)"
|
||||||
|
},
|
||||||
"node_modules/param-case": {
|
"node_modules/param-case": {
|
||||||
"version": "3.0.4",
|
"version": "3.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz",
|
||||||
@@ -14938,6 +15062,12 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/setimmediate": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
|
||||||
|
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/setprototypeof": {
|
"node_modules/setprototypeof": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||||
@@ -16458,6 +16588,12 @@
|
|||||||
"integrity": "sha512-+A5Sja4HP1M08MaXya7p5LvjuM7K6q/2EaC0+iovj/wOcMsTzMvDFbasi/oSapiwOlt252IqsKqPjCl7huKS0A==",
|
"integrity": "sha512-+A5Sja4HP1M08MaXya7p5LvjuM7K6q/2EaC0+iovj/wOcMsTzMvDFbasi/oSapiwOlt252IqsKqPjCl7huKS0A==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/undici-types": {
|
||||||
|
"version": "7.24.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
|
||||||
|
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/unicode-canonical-property-names-ecmascript": {
|
"node_modules/unicode-canonical-property-names-ecmascript": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz",
|
||||||
@@ -17753,6 +17889,24 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/xml": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/xml-js": {
|
||||||
|
"version": "1.6.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/xml-js/-/xml-js-1.6.11.tgz",
|
||||||
|
"integrity": "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"sax": "^1.2.4"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"xml-js": "bin/cli.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/xml-name-validator": {
|
"node_modules/xml-name-validator": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-3.0.0.tgz",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"@types/react": "^19.2.17",
|
"@types/react": "^19.2.17",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"cors": "^2.8.6",
|
"cors": "^2.8.6",
|
||||||
|
"docx": "^9.7.1",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"react": "^19.2.7",
|
"react": "^19.2.7",
|
||||||
"react-dom": "^19.2.7",
|
"react-dom": "^19.2.7",
|
||||||
|
|||||||
@@ -3,20 +3,24 @@ const cors = require('cors');
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { randomUUID } = require('crypto');
|
const { randomUUID } = require('crypto');
|
||||||
|
const {
|
||||||
|
Document, Packer, Paragraph, TextRun, HeadingLevel,
|
||||||
|
AlignmentType, BorderStyle, ShadingType, TableRow, TableCell,
|
||||||
|
Table, WidthType, PageBreak, Header, Footer, PageNumber,
|
||||||
|
NumberFormat,
|
||||||
|
} = require('docx');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data');
|
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data');
|
||||||
const DATA_FILE = path.join(DATA_DIR, 'prayers.json');
|
const DATA_FILE = path.join(DATA_DIR, 'prayers.json');
|
||||||
const PORT = process.env.PORT || 4000;
|
const PORT = process.env.PORT || 4000;
|
||||||
|
|
||||||
// Ensure data directory exists and is writable on startup
|
|
||||||
try {
|
try {
|
||||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||||
fs.accessSync(DATA_DIR, fs.constants.W_OK);
|
fs.accessSync(DATA_DIR, fs.constants.W_OK);
|
||||||
console.log(`Data directory ready: ${DATA_DIR}`);
|
console.log(`Data directory ready: ${DATA_DIR}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`WARNING: Data directory not writable: ${DATA_DIR} — ${err.message}`);
|
console.error(`WARNING: Data directory not writable: ${DATA_DIR} — ${err.message}`);
|
||||||
// Attempt chmod in case it's a simple permission gap
|
|
||||||
try { fs.chmodSync(DATA_DIR, 0o755); } catch {}
|
try { fs.chmodSync(DATA_DIR, 0o755); } catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,6 +42,201 @@ function writePrayers(prayers) {
|
|||||||
fs.writeFileSync(DATA_FILE, JSON.stringify(prayers, null, 2));
|
fs.writeFileSync(DATA_FILE, JSON.stringify(prayers, null, 2));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fmtDate(iso) {
|
||||||
|
return iso ? new Date(iso).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' }) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function prayerToSection(prayer, index) {
|
||||||
|
const paras = [];
|
||||||
|
|
||||||
|
// Prayer number + person
|
||||||
|
const titleRuns = [
|
||||||
|
new TextRun({ text: `${index + 1}. `, bold: true, size: 24, color: '4B6FA8' }),
|
||||||
|
];
|
||||||
|
if (prayer.person) {
|
||||||
|
titleRuns.push(new TextRun({ text: prayer.person, bold: true, size: 24, color: '4B6FA8' }));
|
||||||
|
}
|
||||||
|
paras.push(new Paragraph({ children: titleRuns, spacing: { before: 240, after: 60 } }));
|
||||||
|
|
||||||
|
// Meta: date + tags
|
||||||
|
const metaParts = [`Added ${fmtDate(prayer.createdAt)}`];
|
||||||
|
if (prayer.tags && prayer.tags.length) metaParts.push(`Tags: ${prayer.tags.join(', ')}`);
|
||||||
|
paras.push(new Paragraph({
|
||||||
|
children: [new TextRun({ text: metaParts.join(' · '), size: 18, color: '888888', italics: true })],
|
||||||
|
spacing: { after: 120 },
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Request
|
||||||
|
paras.push(new Paragraph({
|
||||||
|
children: [new TextRun({ text: 'Request', bold: true, size: 20, color: '333333' })],
|
||||||
|
spacing: { after: 60 },
|
||||||
|
}));
|
||||||
|
paras.push(new Paragraph({
|
||||||
|
children: [new TextRun({ text: prayer.request, size: 22 })],
|
||||||
|
spacing: { after: 160 },
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Answer (if present)
|
||||||
|
if (prayer.answer) {
|
||||||
|
paras.push(new Paragraph({
|
||||||
|
children: [new TextRun({ text: 'Answer', bold: true, size: 20, color: '4A8C6A' })],
|
||||||
|
spacing: { after: 60 },
|
||||||
|
}));
|
||||||
|
paras.push(new Paragraph({
|
||||||
|
children: [new TextRun({ text: prayer.answer, size: 22, color: '2D5C40' })],
|
||||||
|
spacing: { after: 80 },
|
||||||
|
}));
|
||||||
|
if (prayer.answeredAt) {
|
||||||
|
paras.push(new Paragraph({
|
||||||
|
children: [new TextRun({ text: `Answered ${fmtDate(prayer.answeredAt)}`, size: 18, italics: true, color: '888888' })],
|
||||||
|
spacing: { after: 160 },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Divider paragraph with bottom border
|
||||||
|
paras.push(new Paragraph({
|
||||||
|
children: [],
|
||||||
|
spacing: { after: 200 },
|
||||||
|
border: { bottom: { style: BorderStyle.SINGLE, size: 1, color: 'E2DAC8' } },
|
||||||
|
}));
|
||||||
|
|
||||||
|
return paras;
|
||||||
|
}
|
||||||
|
|
||||||
|
app.get('/api/export', (req, res) => {
|
||||||
|
try {
|
||||||
|
const all = readPrayers();
|
||||||
|
const status = req.query.status || 'all';
|
||||||
|
const tag = req.query.tag || '';
|
||||||
|
|
||||||
|
const prayers = all.filter(p => {
|
||||||
|
const statusMatch = status === 'all' ? p.status !== 'archived' : p.status === status;
|
||||||
|
const tagMatch = tag ? (p.tags || []).includes(tag) : true;
|
||||||
|
return statusMatch && tagMatch;
|
||||||
|
});
|
||||||
|
|
||||||
|
const active = prayers.filter(p => p.status === 'active');
|
||||||
|
const answered = prayers.filter(p => p.status === 'answered');
|
||||||
|
|
||||||
|
const titleLabel = status === 'active' ? 'Active Prayers'
|
||||||
|
: status === 'answered' ? 'Answered Prayers'
|
||||||
|
: 'Prayer Journal';
|
||||||
|
|
||||||
|
const children = [
|
||||||
|
new Paragraph({
|
||||||
|
text: 'Prayer Journal',
|
||||||
|
heading: HeadingLevel.TITLE,
|
||||||
|
alignment: AlignmentType.CENTER,
|
||||||
|
spacing: { after: 120 },
|
||||||
|
}),
|
||||||
|
new Paragraph({
|
||||||
|
children: [new TextRun({ text: `Exported ${fmtDate(new Date().toISOString())}`, size: 20, italics: true, color: '888888' })],
|
||||||
|
alignment: AlignmentType.CENTER,
|
||||||
|
spacing: { after: tag ? 80 : 480 },
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (tag) {
|
||||||
|
children.push(new Paragraph({
|
||||||
|
children: [new TextRun({ text: `Tag: ${tag}`, size: 20, italics: true, color: '4B6FA8' })],
|
||||||
|
alignment: AlignmentType.CENTER,
|
||||||
|
spacing: { after: 480 },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'all' || status === 'active') {
|
||||||
|
if (active.length) {
|
||||||
|
children.push(new Paragraph({
|
||||||
|
text: `Active Prayers (${active.length})`,
|
||||||
|
heading: HeadingLevel.HEADING_1,
|
||||||
|
spacing: { before: 0, after: 240 },
|
||||||
|
}));
|
||||||
|
active.forEach((p, i) => children.push(...prayerToSection(p, i)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'all' && answered.length && active.length) {
|
||||||
|
children.push(new Paragraph({ children: [new PageBreak()] }));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'all' || status === 'answered') {
|
||||||
|
if (answered.length) {
|
||||||
|
children.push(new Paragraph({
|
||||||
|
text: `Answered Prayers (${answered.length})`,
|
||||||
|
heading: HeadingLevel.HEADING_1,
|
||||||
|
spacing: { before: 0, after: 240 },
|
||||||
|
}));
|
||||||
|
answered.forEach((p, i) => children.push(...prayerToSection(p, i)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prayers.length === 0) {
|
||||||
|
children.push(new Paragraph({
|
||||||
|
children: [new TextRun({ text: 'No prayers found.', italics: true, color: '888888' })],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const doc = new Document({
|
||||||
|
styles: {
|
||||||
|
default: {
|
||||||
|
document: {
|
||||||
|
run: { font: 'Palatino Linotype', size: 22 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
paragraphStyles: [
|
||||||
|
{
|
||||||
|
id: 'Title',
|
||||||
|
name: 'Title',
|
||||||
|
basedOn: 'Normal',
|
||||||
|
run: { size: 56, bold: false, color: '252018', font: 'Palatino Linotype' },
|
||||||
|
paragraph: { spacing: { before: 0, after: 120 } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'Heading1',
|
||||||
|
name: 'Heading 1',
|
||||||
|
basedOn: 'Normal',
|
||||||
|
run: { size: 32, bold: true, color: '4B6FA8', font: 'Palatino Linotype' },
|
||||||
|
paragraph: {
|
||||||
|
spacing: { before: 480, after: 240 },
|
||||||
|
border: { bottom: { style: BorderStyle.SINGLE, size: 2, color: 'C4A882' } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
sections: [{
|
||||||
|
properties: {
|
||||||
|
page: { size: { width: 12240, height: 15840 } },
|
||||||
|
margin: { top: 1440, bottom: 1440, left: 1440, right: 1440 },
|
||||||
|
},
|
||||||
|
footers: {
|
||||||
|
default: new Footer({
|
||||||
|
children: [new Paragraph({
|
||||||
|
alignment: AlignmentType.CENTER,
|
||||||
|
children: [
|
||||||
|
new TextRun({ children: [PageNumber.CURRENT], size: 18, color: '888888' }),
|
||||||
|
new TextRun({ text: ' / ', size: 18, color: '888888' }),
|
||||||
|
new TextRun({ children: [PageNumber.TOTAL_PAGES], size: 18, color: '888888' }),
|
||||||
|
],
|
||||||
|
})],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
children,
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
|
||||||
|
Packer.toBuffer(doc).then(buffer => {
|
||||||
|
const filename = `prayer-journal-${new Date().toISOString().slice(0, 10)}.docx`;
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||||
|
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document');
|
||||||
|
res.send(buffer);
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Export failed:', err.message);
|
||||||
|
res.status(500).json({ error: 'Export failed: ' + err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.get('/api/health', (req, res) => {
|
app.get('/api/health', (req, res) => {
|
||||||
let writable = false;
|
let writable = false;
|
||||||
try { fs.accessSync(DATA_DIR, fs.constants.W_OK); writable = true; } catch {}
|
try { fs.accessSync(DATA_DIR, fs.constants.W_OK); writable = true; } catch {}
|
||||||
@@ -87,7 +286,6 @@ app.delete('/api/prayers/:id', (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Serve React for all other routes
|
|
||||||
app.get('*splat', (req, res) => {
|
app.get('*splat', (req, res) => {
|
||||||
res.sendFile(path.join(__dirname, 'build', 'index.html'));
|
res.sendFile(path.join(__dirname, 'build', 'index.html'));
|
||||||
});
|
});
|
||||||
|
|||||||
+16
@@ -214,6 +214,22 @@ body {
|
|||||||
gap: .5rem;
|
gap: .5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-export {
|
||||||
|
background: var(--surface);
|
||||||
|
border-color: var(--border);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
font-size: .82rem;
|
||||||
|
padding: .38rem .8rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-export:hover {
|
||||||
|
color: var(--text);
|
||||||
|
border-color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
/* Search */
|
/* Search */
|
||||||
.search-wrap {
|
.search-wrap {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|||||||
+11
@@ -103,6 +103,13 @@ export default function App() {
|
|||||||
setActiveTag(prev => prev === tag ? null : tag);
|
setActiveTag(prev => prev === tag ? null : tag);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleExport() {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('status', filter);
|
||||||
|
if (activeTag) params.set('tag', activeTag);
|
||||||
|
window.location.href = `/api/export?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
const emptyMessage = search
|
const emptyMessage = search
|
||||||
? `No results for "${search}".`
|
? `No results for "${search}".`
|
||||||
: activeTag
|
: activeTag
|
||||||
@@ -167,6 +174,10 @@ export default function App() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<button className="btn btn-export" onClick={handleExport} title="Export to Word">
|
||||||
|
↓ Export
|
||||||
|
</button>
|
||||||
|
|
||||||
<div className="search-wrap">
|
<div className="search-wrap">
|
||||||
<span className="search-icon">⌕</span>
|
<span className="search-icon">⌕</span>
|
||||||
<input
|
<input
|
||||||
|
|||||||
Reference in new issue
Block a user