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>
123 lines
8.6 KiB
React
123 lines
8.6 KiB
React
// Everything you have marked in the Bible: highlights (grouped and filtered by colour, each colour
|
||
// named by you — "Promises", "Commands") and bookmarks. Opens from the bookmark button in the top bar.
|
||
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';
|
||
|
||
const tabBtn = (active) => `rounded-full border px-3 py-1 text-xs font-medium ${active ? 'border-[var(--r-text)] bg-[var(--r-text)] text-[var(--r-bg)]' : 'border-[var(--r-line)] text-[var(--r-sub)] hover:bg-black/5'}`;
|
||
|
||
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
|
||
useEffect(() => { if (open) setTab(initialTab); }, [open, initialTab]);
|
||
const [filter, setFilter] = useState(null); // a highlight colour, or null for all
|
||
const [editing, setEditing] = useState(false);
|
||
|
||
const counts = Object.fromEntries(HIGHLIGHT_COLORS.map((c) => [c, highlights.filter((h) => h.color === c).length]));
|
||
const shown = filter ? highlights.filter((h) => h.color === filter) : highlights;
|
||
// Colours in use that aren't one of the picker's (older data) still get listed
|
||
const total = highlights.length + bookmarks.length;
|
||
const [exportError, setExportError] = useState('');
|
||
const exportAs = async (kind) => {
|
||
setExportError('');
|
||
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');
|
||
} catch { setExportError('Couldn’t make the file. Try again.'); }
|
||
};
|
||
|
||
return (
|
||
<Sheet open={open} onClose={onClose} label="Bookmarks and highlights" title={`Bookmarks & highlights${total ? ` (${total})` : ''}`}>
|
||
<div role="tablist" aria-label="What to show" className="mb-3 flex gap-1.5">
|
||
<button type="button" role="tab" aria-selected={tab === 'highlights'} onClick={() => setTab('highlights')} className={tabBtn(tab === 'highlights')}>Highlights ({highlights.length})</button>
|
||
<button type="button" role="tab" aria-selected={tab === 'bookmarks'} onClick={() => setTab('bookmarks')} className={tabBtn(tab === 'bookmarks')}>Bookmarks ({bookmarks.length})</button>
|
||
</div>
|
||
|
||
{tab === 'highlights' && (
|
||
<>
|
||
{/* Colour key: tap a colour to show only that one; "Name colours" lets you say what each means */}
|
||
<div className="mb-3 flex flex-wrap items-center gap-1.5" role="group" aria-label="Filter by colour">
|
||
<button type="button" onClick={() => setFilter(null)} aria-pressed={filter === null} className={tabBtn(filter === null)}>All</button>
|
||
{HIGHLIGHT_COLORS.map((c) => (
|
||
<button key={c} type="button" onClick={() => setFilter(filter === c ? null : c)} aria-pressed={filter === c}
|
||
title={colorTitle(c, colorLabels)}
|
||
className={`flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs ${filter === c ? 'border-[var(--r-text)]' : 'border-[var(--r-line)] hover:bg-black/5'}`}>
|
||
<span className="h-3 w-3 rounded-full border border-black/10" style={{ backgroundColor: c }} />
|
||
<span>{colorLabels[c] || COLOR_NAMES[c]}</span>
|
||
<span className="text-[var(--r-sub)]">{counts[c]}</span>
|
||
</button>
|
||
))}
|
||
<button type="button" onClick={() => setEditing((e) => !e)} className="ml-auto text-xs text-[var(--r-accent)] hover:underline">{editing ? 'Done' : 'Name colours'}</button>
|
||
</div>
|
||
|
||
{editing && (
|
||
<div className="mb-3 grid gap-2 rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] p-3 sm:grid-cols-2">
|
||
{HIGHLIGHT_COLORS.map((c) => (
|
||
<label key={c} className="flex items-center gap-2 text-xs text-[var(--r-sub)]">
|
||
<span className="h-4 w-4 shrink-0 rounded-full border border-black/10" style={{ backgroundColor: c }} />
|
||
<span className="sr-only">{COLOR_NAMES[c]} means</span>
|
||
<input value={colorLabels[c] ?? ''} maxLength={30} onChange={(e) => onSetLabel(c, e.target.value)} placeholder={COLOR_SUGGESTIONS[c]}
|
||
className="min-w-0 flex-1 rounded-lg border border-[var(--r-line)] bg-transparent px-2 py-1 text-sm text-[var(--r-text)] outline-none focus:border-[var(--r-accent)]" />
|
||
</label>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{highlights.length === 0 ? (
|
||
<p className="text-sm text-[var(--r-sub)]">No highlights yet. Select some words in a verse and pick a colour to save them here.</p>
|
||
) : 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="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>
|
||
))}
|
||
</>
|
||
)}
|
||
|
||
{tab === 'bookmarks' && (
|
||
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="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>
|
||
))
|
||
)}
|
||
{total > 0 && (
|
||
<div className="mt-4 flex flex-wrap items-center gap-2 border-t border-[var(--r-line)] pt-3" role="group" aria-label="Export your marks">
|
||
<span className="text-xs text-[var(--r-sub)]">Take them with you</span>
|
||
<button type="button" onClick={() => exportAs('md')} className="rounded-lg border border-[var(--r-line)] px-3 py-1.5 text-xs hover:bg-black/5">Markdown</button>
|
||
<button type="button" onClick={() => exportAs('docx')} className="rounded-lg border border-[var(--r-line)] px-3 py-1.5 text-xs hover:bg-black/5">Word (.docx)</button>
|
||
{exportError && <span role="alert" className="text-xs text-[var(--r-danger,#b42318)]">{exportError}</span>}
|
||
</div>
|
||
)}
|
||
</Sheet>
|
||
);
|
||
}
|