diff --git a/.gitea/workflows/docker.yml b/.gitea/workflows/docker.yml new file mode 100644 index 0000000..aea52c6 --- /dev/null +++ b/.gitea/workflows/docker.yml @@ -0,0 +1,51 @@ +name: Build & Publish Docker Image + +on: + push: + branches: + - main + pull_request: + branches: + - main + +env: + REGISTRY: git.necloud.us + IMAGE_NAME: ${{ github.repository }} + +jobs: + build-and-push: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Log in to Gitea Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ secrets.REGISTRY_USER }} + password: ${{ secrets.REGISTRY_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=ref,event=branch + type=sha,prefix=sha- + type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push + uses: docker/build-push-action@v5 + with: + context: . + push: ${{ github.event_name == 'push' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000..a11777c Binary files /dev/null and b/public/favicon.ico differ diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..0be728f --- /dev/null +++ b/public/index.html @@ -0,0 +1,43 @@ + + + + + + + + + + + + + Prayer Journal + + + +
+ + + diff --git a/public/logo192.png b/public/logo192.png new file mode 100644 index 0000000..fc44b0a Binary files /dev/null and b/public/logo192.png differ diff --git a/public/logo512.png b/public/logo512.png new file mode 100644 index 0000000..a4e47a6 Binary files /dev/null and b/public/logo512.png differ diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 0000000..e9e57dc --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,3 @@ +# https://www.robotstxt.org/robotstxt.html +User-agent: * +Disallow: diff --git a/server.js b/server.js new file mode 100644 index 0000000..cd243c3 --- /dev/null +++ b/server.js @@ -0,0 +1,421 @@ +const express = require('express'); +const cors = require('cors'); +const fs = require('fs'); +const path = require('path'); +const { randomUUID } = require('crypto'); +const cron = require('node-cron'); +const { + Document, Packer, Paragraph, TextRun, HeadingLevel, + AlignmentType, BorderStyle, PageBreak, Footer, PageNumber, +} = require('docx'); + +const app = express(); +const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data'); +const DATA_FILE = path.join(DATA_DIR, 'prayers.json'); +const SETTINGS_FILE = path.join(DATA_DIR, 'settings.json'); +const PORT = process.env.PORT || 4000; + +try { + fs.mkdirSync(DATA_DIR, { recursive: true }); + fs.accessSync(DATA_DIR, fs.constants.W_OK); + console.log(`Data directory ready: ${DATA_DIR}`); +} catch (err) { + console.error(`WARNING: Data directory not writable: ${DATA_DIR} — ${err.message}`); + try { fs.chmodSync(DATA_DIR, 0o755); } catch {} +} + +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)); +} + +function readSettings() { + if (!fs.existsSync(SETTINGS_FILE)) return {}; + try { return JSON.parse(fs.readFileSync(SETTINGS_FILE, 'utf8')); } catch { return {}; } +} + +function writeSettings(s) { + fs.mkdirSync(DATA_DIR, { recursive: true }); + fs.writeFileSync(SETTINGS_FILE, JSON.stringify(s, null, 2)); +} + +function fmtDate(iso) { + return iso ? new Date(iso).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' }) : ''; +} + +async function sendNtfy(topic, title, message, tags = []) { + if (!topic) return; + const url = topic.startsWith('http') ? topic : `https://ntfy.sh/${topic}`; + // HTTP headers must be ASCII-only; replace non-ASCII characters + const safeTitle = title.replace(/[^\x00-\x7F]/g, '-'); + try { + const res = await fetch(url, { + method: 'POST', + headers: { + 'Title': safeTitle, + 'Content-Type': 'text/plain', + ...(tags.length ? { 'Tags': tags.join(',') } : {}), + }, + body: message, + }); + if (!res.ok) console.error('ntfy error:', res.status, await res.text()); + else console.log(`ntfy sent: "${title}"`); + } catch (err) { + console.error('ntfy send failed:', err.message); + } +} + +async function sendDailyDigest() { + const settings = readSettings(); + if (!settings.ntfyTopic) return; + const prayers = readPrayers().filter(p => p.status === 'active'); + if (!prayers.length) return; + const lines = prayers.map((p, i) => { + const who = p.person ? `${p.person}: ` : ''; + return `${i + 1}. ${who}${p.request}`; + }); + await sendNtfy( + settings.ntfyTopic, + `Prayer Journal -${prayers.length} active prayer${prayers.length !== 1 ? 's' : ''}`, + lines.join('\n'), + ['pray', 'raised_hands'] + ); +} + +async function checkPrayerReminders() { + const settings = readSettings(); + if (!settings.ntfyTopic) return; + const prayers = readPrayers().filter(p => p.status === 'active' && p.reminderDays && p.reminderDays.length); + if (!prayers.length) return; + + const now = new Date(); + const dayName = ['sun','mon','tue','wed','thu','fri','sat'][now.getDay()]; + const currentHHMM = `${String(now.getHours()).padStart(2,'0')}:${String(now.getMinutes()).padStart(2,'0')}`; + const reminderTime = settings.reminderTime || '08:00'; + if (currentHHMM !== reminderTime) return; + + for (const prayer of prayers) { + if ((prayer.reminderDays || []).includes(dayName)) { + const title = prayer.person ? `Praying for ${prayer.person}` : 'Prayer reminder'; + await sendNtfy(settings.ntfyTopic, title, prayer.request, ['pray', 'raised_hands']); + } + } +} + +// Cron: every minute checks whether it's time for digest or per-prayer reminders +cron.schedule('* * * * *', async () => { + const settings = readSettings(); + if (!settings.ntfyTopic) return; + const now = new Date(); + const hhmm = `${String(now.getHours()).padStart(2,'0')}:${String(now.getMinutes()).padStart(2,'0')}`; + if (settings.dailyDigestTime && hhmm === settings.dailyDigestTime) await sendDailyDigest(); + if (settings.reminderTime) await checkPrayerReminders(); +}); +console.log('Notification cron scheduled.'); + +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 }, + })); + } + } + + // Notes (if present) + if (prayer.notes && prayer.notes.trim()) { + paras.push(new Paragraph({ + children: [new TextRun({ text: 'Notes', bold: true, size: 20, color: '5A5A2A' })], + spacing: { after: 60 }, + })); + paras.push(new Paragraph({ + children: [new TextRun({ text: prayer.notes, size: 22 })], + spacing: { after: 160 }, + })); + } + + // Prayer log (if present) + if (prayer.prayedLog && prayer.prayedLog.length > 0) { + paras.push(new Paragraph({ + children: [new TextRun({ text: `Prayer Log (${prayer.prayedLog.length} session${prayer.prayedLog.length !== 1 ? 's' : ''})`, bold: true, size: 20, color: '4B6FA8' })], + spacing: { after: 60 }, + })); + const sorted = [...prayer.prayedLog].sort((a, b) => new Date(b.date) - new Date(a.date)); + sorted.forEach(entry => { + const runs = [new TextRun({ text: fmtDate(entry.date), bold: true, size: 19, color: '555555' })]; + if (entry.note) runs.push(new TextRun({ text: ` — ${entry.note}`, size: 19, color: '666666' })); + paras.push(new Paragraph({ children: runs, spacing: { after: 60 } })); + }); + paras.push(new Paragraph({ children: [], spacing: { after: 80 } })); + } + + // 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/settings', (req, res) => { + res.json(readSettings()); +}); + +app.put('/api/settings', (req, res) => { + try { + const updated = { ...readSettings(), ...req.body }; + writeSettings(updated); + res.json(updated); + } catch (err) { + res.status(500).json({ error: 'Could not save settings.' }); + } +}); + +app.post('/api/notifications/test', async (req, res) => { + const settings = readSettings(); + if (!settings.ntfyTopic) return res.status(400).json({ error: 'No ntfy topic configured.' }); + await sendNtfy(settings.ntfyTopic, 'Prayer Journal -Test', 'Notifications are working! 🙏', ['white_check_mark']); + res.json({ ok: true }); +}); + +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) => { + let writable = false; + try { fs.accessSync(DATA_DIR, fs.constants.W_OK); writable = true; } catch {} + res.json({ ok: writable, dataDir: DATA_DIR, dataFile: DATA_FILE }); +}); + +app.get('/api/prayers', (req, res) => { + res.json(readPrayers()); +}); + +app.post('/api/prayers', (req, res) => { + try { + const prayers = readPrayers(); + const prayer = { id: randomUUID(), ...req.body }; + prayers.unshift(prayer); + writePrayers(prayers); + res.status(201).json(prayer); + } catch (err) { + console.error('Write failed:', err.message); + res.status(500).json({ error: 'Could not save prayer. Check data directory permissions.' }); + } +}); + +app.put('/api/prayers/:id', (req, res) => { + try { + 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]); + } catch (err) { + console.error('Write failed:', err.message); + res.status(500).json({ error: 'Could not save prayer. Check data directory permissions.' }); + } +}); + +app.delete('/api/prayers/:id', (req, res) => { + try { + const prayers = readPrayers(); + const filtered = prayers.filter(p => p.id !== req.params.id); + writePrayers(filtered); + res.status(204).end(); + } catch (err) { + console.error('Write failed:', err.message); + res.status(500).json({ error: 'Could not delete prayer. Check data directory permissions.' }); + } +}); + +app.get('*splat', (req, res) => { + res.sendFile(path.join(__dirname, 'build', 'index.html')); +}); + +app.listen(PORT, () => console.log(`Server running on port ${PORT}, data: ${DATA_FILE}`)); diff --git a/src/App.css b/src/App.css new file mode 100644 index 0000000..d6611db --- /dev/null +++ b/src/App.css @@ -0,0 +1,1047 @@ +:root { + --bg: #f7f5f0; + --surface: #ffffff; + --surface-hover: #f9f8f6; + --border: #e5e0d8; + --text: #2d2a25; + --text-muted: #7a7570; + --primary: #6b5b45; + --primary-hover: #56483a; + --answered: #3d6b52; + --answered-bg: #edf4f0; + --active-dot: #c07a3a; + --answered-dot: #3d6b52; + --tag-bg: #ede9e2; + --tag-text: #6b5b45; + --danger: #b54040; + --shadow: 0 1px 4px rgba(0,0,0,0.07); +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #1a1814; + --surface: #242018; + --surface-hover: #2c2820; + --border: #3a3530; + --text: #e8e3da; + --text-muted: #8a8480; + --primary: #c4a882; + --primary-hover: #d4b892; + --answered: #7abf96; + --answered-bg: #1a2e22; + --active-dot: #e09050; + --answered-dot: #7abf96; + --tag-bg: #322e26; + --tag-text: #c4a882; + --danger: #e07070; + --shadow: 0 1px 4px rgba(0,0,0,0.3); + } +} + +* { box-sizing: border-box; margin: 0; padding: 0; } + +body { + background: var(--bg); + color: var(--text); + font-family: Georgia, 'Times New Roman', serif; + min-height: 100vh; +} + +.app { + max-width: 680px; + margin: 0 auto; + padding: 2rem 1rem 4rem; +} + +/* Header */ +.header { + text-align: center; + margin-bottom: 2rem; +} + +.header h1 { + font-size: 2rem; + font-weight: normal; + letter-spacing: 0.01em; + color: var(--text); +} + +.subtitle { + margin-top: 0.35rem; + color: var(--text-muted); + font-style: italic; + font-size: 0.95rem; +} + +/* Add form */ +.add-form { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 10px; + padding: 1.25rem; + box-shadow: var(--shadow); + margin-bottom: 1.5rem; +} + +.request-input, +.answer-input { + width: 100%; + border: 1px solid var(--border); + border-radius: 6px; + padding: 0.6rem 0.75rem; + background: var(--bg); + color: var(--text); + font-family: inherit; + font-size: 0.95rem; + resize: vertical; + outline: none; + transition: border-color 0.15s; +} + +.request-input:focus, +.answer-input:focus { + border-color: var(--primary); +} + +.add-row { + display: flex; + gap: 0.75rem; + margin-top: 0.75rem; + align-items: center; +} + +.tag-input { + flex: 1; + border: 1px solid var(--border); + border-radius: 6px; + padding: 0.5rem 0.75rem; + background: var(--bg); + color: var(--text); + font-family: inherit; + font-size: 0.9rem; + outline: none; +} + +.tag-input:focus { + border-color: var(--primary); +} + +/* Buttons */ +.btn { + padding: 0.55rem 1.1rem; + border-radius: 6px; + border: 1px solid var(--border); + background: var(--surface); + color: var(--text); + font-family: inherit; + font-size: 0.9rem; + cursor: pointer; + transition: background 0.12s, border-color 0.12s; + white-space: nowrap; +} + +.btn:hover { + background: var(--surface-hover); +} + +.btn-primary { + background: var(--primary); + color: #fff; + border-color: var(--primary); +} + +.btn-primary:hover { + background: var(--primary-hover); + border-color: var(--primary-hover); +} + +.btn-sm { + padding: 0.35rem 0.75rem; + font-size: 0.82rem; +} + +.btn.ghost { + background: transparent; + border-color: transparent; + color: var(--text-muted); + font-size: 0.82rem; + padding: 0.3rem 0.5rem; +} + +.btn.ghost:hover { + color: var(--text); + background: var(--surface-hover); +} + +.btn.ghost.danger { + color: var(--danger); +} + +.btn.ghost.danger:hover { + background: var(--surface-hover); +} + +/* Person field */ +.person-input { + width: 100%; + border: 1px solid var(--border); + border-radius: 6px; + padding: .5rem .75rem; + background: var(--bg); + color: var(--text); + font-family: inherit; + font-size: .95rem; + outline: none; + margin-bottom: .6rem; + transition: border-color .15s; +} + +.person-input:focus { border-color: var(--primary); } +.person-input::placeholder { color: var(--text-muted); } + +/* Toolbar: filter + search on one row */ +.toolbar { + display: flex; + gap: .75rem; + align-items: center; + margin-bottom: 1rem; + flex-wrap: wrap; +} + +/* Filter bar */ +.filter-bar { + display: flex; + 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-wrap { + position: relative; + flex: 1; + min-width: 160px; +} + +.search-icon { + position: absolute; + left: .6rem; + top: 50%; + transform: translateY(-50%); + color: var(--text-muted); + font-size: 1.1rem; + pointer-events: none; + line-height: 1; +} + +.search-input { + width: 100%; + border: 1px solid var(--border); + border-radius: 20px; + padding: .38rem .75rem .38rem 1.9rem; + background: var(--surface); + color: var(--text); + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + font-size: .88rem; + outline: none; + transition: border-color .15s; +} + +.search-input:focus { border-color: var(--primary); } +.search-input::placeholder { color: var(--text-muted); } + +.search-clear { + position: absolute; + right: .5rem; + top: 50%; + transform: translateY(-50%); + background: none; + border: none; + color: var(--text-muted); + cursor: pointer; + font-size: .75rem; + padding: .2rem; + line-height: 1; +} + +.search-clear:hover { color: var(--text); } + +.filter-btn { + padding: 0.4rem 0.9rem; + border-radius: 20px; + border: 1px solid var(--border); + background: var(--surface); + color: var(--text-muted); + font-family: inherit; + font-size: 0.88rem; + cursor: pointer; + transition: background 0.12s, color 0.12s; + display: flex; + align-items: center; + gap: 0.4rem; +} + +.filter-btn:hover { + background: var(--surface-hover); + color: var(--text); +} + +.filter-btn.active { + background: var(--primary); + color: #fff; + border-color: var(--primary); +} + +.count { + background: rgba(255,255,255,0.25); + border-radius: 10px; + padding: 0 0.4rem; + font-size: 0.78rem; +} + +.filter-btn:not(.active) .count { + background: var(--tag-bg); + color: var(--tag-text); +} + +/* Prayer list */ +/* Tag filter bar */ +.tag-filter-bar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: .4rem; + margin-bottom: 1rem; + padding: .6rem .75rem; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 8px; +} + +.tag-filter-label { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + font-size: .72rem; + letter-spacing: .06em; + text-transform: uppercase; + color: var(--text-muted); + margin-right: .2rem; +} + +.tag-filter-btn { + background: var(--tag-bg); + color: var(--tag-text); + border: 1px solid transparent; + border-radius: 4px; + padding: .2rem .55rem; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + font-size: .78rem; + cursor: pointer; + transition: background .12s, border-color .12s, color .12s; +} + +.tag-filter-btn:hover { + border-color: var(--primary); + color: var(--primary); +} + +.tag-filter-btn.active { + background: var(--primary); + color: var(--primary-fg); + border-color: var(--primary); +} + +.tag-clear-btn { + background: none; + border: none; + color: var(--text-muted); + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + font-size: .78rem; + cursor: pointer; + padding: .2rem .4rem; + margin-left: .2rem; +} + +.tag-clear-btn:hover { color: var(--danger); } + +/* Clickable tags on cards */ +.tag { + cursor: pointer; + transition: background .12s, color .12s; +} + +.tag:hover { background: var(--primary); color: var(--primary-fg); } +.tag.tag-active { background: var(--primary); color: var(--primary-fg); } + +.prayer-list { + list-style: none; + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.error-banner { + background: #fef2f2; + border: 1px solid #fca5a5; + color: #b91c1c; + border-radius: 8px; + padding: .75rem 1rem; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + font-size: .88rem; + margin-bottom: 1rem; +} + +@media (prefers-color-scheme: dark) { + .error-banner { + background: #2d1515; + border-color: #7f1d1d; + color: #fca5a5; + } +} + +:root[data-theme="dark"] .error-banner { + background: #2d1515; + border-color: #7f1d1d; + color: #fca5a5; +} + +.loading { + text-align: center; + color: var(--text-muted); + font-style: italic; + padding: 1rem 0; +} + +.empty { + text-align: center; + color: var(--text-muted); + font-style: italic; + padding: 2rem 0; +} + +/* Prayer card */ +.prayer-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 10px; + box-shadow: var(--shadow); + overflow: hidden; + transition: border-color 0.15s; +} + +.prayer-card.answered { + border-left: 3px solid var(--answered-dot); +} + +.prayer-card.active { + border-left: 3px solid var(--active-dot); +} + +.card-top { + padding: 1rem 1.1rem; + cursor: pointer; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.card-top:hover { + background: var(--surface-hover); +} + +.card-main { + display: flex; + align-items: flex-start; + gap: 0.6rem; +} + +.status-dot { + width: 10px; + height: 10px; + border-radius: 50%; + flex-shrink: 0; + margin-top: 5px; +} + +.status-dot.active { background: var(--active-dot); } +.status-dot.answered { background: var(--answered-dot); } +.status-dot.archived { background: var(--text-muted); } + +.card-text { + flex: 1; + display: flex; + flex-direction: column; + gap: .15rem; +} + +.person-name { + font-size: .82rem; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + font-weight: 600; + color: var(--primary); + letter-spacing: .01em; +} + +mark.highlight { + background: #fde68a; + color: inherit; + border-radius: 2px; + padding: 0 1px; +} + +@media (prefers-color-scheme: dark) { + mark.highlight { background: #78540a; } +} + +:root[data-theme="dark"] mark.highlight { background: #78540a; } +:root[data-theme="light"] mark.highlight { background: #fde68a; } + +.request-text { + font-size: 0.97rem; + line-height: 1.5; +} + +.card-meta { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; + padding-left: 1.6rem; +} + +.date { + font-size: 0.8rem; + color: var(--text-muted); +} + +.tag { + background: var(--tag-bg); + color: var(--tag-text); + border-radius: 4px; + padding: 0.1rem 0.45rem; + font-size: 0.75rem; + font-family: sans-serif; +} + +.chevron { + margin-left: auto; + color: var(--text-muted); + font-size: 0.7rem; +} + +/* Card body */ +.card-body { + padding: 0 1.1rem 1rem; + border-top: 1px solid var(--border); + display: flex; + flex-direction: column; + gap: 1rem; +} + +.answer-section { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding-top: 0.75rem; +} + +.answer-label { + font-size: 0.78rem; + font-family: sans-serif; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-muted); +} + +.answer-text { + font-size: 0.93rem; + line-height: 1.6; + color: var(--answered); + white-space: pre-wrap; +} + +.answered-date { + font-size: 0.78rem; + color: var(--text-muted); + font-family: sans-serif; +} + +.card-actions { + display: flex; + gap: 0.5rem; + align-items: center; + flex-wrap: wrap; +} + +.card-actions.secondary { + border-top: 1px solid var(--border); + padding-top: 0.5rem; +} + +/* Tab bar */ +.tab-bar { + display: flex; + gap: 0.5rem; + margin-bottom: 1.5rem; + border-bottom: 1px solid var(--border); + padding-bottom: 0; +} + +.tab-btn { + padding: 0.5rem 1.1rem; + border: none; + border-bottom: 2px solid transparent; + background: none; + color: var(--text-muted); + font-family: inherit; + font-size: 0.95rem; + cursor: pointer; + transition: color 0.12s, border-color 0.12s; + margin-bottom: -1px; +} + +.tab-btn:hover { color: var(--text); } + +.tab-btn.active { + color: var(--primary); + border-bottom-color: var(--primary); +} + +/* Prayer count badge on card */ +.prayed-count { + font-size: 0.75rem; + font-family: sans-serif; + color: var(--text-muted); + background: var(--tag-bg); + border-radius: 4px; + padding: 0.1rem 0.4rem; +} + +/* Notes section */ +.notes-section { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding-top: 0.75rem; + border-top: 1px solid var(--border); +} + +.notes-text { + font-size: 0.93rem; + line-height: 1.6; + white-space: pre-wrap; + color: var(--text); +} + +/* Prayer log section inside card */ +.prayed-log-section { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding-top: 0.75rem; + border-top: 1px solid var(--border); +} + +.prayed-log-header { + display: flex; + align-items: center; + justify-content: space-between; +} + +.prayed-log-form { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 0.75rem; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 6px; +} + +.log-date-input { + border: 1px solid var(--border); + border-radius: 6px; + padding: 0.4rem 0.6rem; + background: var(--surface); + color: var(--text); + font-family: inherit; + font-size: 0.9rem; + outline: none; + width: fit-content; +} + +.log-date-input:focus { border-color: var(--primary); } + +.log-note-input { + width: 100%; + border: 1px solid var(--border); + border-radius: 6px; + padding: 0.5rem 0.75rem; + background: var(--surface); + color: var(--text); + font-family: inherit; + font-size: 0.9rem; + resize: vertical; + outline: none; +} + +.log-note-input:focus { border-color: var(--primary); } + +.prayed-log-list { + list-style: none; + display: flex; + flex-direction: column; + gap: 0.4rem; + max-height: 220px; + overflow-y: auto; +} + +.prayed-log-entry { + padding: 0.4rem 0.6rem; + background: var(--bg); + border-radius: 5px; + border-left: 2px solid var(--primary); +} + +.prayed-log-date { + font-size: 0.78rem; + font-family: sans-serif; + color: var(--text-muted); + font-weight: 600; +} + +.prayed-log-note { + font-size: 0.88rem; + line-height: 1.5; + margin-top: 0.2rem; + color: var(--text); + white-space: pre-wrap; +} + +.prayed-log-empty { + font-size: 0.85rem; + color: var(--text-muted); + font-style: italic; +} + +/* Calendar view */ +.calendar-view { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.calendar-header { + display: flex; + align-items: center; + justify-content: center; + gap: 1.25rem; +} + +.cal-month-name { + font-size: 1.2rem; + font-weight: normal; + min-width: 180px; + text-align: center; +} + +.cal-nav-btn { + background: none; + border: 1px solid var(--border); + border-radius: 6px; + padding: 0.3rem 0.75rem; + font-size: 1.2rem; + cursor: pointer; + color: var(--text-muted); + line-height: 1; + transition: background 0.12s, color 0.12s; +} + +.cal-nav-btn:hover { + background: var(--surface-hover); + color: var(--text); +} + +.cal-summary { + text-align: center; + font-size: 0.85rem; + color: var(--text-muted); + font-style: italic; +} + +.cal-grid { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 3px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 10px; + padding: 0.75rem; + box-shadow: var(--shadow); +} + +.cal-dow { + text-align: center; + font-size: 0.72rem; + font-family: sans-serif; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-muted); + padding: 0.3rem 0; +} + +.cal-cell { + min-height: 52px; + border-radius: 6px; + padding: 0.3rem 0.4rem; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.2rem; + transition: background 0.1s; +} + +.cal-blank { background: transparent; } + +.cal-cell:not(.cal-blank) { + cursor: default; +} + +.cal-has-prayers { + cursor: pointer !important; +} + +.cal-has-prayers:hover { + background: var(--surface-hover); +} + +.cal-today .cal-day-num { + background: var(--primary); + color: #fff; + border-radius: 50%; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; +} + +.cal-selected { + background: var(--tag-bg); + outline: 1px solid var(--primary); +} + +.cal-day-num { + font-size: 0.82rem; + font-family: sans-serif; + color: var(--text); + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; +} + +.cal-dot-row { + display: flex; + gap: 2px; + align-items: center; + flex-wrap: wrap; + justify-content: center; +} + +.cal-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--primary); + flex-shrink: 0; +} + +.cal-dot-more { + font-size: 0.6rem; + color: var(--text-muted); + font-family: sans-serif; +} + +/* Calendar day detail */ +.cal-detail { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 10px; + padding: 1rem 1.1rem; + box-shadow: var(--shadow); +} + +.cal-detail-title { + font-size: 1rem; + font-weight: normal; + color: var(--primary); + margin-bottom: 0.75rem; + font-style: italic; +} + +.cal-detail-list { + list-style: none; + display: flex; + flex-direction: column; + gap: 0.6rem; +} + +.cal-detail-entry { + padding: 0.5rem 0.75rem; + background: var(--bg); + border-radius: 6px; + border-left: 2px solid var(--primary); +} + +.cal-detail-prayer { + font-size: 0.92rem; + line-height: 1.4; +} + +/* Settings panel */ +.settings-btn { + margin-top: 0.5rem; + font-size: 0.85rem; +} + +.settings-panel { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 10px; + padding: 1.25rem; + box-shadow: var(--shadow); + margin-bottom: 1.5rem; +} + +.settings-title { + font-size: 1.1rem; + font-weight: normal; + margin-bottom: 0.5rem; +} + +.settings-desc { + font-size: 0.88rem; + color: var(--text-muted); + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + margin-bottom: 1rem; + line-height: 1.5; +} + +.settings-desc a { color: var(--primary); } + +.settings-form { + display: flex; + flex-direction: column; + gap: 0.85rem; +} + +.settings-label { + display: flex; + flex-direction: column; + gap: 0.3rem; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; + font-size: 0.75rem; +} + +.settings-input { + border: 1px solid var(--border); + border-radius: 6px; + padding: 0.5rem 0.75rem; + background: var(--bg); + color: var(--text); + font-family: inherit; + font-size: 0.95rem; + outline: none; + transition: border-color 0.15s; +} + +.settings-input:focus { border-color: var(--primary); } + +.settings-hint { + font-size: 0.78rem; + color: var(--text-muted); + text-transform: none; + letter-spacing: 0; + font-style: italic; +} + +.settings-actions { + display: flex; + gap: 0.5rem; + align-items: center; + flex-wrap: wrap; +} + +.settings-msg { + font-size: 0.85rem; + color: var(--answered); + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; +} + +/* Reminder day picker */ +.reminder-row { + display: flex; + align-items: center; + gap: 0.75rem; + padding-top: 0.5rem; + border-top: 1px solid var(--border); + flex-wrap: wrap; +} + +.reminder-label { + font-size: 0.8rem; + color: var(--text-muted); + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + white-space: nowrap; +} + +.day-picker { + display: flex; + gap: 0.3rem; +} + +.day-btn { + width: 2rem; + height: 2rem; + border-radius: 50%; + border: 1px solid var(--border); + background: var(--bg); + color: var(--text-muted); + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + font-size: 0.72rem; + cursor: pointer; + transition: background 0.12s, color 0.12s, border-color 0.12s; + display: flex; + align-items: center; + justify-content: center; + padding: 0; +} + +.day-btn:hover { + border-color: var(--primary); + color: var(--primary); +} + +.day-btn.active { + background: var(--primary); + color: #fff; + border-color: var(--primary); +} diff --git a/src/App.test.tsx b/src/App.test.tsx new file mode 100644 index 0000000..2a68616 --- /dev/null +++ b/src/App.test.tsx @@ -0,0 +1,9 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import App from './App'; + +test('renders learn react link', () => { + render(); + const linkElement = screen.getByText(/learn react/i); + expect(linkElement).toBeInTheDocument(); +}); diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..29b6eb1 --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,784 @@ +import React, { useState, useMemo } from 'react'; +import { usePrayers } from './usePrayers'; +import { useSettings } from './useSettings'; +import { Prayer, PrayedEntry, AppSettings } from './types'; +import './App.css'; + +type Filter = 'active' | 'answered' | 'all'; +type Tab = 'prayers' | 'calendar'; + +const DOT_PALETTE = [ + '#c07a3a', '#3d6b52', '#6b4545', '#456b8a', + '#7a5c8a', '#8a7a3a', '#3a6b6b', '#8a4a6b', +]; + +function prayerColor(id: string): string { + let hash = 0; + for (let i = 0; i < id.length; i++) hash = (hash * 31 + id.charCodeAt(i)) & 0xffffffff; + return DOT_PALETTE[Math.abs(hash) % DOT_PALETTE.length]; +} + +function normalize(s: string) { + return s.toLowerCase().trim(); +} + +function matches(prayer: Prayer, query: string) { + const q = normalize(query); + if (!q) return true; + return ( + normalize(prayer.person).includes(q) || + normalize(prayer.request).includes(q) || + normalize(prayer.answer).includes(q) || + prayer.tags.some(t => normalize(t).includes(q)) + ); +} + +const DAYS = ['sun','mon','tue','wed','thu','fri','sat'] as const; +const DAY_LABELS: Record = { sun:'Su', mon:'Mo', tue:'Tu', wed:'We', thu:'Th', fri:'Fr', sat:'Sa' }; + +export default function App() { + const { + prayers, + loading, + error: apiError, + addPrayer, + updateAnswer, + markAnswered, + markActive, + archivePrayer, + deletePrayer, + updateRequest, + updateNotes, + logPrayed, + updateReminderDays, + } = usePrayers(); + + const { settings, saveSettings, testNotification } = useSettings(); + const [showSettings, setShowSettings] = useState(false); + const [tab, setTab] = useState('prayers'); + const [filter, setFilter] = useState('active'); + const [activeTag, setActiveTag] = useState(null); + const [search, setSearch] = useState(''); + const [newPerson, setNewPerson] = useState(''); + const [newRequest, setNewRequest] = useState(''); + const [newTags, setNewTags] = useState(''); + const [expandedId, setExpandedId] = useState(null); + const [editingAnswer, setEditingAnswer] = useState<{ id: string; text: string } | null>(null); + const [writeError, setWriteError] = useState(null); + + const allTags = useMemo(() => { + const set = new Set(); + prayers.filter(p => p.status !== 'archived').forEach(p => p.tags.forEach(t => set.add(t))); + return Array.from(set).sort(); + }, [prayers]); + + const filtered = useMemo(() => prayers.filter(p => { + const statusMatch = + filter === 'active' ? p.status === 'active' : + filter === 'answered' ? p.status === 'answered' : + p.status !== 'archived'; + const tagMatch = activeTag ? p.tags.includes(activeTag) : true; + const searchMatch = matches(p, search); + return statusMatch && tagMatch && searchMatch; + }), [prayers, filter, activeTag, search]); + + const counts = { + active: prayers.filter(p => p.status === 'active').length, + answered: prayers.filter(p => p.status === 'answered').length, + }; + + async function handleAdd(e: React.FormEvent) { + e.preventDefault(); + if (!newRequest.trim()) return; + const tags = newTags.split(',').map(t => t.trim()).filter(Boolean); + try { + await addPrayer(newPerson.trim(), newRequest.trim(), tags); + setNewPerson(''); + setNewRequest(''); + setNewTags(''); + setWriteError(null); + } catch (err: any) { + setWriteError(err.message); + } + } + + async function handleSaveAnswer(id: string) { + if (!editingAnswer) return; + try { + await updateAnswer(id, editingAnswer.text); + await markAnswered(id); + setEditingAnswer(null); + setExpandedId(null); + setWriteError(null); + } catch (err: any) { + setWriteError(err.message); + } + } + + function formatDate(iso: string) { + return new Date(iso).toLocaleDateString(undefined, { + month: 'short', day: 'numeric', year: 'numeric', + }); + } + + function handleTagClick(tag: string) { + 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 + ? `No results for "${search}".` + : activeTag + ? `No ${filter === 'all' ? '' : filter + ' '}prayers tagged "${activeTag}".` + : filter === 'active' + ? 'No active prayers. Add one above.' + : 'Nothing here yet.'; + + return ( +
+
+

Prayer Journal

+

Bring your requests. Record His faithfulness.

+ +
+ + {showSettings && ( + + )} + +
+ + +
+ + {tab === 'prayers' && ( + <> +
+ setNewPerson(e.target.value)} + /> +