Version history, notes on highlights and bookmarks, "you've studied this", prayer journal
Build and Push Docker Image / build-and-push (push) Successful in 5m13s
Build and Push Docker Image / build-and-push (push) Successful in 5m13s
- Earlier versions of a study: the server keeps compressed copies (about one per ten minutes of editing, the latest 30); Export shows what differs and puts one back (the replaced copy is kept) - Notes on highlights and bookmarks: synced, merged without loss, in exports and backups - Reader verse panel lists your studies and chunks that cover the verse, and opens them - Prayer journal: requests, who for, linked verses, days prayed, answers; synced, in backups and the account export; daily prayer reminder through ntfy - TODO.md lists the ideas not built yet Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
54cb1c217d
commit
207bfcb919
45 files changed
+1669
-75
No files matched your search
@@ -0,0 +1,67 @@
|
||||
// Export → Earlier versions: the copies the server kept of this study as you worked. Look at what is different
|
||||
// from now, and put one back. The version you replace is kept, so putting one back can be undone too.
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { getStudyVersion, listStudyVersions } from '../syncService.js';
|
||||
import { describeDiff, diffProjects } from '../utils/projectDiff.js';
|
||||
|
||||
const btn = 'rounded-lg border border-[var(--r-line)] px-3 py-1.5 text-xs hover:bg-black/5 disabled:opacity-50';
|
||||
|
||||
export default function VersionHistory({ project, onRestore }) {
|
||||
const [state, setState] = useState({ status: 'loading', versions: [] });
|
||||
const [open, setOpen] = useState(null); // { id, lines } the version being looked at
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!project?.id) return;
|
||||
const r = await listStudyVersions(project.id);
|
||||
setState(r.ok ? { status: 'ready', versions: r.data.versions } : { status: 'unavailable', versions: [] });
|
||||
}, [project?.id]);
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const preview = async (v) => {
|
||||
if (open?.id === v.id) { setOpen(null); return; }
|
||||
setError('');
|
||||
const r = await getStudyVersion(project.id, v.id);
|
||||
if (!r.ok) { setError(r.error ?? 'Could not open that version.'); return; }
|
||||
setOpen({ id: v.id, lines: describeDiff(diffProjects(project, r.data)) });
|
||||
};
|
||||
const restore = async (v) => {
|
||||
if (!window.confirm(`Put back the version from ${new Date(v.savedAt).toLocaleString()}? What you have now is kept in this list, so you can undo it.`)) return;
|
||||
setBusy(true); setError('');
|
||||
const ok = await onRestore(v.id);
|
||||
setBusy(false);
|
||||
if (ok) { setOpen(null); load(); } else setError('Could not restore that version. Check your connection and try again.');
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5" aria-label="Earlier versions">
|
||||
<h2 className="text-sm font-medium">Earlier versions</h2>
|
||||
<p className="mb-3 mt-1 text-xs text-[var(--r-sub)]">As you work, your account keeps earlier copies of this study (about one for every ten minutes of editing, the latest 30). Use one to undo a bad merge or a deletion.</p>
|
||||
{state.status === 'loading' && <p className="text-xs text-[var(--r-sub)]">Loading…</p>}
|
||||
{state.status === 'unavailable' && <p className="text-xs text-[var(--r-sub)]">Earlier versions need your account and a connection, and the study has to have synced once.</p>}
|
||||
{state.status === 'ready' && state.versions.length === 0 && <p className="text-xs text-[var(--r-sub)]">Nothing kept yet. The first earlier version appears after you’ve changed the study a couple of times.</p>}
|
||||
{state.versions.length > 0 && (
|
||||
<ul className="divide-y divide-[var(--r-line)] text-sm">
|
||||
{state.versions.map((v) => (
|
||||
<li key={v.id} className="py-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span><span className="font-medium">{new Date(v.savedAt).toLocaleString()}</span> <span className="text-xs text-[var(--r-sub)]">{v.chunks} chunk{v.chunks === 1 ? '' : 's'} · {v.words} words</span></span>
|
||||
<span className="flex gap-2">
|
||||
<button type="button" onClick={() => preview(v)} aria-expanded={open?.id === v.id} className={btn}>{open?.id === v.id ? 'Hide' : 'What’s different'}</button>
|
||||
<button type="button" onClick={() => restore(v)} disabled={busy} className={btn}>Put this back</button>
|
||||
</span>
|
||||
</div>
|
||||
{open?.id === v.id && (
|
||||
<ul className="mt-2 list-disc space-y-0.5 rounded-lg bg-[var(--r-bg)] py-2 pl-7 pr-3 text-xs text-[var(--r-sub)]">
|
||||
{open.lines.map((line) => <li key={line}>{line}</li>)}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{error && <p role="alert" className="mt-2 text-xs text-[var(--r-danger,#b42318)]">{error}</p>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { vi, test, expect, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('../syncService.js', () => ({ listStudyVersions: vi.fn(), getStudyVersion: vi.fn() }));
|
||||
import VersionHistory from './VersionHistory.jsx';
|
||||
import { getStudyVersion, listStudyVersions } from '../syncService.js';
|
||||
|
||||
const project = { id: 'p1', title: 'Romans', chapters: [{ book: 'Romans', chapter: '8', chunks: [{ id: 'a', startVerse: 1, endVerse: 4, observation: 'new words' }] }] };
|
||||
const older = { id: 'p1', title: 'Romans', chapters: [{ book: 'Romans', chapter: '8', chunks: [{ id: 'a', startVerse: 1, endVerse: 4, observation: 'old words' }, { id: 'b', startVerse: 5, endVerse: 9, observation: 'lost' }] }] };
|
||||
const at = new Date(2026, 9, 5, 14, 30).getTime();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
listStudyVersions.mockResolvedValue({ ok: true, data: { versions: [{ id: 7, savedAt: at, chunks: 2, words: 12 }] } });
|
||||
getStudyVersion.mockResolvedValue({ ok: true, data: older });
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
});
|
||||
|
||||
test('lists the kept versions and says what is different from now', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<VersionHistory project={project} onRestore={vi.fn()} />);
|
||||
expect(await screen.findByText(/2 chunks · 12 words/)).toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: /what’s different/i }));
|
||||
expect(await screen.findByText('Romans 8:1–4: Observe differs.')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Brings back 1 chunk deleted since: Romans 8:5–9/)).toBeInTheDocument();
|
||||
expect(getStudyVersion).toHaveBeenCalledWith('p1', 7);
|
||||
});
|
||||
|
||||
test('putting one back asks first, then restores and refreshes the list', async () => {
|
||||
const onRestore = vi.fn(async () => true);
|
||||
const user = userEvent.setup();
|
||||
render(<VersionHistory project={project} onRestore={onRestore} />);
|
||||
await user.click(await screen.findByRole('button', { name: /put this back/i }));
|
||||
expect(window.confirm).toHaveBeenCalled();
|
||||
expect(onRestore).toHaveBeenCalledWith(7);
|
||||
expect(listStudyVersions).toHaveBeenCalledTimes(2); // reloaded after
|
||||
});
|
||||
|
||||
test('declining the question restores nothing; a failed restore says so', async () => {
|
||||
const onRestore = vi.fn(async () => false);
|
||||
const user = userEvent.setup();
|
||||
window.confirm.mockReturnValueOnce(false);
|
||||
render(<VersionHistory project={project} onRestore={onRestore} />);
|
||||
await user.click(await screen.findByRole('button', { name: /put this back/i }));
|
||||
expect(onRestore).not.toHaveBeenCalled();
|
||||
await user.click(screen.getByRole('button', { name: /put this back/i }));
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(/Could not restore/);
|
||||
});
|
||||
|
||||
test('with nothing kept, or no connection, it says so', async () => {
|
||||
listStudyVersions.mockResolvedValueOnce({ ok: true, data: { versions: [] } });
|
||||
const { unmount } = render(<VersionHistory project={project} onRestore={vi.fn()} />);
|
||||
expect(await screen.findByText(/Nothing kept yet/)).toBeInTheDocument();
|
||||
unmount();
|
||||
listStudyVersions.mockResolvedValueOnce({ ok: false, status: 404 });
|
||||
render(<VersionHistory project={project} onRestore={vi.fn()} />);
|
||||
expect(await screen.findByText(/need your account and a connection/)).toBeInTheDocument();
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
// A short note on a highlight or a bookmark: shown under it, added or edited in place.
|
||||
import { useState } from 'react';
|
||||
|
||||
export default function MarkNote({ note = '', onSave, label = 'note', className = '' }) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState('');
|
||||
const open = () => { setDraft(note); setEditing(true); };
|
||||
const save = () => { onSave(draft.trim()); setEditing(false); };
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<textarea autoFocus value={draft} maxLength={500} rows={2} aria-label={`Your ${label}`} onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); save(); } else if (e.key === 'Escape') { e.stopPropagation(); setEditing(false); } }}
|
||||
className="w-full resize-y rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2.5 py-1.5 text-sm text-[var(--r-text)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]" placeholder="What does this mean to you?" />
|
||||
<div className="mt-1 flex items-center gap-3 text-xs">
|
||||
<button type="button" onClick={save} className="font-medium text-[var(--r-accent)] hover:underline">Save</button>
|
||||
<button type="button" onClick={() => setEditing(false)} className="text-[var(--r-sub)] hover:underline">Cancel</button>
|
||||
<span className="ml-auto text-[10px] text-[var(--r-sub)]">{draft.length}/500</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!note) {
|
||||
return <div className={className}><button type="button" onClick={open} className="text-xs text-[var(--r-accent)] hover:underline">+ Add {label}</button></div>;
|
||||
}
|
||||
return (
|
||||
<div className={`${className} flex items-start gap-2`}>
|
||||
<p className="min-w-0 flex-1 whitespace-pre-wrap rounded-lg bg-[var(--r-bg)] px-2.5 py-1.5 text-[13px] italic text-[var(--r-text)]">{note}</p>
|
||||
<button type="button" onClick={open} aria-label={`Edit ${label}`} className="shrink-0 text-xs text-[var(--r-sub)] hover:underline">Edit</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { vi, test, expect } from 'vitest';
|
||||
import MarkNote from './MarkNote.jsx';
|
||||
|
||||
test('a note can be added, and is saved trimmed', async () => {
|
||||
const onSave = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(<MarkNote note="" onSave={onSave} label="highlight note" />);
|
||||
await user.click(screen.getByRole('button', { name: /add highlight note/i }));
|
||||
await user.type(screen.getByLabelText('Your highlight note'), ' Pray this daily ');
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }));
|
||||
expect(onSave).toHaveBeenCalledWith('Pray this daily');
|
||||
});
|
||||
|
||||
test('an existing note is shown, and can be edited or cleared', async () => {
|
||||
const onSave = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(<MarkNote note="First thought" onSave={onSave} label="note" />);
|
||||
expect(screen.getByText('First thought')).toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: 'Edit note' }));
|
||||
const box = screen.getByLabelText('Your note');
|
||||
expect(box).toHaveValue('First thought');
|
||||
await user.clear(box);
|
||||
await user.click(screen.getByRole('button', { name: 'Save' })); // an empty note removes it
|
||||
expect(onSave).toHaveBeenCalledWith('');
|
||||
});
|
||||
|
||||
test('cancelling keeps what was there; Ctrl+Enter saves', async () => {
|
||||
const onSave = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(<MarkNote note="Keep me" onSave={onSave} />);
|
||||
await user.click(screen.getByRole('button', { name: /edit/i }));
|
||||
await user.type(screen.getByLabelText(/your/i), ' more');
|
||||
await user.click(screen.getByRole('button', { name: 'Cancel' }));
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
expect(screen.getByText('Keep me')).toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: /edit/i }));
|
||||
await user.type(screen.getByLabelText(/your/i), ' more{Control>}{Enter}{/Control}');
|
||||
expect(onSave).toHaveBeenCalledWith('Keep me more');
|
||||
});
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import Sheet from './Sheet.jsx';
|
||||
import { CloseIcon } from './icons.jsx';
|
||||
import MarkNote from './MarkNote.jsx';
|
||||
import { buildMarksDocxBlob, buildMarksModel, downloadMarksFile, marksToMarkdown } from '../../utils/marksExport.js';
|
||||
import { COLOR_NAMES, COLOR_SUGGESTIONS, HIGHLIGHT_COLORS, colorTitle } from '../../utils/highlightColors.js';
|
||||
|
||||
@@ -10,6 +11,7 @@ const tabBtn = (active) => `rounded-full border px-3 py-1 text-xs font-medium ${
|
||||
|
||||
export default function MarksSheet({
|
||||
open, onClose, initialTab = 'highlights', bookmarks, highlights, colorLabels, onSetLabel, onJump, onRemoveBookmark, onRemoveHighlight,
|
||||
bookmarkNotes = {}, onSetHighlightNote, onSetBookmarkNote,
|
||||
}) {
|
||||
const [tab, setTab] = useState(initialTab);
|
||||
// Opening from the Highlights button shows highlights; from the Bookmarks button, bookmarks
|
||||
@@ -24,7 +26,7 @@ export default function MarksSheet({
|
||||
const [exportError, setExportError] = useState('');
|
||||
const exportAs = async (kind) => {
|
||||
setExportError('');
|
||||
const model = buildMarksModel({ highlights, bookmarks, colorLabels });
|
||||
const model = buildMarksModel({ highlights, bookmarks, colorLabels, bookmarkNotes });
|
||||
try {
|
||||
if (kind === 'md') downloadMarksFile(new Blob([marksToMarkdown(model)], { type: 'text/markdown;charset=utf-8' }), 'highlights-and-bookmarks.md');
|
||||
else downloadMarksFile(await buildMarksDocxBlob(model), 'highlights-and-bookmarks.docx');
|
||||
@@ -73,15 +75,18 @@ export default function MarksSheet({
|
||||
) : shown.length === 0 ? (
|
||||
<p className="text-sm text-[var(--r-sub)]">Nothing highlighted in that colour yet.</p>
|
||||
) : shown.map((h) => (
|
||||
<div key={h.id} className="flex items-start gap-2 rounded-lg px-1 py-1.5 hover:bg-black/5">
|
||||
<span className="mt-1 h-3 w-3 shrink-0 rounded-full border border-black/10" style={{ backgroundColor: h.color }} title={colorTitle(h.color, colorLabels)} />
|
||||
<button type="button" onClick={() => onJump(h.book, h.chapter, h.verse)} className="min-w-0 flex-1 text-left text-sm">
|
||||
<span className="font-medium">{h.bookName} {h.chapter}:{h.verse}</span>
|
||||
{colorLabels[h.color] && <span className="ml-1.5 text-[11px] text-[var(--r-sub)]">{colorLabels[h.color]}</span>}
|
||||
{h.text && <span className="mt-0.5 block font-serif text-[var(--r-sub)]">“{h.text}”</span>}
|
||||
</button>
|
||||
<button type="button" onClick={() => onRemoveHighlight(h)} aria-label={`Remove highlight on ${h.bookName} ${h.chapter}:${h.verse}`}
|
||||
className="rounded p-1 text-[var(--r-sub)] hover:text-[var(--r-danger,#b42318)]"><CloseIcon /></button>
|
||||
<div key={h.id} className="rounded-lg px-1 py-1.5 hover:bg-black/5">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="mt-1 h-3 w-3 shrink-0 rounded-full border border-black/10" style={{ backgroundColor: h.color }} title={colorTitle(h.color, colorLabels)} />
|
||||
<button type="button" onClick={() => onJump(h.book, h.chapter, h.verse)} className="min-w-0 flex-1 text-left text-sm">
|
||||
<span className="font-medium">{h.bookName} {h.chapter}:{h.verse}</span>
|
||||
{colorLabels[h.color] && <span className="ml-1.5 text-[11px] text-[var(--r-sub)]">{colorLabels[h.color]}</span>}
|
||||
{h.text && <span className="mt-0.5 block font-serif text-[var(--r-sub)]">“{h.text}”</span>}
|
||||
</button>
|
||||
<button type="button" onClick={() => onRemoveHighlight(h)} aria-label={`Remove highlight on ${h.bookName} ${h.chapter}:${h.verse}`}
|
||||
className="rounded p-1 text-[var(--r-sub)] hover:text-[var(--r-danger,#b42318)]"><CloseIcon /></button>
|
||||
</div>
|
||||
{onSetHighlightNote && <MarkNote className="ml-5 mt-1" note={h.note} label={`note on ${h.bookName} ${h.chapter}:${h.verse}`} onSave={(text) => onSetHighlightNote(h.id, text)} />}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
@@ -91,13 +96,16 @@ export default function MarksSheet({
|
||||
bookmarks.length === 0 ? (
|
||||
<p className="text-sm text-[var(--r-sub)]">No bookmarks yet. Tap a verse, then the bookmark icon, to save it here.</p>
|
||||
) : bookmarks.map((e) => (
|
||||
<div key={e.key} className="flex items-center gap-2 rounded-lg px-1 py-1.5 hover:bg-black/5">
|
||||
<span className="h-3 w-3 shrink-0 rounded-full" style={{ backgroundColor: e.color }} />
|
||||
<button type="button" onClick={() => onJump(e.bookAbbrev, e.chapter, e.verse)} className="flex-1 truncate text-left text-sm">
|
||||
{e.bookName} {e.chapter}:{e.verse}
|
||||
</button>
|
||||
<button type="button" onClick={() => onRemoveBookmark(e)} aria-label={`Remove bookmark on ${e.bookName} ${e.chapter}:${e.verse}`}
|
||||
className="rounded p-1 text-[var(--r-sub)] hover:text-[var(--r-danger,#b42318)]"><CloseIcon /></button>
|
||||
<div key={e.key} className="rounded-lg px-1 py-1.5 hover:bg-black/5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="h-3 w-3 shrink-0 rounded-full" style={{ backgroundColor: e.color }} />
|
||||
<button type="button" onClick={() => onJump(e.bookAbbrev, e.chapter, e.verse)} className="flex-1 truncate text-left text-sm">
|
||||
{e.bookName} {e.chapter}:{e.verse}
|
||||
</button>
|
||||
<button type="button" onClick={() => onRemoveBookmark(e)} aria-label={`Remove bookmark on ${e.bookName} ${e.chapter}:${e.verse}`}
|
||||
className="rounded p-1 text-[var(--r-sub)] hover:text-[var(--r-danger,#b42318)]"><CloseIcon /></button>
|
||||
</div>
|
||||
{onSetBookmarkNote && <MarkNote className="ml-5 mt-1" note={bookmarkNotes[e.key]} label={`note on ${e.bookName} ${e.chapter}:${e.verse}`} onSave={(text) => onSetBookmarkNote(e.key, text)} />}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { DiffText } from './DiffText.jsx';
|
||||
import { HIGHLIGHT_COLORS, colorTitle } from '../../utils/highlightColors.js';
|
||||
import { isWholeVerse } from '../../utils/highlightSpans.js';
|
||||
import { useIqAvailable } from '../../hooks/useIq.js';
|
||||
import MarkNote from './MarkNote.jsx';
|
||||
import VerseShareImage from '../VerseShareImage.jsx';
|
||||
import IqLexicon from '../iq/IqLexicon.jsx';
|
||||
import IqStudyAids from '../iq/IqStudyAids.jsx';
|
||||
@@ -155,7 +156,7 @@ function CompareTab({ book, chapter, verse, current }) {
|
||||
|
||||
export default function VerseSheet({
|
||||
verse, book, bookName, chapter, translation, words, crossRefs, crossRefsLoading, onLoadCrossRefs,
|
||||
bookmarkColor, onToggleBookmark, onCycleColor, onJump, onClose, request,
|
||||
bookmarkColor, onToggleBookmark, onCycleColor, bookmarkNote = '', onSetBookmarkNote, onSetHighlightNote, studiesHere = [], onOpenStudy, onPray, onJump, onClose, request,
|
||||
verseHighlights = [], colorLabels = {}, onHighlightVerse, onClearVerseHighlights, panelRef, resizeHandle,
|
||||
}) {
|
||||
const iqOn = useIqAvailable();
|
||||
@@ -204,6 +205,11 @@ export default function VerseSheet({
|
||||
<span className="block h-4 w-4 rounded-full border border-black/10" style={{ backgroundColor: bookmarkColor }} />
|
||||
</button>
|
||||
)}
|
||||
{onPray && (
|
||||
<button type="button" onClick={() => onPray(`${bookName} ${chapter}:${verse.number}`)} className={`${actionBtn} text-xs`} aria-label="Pray about this verse" title="Add a prayer about this verse">
|
||||
Pray
|
||||
</button>
|
||||
)}
|
||||
<button type="button" onClick={() => setImageOpen((o) => !o)} aria-pressed={imageOpen} className={`${actionBtn} text-xs`} aria-label="Share verse as image" title="Share as image">
|
||||
Image
|
||||
</button>
|
||||
@@ -240,6 +246,40 @@ export default function VerseSheet({
|
||||
<span className="basis-full text-[11px] text-[var(--r-sub)]">Or select words in the text to highlight just those.</span>
|
||||
</div>
|
||||
)}
|
||||
{studiesHere.length > 0 && onOpenStudy && (
|
||||
<div className="mb-3 rounded-xl border border-[var(--r-line)] p-3" aria-label="In your studies">
|
||||
<p className="mb-1.5 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">You’ve studied this</p>
|
||||
<ul className="space-y-2">
|
||||
{studiesHere.slice(0, 5).map((e) => (
|
||||
<li key={`${e.projectId}/${e.chunkId}`} className="flex items-start justify-between gap-3 text-sm">
|
||||
<span className="min-w-0">
|
||||
<span className="font-medium">{e.title}</span> <span className="text-xs text-[var(--r-sub)]">{e.ref}</span>
|
||||
{e.snippet && <span className="mt-0.5 block truncate font-serif text-[13px] text-[var(--r-sub)]">{e.snippet}</span>}
|
||||
</span>
|
||||
<button type="button" onClick={() => onOpenStudy(e.projectId, e.chunkId)} className="shrink-0 text-xs font-medium text-[var(--r-accent)] hover:underline">Open</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{studiesHere.length > 5 && <p className="mt-1.5 text-[11px] text-[var(--r-sub)]">and {studiesHere.length - 5} more</p>}
|
||||
</div>
|
||||
)}
|
||||
{(bookmarkColor && onSetBookmarkNote) || (verseHighlights.length > 0 && onSetHighlightNote) ? (
|
||||
<div className="mb-3 space-y-2 rounded-xl border border-[var(--r-line)] p-3" aria-label="Your notes on this verse">
|
||||
<p className="text-[11px] uppercase tracking-wide text-[var(--r-sub)]">Your notes</p>
|
||||
{bookmarkColor && onSetBookmarkNote && (
|
||||
<div>
|
||||
<p className="flex items-center gap-1.5 text-xs text-[var(--r-sub)]"><span className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: bookmarkColor }} />Bookmark</p>
|
||||
<MarkNote note={bookmarkNote} label="bookmark note" onSave={onSetBookmarkNote} className="mt-1" />
|
||||
</div>
|
||||
)}
|
||||
{onSetHighlightNote && verseHighlights.map((h) => (
|
||||
<div key={h.id}>
|
||||
<p className="flex items-center gap-1.5 truncate text-xs text-[var(--r-sub)]"><span className="h-2.5 w-2.5 shrink-0 rounded-full border border-black/10" style={{ backgroundColor: h.color }} /><span className="truncate">{isWholeVerse(h, verse.text) ? 'Whole verse' : `“${h.text}”`}</span></p>
|
||||
<MarkNote note={h.note} label="highlight note" onSave={(text) => onSetHighlightNote(h.id, text)} className="mt-1" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<div role="tablist" aria-label="About this verse" className="mb-3 flex gap-1.5 overflow-x-auto pb-1">
|
||||
{TABS.map(([id, label]) => (
|
||||
<button key={id} type="button" role="tab" aria-selected={tab === id} onClick={() => setTab(id)}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { vi, test, expect } from 'vitest';
|
||||
import VerseSheet from './VerseSheet.jsx';
|
||||
|
||||
vi.mock('../StudyPanel.jsx', () => ({ CommentaryTab: () => null, PeoplePlacesTab: () => null }));
|
||||
|
||||
const base = {
|
||||
verse: { number: 1, text: 'Paul, a servant of God.' }, book: 'TIT', bookName: 'Titus', chapter: 1, translation: 'BSB',
|
||||
onToggleBookmark: () => {}, onCycleColor: () => {}, onJump: () => {}, onClose: () => {}, onLoadCrossRefs: () => {},
|
||||
};
|
||||
const here = [{ projectId: 'p1', chunkId: 'a', title: 'Titus Notes', ref: 'Titus 1:1–4', snippet: 'Paul calls himself a servant' }];
|
||||
|
||||
test('the verse panel lists your studies that cover the verse, and opens one at that chunk', async () => {
|
||||
const onOpenStudy = vi.fn();
|
||||
render(<VerseSheet {...base} studiesHere={here} onOpenStudy={onOpenStudy} />);
|
||||
expect(screen.getByText('You’ve studied this')).toBeInTheDocument();
|
||||
expect(screen.getByText('Titus Notes')).toBeInTheDocument();
|
||||
expect(screen.getByText('Paul calls himself a servant')).toBeInTheDocument();
|
||||
await userEvent.setup().click(screen.getByRole('button', { name: 'Open' }));
|
||||
expect(onOpenStudy).toHaveBeenCalledWith('p1', 'a');
|
||||
});
|
||||
|
||||
test('nothing is shown when no study covers the verse', () => {
|
||||
render(<VerseSheet {...base} studiesHere={[]} onOpenStudy={() => {}} />);
|
||||
expect(screen.queryByText('You’ve studied this')).toBeNull();
|
||||
});
|
||||
|
||||
test('your notes on the verse show up in the panel and can be added', async () => {
|
||||
const onSetBookmarkNote = vi.fn();
|
||||
const onSetHighlightNote = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(<VerseSheet {...base} bookmarkColor="#fef08a" bookmarkNote="Memorise" onSetBookmarkNote={onSetBookmarkNote}
|
||||
verseHighlights={[{ id: 'h1', color: '#bbf7d0', startOffset: 0, endOffset: 4, text: 'Paul' }]} onSetHighlightNote={onSetHighlightNote} />);
|
||||
expect(screen.getByText('Memorise')).toBeInTheDocument();
|
||||
expect(screen.getByText('“Paul”')).toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: /add highlight note/i }));
|
||||
await user.type(screen.getByLabelText('Your highlight note'), 'Who he is');
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }));
|
||||
expect(onSetHighlightNote).toHaveBeenCalledWith('h1', 'Who he is');
|
||||
});
|
||||
@@ -13,8 +13,8 @@ const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
||||
|
||||
export default function DataSection() {
|
||||
const {
|
||||
authUser, readerBookmarks, readerTextHighlights, readerColorLabels, readerInkByPage, readingPlansState,
|
||||
importReaderMarks, importReadingPlans, refreshProjectIndex,
|
||||
authUser, readerBookmarks, readerBookmarkNotes, readerTextHighlights, readerColorLabels, readerInkByPage, readingPlansState,
|
||||
importReaderMarks, importReadingPlans, refreshProjectIndex, prayers, importPrayers,
|
||||
} = useApp();
|
||||
const { toast } = useToast();
|
||||
const offline = useOfflineSave();
|
||||
@@ -26,8 +26,9 @@ export default function DataSection() {
|
||||
const download = () => {
|
||||
const backup = buildBackup({
|
||||
email: authUser?.email ?? null,
|
||||
reader: { bookmarks: readerBookmarks, highlights: readerTextHighlights, colorLabels: readerColorLabels, ink: readerInkByPage },
|
||||
reader: { bookmarks: readerBookmarks, bookmarkNotes: readerBookmarkNotes, highlights: readerTextHighlights, colorLabels: readerColorLabels, ink: readerInkByPage },
|
||||
readingPlans: readingPlansState,
|
||||
prayers,
|
||||
});
|
||||
const url = URL.createObjectURL(new Blob([JSON.stringify(backup, null, 2)], { type: 'application/json' }));
|
||||
const link = document.createElement('a');
|
||||
@@ -59,6 +60,7 @@ export default function DataSection() {
|
||||
const written = restoreProjects(backup.projects);
|
||||
importReaderMarks(backup.reader);
|
||||
importReadingPlans(backup.readingPlans);
|
||||
importPrayers?.(backup.prayers);
|
||||
refreshProjectIndex();
|
||||
// Studies restored here also go to the account, so they follow you to other devices
|
||||
if (authUser) await Promise.all(written.map((p) => saveRemoteProject(p)));
|
||||
@@ -88,7 +90,7 @@ export default function DataSection() {
|
||||
<div className="rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] p-4 text-sm">
|
||||
<p className="font-medium">{pending.name}</p>
|
||||
<p className="mt-1 text-[var(--r-sub)]">
|
||||
Contains {plural(s.projects, 'study', 'studies')}, {plural(s.bookmarks, 'bookmark')}, {plural(s.highlights, 'highlight')}, {plural(s.inkPages, 'ink page')} and {plural(s.plans, 'reading plan')}.
|
||||
Contains {plural(s.projects, 'study', 'studies')}, {plural(s.bookmarks, 'bookmark')}, {plural(s.highlights, 'highlight')}, {plural(s.inkPages, 'ink page')}, {plural(s.plans, 'reading plan')} and {plural(s.prayers ?? 0, 'prayer')}.
|
||||
Restoring adds these to what you have — nothing is deleted, and a study on this device is only replaced if the backup’s copy is newer.
|
||||
</p>
|
||||
<div className="mt-3 flex gap-2">
|
||||
|
||||
@@ -10,6 +10,7 @@ const NAV = [
|
||||
{ id: 'studies', label: 'Studies', short: 'Studies' },
|
||||
{ id: 'reader', label: 'Read Bible', short: 'Bible' },
|
||||
{ id: 'plans', label: 'Reading Plans', short: 'Plans' },
|
||||
{ id: 'prayer', label: 'Prayer', short: 'Prayer' },
|
||||
];
|
||||
|
||||
// Avatar button with a dropdown: who you are, the theme, Settings, Admin, Log out.
|
||||
@@ -62,8 +63,8 @@ function AccountMenu() {
|
||||
}
|
||||
|
||||
export default function PageShell({ title, onBack, actions, active = null, maxWidth = 'max-w-6xl', children }) {
|
||||
const { readerTheme, goHome, goStudies, openBibleReader, openReadingPlans, openPalette } = useApp();
|
||||
const go = { studies: goStudies, reader: openBibleReader, plans: openReadingPlans };
|
||||
const { readerTheme, goHome, goStudies, openBibleReader, openReadingPlans, openPrayer, openPalette } = useApp();
|
||||
const go = { studies: goStudies, reader: openBibleReader, plans: openReadingPlans, prayer: openPrayer };
|
||||
return (
|
||||
<div className="min-h-dvh bg-[var(--r-bg)] font-sans text-[var(--r-text)]" style={themeVars(readerTheme)}>
|
||||
<a href="#content" className="skip-link">Skip to content</a>
|
||||
|
||||
Reference in new issue
Block a user