Invite-only sign-up, present mode, series, handouts, templates, dictation, progress, sharing
Build and Push Docker Image / build-and-push (push) Successful in 6m47s

- Admin: sign-up Open / Invite only / Closed with single-use invite links
- Present mode for scripts (auto-scroll) and outlines (step through, pace vs planned minutes)
- Series: group studies on the dashboard, filter by series, calendar file for a study's sessions
- Lesson handout: printable page per session (passage, outline, questions, writing lines)
- Save a chunk's notes as your own study template (synced)
- Voice dictation for notes and scripts (browser speech recognition)
- Reading progress: map of the 66 books and a calendar of days read, synced and merged across devices
- Export highlights and bookmarks as Markdown or Word, grouped by named colour
- Verse of the day push to the ntfy topic (needs IQBIBLE_API_KEY)
- Sharing: co-teacher editing via single-use invite links; read-only links can expire and take a passcode

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-06 10:26:43 -04:00
1 parent 88bc59bac0
commit 54cb1c217d
73 files changed
+2739 -134

No files matched your search

+4
View File
@@ -155,4 +155,8 @@ npm run test:run # all tests
npm run lint # ESLint (errors fail; warnings are advice) npm run lint # ESLint (errors fail; warnings are advice)
``` ```
Who can sign up: the administrator (the `ADMIN_EMAIL` account) chooses Open, Invite only or Closed under Admin → Sign-ups, and makes single-use invite links there. The very first account can always be created.
Verse of the day push: with `IQBIBLE_API_KEY` set, each person can turn on a daily verse notification in Settings → Reading reminders (it goes to the same ntfy topic).
Forgotten passwords: the sign-in page's "Forgot your password?" sends a one-time code to the account's ntfy topic (the one used for reading reminders). Accounts without a topic still ask the administrator for a temporary password. Forgotten passwords: the sign-in page's "Forgot your password?" sends a one-time code to the account's ntfy topic (the one used for reading reminders). Accounts without a topic still ask the administrator for a temporary password.
+1
View File
@@ -21,6 +21,7 @@ export function buildAccountExport(user, store, now = new Date()) {
colorLabels: prefs.colorLabels?.v ?? {}, colorLabels: prefs.colorLabels?.v ?? {},
ink: store.ink() ?? {}, ink: store.ink() ?? {},
}, },
readingHistory: store.history?.() ?? { chapters: {}, days: [] },
readingPlans: { readingPlans: {
progress: plans.progress ?? [], progress: plans.progress ?? [],
customPlans: plans.customPlans ?? [], customPlans: plans.customPlans ?? [],
+6
View File
@@ -28,6 +28,12 @@ const ABBREV_BY_NAME = new Map(ESV_NAMES.map((n, i) => [n.toLowerCase(), BOOK_OR
ABBREV_BY_NAME.set('psalms', 'PSA'); ABBREV_BY_NAME.set('psalms', 'PSA');
ABBREV_BY_NAME.set('song of songs', 'SNG'); ABBREV_BY_NAME.set('song of songs', 'SNG');
// The full book name for a USFM code ("JHN" -> "John"), or null.
export function bookNameFor(abbrev) {
const i = BOOK_ORDER.indexOf(String(abbrev ?? '').toUpperCase());
return i >= 0 ? ESV_NAMES[i] : null;
}
export function parseEsvReference(ref) { export function parseEsvReference(ref) {
const m = String(ref).match(/^(.+?)\s+(\d+):(\d+)/); const m = String(ref).match(/^(.+?)\s+(\d+):(\d+)/);
const bookAbbrev = m && ABBREV_BY_NAME.get(m[1].toLowerCase()); const bookAbbrev = m && ABBREV_BY_NAME.get(m[1].toLowerCase());
+231 -13
View File
@@ -81,6 +81,15 @@ export function initDb() {
`); `);
} }
// Optional daily verse of the day, sent to the same ntfy topic.
if (!userCols.some((c) => c.name === 'votd_enabled')) {
db.exec(`
ALTER TABLE users ADD COLUMN votd_enabled INTEGER NOT NULL DEFAULT 0;
ALTER TABLE users ADD COLUMN votd_time TEXT;
ALTER TABLE users ADD COLUMN last_votd_date TEXT;
`);
}
// Reminders fire at the user's own local time, so remember their timezone. // Reminders fire at the user's own local time, so remember their timezone.
if (!userCols.some((c) => c.name === 'reminder_tz')) { if (!userCols.some((c) => c.name === 'reminder_tz')) {
db.exec('ALTER TABLE users ADD COLUMN reminder_tz TEXT'); db.exec('ALTER TABLE users ADD COLUMN reminder_tz TEXT');
@@ -93,9 +102,56 @@ export function initDb() {
if (!projectCols.some((c) => c.name === 'outline_share_token')) { if (!projectCols.some((c) => c.name === 'outline_share_token')) {
db.exec('ALTER TABLE projects ADD COLUMN outline_share_token TEXT'); db.exec('ALTER TABLE projects ADD COLUMN outline_share_token TEXT');
} }
// Share links can expire and be protected by a passcode.
for (const col of ['share_expires INTEGER', 'share_pass TEXT', 'outline_share_expires INTEGER', 'outline_share_pass TEXT']) {
if (!projectCols.some((c) => c.name === col.split(' ')[0])) db.exec(`ALTER TABLE projects ADD COLUMN ${col}`);
}
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_projects_outline_share_token ON projects(outline_share_token) WHERE outline_share_token IS NOT NULL'); db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_projects_outline_share_token ON projects(outline_share_token) WHERE outline_share_token IS NOT NULL');
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_projects_share_token ON projects(share_token) WHERE share_token IS NOT NULL'); db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_projects_share_token ON projects(share_token) WHERE share_token IS NOT NULL');
// Server-wide settings (who may sign up, ...) and the invite codes that let someone sign up when sign-up is invite-only.
db.exec(`
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS invites (
code TEXT PRIMARY KEY,
note TEXT,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
used_by TEXT,
used_at INTEGER
);
`);
// Co-teachers: other accounts allowed to edit a study, and the single-use links that let them join.
db.exec(`
CREATE TABLE IF NOT EXISTS collaborators (
project_id TEXT NOT NULL,
user_id TEXT NOT NULL,
added_at INTEGER NOT NULL,
PRIMARY KEY (project_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_collaborators_user ON collaborators(user_id);
CREATE TABLE IF NOT EXISTS edit_invites (
token TEXT PRIMARY KEY,
project_id TEXT NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
used_by TEXT
);
`);
// What the person has read in the Reader (chapters and days), merged from every device.
db.exec(`
CREATE TABLE IF NOT EXISTS reader_history (
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
data TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
`);
// One-time codes for "forgot password": hashed, short-lived, limited tries. // One-time codes for "forgot password": hashed, short-lived, limited tries.
db.exec(` db.exec(`
CREATE TABLE IF NOT EXISTS password_resets ( CREATE TABLE IF NOT EXISTS password_resets (
@@ -182,6 +238,7 @@ function parseUserRow(row) {
...row, ...row,
totpEnabled: !!row.totpEnabled, totpEnabled: !!row.totpEnabled,
reminderEnabled: !!row.reminderEnabled, reminderEnabled: !!row.reminderEnabled,
votdEnabled: !!row.votdEnabled,
backupCodeHashes: row.backupCodesRaw ? JSON.parse(row.backupCodesRaw) : [], backupCodeHashes: row.backupCodesRaw ? JSON.parse(row.backupCodesRaw) : [],
}; };
} }
@@ -191,7 +248,8 @@ const USER_SELECT = `
totp_secret AS totpSecret, totp_enabled AS totpEnabled, backup_codes AS backupCodesRaw, totp_secret AS totpSecret, totp_enabled AS totpEnabled, backup_codes AS backupCodesRaw,
podcast_name AS podcastName, podcast_name AS podcastName,
ntfy_url AS ntfyUrl, ntfy_topic AS ntfyTopic, reminder_time AS reminderTime, ntfy_url AS ntfyUrl, ntfy_topic AS ntfyTopic, reminder_time AS reminderTime,
reminder_enabled AS reminderEnabled, last_reminder_date AS lastReminderDate, reminder_tz AS reminderTz reminder_enabled AS reminderEnabled, last_reminder_date AS lastReminderDate, reminder_tz AS reminderTz,
votd_enabled AS votdEnabled, votd_time AS votdTime, last_votd_date AS lastVotdDate
FROM users FROM users
`; `;
@@ -234,6 +292,25 @@ export function setReminderSettings(userId, { ntfyUrl, ntfyTopic, reminderTime,
`).run(ntfyUrl || null, ntfyTopic || null, reminderTime || null, reminderEnabled ? 1 : 0, reminderTz || null, userId); `).run(ntfyUrl || null, ntfyTopic || null, reminderTime || null, reminderEnabled ? 1 : 0, reminderTz || null, userId);
} }
/** Turns the daily verse of the day on or off, and says when to send it. */
export function setVotdSettings(userId, { votdEnabled, votdTime }) {
db.prepare('UPDATE users SET votd_enabled = ?, votd_time = ? WHERE id = ?').run(votdEnabled ? 1 : 0, votdTime || null, userId);
}
/** Every account with the verse of the day turned on and a topic to send it to. */
export function getUsersWithVotdEnabled() {
return db.prepare(`
SELECT id, email, ntfy_url AS ntfyUrl, ntfy_topic AS ntfyTopic, votd_time AS votdTime,
last_votd_date AS lastVotdDate, reminder_tz AS reminderTz
FROM users
WHERE votd_enabled = 1 AND ntfy_topic IS NOT NULL AND votd_time IS NOT NULL
`).all();
}
export function setLastVotdDate(userId, dateString) {
db.prepare('UPDATE users SET last_votd_date = ? WHERE id = ?').run(dateString, userId);
}
/** Every account with reminders turned on and a topic configured — checked once a minute. */ /** Every account with reminders turned on and a topic configured — checked once a minute. */
export function getUsersWithRemindersEnabled() { export function getUsersWithRemindersEnabled() {
return db.prepare(` return db.prepare(`
@@ -249,6 +326,41 @@ export function setLastReminderDate(userId, dateString) {
db.prepare('UPDATE users SET last_reminder_date = ? WHERE id = ?').run(dateString, userId); db.prepare('UPDATE users SET last_reminder_date = ? WHERE id = ?').run(dateString, userId);
} }
// ---------------------------------------------------------------------------
// Settings and sign-up invites
// ---------------------------------------------------------------------------
export function getSetting(key, fallback = null) {
const row = db.prepare('SELECT value FROM settings WHERE key = ?').get(key);
return row ? row.value : fallback;
}
export function setSetting(key, value) {
db.prepare('INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value').run(key, String(value));
}
export function createInvite(code, note, expiresAt) {
db.prepare('INSERT INTO invites (code, note, created_at, expires_at) VALUES (?, ?, ?, ?)').run(code, note || null, Date.now(), expiresAt);
}
export function listInvites() {
return db.prepare('SELECT code, note, created_at AS createdAt, expires_at AS expiresAt, used_by AS usedBy, used_at AS usedAt FROM invites ORDER BY created_at DESC LIMIT 100').all();
}
export function deleteInvite(code) {
db.prepare('DELETE FROM invites WHERE code = ?').run(code);
}
/** An invite that exists, hasn't expired and hasn't been used. */
export function findOpenInvite(code) {
return db.prepare('SELECT code FROM invites WHERE code = ? AND used_by IS NULL AND expires_at > ?').get(code, Date.now()) ?? null;
}
/** Marks an invite used by a new account; false if someone else got there first. */
export function useInvite(code, userId) {
return db.prepare('UPDATE invites SET used_by = ?, used_at = ? WHERE code = ? AND used_by IS NULL AND expires_at > ?').run(userId, Date.now(), code, Date.now()).changes > 0;
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Password reset codes // Password reset codes
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -325,6 +437,18 @@ export function setReaderMarks(userId, { bookmarks, highlights }) {
return updatedAt; return updatedAt;
} }
export function getReaderHistory(userId) {
const row = db.prepare('SELECT data FROM reader_history WHERE user_id = ?').get(userId);
try { return row ? JSON.parse(row.data) : { chapters: {}, days: [] }; } catch { return { chapters: {}, days: [] }; }
}
export function setReaderHistory(userId, history) {
db.prepare(`
INSERT INTO reader_history (user_id, data, updated_at) VALUES (?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at
`).run(userId, JSON.stringify(history), Date.now());
}
/** Upserts the ink strokes for one reader page. */ /** Upserts the ink strokes for one reader page. */
export function setReaderInkPage(userId, bookAbbrev, chapter, strokes) { export function setReaderInkPage(userId, bookAbbrev, chapter, strokes) {
db.prepare(` db.prepare(`
@@ -376,20 +500,33 @@ export function claimOrphanProjects(userId) {
* Does NOT return full project data to keep the response small. * Does NOT return full project data to keep the response small.
*/ */
export function getAllProjects(userId) { export function getAllProjects(userId) {
// the studies you own, plus the ones another account has invited you to edit
const rows = db.prepare(` const rows = db.prepare(`
SELECT id, title, last_edited AS lastEdited, chapter_summary AS chapterSummary SELECT p.id, p.title, p.last_edited AS lastEdited, p.chapter_summary AS chapterSummary,
FROM projects CASE WHEN p.user_id = ? THEN 0 ELSE 1 END AS shared, u.email AS ownerEmail
WHERE user_id = ? FROM projects p
ORDER BY last_edited DESC LEFT JOIN users u ON u.id = p.user_id
`).all(userId); WHERE p.user_id = ? OR p.id IN (SELECT project_id FROM collaborators WHERE user_id = ?)
return rows; ORDER BY p.last_edited DESC
`).all(userId, userId, userId);
return rows.map((r) => (r.shared ? { ...r, shared: true } : { id: r.id, title: r.title, lastEdited: r.lastEdited, chapterSummary: r.chapterSummary }));
} }
/** True when the account owns the study or has been invited to edit it. */
export function canEditProject(id, userId) {
return !!db.prepare(`
SELECT 1 FROM projects p WHERE p.id = ? AND (p.user_id = ? OR EXISTS (SELECT 1 FROM collaborators c WHERE c.project_id = p.id AND c.user_id = ?))
`).get(id, userId, userId);
}
export const isProjectOwner = (id, userId) => !!db.prepare('SELECT 1 FROM projects WHERE id = ? AND user_id = ?').get(id, userId);
/** /**
* Returns a single full project by id, scoped to userId, or null if not found/not owned. * Returns a single full project by id, scoped to userId, or null if not found/not owned.
*/ */
export function getProject(id, userId) { export function getProject(id, userId) {
const row = db.prepare('SELECT data FROM projects WHERE id = ? AND user_id = ?').get(id, userId); if (!canEditProject(id, userId)) return null;
const row = db.prepare('SELECT data FROM projects WHERE id = ?').get(id);
if (!row) return null; if (!row) return null;
try { try {
return JSON.parse(row.data); return JSON.parse(row.data);
@@ -400,7 +537,8 @@ export function getProject(id, userId) {
/** The server copy's lastEdited for a project owned by userId, or null when there isn't one. */ /** The server copy's lastEdited for a project owned by userId, or null when there isn't one. */
export function getProjectEdited(id, userId) { export function getProjectEdited(id, userId) {
const row = db.prepare('SELECT last_edited AS lastEdited FROM projects WHERE id = ? AND user_id = ?').get(id, userId); if (!canEditProject(id, userId)) return null;
const row = db.prepare('SELECT last_edited AS lastEdited FROM projects WHERE id = ?').get(id);
return row ? row.lastEdited : null; return row ? row.lastEdited : null;
} }
@@ -410,7 +548,7 @@ export function getProjectEdited(id, userId) {
*/ */
export function upsertProject(project, userId) { export function upsertProject(project, userId) {
const existing = db.prepare('SELECT user_id AS userId FROM projects WHERE id = ?').get(project.id); const existing = db.prepare('SELECT user_id AS userId FROM projects WHERE id = ?').get(project.id);
if (existing && existing.userId !== userId) { if (existing && existing.userId !== userId && !canEditProject(project.id, userId)) {
return null; return null;
} }
@@ -437,7 +575,54 @@ export function upsertProject(project, userId) {
* Delete a project by id, scoped to userId. No-op if not found/not owned. * Delete a project by id, scoped to userId. No-op if not found/not owned.
*/ */
export function deleteProject(id, userId) { export function deleteProject(id, userId) {
db.prepare('DELETE FROM projects WHERE id = ? AND user_id = ?').run(id, userId); db.transaction(() => {
if (!db.prepare('DELETE FROM projects WHERE id = ? AND user_id = ?').run(id, userId).changes) {
// not the owner: "deleting" a study you were invited to just removes you from it
db.prepare('DELETE FROM collaborators WHERE project_id = ? AND user_id = ?').run(id, userId);
return;
}
db.prepare('DELETE FROM collaborators WHERE project_id = ?').run(id);
db.prepare('DELETE FROM edit_invites WHERE project_id = ?').run(id);
})();
}
// ---------------------------------------------------------------------------
// Co-teachers
// ---------------------------------------------------------------------------
export function createEditInvite(token, projectId, expiresAt) {
db.prepare('INSERT INTO edit_invites (token, project_id, created_at, expires_at) VALUES (?, ?, ?, ?)').run(token, projectId, Date.now(), expiresAt);
}
export function listCollaborators(projectId) {
return db.prepare(`
SELECT c.user_id AS userId, u.email, c.added_at AS addedAt FROM collaborators c JOIN users u ON u.id = c.user_id
WHERE c.project_id = ? ORDER BY c.added_at
`).all(projectId);
}
export function countOpenEditInvites(projectId) {
return db.prepare('SELECT COUNT(*) AS n FROM edit_invites WHERE project_id = ? AND used_by IS NULL AND expires_at > ?').get(projectId, Date.now()).n;
}
export function removeCollaborator(projectId, userId) {
db.prepare('DELETE FROM collaborators WHERE project_id = ? AND user_id = ?').run(projectId, userId);
}
/** Uses a single-use invite: -> { projectId, title } | { error }. The owner is told "you own this" rather than added. */
export function acceptEditInvite(token, userId) {
return db.transaction(() => {
const inv = db.prepare('SELECT project_id AS projectId, expires_at AS expiresAt, used_by AS usedBy FROM edit_invites WHERE token = ?').get(token);
if (!inv) return { error: 'This invite link is not valid.' };
if (inv.usedBy) return { error: 'This invite link has already been used.' };
if (inv.expiresAt <= Date.now()) return { error: 'This invite link has expired. Ask for a new one.' };
const project = db.prepare('SELECT title, user_id AS ownerId FROM projects WHERE id = ?').get(inv.projectId);
if (!project) return { error: 'That study no longer exists.' };
if (project.ownerId === userId) return { error: 'That is your own study.' };
db.prepare('INSERT OR IGNORE INTO collaborators (project_id, user_id, added_at) VALUES (?, ?, ?)').run(inv.projectId, userId, Date.now());
db.prepare('UPDATE edit_invites SET used_by = ? WHERE token = ?').run(userId, token);
return { projectId: inv.projectId, title: project.title };
})();
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -458,7 +643,7 @@ export function setShareToken(id, userId, token) {
/** Revokes a project's share link, scoped to userId. */ /** Revokes a project's share link, scoped to userId. */
export function clearShareToken(id, userId) { export function clearShareToken(id, userId) {
db.prepare('UPDATE projects SET share_token = NULL WHERE id = ? AND user_id = ?').run(id, userId); db.prepare('UPDATE projects SET share_token = NULL, share_expires = NULL, share_pass = NULL WHERE id = ? AND user_id = ?').run(id, userId);
} }
// ---- read-only outline links: a separate token, so sharing an outline never shares the whole study ---- // ---- read-only outline links: a separate token, so sharing an outline never shares the whole study ----
@@ -473,7 +658,39 @@ export function setOutlineShareToken(id, userId, token) {
} }
export function clearOutlineShareToken(id, userId) { export function clearOutlineShareToken(id, userId) {
db.prepare('UPDATE projects SET outline_share_token = NULL WHERE id = ? AND user_id = ?').run(id, userId); db.prepare('UPDATE projects SET outline_share_token = NULL, outline_share_expires = NULL, outline_share_pass = NULL WHERE id = ? AND user_id = ?').run(id, userId);
}
// ---- expiry and passcode for either kind of read-only link (outline = the outline-only link) ----
const shareCols = (outline) => (outline
? { token: 'outline_share_token', expires: 'outline_share_expires', pass: 'outline_share_pass' }
: { token: 'share_token', expires: 'share_expires', pass: 'share_pass' });
/** { token, expiresAt, hasPasscode } for the owner's link settings. */
export function getShareSettings(id, userId, outline = false) {
const c = shareCols(outline);
const row = db.prepare(`SELECT ${c.token} AS token, ${c.expires} AS expiresAt, ${c.pass} AS pass FROM projects WHERE id = ? AND user_id = ?`).get(id, userId);
return row ? { token: row.token ?? null, expiresAt: row.expiresAt ?? null, hasPasscode: !!row.pass } : null;
}
/** The stored passcode hash of a link (kept when only the expiry is changed). */
export function getSharePassHash(id, userId, outline = false) {
const c = shareCols(outline);
return db.prepare(`SELECT ${c.pass} AS pass FROM projects WHERE id = ? AND user_id = ?`).get(id, userId)?.pass ?? null;
}
/** Sets expiry (ms timestamp or null) and passcode hash (or null) on an existing link. */
export function setShareSettings(id, userId, { expiresAt, passHash }, outline = false) {
const c = shareCols(outline);
return db.prepare(`UPDATE projects SET ${c.expires} = ?, ${c.pass} = ? WHERE id = ? AND user_id = ?`).run(expiresAt ?? null, passHash ?? null, id, userId).changes > 0;
}
/** Public lookup for a link: the project plus the link's rules, or null for an unknown token. */
export function getSharedByToken(token, outline = false) {
const c = shareCols(outline);
const row = db.prepare(`SELECT data, ${c.expires} AS expiresAt, ${c.pass} AS passHash FROM projects WHERE ${c.token} = ?`).get(token);
if (!row) return null;
try { return { project: JSON.parse(row.data), expiresAt: row.expiresAt ?? null, passHash: row.passHash ?? null }; } catch { return null; }
} }
/** Public lookup: the project data behind an outline link (callers must only ever send the outlines on). */ /** Public lookup: the project data behind an outline link (callers must only ever send the outlines on). */
@@ -513,6 +730,7 @@ export function adminGetAllUsers() {
export function adminDeleteUser(userId) { export function adminDeleteUser(userId) {
// projects.user_id is a foreign key, so release the projects first (they stay as unowned rows). // projects.user_id is a foreign key, so release the projects first (they stay as unowned rows).
db.transaction(() => { db.transaction(() => {
db.prepare('DELETE FROM collaborators WHERE user_id = ?').run(userId);
db.prepare('UPDATE projects SET user_id = NULL, share_token = NULL, outline_share_token = NULL WHERE user_id = ?').run(userId); db.prepare('UPDATE projects SET user_id = NULL, share_token = NULL, outline_share_token = NULL WHERE user_id = ?').run(userId);
db.prepare('DELETE FROM users WHERE id = ?').run(userId); db.prepare('DELETE FROM users WHERE id = ?').run(userId);
})(); })();
+151 -36
View File
@@ -10,13 +10,16 @@ import {
initDb, dataDir, getAllProjects, getProject, getProjectEdited, upsertProject, deleteProject, initDb, dataDir, getAllProjects, getProject, getProjectEdited, upsertProject, deleteProject,
countUsers, createUser, getUserByEmail, getUserById, claimOrphanProjects, countUsers, createUser, getUserByEmail, getUserById, claimOrphanProjects,
enableTotp, disableTotp, setBackupCodeHashes, setPodcastName, enableTotp, disableTotp, setBackupCodeHashes, setPodcastName,
getShareToken, setShareToken, clearShareToken, getProjectByShareToken, setShareToken, clearShareToken, getShareSettings, setShareSettings, getSharedByToken, getSharePassHash,
getOutlineShareToken, setOutlineShareToken, clearOutlineShareToken, getProjectByOutlineToken, setOutlineShareToken, clearOutlineShareToken,
adminGetAllUsers, adminDeleteUser, adminGetAllProjects, adminGetProject, adminDeleteProject, adminGetAllUsers, adminDeleteUser, adminGetAllProjects, adminGetProject, adminDeleteProject,
setUserPassword, destroyAllSessionsForUser, setUserPassword, destroyAllSessionsForUser,
getAllReaderInk, setReaderInkPage, getReaderMarks, setReaderMarks, getUserPrefs, setUserPrefs, getAllReaderInk, setReaderInkPage, getReaderMarks, setReaderMarks, getUserPrefs, setUserPrefs,
getReadingProgress, setReadingProgress, getReadingProgress, setReadingProgress,
setReminderSettings, setReminderSettings, setVotdSettings,
getReaderHistory, setReaderHistory,
isProjectOwner, createEditInvite, listCollaborators, countOpenEditInvites, removeCollaborator, acceptEditInvite,
getSetting, setSetting, createInvite, listInvites, deleteInvite, findOpenInvite, useInvite,
setPasswordReset, getPasswordReset, bumpPasswordResetAttempts, clearPasswordReset, setPasswordReset, getPasswordReset, bumpPasswordResetAttempts, clearPasswordReset,
} from './db.js'; } from './db.js';
import { SqliteSessionStore } from './sessionStore.js'; import { SqliteSessionStore } from './sessionStore.js';
@@ -26,9 +29,13 @@ import {
generateBackupCodes, hashBackupCodes, consumeBackupCode, generateTemporaryPassword, generateBackupCodes, hashBackupCodes, consumeBackupCode, generateTemporaryPassword,
generateResetCode, normalizeResetCode, generateResetCode, normalizeResetCode,
} from './auth.js'; } from './auth.js';
import { votdAvailable } from './votd.js';
import { startReminderScheduler, sendTestReminder, isValidTimeZone, assertPublicUrl, sendNtfy } from './reminders.js'; import { startReminderScheduler, sendTestReminder, isValidTimeZone, assertPublicUrl, sendNtfy } from './reminders.js';
import { registerBibleRoutes } from './bibleApi.js'; import { registerBibleRoutes } from './bibleApi.js';
import { registerIqBibleRoutes } from './iqbible.js'; import { registerIqBibleRoutes } from './iqbible.js';
import { expiryFromDays, hashPasscode, checkShareAccess } from './shareLinks.js';
import { SIGNUP_MODES, isSignupMode, checkSignup, generateInviteCode, normalizeInviteCode } from './signup.js';
import { mergeHistory } from './readerHistory.js';
import { buildAccountExport, accountExportName } from './accountExport.js'; import { buildAccountExport, accountExportName } from './accountExport.js';
import { startBackupScheduler } from './backups.js'; import { startBackupScheduler } from './backups.js';
import { cleanMarks } from './readerMarks.js'; import { cleanMarks } from './readerMarks.js';
@@ -43,6 +50,7 @@ function userPayload(user) {
isAdmin: isAdminEmail(user.email), isAdmin: isAdminEmail(user.email),
reminderEnabled: user.reminderEnabled, ntfyUrl: user.ntfyUrl ?? null, ntfyTopic: user.ntfyTopic ?? null, reminderEnabled: user.reminderEnabled, ntfyUrl: user.ntfyUrl ?? null, ntfyTopic: user.ntfyTopic ?? null,
reminderTime: user.reminderTime ?? null, reminderTime: user.reminderTime ?? null,
votdEnabled: !!user.votdEnabled, votdTime: user.votdTime ?? null, votdAvailable: votdAvailable(),
}; };
} }
@@ -146,6 +154,9 @@ app.post('/api/auth/register', async (req, res) => {
const email = String(req.body?.email ?? '').trim().toLowerCase(); const email = String(req.body?.email ?? '').trim().toLowerCase();
const password = String(req.body?.password ?? ''); const password = String(req.body?.password ?? '');
const inviteCode = normalizeInviteCode(req.body?.invite);
const allowed = checkSignup({ mode: getSetting('signup', 'open'), userCount: countUsers(), inviteOk: !!findOpenInvite(inviteCode) });
if (!allowed.ok) return res.status(allowed.status).json({ error: allowed.error });
if (!isValidEmail(email)) { if (!isValidEmail(email)) {
return res.status(400).json({ error: 'Enter a valid email address.' }); return res.status(400).json({ error: 'Enter a valid email address.' });
} }
@@ -158,6 +169,10 @@ app.post('/api/auth/register', async (req, res) => {
const passwordHash = await hashPassword(password); const passwordHash = await hashPassword(password);
const user = createUser({ id: randomUUID(), email, passwordHash }); const user = createUser({ id: randomUUID(), email, passwordHash });
if (getSetting('signup', 'open') === 'invite' && countUsers() > 1 && !useInvite(inviteCode, user.id)) {
adminDeleteUser(user.id); // someone used the code between the check and now
return res.status(403).json({ error: 'That invite code was just used. Ask for a new one.' });
}
// The very first account inherits any projects created before multi-user support existed. // The very first account inherits any projects created before multi-user support existed.
if (countUsers() === 1) { if (countUsers() === 1) {
@@ -313,6 +328,11 @@ app.post('/api/auth/reset', async (req, res) => {
} }
}); });
// What the sign-in page needs to know: whether to offer "create an account", and whether it needs an invite code.
app.get('/api/auth/signup-mode', (_req, res) => {
res.json({ mode: countUsers() === 0 ? 'open' : getSetting('signup', 'open') });
});
app.post('/api/auth/logout', (req, res) => { app.post('/api/auth/logout', (req, res) => {
req.session.destroy(() => { req.session.destroy(() => {
res.clearCookie('connect.sid'); res.clearCookie('connect.sid');
@@ -367,6 +387,14 @@ app.patch('/api/auth/reminder-settings', requireAuth, async (req, res) => {
} }
} }
// the verse of the day is optional, and only offered when the server can look it up
const votdEnabled = !!req.body?.votdEnabled && votdAvailable();
const votdTime = String(req.body?.votdTime ?? '').trim();
if (votdEnabled) {
if (!ntfyTopic) return res.status(400).json({ error: 'A topic is required to send the verse of the day.' });
if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(votdTime)) return res.status(400).json({ error: 'The verse of the day time must be in HH:MM format.' });
}
setVotdSettings(req.session.userId, { votdEnabled, votdTime: votdTime || null });
setReminderSettings(req.session.userId, { setReminderSettings(req.session.userId, {
ntfyUrl: ntfyUrl || null, ntfyUrl: ntfyUrl || null,
ntfyTopic: ntfyTopic || null, ntfyTopic: ntfyTopic || null,
@@ -376,6 +404,7 @@ app.patch('/api/auth/reminder-settings', requireAuth, async (req, res) => {
}); });
res.json({ res.json({
reminderEnabled, ntfyUrl: ntfyUrl || null, ntfyTopic: ntfyTopic || null, reminderTime: reminderTime || null, reminderEnabled, ntfyUrl: ntfyUrl || null, ntfyTopic: ntfyTopic || null, reminderTime: reminderTime || null,
votdEnabled, votdTime: votdTime || null,
}); });
} catch (err) { } catch (err) {
console.error('PATCH /api/auth/reminder-settings error:', err); console.error('PATCH /api/auth/reminder-settings error:', err);
@@ -489,6 +518,7 @@ app.get('/api/account/export', requireAuth, (req, res) => {
marks: () => getReaderMarks(userId), marks: () => getReaderMarks(userId),
ink: () => getAllReaderInk(userId), ink: () => getAllReaderInk(userId),
plans: () => getReadingProgress(userId), plans: () => getReadingProgress(userId),
history: () => getReaderHistory(userId),
}); });
res.set('Content-Disposition', `attachment; filename="${accountExportName()}"`); res.set('Content-Disposition', `attachment; filename="${accountExportName()}"`);
res.json(backup); res.json(backup);
@@ -632,6 +662,24 @@ app.get('/api/reader/marks', requireAuth, (req, res) => {
} }
}); });
// GET /api/reader/history — what has been read, and PUT merges this device's copy in and returns the whole
app.get('/api/reader/history', requireAuth, (req, res) => {
try { res.json(mergeHistory(getReaderHistory(req.session.userId), {})); } catch (err) {
console.error('GET /api/reader/history error:', err);
res.status(500).json({ error: 'Failed to load your reading history.' });
}
});
app.put('/api/reader/history', requireAuth, (req, res) => {
try {
const merged = mergeHistory(getReaderHistory(req.session.userId), req.body);
setReaderHistory(req.session.userId, merged);
res.json(merged);
} catch (err) {
console.error('PUT /api/reader/history error:', err);
res.status(500).json({ error: 'Failed to save your reading history.' });
}
});
// PUT /api/reader/marks — replace the user's bookmarks + highlights ({ bookmarks, highlights }) // PUT /api/reader/marks — replace the user's bookmarks + highlights ({ bookmarks, highlights })
app.put('/api/reader/marks', requireAuth, (req, res) => { app.put('/api/reader/marks', requireAuth, (req, res) => {
try { try {
@@ -715,28 +763,37 @@ app.post('/api/auth/reminder-test', requireAuth, async (req, res) => {
// GET /api/projects/:id/share — current share status for the project owner // GET /api/projects/:id/share — current share status for the project owner
app.get('/api/projects/:id/share', requireAuth, (req, res) => { app.get('/api/projects/:id/share', requireAuth, (req, res) => {
try { try {
const project = getProject(req.params.id, req.session.userId); const settings = getShareSettings(req.params.id, req.session.userId);
if (!project) return res.status(404).json({ error: 'Project not found.' }); if (!settings) return res.status(404).json({ error: 'Project not found.' });
res.json({ shareToken: getShareToken(req.params.id, req.session.userId) }); res.json({ shareToken: settings.token, expiresAt: settings.expiresAt, hasPasscode: settings.hasPasscode });
} catch (err) { } catch (err) {
console.error('GET /api/projects/:id/share error:', err); console.error('GET /api/projects/:id/share error:', err);
res.status(500).json({ error: 'Failed to load share status.' }); res.status(500).json({ error: 'Failed to load share status.' });
} }
}); });
// POST /api/projects/:id/share — enable sharing, returns the (new or existing) token // Sets up (or changes) a read-only link: body { expiresInDays?: number | null, passcode?: string | null }.
app.post('/api/projects/:id/share', requireAuth, (req, res) => { // Leaving a field out keeps what it was; null/"" clears it. Returns the (new or existing) token.
async function saveLink(req, res, outline) {
try { try {
const existing = getShareToken(req.params.id, req.session.userId); const userId = req.session.userId;
const token = existing || randomUUID().replace(/-/g, ''); const settings = getShareSettings(req.params.id, userId, outline);
const ok = setShareToken(req.params.id, req.session.userId, token); if (!settings) return res.status(404).json({ error: 'Project not found.' });
if (!ok) return res.status(404).json({ error: 'Project not found.' }); const token = settings.token || randomUUID().replace(/-/g, '');
res.json({ shareToken: token }); if (!settings.token && !(outline ? setOutlineShareToken : setShareToken)(req.params.id, userId, token)) return res.status(404).json({ error: 'Project not found.' });
const body = req.body ?? {};
const expiresAt = 'expiresInDays' in body ? expiryFromDays(body.expiresInDays) : settings.expiresAt;
// a blank passcode field means "keep it"; null clears; a string sets
const current = getSharePassHash(req.params.id, userId, outline);
const passHash = 'passcode' in body ? (body.passcode === null ? null : await hashPasscode(body.passcode) ?? current) : current;
setShareSettings(req.params.id, userId, { expiresAt, passHash }, outline);
res.json({ shareToken: token, expiresAt, hasPasscode: !!passHash });
} catch (err) { } catch (err) {
console.error('POST /api/projects/:id/share error:', err); console.error(`POST share (${outline ? 'outline' : 'study'}) error:`, err);
res.status(500).json({ error: 'Failed to enable sharing.' }); res.status(500).json({ error: 'Failed to enable sharing.' });
} }
}); }
app.post('/api/projects/:id/share', requireAuth, (req, res) => saveLink(req, res, false));
// DELETE /api/projects/:id/share — revoke the share link // DELETE /api/projects/:id/share — revoke the share link
app.delete('/api/projects/:id/share', requireAuth, (req, res) => { app.delete('/api/projects/:id/share', requireAuth, (req, res) => {
@@ -749,27 +806,49 @@ app.delete('/api/projects/:id/share', requireAuth, (req, res) => {
} }
}); });
// ---------------------------------------------------------------------------
// Co-teachers: other accounts that may edit a study. The owner makes a single-use invite link; whoever
// opens it while signed in joins. The owner (or the co-teacher themself) can remove an editor.
// ---------------------------------------------------------------------------
app.get('/api/projects/:id/collaborators', requireAuth, (req, res) => {
if (!isProjectOwner(req.params.id, req.session.userId)) return res.status(404).json({ error: 'Project not found.' });
res.json({ collaborators: listCollaborators(req.params.id), pendingInvites: countOpenEditInvites(req.params.id) });
});
app.post('/api/projects/:id/collaborators/invite', requireAuth, (req, res) => {
if (!isProjectOwner(req.params.id, req.session.userId)) return res.status(404).json({ error: 'Project not found.' });
const token = randomUUID().replace(/-/g, '');
const expiresAt = Date.now() + 7 * 86_400_000;
createEditInvite(token, req.params.id, expiresAt);
res.json({ token, expiresAt });
});
app.delete('/api/projects/:id/collaborators/:userId', requireAuth, (req, res) => {
const me = req.session.userId;
if (!isProjectOwner(req.params.id, me) && req.params.userId !== me) return res.status(403).json({ error: 'Only the owner can remove someone else.' });
removeCollaborator(req.params.id, req.params.userId);
res.json({ ok: true });
});
app.post('/api/join/:token', requireAuth, (req, res) => {
const result = acceptEditInvite(req.params.token, req.session.userId);
if (result.error) return res.status(400).json({ error: result.error });
res.json(result);
});
// Read-only outline links. A separate token from the study share link, and the public endpoint returns // Read-only outline links. A separate token from the study share link, and the public endpoint returns
// only the outlines (see outlineShare.js), never the notes or scripts. // only the outlines (see outlineShare.js), never the notes or scripts.
app.get('/api/projects/:id/outline-share', requireAuth, (req, res) => { app.get('/api/projects/:id/outline-share', requireAuth, (req, res) => {
try { try {
res.json({ shareToken: getOutlineShareToken(req.params.id, req.session.userId) }); const settings = getShareSettings(req.params.id, req.session.userId, true);
res.json({ shareToken: settings?.token ?? null, expiresAt: settings?.expiresAt ?? null, hasPasscode: !!settings?.hasPasscode });
} catch (err) { } catch (err) {
console.error('GET /api/projects/:id/outline-share error:', err); console.error('GET /api/projects/:id/outline-share error:', err);
res.status(500).json({ error: 'Failed to load share status.' }); res.status(500).json({ error: 'Failed to load share status.' });
} }
}); });
app.post('/api/projects/:id/outline-share', requireAuth, (req, res) => { app.post('/api/projects/:id/outline-share', requireAuth, (req, res) => saveLink(req, res, true));
try {
const token = getOutlineShareToken(req.params.id, req.session.userId) || randomUUID().replace(/-/g, '');
if (!setOutlineShareToken(req.params.id, req.session.userId, token)) return res.status(404).json({ error: 'Project not found.' });
res.json({ shareToken: token });
} catch (err) {
console.error('POST /api/projects/:id/outline-share error:', err);
res.status(500).json({ error: 'Failed to enable sharing.' });
}
});
app.delete('/api/projects/:id/outline-share', requireAuth, (req, res) => { app.delete('/api/projects/:id/outline-share', requireAuth, (req, res) => {
try { try {
@@ -781,24 +860,37 @@ app.delete('/api/projects/:id/outline-share', requireAuth, (req, res) => {
} }
}); });
// GET /api/share-outline/:token — PUBLIC, no login required // A wrong passcode counts as a failed attempt for that link and address
app.get('/api/share-outline/:token', (req, res) => { async function openSharedLink(req, res, outline) {
const found = getSharedByToken(req.params.token, outline);
if (!found) { res.status(404).json({ error: outline ? 'This outline link is invalid or has been turned off.' : 'This share link is invalid or has been revoked.' }); return null; }
const keys = [`ip:${req.ip}`, `share:${req.params.token}`];
if (isLimited(...keys)) { res.status(429).json(TOO_MANY); return null; }
const access = await checkShareAccess({ expiresAt: found.expiresAt, passHash: found.passHash, supplied: req.get('X-Share-Passcode') });
if (!access.ok) {
if (access.needsPasscode && req.get('X-Share-Passcode')) recordFailure(...keys);
res.status(access.status).json({ error: access.error, needsPasscode: !!access.needsPasscode, expired: access.status === 410 });
return null;
}
return found.project;
}
// GET /api/share-outline/:token — PUBLIC, no login required (a passcode may be needed)
app.get('/api/share-outline/:token', async (req, res) => {
try { try {
const project = getProjectByOutlineToken(req.params.token); const project = await openSharedLink(req, res, true);
if (!project) return res.status(404).json({ error: 'This outline link is invalid or has been turned off.' }); if (project) res.json(buildOutlinePayload(project));
res.json(buildOutlinePayload(project));
} catch (err) { } catch (err) {
console.error('GET /api/share-outline/:token error:', err); console.error('GET /api/share-outline/:token error:', err);
res.status(500).json({ error: 'Failed to load the shared outline.' }); res.status(500).json({ error: 'Failed to load the shared outline.' });
} }
}); });
// GET /api/share/:token — PUBLIC, no login required: fetch a shared project read-only // GET /api/share/:token — PUBLIC, no login required: fetch a shared project read-only (a passcode may be needed)
app.get('/api/share/:token', (req, res) => { app.get('/api/share/:token', async (req, res) => {
try { try {
const project = getProjectByShareToken(req.params.token); const project = await openSharedLink(req, res, false);
if (!project) return res.status(404).json({ error: 'This share link is invalid or has been revoked.' }); if (project) res.json(project);
res.json(project);
} catch (err) { } catch (err) {
console.error('GET /api/share/:token error:', err); console.error('GET /api/share/:token error:', err);
res.status(500).json({ error: 'Failed to load shared project.' }); res.status(500).json({ error: 'Failed to load shared project.' });
@@ -848,6 +940,29 @@ app.post('/api/admin/users/:id/reset-password', requireAuth, requireAdmin, async
} }
}); });
// Who may sign up, and the invite codes for it
app.get('/api/admin/signup', requireAuth, requireAdmin, (_req, res) => {
res.json({ mode: getSetting('signup', 'open'), invites: listInvites() });
});
app.put('/api/admin/signup', requireAuth, requireAdmin, (req, res) => {
if (!isSignupMode(req.body?.mode)) return res.status(400).json({ error: `Mode must be one of: ${SIGNUP_MODES.join(', ')}.` });
setSetting('signup', req.body.mode);
res.json({ mode: req.body.mode });
});
app.post('/api/admin/invites', requireAuth, requireAdmin, (req, res) => {
const days = Math.min(90, Math.max(1, Number(req.body?.days) || 7));
const code = generateInviteCode();
createInvite(code, String(req.body?.note ?? '').trim().slice(0, 80), Date.now() + days * 86_400_000);
res.json({ code, expiresInDays: days });
});
app.delete('/api/admin/invites/:code', requireAuth, requireAdmin, (req, res) => {
deleteInvite(normalizeInviteCode(req.params.code));
res.json({ ok: true });
});
app.get('/api/admin/projects', requireAuth, requireAdmin, (req, res) => { app.get('/api/admin/projects', requireAuth, requireAdmin, (req, res) => {
try { try {
res.json(adminGetAllProjects()); res.json(adminGetAllProjects());
+15
View File
@@ -24,6 +24,21 @@ export function cleanPrefValue(key, value) {
} }
return out; return out;
} }
if (key === 'studyTemplates') {
// The person's own study templates: prompts for the note boxes and scaffold text to start each chunk with
if (!Array.isArray(value)) return undefined;
const text = (v, max) => (typeof v === 'string' ? v.slice(0, max) : '');
const fields = (o, names, max) => {
const out = {};
for (const n of names) { const t = text(o?.[n], max); if (t.trim()) out[n] = t; }
return out;
};
return value.slice(0, 12).filter((t) => t && typeof t.id === 'string' && /^user-[\w-]{1,40}$/.test(t.id) && typeof t.name === 'string' && t.name.trim()).map((t) => {
const prompts = fields(t.prompts, ['observation', 'interpretation', 'application'], 800);
const starters = fields(t.starters, ['generalNotes', 'observation', 'interpretation', 'application'], 2000);
return { id: t.id, name: text(t.name, 40).trim(), description: text(t.description, 120), ...(Object.keys(prompts).length ? { prompts } : {}), ...(Object.keys(starters).length ? { starters } : {}) };
});
}
if (key === 'position') { if (key === 'position') {
if (!value || typeof value !== 'object') return undefined; if (!value || typeof value !== 'object') return undefined;
const book = String(value.book ?? '').toUpperCase(); const book = String(value.book ?? '').toUpperCase();
+11
View File
@@ -67,3 +67,14 @@ test('the database keeps one row per user', async () => {
expect(db.getUserPrefs('u1')).toEqual({ theme: { v: 'sepia', at: 2 } }); expect(db.getUserPrefs('u1')).toEqual({ theme: { v: 'sepia', at: 2 } });
expect(db.getUserPrefs('u2')).toEqual({}); expect(db.getUserPrefs('u2')).toEqual({});
}); });
test('study templates are kept only when well-formed, and trimmed to sane sizes', async () => {
const { cleanPrefValue } = await import('./prefs.js');
const good = { id: 'user-abc1', name: ' Lesson ', description: 'x', prompts: { observation: 'Look', junk: 'no' }, starters: { generalNotes: 'Context:', bogus: 'no' } };
expect(cleanPrefValue('studyTemplates', [good, { id: 'other', name: 'bad id' }, { id: 'user-2' }, null])).toEqual([
{ id: 'user-abc1', name: 'Lesson', description: 'x', prompts: { observation: 'Look' }, starters: { generalNotes: 'Context:' } },
]);
expect(cleanPrefValue('studyTemplates', 'nope')).toBeUndefined();
expect(cleanPrefValue('studyTemplates', Array.from({ length: 30 }, (_, i) => ({ id: `user-${i}`, name: `T${i}` }))).length).toBe(12);
expect(cleanPrefValue('studyTemplates', [{ id: 'user-a', name: 'x', starters: { observation: 'y'.repeat(5000) } }])[0].starters.observation.length).toBe(2000);
});
+32
View File
@@ -0,0 +1,32 @@
// What a person has read in the Reader: which chapters (how many times, and when last) and on which days.
// Merging two copies never loses anything, so two devices can both add to it: each chapter keeps the larger
// count and the later date, and the days are combined. The data comes from the browser, so it is rebuilt field by field.
const KEY = /^[1-3A-Z]{3}\.\d{1,3}$/;
const DATE = /^\d{4}-\d{2}-\d{2}$/;
const MAX_DAYS = 5000;
export const emptyHistory = () => ({ chapters: {}, days: [] });
export function cleanHistory(raw) {
const chapters = {};
if (raw?.chapters && typeof raw.chapters === 'object' && !Array.isArray(raw.chapters)) {
for (const [key, v] of Object.entries(raw.chapters).slice(0, 1300)) {
if (!KEY.test(key) || !v || typeof v !== 'object') continue;
const n = Number.isInteger(v.n) ? Math.min(9999, Math.max(1, v.n)) : 1;
chapters[key] = { n, last: DATE.test(v.last ?? '') ? v.last : '' };
}
}
const days = Array.isArray(raw?.days) ? [...new Set(raw.days.filter((d) => typeof d === 'string' && DATE.test(d)))].sort().slice(-MAX_DAYS) : [];
return { chapters, days };
}
export function mergeHistory(a, b) {
const x = cleanHistory(a);
const y = cleanHistory(b);
const chapters = { ...x.chapters };
for (const [key, v] of Object.entries(y.chapters)) {
const mine = chapters[key];
chapters[key] = mine ? { n: Math.max(mine.n, v.n), last: mine.last > v.last ? mine.last : v.last } : v;
}
return { chapters, days: [...new Set([...x.days, ...y.days])].sort().slice(-MAX_DAYS) };
}
+23
View File
@@ -0,0 +1,23 @@
// @vitest-environment node
import { test, expect } from 'vitest';
import { cleanHistory, mergeHistory } from './readerHistory.js';
test('only well-formed chapters and days are kept', () => {
const out = cleanHistory({
chapters: { 'JHN.3': { n: 2, last: '2026-10-01' }, 'bad': { n: 1 }, 'GEN.1': { n: -5, last: 'yesterday' }, '1JN.2': null },
days: ['2026-10-02', 'nope', '2026-10-01', '2026-10-01'],
});
expect(out.chapters).toEqual({ 'JHN.3': { n: 2, last: '2026-10-01' }, 'GEN.1': { n: 1, last: '' } });
expect(out.days).toEqual(['2026-10-01', '2026-10-02']);
expect(cleanHistory(null)).toEqual({ chapters: {}, days: [] });
});
test('merging keeps the larger count, the later date and every day', () => {
const a = { chapters: { 'JHN.3': { n: 2, last: '2026-10-01' }, 'GEN.1': { n: 1, last: '2026-09-01' } }, days: ['2026-10-01'] };
const b = { chapters: { 'JHN.3': { n: 1, last: '2026-10-05' }, 'ROM.8': { n: 1, last: '2026-10-04' } }, days: ['2026-10-04', '2026-10-05'] };
const m = mergeHistory(a, b);
expect(m.chapters['JHN.3']).toEqual({ n: 2, last: '2026-10-05' });
expect(Object.keys(m.chapters).sort()).toEqual(['GEN.1', 'JHN.3', 'ROM.8']);
expect(m.days).toEqual(['2026-10-01', '2026-10-04', '2026-10-05']);
expect(mergeHistory(m, m)).toEqual(m); // merging again changes nothing
});
+23 -1
View File
@@ -4,7 +4,8 @@
// No API keys or push-subscription plumbing needed. // No API keys or push-subscription plumbing needed.
import { lookup } from 'dns/promises'; import { lookup } from 'dns/promises';
import { isIP } from 'net'; import { isIP } from 'net';
import { getUsersWithRemindersEnabled, setLastReminderDate, getReadingProgress } from './db.js'; import { getUsersWithRemindersEnabled, setLastReminderDate, getReadingProgress, getUsersWithVotdEnabled, setLastVotdDate } from './db.js';
import { fetchVerseOfDay, votdMessage } from './votd.js';
// A reminder still goes out if the server was briefly down (or restarting) at the // A reminder still goes out if the server was briefly down (or restarting) at the
// exact minute — as long as it's within this window after the chosen time. // exact minute — as long as it's within this window after the chosen time.
@@ -121,9 +122,30 @@ export async function checkReminders(now = new Date()) {
} }
} }
// Sends the verse of the day to everyone who asked for it, at their own time. One verse a day, the same for all.
export async function checkVotd(now = new Date()) {
const due = getUsersWithVotdEnabled().filter((u) => isDue({ reminderTime: u.votdTime, reminderTz: u.reminderTz, lastReminderDate: u.lastVotdDate }, now));
if (due.length === 0) return;
const verse = await fetchVerseOfDay(now);
if (!verse) return; // no key, or IQ Bible didn't answer: try again next minute (within the grace window)
for (const user of due) {
const key = `votd:${user.id}`;
if (now.getTime() - (_lastAttempt.get(key) ?? 0) < RETRY_AFTER_MS) continue;
_lastAttempt.set(key, now.getTime());
try {
await sendNtfy(user, votdMessage(verse));
_lastAttempt.delete(key);
setLastVotdDate(user.id, zonedNow(now, user.reminderTz).date);
} catch (err) {
console.error(`Verse of the day failed for user ${user.id}:`, err.message);
}
}
}
/** Checks once a minute whether any user's reminder time has arrived. */ /** Checks once a minute whether any user's reminder time has arrived. */
export function startReminderScheduler() { export function startReminderScheduler() {
setInterval(() => { setInterval(() => {
checkReminders().catch((err) => console.error('Reminder scheduler error:', err)); checkReminders().catch((err) => console.error('Reminder scheduler error:', err));
checkVotd().catch((err) => console.error('Verse of the day scheduler error:', err));
}, 60 * 1000); }, 60 * 1000);
} }
+27
View File
@@ -0,0 +1,27 @@
// The rules for a read-only link: it can expire, and it can need a passcode. Kept separate from the routes
// so they can be tested on their own.
import bcrypt from 'bcryptjs';
export const MAX_PASSCODE = 100;
// "never" (null/0) or a number of days, up to a year -> an expiry time in ms (or null)
export function expiryFromDays(days, now = Date.now()) {
const n = Number(days);
if (!days || !Number.isFinite(n) || n <= 0) return null;
return now + Math.min(365, Math.round(n)) * 86_400_000;
}
export async function hashPasscode(passcode) {
const p = String(passcode ?? '').slice(0, MAX_PASSCODE);
return p.trim() ? bcrypt.hash(p.trim(), 10) : null;
}
// -> { ok: true } | { ok: false, status: 410, error } (expired) | { ok: false, status: 401, needsPasscode: true, error }
export async function checkShareAccess({ expiresAt, passHash, supplied }, now = Date.now()) {
if (expiresAt && expiresAt <= now) return { ok: false, status: 410, error: 'This link has expired. Ask the person who shared it for a new one.' };
if (!passHash) return { ok: true };
const given = String(supplied ?? '').slice(0, MAX_PASSCODE).trim();
if (!given) return { ok: false, status: 401, needsPasscode: true, error: 'This link needs a passcode.' };
if (!(await bcrypt.compare(given, passHash))) return { ok: false, status: 401, needsPasscode: true, error: 'That passcode is not right.' };
return { ok: true };
}
+33
View File
@@ -0,0 +1,33 @@
// @vitest-environment node
import { test, expect } from 'vitest';
import { checkShareAccess, expiryFromDays, hashPasscode } from './shareLinks.js';
const NOW = 1_800_000_000_000;
test('expiry is a number of days from now, or never', () => {
expect(expiryFromDays(null, NOW)).toBeNull();
expect(expiryFromDays(0, NOW)).toBeNull();
expect(expiryFromDays('x', NOW)).toBeNull();
expect(expiryFromDays(7, NOW)).toBe(NOW + 7 * 86_400_000);
expect(expiryFromDays(9999, NOW)).toBe(NOW + 365 * 86_400_000); // never more than a year
});
test('a link with no rules is open; an expired one says so', async () => {
expect((await checkShareAccess({}, NOW)).ok).toBe(true);
expect(await checkShareAccess({ expiresAt: NOW + 1000 }, NOW)).toEqual({ ok: true });
expect(await checkShareAccess({ expiresAt: NOW - 1 }, NOW)).toMatchObject({ ok: false, status: 410 });
});
test('a passcode is asked for, and only the right one opens the link', async () => {
const passHash = await hashPasscode(' moses ');
expect(passHash).not.toContain('moses');
expect(await checkShareAccess({ passHash }, NOW)).toMatchObject({ ok: false, status: 401, needsPasscode: true });
expect(await checkShareAccess({ passHash, supplied: 'aaron' }, NOW)).toMatchObject({ ok: false, status: 401, needsPasscode: true, error: expect.stringMatching(/not right/) });
expect(await checkShareAccess({ passHash, supplied: ' moses ' }, NOW)).toEqual({ ok: true });
expect(await hashPasscode(' ')).toBeNull();
});
test('an expired link is reported as expired even before the passcode is checked', async () => {
const passHash = await hashPasscode('x');
expect(await checkShareAccess({ passHash, expiresAt: NOW - 5, supplied: 'x' }, NOW)).toMatchObject({ status: 410 });
});
+25
View File
@@ -0,0 +1,25 @@
// Who may create an account. The administrator picks one of three settings:
// open anyone who can reach the server (the original behaviour)
// invite only someone with an unused invite code from the administrator
// closed nobody new
// The very first account can always be made, so a fresh install can't lock itself out.
import { randomBytes } from 'crypto';
export const SIGNUP_MODES = ['open', 'invite', 'closed'];
export const isSignupMode = (m) => SIGNUP_MODES.includes(m);
// -> { ok: true } or { ok: false, status, error }. `inviteOk` says whether the code given is a valid unused one.
export function checkSignup({ mode, userCount, inviteOk }) {
if (userCount === 0 || mode === 'open') return { ok: true };
if (mode === 'closed') return { ok: false, status: 403, error: 'Sign-up is closed. Ask the administrator for an account.' };
if (!inviteOk) return { ok: false, status: 403, error: 'Sign-up needs an invite code from the administrator.' };
return { ok: true };
}
// An easy-to-read invite code (no look-alike characters), e.g. K7QP-4MXD-9TRW.
const ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789';
export function generateInviteCode() {
const chars = Array.from(randomBytes(12), (b) => ALPHABET[b % ALPHABET.length]).join('');
return `${chars.slice(0, 4)}-${chars.slice(4, 8)}-${chars.slice(8, 12)}`;
}
export const normalizeInviteCode = (code) => String(code ?? '').toUpperCase().replace(/[^A-Z0-9]/g, '').replace(/^(.{4})(.{4})(.{4})$/, '$1-$2-$3');
+24
View File
@@ -0,0 +1,24 @@
// @vitest-environment node
import { test, expect } from 'vitest';
import { checkSignup, generateInviteCode, normalizeInviteCode, isSignupMode } from './signup.js';
test('open lets anyone in; the first account is always allowed', () => {
expect(checkSignup({ mode: 'open', userCount: 5, inviteOk: false }).ok).toBe(true);
expect(checkSignup({ mode: 'closed', userCount: 0, inviteOk: false }).ok).toBe(true);
expect(checkSignup({ mode: 'invite', userCount: 0, inviteOk: false }).ok).toBe(true);
});
test('invite-only needs a good code, closed lets nobody in', () => {
expect(checkSignup({ mode: 'invite', userCount: 2, inviteOk: false })).toMatchObject({ ok: false, status: 403 });
expect(checkSignup({ mode: 'invite', userCount: 2, inviteOk: true }).ok).toBe(true);
expect(checkSignup({ mode: 'closed', userCount: 2, inviteOk: true })).toMatchObject({ ok: false, status: 403 });
});
test('invite codes are readable and typed forgivingly', () => {
const code = generateInviteCode();
expect(code).toMatch(/^[A-HJKMNP-Z2-9]{4}-[A-HJKMNP-Z2-9]{4}-[A-HJKMNP-Z2-9]{4}$/);
expect(normalizeInviteCode(` ${code.toLowerCase().replace(/-/g, ' ')} `)).toBe(code);
expect(normalizeInviteCode('abcd2345jkmn')).toBe('ABCD-2345-JKMN');
expect(isSignupMode('invite')).toBe(true);
expect(isSignupMode('nope')).toBe(false);
});
+33
View File
@@ -0,0 +1,33 @@
// Today's verse of the day for the push notification: IQ Bible picks it (the same verse for everyone each UTC day),
// in the KJV, which is free to quote. Needs IQBIBLE_API_KEY on the server. One lookup a day serves every user.
import { bookNameFor } from './bibleApi.js';
let cached = null; // { date, verse }
export const votdAvailable = () => Boolean(process.env.IQBIBLE_API_KEY);
// IQ Bible's answer -> { reference, text }, or null when it isn't one we can use
export function parseVotd(body) {
const d = body?.data;
const book = bookNameFor(d?.book_usfm);
if (!book || !d?.chapter_number || !d?.verse_number || typeof d.text !== 'string' || !d.text.trim()) return null;
return { reference: `${book} ${d.chapter_number}:${d.verse_number}`, text: d.text.replace(/^[¶§*\s]+/, '').replace(/\s+/g, ' ').trim() };
}
export async function fetchVerseOfDay(now = new Date(), fetcher = fetch) {
const date = now.toISOString().slice(0, 10);
if (cached?.date === date) return cached.verse;
if (!votdAvailable()) return null;
const base = (process.env.IQBIBLE_API_BASE || 'https://api.iqbible.com/api/v2').replace(/\/+$/, '');
try {
const res = await fetcher(`${base}/bibles/eng_kjv/verse-of-the-day`, { headers: { 'X-IQBible-API-Key': process.env.IQBIBLE_API_KEY }, signal: AbortSignal.timeout(10_000) });
if (!res.ok) return null;
const verse = parseVotd(await res.json());
if (verse) cached = { date, verse };
return verse;
} catch { return null; }
}
export const clearVotdCache = () => { cached = null; };
export const votdMessage = ({ reference, text }) => ({ title: `Verse of the day: ${reference}`, body: `${text}\n\n${reference} (KJV)`, tags: 'sunrise' });
+40
View File
@@ -0,0 +1,40 @@
// @vitest-environment node
import { test, expect, beforeEach, afterEach, vi } from 'vitest';
import { parseVotd, fetchVerseOfDay, clearVotdCache, votdMessage } from './votd.js';
beforeEach(() => { clearVotdCache(); process.env.IQBIBLE_API_KEY = 'k'; });
afterEach(() => { delete process.env.IQBIBLE_API_KEY; vi.restoreAllMocks(); });
const body = { data: { book_usfm: 'ROM', chapter_number: 8, verse_number: 28, text: '¶ And we know that all things work together for good.' } };
const ok = () => vi.fn(async () => ({ ok: true, json: async () => body }));
test('IQ Bible\'s verse becomes a reference and clean text', () => {
expect(parseVotd(body)).toEqual({ reference: 'Romans 8:28', text: 'And we know that all things work together for good.' });
expect(parseVotd({ data: { book_usfm: 'XXX', chapter_number: 1, verse_number: 1, text: 'x' } })).toBeNull();
expect(parseVotd({})).toBeNull();
});
test('the notification carries the verse and its reference', () => {
expect(votdMessage({ reference: 'Romans 8:28', text: 'All things.' })).toEqual({ title: 'Verse of the day: Romans 8:28', body: 'All things.\n\nRomans 8:28 (KJV)', tags: 'sunrise' });
});
test('one lookup a day serves everyone, and a new day looks again', async () => {
const fetcher = ok();
const day1 = new Date('2026-10-06T12:00:00Z');
expect((await fetchVerseOfDay(day1, fetcher)).reference).toBe('Romans 8:28');
await fetchVerseOfDay(new Date('2026-10-06T23:00:00Z'), fetcher);
expect(fetcher).toHaveBeenCalledTimes(1);
await fetchVerseOfDay(new Date('2026-10-07T01:00:00Z'), fetcher);
expect(fetcher).toHaveBeenCalledTimes(2);
expect(fetcher.mock.calls[0][0]).toContain('/bibles/eng_kjv/verse-of-the-day');
expect(fetcher.mock.calls[0][1].headers['X-IQBible-API-Key']).toBe('k');
});
test('without a key, or when IQ Bible fails, there is no verse (and nothing is remembered)', async () => {
delete process.env.IQBIBLE_API_KEY;
expect(await fetchVerseOfDay(new Date(), ok())).toBeNull();
process.env.IQBIBLE_API_KEY = 'k';
expect(await fetchVerseOfDay(new Date(), vi.fn(async () => ({ ok: false })))).toBeNull();
expect(await fetchVerseOfDay(new Date(), vi.fn(async () => { throw new Error('down'); }))).toBeNull();
expect((await fetchVerseOfDay(new Date(), ok())).reference).toBe('Romans 8:28'); // a later good answer still works
});
+26 -2
View File
@@ -5,6 +5,7 @@ import { AppContext, useApp } from './context/AppContext.js';
import { ToastProvider, useToast } from './components/Toasts.jsx'; import { ToastProvider, useToast } from './components/Toasts.jsx';
import CommandPalette from './components/CommandPalette.jsx'; import CommandPalette from './components/CommandPalette.jsx';
import ShortcutsHelp from './components/ShortcutsHelp.jsx'; import ShortcutsHelp from './components/ShortcutsHelp.jsx';
import SharedPasscodeForm from './components/SharedPasscodeForm.jsx';
import SharedOutlineView from './components/SharedOutlineView.jsx'; import SharedOutlineView from './components/SharedOutlineView.jsx';
import Tooltips from './components/Tooltips.jsx'; import Tooltips from './components/Tooltips.jsx';
import { useAudio } from './hooks/useAudio.js'; import { useAudio } from './hooks/useAudio.js';
@@ -12,11 +13,12 @@ import { useAuth } from './hooks/useAuth.js';
import { useBibleReader } from './hooks/useBibleReader.js'; import { useBibleReader } from './hooks/useBibleReader.js';
import { useProjectLibrary } from './hooks/useProjectLibrary.js'; import { useProjectLibrary } from './hooks/useProjectLibrary.js';
import { useHistoryNav } from './hooks/useHistoryNav.js'; import { useHistoryNav } from './hooks/useHistoryNav.js';
import { useReadingHistory } from './hooks/useReadingHistory.js';
import { useReadingPlans } from './hooks/useReadingPlans.js'; import { useReadingPlans } from './hooks/useReadingPlans.js';
import { useSharedView } from './hooks/useSharedView.js'; import { useSharedView } from './hooks/useSharedView.js';
import HomePage from './pages/HomePage.jsx'; import HomePage from './pages/HomePage.jsx';
import { themeVars } from './components/reader/readerThemes.js'; import { themeVars } from './components/reader/readerThemes.js';
import { loadRemoteProject, saveRemoteProject } from './syncService.js'; import { joinStudy, loadRemoteProject, saveRemoteProject } from './syncService.js';
import { getSyncBase, setSyncBase } from './utils/syncBase.js'; import { getSyncBase, setSyncBase } from './utils/syncBase.js';
import { import {
bookOptions, buildExportHtml, loadProjectIndex, makeId, parseBibleChapter, switchStorageUser, bookOptions, buildExportHtml, loadProjectIndex, makeId, parseBibleChapter, switchStorageUser,
@@ -73,6 +75,7 @@ const AppInner = () => {
const [readerPlan, setReaderPlan] = useState(null); const [readerPlan, setReaderPlan] = useState(null);
const readerPlanProgress = readerPlan ? readingPlans.planProgress.find((p) => p.id === readerPlan.progressId) ?? null : null; const readerPlanProgress = readerPlan ? readingPlans.planProgress.find((p) => p.id === readerPlan.progressId) ?? null : null;
const reader = useBibleReader(currentPage, readerPlanProgress?.translation ?? null); const reader = useBibleReader(currentPage, readerPlanProgress?.translation ?? null);
const readingHistory = useReadingHistory({ active: currentPage === 'reader', book: reader.readerBookAbbrev, chapter: reader.readerChapter });
const audio = useAudio(reader.readerBookAbbrev, reader.readerChapter, reader.readerTranslation); const audio = useAudio(reader.readerBookAbbrev, reader.readerChapter, reader.readerTranslation);
useEffect(() => { if (currentPage !== 'reader') setReaderPlan(null); }, [currentPage]); useEffect(() => { if (currentPage !== 'reader') setReaderPlan(null); }, [currentPage]);
// A new page starts at the top. Without this the scroll position of the previous page carries over, and on // A new page starts at the top. Without this the scroll position of the previous page carries over, and on
@@ -99,11 +102,14 @@ const AppInner = () => {
reader.pullServerMarks(); reader.pullServerMarks();
// Theme, default translation, layout, commentary and reading position. // Theme, default translation, layout, commentary and reading position.
reader.pullServerPrefs(); reader.pullServerPrefs();
// What you've read, merged with this device's.
readingHistory.pullReadingHistory();
}, },
onSignOut: () => { onSignOut: () => {
reader.stopMarksSync(); reader.stopMarksSync();
reader.stopPrefsSync(); reader.stopPrefsSync();
reader.stopInkSync(); reader.stopInkSync();
readingHistory.stopReadingHistory();
readingPlans.stopReadingPlansSync(); readingPlans.stopReadingPlansSync();
setProjectIndex(switchStorageUser(null)); setProjectIndex(switchStorageUser(null));
goStudies(); goStudies();
@@ -117,6 +123,22 @@ const AppInner = () => {
setCurrentPage, setCurrentPage,
}); });
// Opening an invite link (?join=TOKEN) while signed in adds that study to your list, ready to edit.
useEffect(() => {
if (!authUser) return;
const token = new URLSearchParams(window.location.search).get('join');
if (!token) return;
window.history.replaceState({}, '', window.location.pathname);
joinStudy(token).then(async (joined) => {
if (!joined.ok) { toast(joined.error ?? 'Could not use that invite link.', { tone: 'error' }); return; }
const full = await loadRemoteProject(joined.data.projectId);
if (full.ok) { saveProjectToStorage(full.data); setSyncBase(full.data.id, full.data.lastEdited); setProjectIndex(loadProjectIndex()); }
library.refreshLibrary();
toast(`You can now edit “${joined.data.title}”. It's in My Studies.`);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [authUser]);
// ⌘K search/go-to, the "?" shortcut list, and requests other pages make of the Reader. // ⌘K search/go-to, the "?" shortcut list, and requests other pages make of the Reader.
const [paletteOpen, setPaletteOpen] = useState(false); const [paletteOpen, setPaletteOpen] = useState(false);
const [helpOpen, setHelpOpen] = useState(false); const [helpOpen, setHelpOpen] = useState(false);
@@ -479,6 +501,7 @@ const AppInner = () => {
...audio, ...audio,
// Reader // Reader
...reader, ...reader,
readingHistory: readingHistory.readingHistory,
readerPlan, setReaderPlan, readerPlan, setReaderPlan,
readerPlanActive: !!readerPlanProgress, readerPlanActive: !!readerPlanProgress,
openPlanReading, openPlanReading,
@@ -523,13 +546,14 @@ function PageLoading() {
function AppRouter() { function AppRouter() {
const { currentPage, authUser, authServerDown } = useApp(); const { currentPage, authUser, authServerDown } = useApp();
const { token: sharedViewToken, project: sharedProject, error: sharedError } = useSharedView(); const { token: sharedViewToken, project: sharedProject, error: sharedError, needsPasscode, passcodeError, checking, submitPasscode } = useSharedView();
const sharedOutlineToken = new URLSearchParams(window.location.search).get('outline'); const sharedOutlineToken = new URLSearchParams(window.location.search).get('outline');
// A read-only outline link: no sign-in, nothing editable // A read-only outline link: no sign-in, nothing editable
if (sharedOutlineToken) return <SharedOutlineView token={sharedOutlineToken} />; if (sharedOutlineToken) return <SharedOutlineView token={sharedOutlineToken} />;
if (sharedViewToken) { if (sharedViewToken) {
if (needsPasscode) return <SharedPasscodeForm dark error={passcodeError} busy={checking} onSubmit={submitPasscode} />;
if (sharedError) { if (sharedError) {
return ( return (
<div className="flex min-h-dvh items-center justify-center bg-slate-900 px-4"> <div className="flex min-h-dvh items-center justify-center bg-slate-900 px-4">
+87
View File
@@ -0,0 +1,87 @@
// Admin → Sign-ups: who may create an account, and invite codes for when it is invite-only.
import { useEffect, useState } from 'react';
import { adminCreateInvite, adminDeleteInvite, adminGetSignup, adminSetSignupMode } from '../syncService.js';
const MODES = [
['open', 'Open', 'Anyone who can reach this site can create an account.'],
['invite', 'Invite only', 'A new account needs an invite code from you.'],
['closed', 'Closed', 'Nobody new can sign up.'],
];
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 AdminSignup() {
const [state, setState] = useState({ mode: 'open', invites: [], loading: true, error: '' });
const [note, setNote] = useState('');
const [days, setDays] = useState(7);
const [copied, setCopied] = useState('');
const load = async () => {
const r = await adminGetSignup();
setState(r.ok ? { ...r.data, loading: false, error: '' } : { mode: 'open', invites: [], loading: false, error: r.error ?? 'Could not load sign-up settings.' });
};
useEffect(() => { load(); }, []);
const setMode = async (mode) => {
const r = await adminSetSignupMode(mode);
if (r.ok) setState((s) => ({ ...s, mode, error: '' })); else setState((s) => ({ ...s, error: r.error ?? 'Could not change that.' }));
};
const create = async () => {
const r = await adminCreateInvite({ note, days });
if (!r.ok) { setState((s) => ({ ...s, error: r.error ?? 'Could not make an invite.' })); return; }
setNote('');
load();
};
const link = (code) => `${window.location.origin}/?invite=${code}`;
const copy = async (code) => {
try { await navigator.clipboard.writeText(link(code)); setCopied(code); window.setTimeout(() => setCopied(''), 1800); } catch { /* clipboard unavailable */ }
};
const status = (inv) => (inv.usedBy ? 'Used' : inv.expiresAt < Date.now() ? 'Expired' : `Until ${new Date(inv.expiresAt).toLocaleDateString()}`);
if (state.loading) return <p className="text-sm text-[var(--r-sub)]">Loading…</p>;
return (
<div className="space-y-5">
<section className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5" aria-label="Who can sign up">
<h2 className="text-base font-medium">Who can sign up</h2>
<div role="radiogroup" aria-label="Sign-up setting" className="mt-3 space-y-2">
{MODES.map(([id, label, hint]) => (
<label key={id} className="flex cursor-pointer items-start gap-3 text-sm">
<input type="radio" name="signup-mode" checked={state.mode === id} onChange={() => setMode(id)} className="mt-1 accent-[var(--r-accent)]" />
<span><span className="font-medium">{label}</span><span className="block text-xs text-[var(--r-sub)]">{hint}</span></span>
</label>
))}
</div>
{state.error && <p role="alert" className="mt-3 text-sm text-[var(--r-danger,#b42318)]">{state.error}</p>}
</section>
<section className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5" aria-label="Invite codes">
<h2 className="text-base font-medium">Invite codes</h2>
<p className="mt-1 text-xs text-[var(--r-sub)]">Each code works once. Send the link, and it opens the sign-up page with the code filled in.{state.mode !== 'invite' ? ' (Codes only matter while sign-up is “Invite only”.)' : ''}</p>
<div className="mt-3 flex flex-wrap items-end gap-2">
<label className="text-xs text-[var(--r-sub)]">Who is it for (optional)
<input type="text" value={note} onChange={(e) => setNote(e.target.value)} maxLength={80} className="mt-1 block w-52 rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2.5 py-1.5 text-sm text-[var(--r-text)]" />
</label>
<label className="text-xs text-[var(--r-sub)]">Good for
<select value={days} onChange={(e) => setDays(Number(e.target.value))} className="mt-1 block rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2.5 py-1.5 text-sm text-[var(--r-text)]">
{[1, 7, 30].map((d) => <option key={d} value={d}>{d} day{d === 1 ? '' : 's'}</option>)}
</select>
</label>
<button type="button" onClick={create} className="rounded-xl bg-[var(--r-accent)] px-4 py-2 text-sm font-medium text-[var(--r-on)] hover:opacity-90">Make an invite</button>
</div>
{state.invites.length > 0 && (
<ul className="mt-4 divide-y divide-[var(--r-line)] text-sm">
{state.invites.map((inv) => (
<li key={inv.code} className="flex flex-wrap items-center justify-between gap-2 py-2">
<span><span className="font-mono tracking-wider">{inv.code}</span>{inv.note ? <span className="ml-2 text-[var(--r-sub)]">{inv.note}</span> : null}</span>
<span className="flex items-center gap-2">
<span className="text-xs text-[var(--r-sub)]">{status(inv)}</span>
{!inv.usedBy && inv.expiresAt >= Date.now() && <button type="button" onClick={() => copy(inv.code)} className={btn}>{copied === inv.code ? 'Copied' : 'Copy link'}</button>}
<button type="button" onClick={async () => { await adminDeleteInvite(inv.code); load(); }} className={`${btn} text-[var(--r-danger,#b42318)]`}>Delete</button>
</span>
</li>
))}
</ul>
)}
</section>
</div>
);
}
+85
View File
@@ -0,0 +1,85 @@
// Export → Co-teachers: let other accounts edit this study with you. You make a single-use invite link;
// whoever opens it (signed in) can edit the study, and you can remove them any time. Edits from both of you
// sync through the account; if you both change the same study, "Combine both" keeps everything.
import { useCallback, useEffect, useState } from 'react';
import { createEditInvite, getCollaborators, removeCollaborator } from '../syncService.js';
const btn = 'rounded-xl border border-[var(--r-line)] px-4 py-2 text-sm hover:bg-black/5 disabled:opacity-50';
export default function CoTeachers({ projectId, sharedBy, onLeave }) {
const [state, setState] = useState({ status: 'loading', collaborators: [], pendingInvites: 0 });
const [invite, setInvite] = useState(null); // { url, expiresAt }
const [busy, setBusy] = useState(false);
const [copied, setCopied] = useState(false);
const [error, setError] = useState('');
const load = useCallback(async () => {
if (!projectId || sharedBy !== undefined) return;
const r = await getCollaborators(projectId);
setState(r.ok ? { status: 'ready', ...r.data } : { status: 'unavailable', collaborators: [], pendingInvites: 0 });
}, [projectId, sharedBy]);
useEffect(() => { setInvite(null); load(); }, [load]);
// Someone else's study that you were invited to edit
if (sharedBy !== undefined) {
return (
<section className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5" aria-label="Co-teachers">
<h2 className="text-sm font-medium">Shared with you</h2>
<p className="mb-3 mt-1 text-xs text-[var(--r-sub)]">{sharedBy || 'Its owner'} invited you to edit this study. Your changes sync to them, and theirs to you.</p>
<button type="button" onClick={onLeave} className={btn}>Leave this study</button>
</section>
);
}
const makeInvite = async () => {
setBusy(true); setError('');
const r = await createEditInvite(projectId);
setBusy(false);
if (!r.ok) { setError(r.error ?? 'Could not make the link.'); return; }
setInvite({ url: `${window.location.origin}${window.location.pathname}?join=${r.data.token}`, expiresAt: r.data.expiresAt });
load();
};
const copy = async () => {
try { await navigator.clipboard.writeText(invite.url); setCopied(true); window.setTimeout(() => setCopied(false), 1800); } catch { setError('Couldn’t copy. Select the link and copy it.'); }
};
const remove = async (userId, email) => {
if (!window.confirm(`Remove ${email}? They will no longer be able to edit this study.`)) return;
const r = await removeCollaborator(projectId, userId);
if (r.ok) load(); else setError(r.error ?? 'Could not remove them.');
};
return (
<section className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5" aria-label="Co-teachers">
<h2 className="text-sm font-medium">Co-teachers</h2>
<p className="mb-3 mt-1 text-xs text-[var(--r-sub)]">Let someone else edit this study with you. They need an account here. Each invite link works once and lasts 7 days.</p>
{state.status === 'unavailable' && <p className="text-xs text-[var(--r-sub)]">Invites need your account and a connection, and the study has to have synced once. Try again in a moment.</p>}
{state.status === 'ready' && (
<>
{state.collaborators.length > 0 && (
<ul className="mb-3 divide-y divide-[var(--r-line)] text-sm">
{state.collaborators.map((c) => (
<li key={c.userId} className="flex items-center justify-between gap-3 py-2">
<span>{c.email}</span>
<button type="button" onClick={() => remove(c.userId, c.email)} className="text-xs text-[var(--r-danger,#b42318)] hover:underline">Remove</button>
</li>
))}
</ul>
)}
{invite ? (
<div className="space-y-2">
<div className="flex items-center gap-2">
<input readOnly aria-label="Invite link" value={invite.url} onFocus={(e) => e.target.select()} className="min-w-0 flex-1 truncate rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2 text-xs text-[var(--r-sub)]" />
<button type="button" onClick={copy} className="shrink-0 rounded-lg bg-[var(--r-accent)] px-4 py-2 text-xs font-medium text-[var(--r-on)]">{copied ? 'Copied!' : 'Copy'}</button>
</div>
<p className="text-[11px] text-[var(--r-sub)]">Send it to one person. It stops working once they use it, or on {new Date(invite.expiresAt).toLocaleDateString()}.</p>
</div>
) : (
<button type="button" onClick={makeInvite} disabled={busy} className={btn}>{busy ? 'Making a link…' : 'Invite a co-teacher'}</button>
)}
{state.pendingInvites > 0 && !invite && <p className="mt-2 text-[11px] text-[var(--r-sub)]">{state.pendingInvites} invite{state.pendingInvites === 1 ? '' : 's'} waiting to be used.</p>}
</>
)}
{error && <p role="alert" className="mt-2 text-xs text-[var(--r-danger,#b42318)]">{error}</p>}
</section>
);
}
+49
View File
@@ -0,0 +1,49 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi, test, expect, beforeEach } from 'vitest';
vi.mock('../syncService.js', () => ({ getCollaborators: vi.fn(), createEditInvite: vi.fn(), removeCollaborator: vi.fn() }));
import CoTeachers from './CoTeachers.jsx';
import { createEditInvite, getCollaborators, removeCollaborator } from '../syncService.js';
beforeEach(() => {
vi.clearAllMocks();
getCollaborators.mockResolvedValue({ ok: true, data: { collaborators: [{ userId: 'u2', email: 'sam@x.co' }], pendingInvites: 0 } });
createEditInvite.mockResolvedValue({ ok: true, data: { token: 'abc123', expiresAt: Date.now() + 7 * 86_400_000 } });
removeCollaborator.mockResolvedValue({ ok: true });
vi.spyOn(window, 'confirm').mockReturnValue(true);
});
test('the owner sees the co-teachers and can make a single-use invite link', async () => {
const user = userEvent.setup();
render(<CoTeachers projectId="p1" />);
expect(await screen.findByText('sam@x.co')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /invite a co-teacher/i }));
const link = await screen.findByLabelText('Invite link');
expect(link.value).toContain('?join=abc123');
expect(createEditInvite).toHaveBeenCalledWith('p1');
});
test('the owner can remove a co-teacher after confirming', async () => {
const user = userEvent.setup();
render(<CoTeachers projectId="p1" />);
await user.click(await screen.findByRole('button', { name: 'Remove' }));
expect(removeCollaborator).toHaveBeenCalledWith('p1', 'u2');
});
test('someone invited to a study sees who shared it and can leave', async () => {
const onLeave = vi.fn();
const user = userEvent.setup();
render(<CoTeachers projectId="p1" sharedBy="owner@x.co" onLeave={onLeave} />);
expect(screen.getByText(/owner@x.co invited you to edit/)).toBeInTheDocument();
expect(getCollaborators).not.toHaveBeenCalled(); // only owners can list co-teachers
await user.click(screen.getByRole('button', { name: /leave this study/i }));
expect(onLeave).toHaveBeenCalled();
});
test('when the server can\'t be reached, it says so instead of offering a link', async () => {
getCollaborators.mockResolvedValue({ ok: false, status: 404, error: 'Project not found.' });
render(<CoTeachers projectId="p1" />);
await waitFor(() => expect(screen.getByText(/Invites need your account/)).toBeInTheDocument());
expect(screen.queryByRole('button', { name: /invite a co-teacher/i })).toBeNull();
});
+68
View File
@@ -0,0 +1,68 @@
// A microphone button for a text box: tap, speak, and what you say is added to the text. It uses the browser's
// own speech recognition (Safari and Chrome have it; Firefox does not, and then the button isn't shown). The
// browser may send the audio to its maker's speech service to be turned into words, which is why it asks to use
// the microphone, and why this is a button you press rather than something that listens in the background.
import { useEffect, useRef, useState } from 'react';
export const speechRecognitionClass = () => (typeof window === 'undefined' ? null : window.SpeechRecognition ?? window.webkitSpeechRecognition ?? null);
// Adds spoken words to existing text: a space (or nothing after a newline) between, and a first letter capitalised after a full stop.
export function appendSpoken(existing, spoken) {
const said = String(spoken ?? '').trim();
if (!said) return existing ?? '';
const base = existing ?? '';
if (!base.trim()) return `${said.charAt(0).toUpperCase()}${said.slice(1)}`;
const gap = /[\s]$/.test(base) ? '' : ' ';
const afterStop = /[.!?]["')\]]*\s*$/.test(base);
return `${base}${gap}${afterStop ? `${said.charAt(0).toUpperCase()}${said.slice(1)}` : said}`;
}
const MESSAGES = {
'not-allowed': 'The microphone is blocked. Allow it for this site in your browser’s settings.',
'service-not-allowed': 'The microphone is blocked. Allow it for this site in your browser’s settings.',
'audio-capture': 'No microphone was found.',
network: 'Speech recognition needs a connection.',
};
export default function DictateButton({ onText, label = 'Dictate', className = '' }) {
const Recognition = speechRecognitionClass();
const [listening, setListening] = useState(false);
const [error, setError] = useState('');
const recRef = useRef(null);
const onTextRef = useRef(onText);
onTextRef.current = onText;
useEffect(() => () => { try { recRef.current?.abort(); } catch { /* already stopped */ } }, []);
if (!Recognition) return null;
const start = () => {
setError('');
const rec = new Recognition();
rec.lang = navigator.language || 'en-US';
rec.continuous = true;
rec.interimResults = false;
rec.onresult = (event) => {
for (let i = event.resultIndex; i < event.results.length; i += 1) {
if (event.results[i].isFinal) onTextRef.current(event.results[i][0].transcript);
}
};
rec.onerror = (event) => { if (event.error !== 'no-speech' && event.error !== 'aborted') setError(MESSAGES[event.error] ?? 'Dictation stopped. Try again.'); };
rec.onend = () => { setListening(false); recRef.current = null; };
recRef.current = rec;
try { rec.start(); setListening(true); } catch { setListening(false); }
};
const stop = () => { try { recRef.current?.stop(); } catch { /* already stopped */ } };
return (
<span className={`inline-flex items-center gap-2 ${className}`}>
<button type="button" onClick={listening ? stop : start} aria-pressed={listening} aria-label={listening ? 'Stop dictating' : label}
title={listening ? 'Stop dictating' : 'Speak your notes'}
className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs ${listening ? 'border-[var(--r-danger,#b42318)] text-[var(--r-danger,#b42318)]' : 'border-[var(--r-line)] text-[var(--r-sub)] hover:bg-black/5'}`}>
<svg viewBox="0 0 20 20" width="13" height="13" fill="none" stroke="currentColor" strokeWidth="1.6" aria-hidden="true"><rect x="7" y="2.5" width="6" height="10" rx="3" /><path d="M4.5 9.5a5.5 5.5 0 0 0 11 0M10 15v2.5" strokeLinecap="round" /></svg>
{listening ? 'Listening… tap to stop' : label}
{listening && <span className="h-1.5 w-1.5 animate-pulse rounded-full bg-[var(--r-danger,#b42318)] motion-reduce:animate-none" aria-hidden="true" />}
</button>
{error && <span role="alert" className="text-[11px] text-[var(--r-danger,#b42318)]">{error}</span>}
</span>
);
}
+59
View File
@@ -0,0 +1,59 @@
import { render, screen, act } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi, test, expect, afterEach } from 'vitest';
import DictateButton, { appendSpoken } from './DictateButton.jsx';
afterEach(() => { delete window.SpeechRecognition; delete window.webkitSpeechRecognition; });
// A stand-in for the browser's recogniser that we can make "hear" things
function fakeRecognition() {
const instances = [];
class Fake {
constructor() { instances.push(this); this.started = false; }
start() { this.started = true; }
stop() { this.started = false; this.onend?.(); }
abort() { this.started = false; }
}
window.webkitSpeechRecognition = Fake;
return instances;
}
const hear = (rec, text, isFinal = true) => rec.onresult({ resultIndex: 0, results: [Object.assign([{ transcript: text }], { isFinal })] });
test('spoken words are added to what is already written', () => {
expect(appendSpoken('', ' grace is a gift ')).toBe('Grace is a gift');
expect(appendSpoken('Paul writes', 'to Titus')).toBe('Paul writes to Titus');
expect(appendSpoken('He saved us.', 'not by works')).toBe('He saved us. Not by works');
expect(appendSpoken('Line one\n', 'line two')).toBe('Line one\nline two');
expect(appendSpoken('Keep this', ' ')).toBe('Keep this');
});
test('with no speech recognition in the browser, the button is not shown', () => {
const { container } = render(<DictateButton onText={() => {}} />);
expect(container).toBeEmptyDOMElement();
});
test('tap to listen, final phrases are passed on, tap again to stop', async () => {
const instances = fakeRecognition();
const onText = vi.fn();
const user = userEvent.setup();
render(<DictateButton onText={onText} label="Dictate script" />);
await user.click(screen.getByRole('button', { name: 'Dictate script' }));
expect(instances[0].started).toBe(true);
expect(screen.getByRole('button', { name: 'Stop dictating' })).toHaveAttribute('aria-pressed', 'true');
act(() => hear(instances[0], 'the Lord is my shepherd', false)); // still being worked out: ignored
act(() => hear(instances[0], 'the Lord is my shepherd'));
expect(onText).toHaveBeenCalledTimes(1);
expect(onText).toHaveBeenCalledWith('the Lord is my shepherd');
await user.click(screen.getByRole('button', { name: 'Stop dictating' }));
expect(screen.getByRole('button', { name: 'Dictate script' })).toHaveAttribute('aria-pressed', 'false');
});
test('a blocked microphone is explained', async () => {
const instances = fakeRecognition();
const user = userEvent.setup();
render(<DictateButton onText={() => {}} />);
await user.click(screen.getByRole('button', { name: 'Dictate' }));
act(() => instances[0].onerror({ error: 'not-allowed' }));
expect(await screen.findByRole('alert')).toHaveTextContent(/microphone is blocked/i);
act(() => instances[0].onerror({ error: 'no-speech' })); // silence is not an error worth showing
});
+29
View File
@@ -0,0 +1,29 @@
// Export → Lesson handout: a printable page for each session (or one session), for a group to follow along.
import { useState } from 'react';
import { printHandout, sessionsFor } from '../utils/handout.js';
export default function HandoutExport({ project }) {
const sessions = sessionsFor(project);
const [which, setWhich] = useState('all');
const [lines, setLines] = useState(true);
const [blocked, setBlocked] = useState(false);
if (sessions.length === 0) return null;
const go = () => setBlocked(!printHandout(project, which === 'all' ? null : [which], { answerLines: lines }));
return (
<section className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5" aria-label="Lesson handout">
<h2 className="text-sm font-medium">Lesson handout</h2>
<p className="mb-3 mt-1 text-xs text-[var(--r-sub)]">A page per session with the passage, the outline, discussion questions and room to write. Use “Save as PDF” in the print dialog. Write the questions in the Study stage under “More”; without them, your Application notes are used.</p>
<div className="flex flex-wrap items-end gap-3">
<label className="text-xs text-[var(--r-sub)]">Sessions
<select value={which} onChange={(e) => setWhich(e.target.value)} className="mt-1 block rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2.5 py-1.5 text-sm text-[var(--r-text)]">
<option value="all">All {sessions.length} (one page each)</option>
{sessions.map((s) => <option key={s.chunk.id} value={s.chunk.id}>{s.number}. {s.reference}</option>)}
</select>
</label>
<label className="flex items-center gap-2 pb-2 text-xs text-[var(--r-sub)]"><input type="checkbox" checked={lines} onChange={(e) => setLines(e.target.checked)} className="accent-[var(--r-accent)]" /> Lines to write on</label>
<button type="button" onClick={go} className="rounded-xl border border-[var(--r-line)] px-4 py-2 text-sm hover:bg-black/5">Print / Save PDF</button>
</div>
{blocked && <p role="alert" className="mt-2 text-xs text-[var(--r-danger,#b42318)]">Allow pop-ups for this site to print the handout.</p>}
</section>
);
}
+45
View File
@@ -0,0 +1,45 @@
// The rules for a read-only link: when it stops working, and an optional passcode. Shown under the link.
import { useState } from 'react';
const OPTIONS = [['', 'Never'], ['1', 'In 1 day'], ['7', 'In 7 days'], ['30', 'In 30 days']];
const field = '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)]';
export const expiryText = (expiresAt, now = Date.now()) => {
if (!expiresAt) return 'Never expires';
if (expiresAt <= now) return 'Expired';
const days = Math.ceil((expiresAt - now) / 86_400_000);
return `Expires ${new Date(expiresAt).toLocaleDateString()} (${days} day${days === 1 ? '' : 's'} left)`;
};
export default function LinkRules({ expiresAt, hasPasscode, busy, onSave }) {
const [days, setDays] = useState('keep'); // 'keep' | '' | '1' | '7' | '30'
const [passcode, setPasscode] = useState('');
const dirty = days !== 'keep' || passcode.trim() !== '';
const save = async () => {
const options = {};
if (days !== 'keep') options.expiresInDays = days === '' ? null : Number(days);
if (passcode.trim()) options.passcode = passcode.trim();
if (await onSave(options)) { setDays('keep'); setPasscode(''); }
};
return (
<div className="mt-3 rounded-xl border border-[var(--r-line)] p-3" role="group" aria-label="Link settings">
<p className="text-xs text-[var(--r-sub)]">{expiryText(expiresAt)} · {hasPasscode ? 'Needs a passcode' : 'No passcode'}</p>
<div className="mt-2 flex flex-wrap items-end gap-3">
<label className="text-xs text-[var(--r-sub)]">Stop working
<select value={days} onChange={(e) => setDays(e.target.value)} className={`${field} mt-1 block`}>
<option value="keep">{expiresAt ? 'Keep as is' : 'Never (as is)'}</option>
{OPTIONS.map(([v, label]) => <option key={v || 'never'} value={v}>{label}</option>)}
</select>
</label>
<label className="text-xs text-[var(--r-sub)]">{hasPasscode ? 'New passcode' : 'Passcode (optional)'}
<input type="text" value={passcode} onChange={(e) => setPasscode(e.target.value)} maxLength={100} autoComplete="off" placeholder={hasPasscode ? 'Type to change' : 'None'} className={`${field} mt-1 block w-44`} />
</label>
<button type="button" onClick={save} disabled={!dirty || busy} className="rounded-lg border border-[var(--r-line)] px-3 py-1.5 text-sm hover:bg-black/5 disabled:opacity-50">Save settings</button>
{hasPasscode && <button type="button" onClick={() => onSave({ passcode: null })} disabled={busy} className="text-xs text-[var(--r-danger,#b42318)] hover:underline">Remove passcode</button>}
</div>
<p className="mt-2 text-[11px] text-[var(--r-sub)]">Send the passcode separately from the link. Anyone with both can read it until it expires or you turn it off.</p>
</div>
);
}
+50
View File
@@ -0,0 +1,50 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi, test, expect } from 'vitest';
import LinkRules, { expiryText } from './LinkRules.jsx';
import SharedPasscodeForm from './SharedPasscodeForm.jsx';
test('says when a link expires and whether it has a passcode', () => {
const now = Date.UTC(2026, 9, 6);
expect(expiryText(null, now)).toBe('Never expires');
expect(expiryText(now - 1, now)).toBe('Expired');
expect(expiryText(now + 3 * 86_400_000, now)).toMatch(/3 days left/);
render(<LinkRules expiresAt={null} hasPasscode busy={false} onSave={vi.fn()} />);
expect(screen.getByText(/Needs a passcode/)).toBeInTheDocument();
});
test('only the settings you change are sent', async () => {
const onSave = vi.fn(async () => true);
const user = userEvent.setup();
render(<LinkRules expiresAt={null} hasPasscode={false} busy={false} onSave={onSave} />);
const save = screen.getByRole('button', { name: 'Save settings' });
expect(save).toBeDisabled();
await user.selectOptions(screen.getByLabelText('Stop working'), '7');
await user.click(save);
expect(onSave).toHaveBeenLastCalledWith({ expiresInDays: 7 }); // no passcode key: the passcode is left alone
await user.type(screen.getByLabelText(/Passcode/), ' moses ');
await user.click(screen.getByRole('button', { name: 'Save settings' }));
expect(onSave).toHaveBeenLastCalledWith({ passcode: 'moses' });
});
test('a passcode can be removed, and "Never" clears the expiry', async () => {
const onSave = vi.fn(async () => true);
const user = userEvent.setup();
render(<LinkRules expiresAt={Date.now() + 86_400_000} hasPasscode busy={false} onSave={onSave} />);
await user.click(screen.getByRole('button', { name: 'Remove passcode' }));
expect(onSave).toHaveBeenLastCalledWith({ passcode: null });
await user.selectOptions(screen.getByLabelText('Stop working'), 'Never');
await user.click(screen.getByRole('button', { name: 'Save settings' }));
expect(onSave).toHaveBeenLastCalledWith({ expiresInDays: null });
});
test('the viewer\'s passcode form submits what was typed and shows a wrong-passcode message', async () => {
const onSubmit = vi.fn();
const user = userEvent.setup();
render(<SharedPasscodeForm error="That passcode is not right." onSubmit={onSubmit} />);
expect(screen.getByRole('alert')).toHaveTextContent(/not right/);
expect(screen.getByRole('button', { name: 'Open' })).toBeDisabled();
await user.type(screen.getByLabelText('Passcode'), 'moses');
await user.click(screen.getByRole('button', { name: 'Open' }));
expect(onSubmit).toHaveBeenCalledWith('moses');
});
+137
View File
@@ -0,0 +1,137 @@
// Present mode: the script or the outline full-screen in large type, with a timer. The script scrolls by
// itself at a speed you set (a teleprompter), and the outline moves one point at a time with the minutes you
// planned beside the clock. The screen is kept awake while it is open. Esc, or Close, leaves.
import { useCallback, useEffect, useRef, useState } from 'react';
import { formatClock, outlineSteps, paceText, pacing } from '../utils/present.js';
const SIZES = [20, 24, 28, 34, 40, 48, 60, 72];
const btn = 'rounded-lg border border-current/30 px-3 py-1.5 text-sm hover:bg-white/10 disabled:opacity-40';
export default function PresentMode({ title, subtitle = '', mode, script = '', tree = [], onClose }) {
const [dark, setDark] = useState(true);
const [size, setSize] = useState(() => { try { return Math.max(0, SIZES.indexOf(Number(localStorage.getItem('present-size')) || 34)); } catch { return 3; } });
const [elapsed, setElapsed] = useState(0);
const [running, setRunning] = useState(false);
const [scrolling, setScrolling] = useState(false);
const [speed, setSpeed] = useState(30); // pixels per second
const [index, setIndex] = useState(0);
const scroller = useRef(null);
const steps = mode === 'outline' ? outlineSteps(tree) : [];
const step = steps[index];
const pace = mode === 'outline' ? pacing(steps, index, elapsed) : null;
// the clock
useEffect(() => {
if (!running) return undefined;
const t = window.setInterval(() => setElapsed((e) => e + 1), 1000);
return () => window.clearInterval(t);
}, [running]);
// the teleprompter
useEffect(() => {
if (!scrolling || mode !== 'script') return undefined;
let last = performance.now();
let frame;
let carry = 0;
const tick = (now) => {
const el = scroller.current;
carry += ((now - last) / 1000) * speed;
last = now;
if (el && carry >= 1) { el.scrollTop += Math.floor(carry); carry -= Math.floor(carry); }
if (el && el.scrollTop + el.clientHeight >= el.scrollHeight - 2) { setScrolling(false); return; }
frame = requestAnimationFrame(tick);
};
frame = requestAnimationFrame(tick);
return () => cancelAnimationFrame(frame);
}, [scrolling, speed, mode]);
// keep the screen on
useEffect(() => {
let lock;
let gone = false;
navigator.wakeLock?.request?.('screen').then((l) => { if (gone) l.release?.(); else lock = l; }).catch(() => {});
return () => { gone = true; lock?.release?.().catch?.(() => {}); };
}, []);
const bigger = (d) => setSize((s) => {
const next = Math.min(SIZES.length - 1, Math.max(0, s + d));
try { localStorage.setItem('present-size', String(SIZES[next])); } catch { /* storage unavailable */ }
return next;
});
const go = useCallback((d) => setIndex((i) => Math.min(steps.length - 1, Math.max(0, i + d))), [steps.length]);
useEffect(() => {
const onKey = (e) => {
if (e.key === 'Escape') { onClose(); return; }
if (e.target?.tagName === 'INPUT') return;
if (mode === 'outline') {
if (e.key === 'ArrowRight' || e.key === 'ArrowDown' || e.key === ' ' || e.key === 'PageDown') { e.preventDefault(); go(1); }
else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp' || e.key === 'PageUp') { e.preventDefault(); go(-1); }
} else if (e.key === ' ') { e.preventDefault(); setScrolling((s) => !s); }
if (e.key === '+' || e.key === '=') bigger(1);
if (e.key === '-') bigger(-1);
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [mode, go, onClose]);
const colors = dark ? 'bg-[#111] text-[#f2efe8]' : 'bg-[#faf7f0] text-[#2b2620]';
const fontSize = `${SIZES[size]}px`;
const total = steps.length ? steps[steps.length - 1].plannedEnd : 0;
return (
<div role="dialog" aria-modal="true" aria-label={`Presenting ${title}`} className={`fixed inset-0 z-[80] flex flex-col ${colors}`}>
<div className="flex shrink-0 flex-wrap items-center gap-x-4 gap-y-2 border-b border-current/15 px-4 py-2.5">
<div className="min-w-0 flex-1"><p className="truncate text-sm font-medium">{title}</p>{subtitle && <p className="truncate text-xs opacity-60">{subtitle}</p>}</div>
<div className="flex items-center gap-2" role="group" aria-label="Timer">
<span className="min-w-[4.5rem] text-center font-mono text-xl tabular-nums" aria-live="off">{formatClock(elapsed)}</span>
<button type="button" onClick={() => setRunning((r) => !r)} className={btn}>{running ? 'Pause' : elapsed ? 'Resume' : 'Start'}</button>
<button type="button" onClick={() => { setElapsed(0); setRunning(false); }} disabled={!elapsed} className={btn}>Reset</button>
</div>
{pace && <span className={`text-sm ${pace.diffSeconds < -20 ? 'text-amber-400' : 'opacity-80'}`} role="status">{paceText(pace)} · plan {formatClock(pace.totalSeconds)}</span>}
<div className="flex items-center gap-1.5" role="group" aria-label="Text size">
<button type="button" onClick={() => bigger(-1)} disabled={size === 0} aria-label="Smaller text" className={btn}>A−</button>
<button type="button" onClick={() => bigger(1)} disabled={size === SIZES.length - 1} aria-label="Larger text" className={btn}>A+</button>
<button type="button" onClick={() => setDark((d) => !d)} aria-pressed={dark} className={btn}>{dark ? 'Light' : 'Dark'}</button>
</div>
{mode === 'script' && (
<div className="flex items-center gap-2" role="group" aria-label="Auto-scroll">
<button type="button" onClick={() => setScrolling((s) => !s)} aria-pressed={scrolling} className={btn}>{scrolling ? 'Stop scrolling' : 'Auto-scroll'}</button>
<label className="flex items-center gap-1.5 text-xs opacity-80">Speed
<input type="range" min="8" max="120" value={speed} onChange={(e) => setSpeed(Number(e.target.value))} aria-label="Scroll speed" />
</label>
</div>
)}
<button type="button" onClick={onClose} className={`${btn} font-medium`}>Close</button>
</div>
{mode === 'script' ? (
<div ref={scroller} className="min-h-0 flex-1 overflow-y-auto px-[6vw] py-[8vh]" style={{ scrollBehavior: 'auto' }}>
{script.trim()
? <div className="mx-auto max-w-[46em] whitespace-pre-wrap font-serif" style={{ fontSize, lineHeight: 1.5 }}>{script}<div style={{ height: '50vh' }} aria-hidden="true" /></div>
: <p className="mt-20 text-center text-lg opacity-70">There’s no script to present yet. Write one on the Script page.</p>}
</div>
) : (
<div className="flex min-h-0 flex-1 flex-col">
<div className="min-h-0 flex-1 overflow-y-auto px-[6vw] py-[6vh]">
{step ? (
<div className="mx-auto max-w-[46em] font-serif" style={{ fontSize, lineHeight: 1.4 }}>
<p className="font-sans text-[0.4em] uppercase tracking-[0.16em] opacity-60">{step.kind ? `${step.kind} · ` : ''}Point {index + 1} of {steps.length}{step.minutes ? ` · ${step.minutes} min` : ''}</p>
<h1 className="mt-2 font-semibold"><span className="mr-3 opacity-60">{step.label}</span>{step.text || '(untitled)'}</h1>
{step.ref && <p className="mt-2 text-[0.6em] opacity-70">{step.ref}</p>}
{step.scripture?.text && <blockquote className="mt-5 border-l-4 border-current/30 pl-5 text-[0.8em] italic opacity-90">“{step.scripture.text}” <span className="not-italic opacity-60">— {step.scripture.ref}</span></blockquote>}
{step.note && <p className="mt-5 whitespace-pre-wrap text-[0.7em] opacity-85">{step.note}</p>}
{step.attachments.length > 0 && <p className="mt-5 text-[0.55em] opacity-60">{step.attachments.map((a) => `${a.type === 'word' ? 'Word' : 'See'}: ${a.label}`).join(' · ')}</p>}
</div>
) : <p className="mt-20 text-center text-lg opacity-70">There’s no outline to present yet. Build one on the Outline page.</p>}
</div>
<div className="flex shrink-0 items-center justify-between gap-3 border-t border-current/15 px-4 py-3">
<button type="button" onClick={() => go(-1)} disabled={index === 0} className={btn}>← Back</button>
<span className="text-xs opacity-60">{total ? `${total} min planned` : 'Set minutes on points to see your pace'} · arrows or space to move</span>
<button type="button" onClick={() => go(1)} disabled={index >= steps.length - 1} className={btn}>Next →</button>
</div>
</div>
)}
</div>
);
}
+60
View File
@@ -0,0 +1,60 @@
import { render, screen, act } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi, test, expect, afterEach } from 'vitest';
import PresentMode from './PresentMode.jsx';
afterEach(() => vi.useRealTimers());
const n = (id, text, minutes, extra = {}) => ({ id, text, ref: '', chunkId: null, minutes, note: '', kind: '', scripture: null, attachments: [], children: [], ...extra });
const tree = [n('a', 'Welcome', 2, { note: 'Say hello' }), n('b', 'The gospel', 10, { ref: 'Rom 1:16' })];
test('the outline is stepped through a point at a time, with the keyboard or the buttons', async () => {
const user = userEvent.setup();
render(<PresentMode mode="outline" title="Romans" tree={tree} onClose={() => {}} />);
expect(screen.getByText('Welcome')).toBeInTheDocument();
expect(screen.getByText('Say hello')).toBeInTheDocument();
expect(screen.getByText(/Point 1 of 2/)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /back/i })).toBeDisabled();
await user.keyboard('{ArrowRight}');
expect(screen.getByText('The gospel')).toBeInTheDocument();
expect(screen.getByText('Rom 1:16')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /next/i })).toBeDisabled();
await user.click(screen.getByRole('button', { name: /back/i }));
expect(screen.getByText('Welcome')).toBeInTheDocument();
});
test('the timer runs, pauses and resets, and tells you how you are doing against the plan', async () => {
vi.useFakeTimers();
render(<PresentMode mode="outline" title="Romans" tree={tree} onClose={() => {}} />);
const group = screen.getByRole('group', { name: 'Timer' });
const click = (name) => act(() => { screen.getByRole('button', { name }).click(); });
click('Start');
act(() => { vi.advanceTimersByTime(65_000); });
expect(group).toHaveTextContent('1:05');
expect(screen.getByRole('status')).toHaveTextContent(/On pace/); // a minute in, on a 2-minute point
click('Pause');
act(() => { vi.advanceTimersByTime(10_000); });
expect(group).toHaveTextContent('1:05'); // paused
click('Reset');
expect(group).toHaveTextContent('0:00');
});
test('the text size can be changed and the choice is remembered', async () => {
const user = userEvent.setup();
localStorage.clear();
render(<PresentMode mode="script" title="Romans 8" script="In the beginning" onClose={() => {}} />);
const text = screen.getByText('In the beginning');
expect(text.style.fontSize).toBe('34px');
await user.click(screen.getByRole('button', { name: 'Larger text' }));
expect(text.style.fontSize).toBe('40px');
expect(localStorage.getItem('present-size')).toBe('40');
});
test('Escape and Close both leave, and an empty script says what to do', async () => {
const onClose = vi.fn();
const user = userEvent.setup();
render(<PresentMode mode="script" title="T" script=" " onClose={onClose} />);
expect(screen.getByText(/no script to present yet/i)).toBeInTheDocument();
await user.keyboard('{Escape}');
await user.click(screen.getByRole('button', { name: 'Close' }));
expect(onClose).toHaveBeenCalledTimes(2);
});
+60
View File
@@ -0,0 +1,60 @@
// "Your reading": how much of the Bible you have read (every chapter you spent time on in the Reader, plus the
// ones you finished in reading plans), a tile for each book that fills as you read it, and a calendar of the
// days you read in the last six months.
import { useMemo } from 'react';
import { getPlanSchedule } from '../utils/readingPlans.js';
import { heatmapWeeks, planChapterKeys, progressSummary } from '../utils/readingHistory.js';
const pct = (n) => `${Math.round(n * 100)}%`;
export default function ReadingProgress({ history, planProgress = [], activity = [], onOpenBook }) {
const summary = useMemo(() => progressSummary(history, planChapterKeys(planProgress, getPlanSchedule)), [history, planProgress]);
const days = useMemo(() => new Set([...(history.days ?? []), ...activity]), [history.days, activity]);
const weeks = useMemo(() => heatmapWeeks(days), [days]);
const daysInView = weeks.flat().filter((d) => d.on).length;
const group = (list) => (
<div className="grid grid-cols-[repeat(auto-fill,minmax(5.5rem,1fr))] gap-1.5">
{list.map((b) => (
<button key={b.abbrev} type="button" onClick={() => onOpenBook?.(b.abbrev)} title={`${b.name}: ${b.read} of ${b.total} chapters read`}
aria-label={`${b.name}, ${b.read} of ${b.total} chapters read`}
className="relative overflow-hidden rounded-lg border border-[var(--r-line)] px-2 py-1.5 text-left text-xs hover:border-[var(--r-accent)]">
<span aria-hidden="true" className="absolute inset-y-0 left-0 bg-[var(--r-accent)] opacity-25" style={{ width: pct(b.pct) }} />
<span className="relative block truncate font-medium">{b.name}</span>
<span className="relative block text-[10px] text-[var(--r-sub)]">{b.read}/{b.total}</span>
</button>
))}
</div>
);
return (
<section aria-label="Your reading" className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5">
<div className="flex flex-wrap items-baseline justify-between gap-2">
<h2 className="text-sm font-medium">Your reading</h2>
<p className="text-xs text-[var(--r-sub)]">
<span className="text-base font-medium text-[var(--r-text)]">{pct(summary.pct)}</span> of the Bible · {summary.read} of {summary.total} chapters
</p>
</div>
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-[var(--r-line)]" role="progressbar" aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.round(summary.pct * 100)} aria-label="Bible read">
<div className="h-full bg-[var(--r-accent)]" style={{ width: pct(summary.pct) }} />
</div>
<p className="mt-1.5 text-[11px] text-[var(--r-sub)]">Old Testament {summary.ot.read}/{summary.ot.total} · New Testament {summary.nt.read}/{summary.nt.total}. A chapter counts once you’ve had it open for a bit in the Reader, or finished it in a plan.</p>
<h3 className="mb-1.5 mt-4 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">Old Testament</h3>
{group(summary.books.filter((b) => b.testament === 'ot'))}
<h3 className="mb-1.5 mt-4 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">New Testament</h3>
{group(summary.books.filter((b) => b.testament === 'nt'))}
<h3 className="mb-1.5 mt-4 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">Days you read · {daysInView} in the last 6 months</h3>
<div className="flex gap-[3px] overflow-x-auto pb-1" role="img" aria-label={`${daysInView} days read in the last six months`}>
{weeks.map((week) => (
<div key={week[0].date} className="flex flex-col gap-[3px]">
{week.map((d) => (
<span key={d.date} title={d.future ? '' : `${d.date}${d.on ? ': read' : ''}`}
className={`h-3 w-3 rounded-[3px] ${d.future ? 'opacity-0' : d.on ? 'bg-[var(--r-accent)]' : 'bg-[var(--r-line)]'}`} />
))}
</div>
))}
</div>
</section>
);
}
+30
View File
@@ -0,0 +1,30 @@
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi, test, expect } from 'vitest';
import ReadingProgress from './ReadingProgress.jsx';
const history = { chapters: { 'GEN.1': { n: 1, last: '2026-10-01' }, 'GEN.2': { n: 2, last: '2026-10-02' }, 'JHN.3': { n: 1, last: '2026-10-03' } }, days: ['2026-10-01', '2026-10-02'] };
test('shows how much of the Bible is read, and each book\'s progress', () => {
render(<ReadingProgress history={history} planProgress={[]} activity={[]} />);
expect(screen.getByText(/3 of 1189 chapters/)).toBeInTheDocument();
expect(screen.getByRole('progressbar', { name: 'Bible read' })).toHaveAttribute('aria-valuenow', '0');
expect(screen.getByRole('button', { name: 'Genesis, 2 of 50 chapters read' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'John, 1 of 21 chapters read' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Revelation, 0 of 22 chapters read' })).toBeInTheDocument();
expect(screen.getAllByRole('button')).toHaveLength(66);
});
test('days from plans count on the calendar too, and a book opens in the Reader', async () => {
const onOpenBook = vi.fn();
render(<ReadingProgress history={history} planProgress={[]} activity={['2026-10-05']} onOpenBook={onOpenBook} />);
expect(screen.getByRole('img', { name: /3 days read in the last six months/ })).toBeInTheDocument();
await userEvent.setup().click(screen.getByRole('button', { name: /^Genesis/ }));
expect(onOpenBook).toHaveBeenCalledWith('GEN');
});
test('an empty history is fine', () => {
render(<ReadingProgress history={{ chapters: {}, days: [] }} />);
expect(screen.getByText(/0 of 1189 chapters/)).toBeInTheDocument();
expect(within(screen.getByRole('img')).queryAllByTitle(/: read/)).toHaveLength(0);
});
+64
View File
@@ -0,0 +1,64 @@
// Export → Schedule: the study's sessions (one per chunk) as a calendar file, starting on a date you pick.
import { useState } from 'react';
import { formatChunkReference } from '../utils/bibleUtils.jsx';
import { buildIcs, downloadIcs, sessionDates } from '../utils/icsFile.js';
const field = '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)]';
const isoDay = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
// The study's sessions in order: { title, description } for each chunk.
export function sessionsOf(project) {
const list = [];
(project?.chapters ?? []).forEach((chapter, chapterIndex) => {
for (const chunk of chapter.chunks ?? []) {
const ref = formatChunkReference(project, chapterIndex, chunk, '–');
const n = list.length + 1;
const label = `Session ${(chunk.episodeNumber ?? '').toString().trim() || n}`;
const name = (project.series ?? '').trim() || project.title;
list.push({ title: `${name} — ${label}: ${(chunk.episodeTitle ?? '').trim() || ref}`, description: `${project.title}\n${ref}` });
}
});
return list;
}
export default function SessionSchedule({ project }) {
const sessions = sessionsOf(project);
const [start, setStart] = useState(() => isoDay(new Date()));
const [every, setEvery] = useState('7');
const [time, setTime] = useState('');
const [minutes, setMinutes] = useState('60');
if (sessions.length === 0) return null;
const download = () => {
const [y, m, d] = start.split('-').map(Number);
const dates = sessionDates(new Date(y, m - 1, d), sessions.length, Number(every));
const events = sessions.map((s, i) => ({ ...s, date: dates[i], time: time || undefined, minutes: Number(minutes) || 60 }));
downloadIcs(buildIcs(events, { name: (project.series ?? '').trim() || project.title }), `${(project.title || 'study').replace(/[^\w]+/g, '-').toLowerCase()}-schedule.ics`);
};
return (
<section className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5" aria-label="Schedule">
<h2 className="text-sm font-medium">Schedule</h2>
<p className="mb-3 mt-1 text-xs text-[var(--r-sub)]">Put the {sessions.length} session{sessions.length === 1 ? '' : 's'} on your calendar: one per chunk, in order, using each chunk’s session number and title.</p>
<div className="flex flex-wrap items-end gap-3">
<label className="text-xs text-[var(--r-sub)]">First session
<input type="date" value={start} onChange={(e) => setStart(e.target.value)} className={`${field} mt-1 block`} />
</label>
<label className="text-xs text-[var(--r-sub)]">Then every
<select value={every} onChange={(e) => setEvery(e.target.value)} className={`${field} mt-1 block`}>
<option value="1">day</option><option value="7">week</option><option value="14">2 weeks</option><option value="30">30 days</option>
</select>
</label>
<label className="text-xs text-[var(--r-sub)]">At (optional)
<input type="time" value={time} onChange={(e) => setTime(e.target.value)} className={`${field} mt-1 block`} />
</label>
{time && (
<label className="text-xs text-[var(--r-sub)]">Minutes
<input type="number" min="5" max="480" value={minutes} onChange={(e) => setMinutes(e.target.value)} className={`${field} mt-1 block w-20`} />
</label>
)}
<button type="button" onClick={download} disabled={!start} className="rounded-xl border border-[var(--r-line)] px-4 py-2 text-sm hover:bg-black/5 disabled:opacity-50">Download calendar file (.ics)</button>
</div>
</section>
);
}
+38
View File
@@ -0,0 +1,38 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi, test, expect } from 'vitest';
vi.mock('../utils/icsFile.js', async (orig) => ({ ...(await orig()), downloadIcs: vi.fn() }));
import SessionSchedule, { sessionsOf } from './SessionSchedule.jsx';
import { downloadIcs } from '../utils/icsFile.js';
const chunk = (id, s, e, x = {}) => ({ id, startVerse: s, endVerse: e, ...x });
const project = {
title: 'Romans 8', series: 'Romans', chapters: [{ book: 'Romans', bookAbbrev: 'ROM', chapter: '8', verses: [], chunks: [
chunk('a', 1, 11, { episodeNumber: '4', episodeTitle: 'No condemnation' }), chunk('b', 12, 17),
] }],
};
test('each chunk is a session named for the series, its number and title (or passage)', () => {
const s = sessionsOf(project);
expect(s[0].title).toBe('Romans — Session 4: No condemnation');
expect(s[1].title).toMatch(/^Romans — Session 2: Romans 8:12/);
});
test('the calendar file has one event per session, spaced as asked', async () => {
const user = userEvent.setup();
render(<SessionSchedule project={project} />);
await user.clear(screen.getByLabelText('First session'));
await user.type(screen.getByLabelText('First session'), '2026-10-11');
await user.click(screen.getByRole('button', { name: /download calendar file/i }));
const [text, name] = downloadIcs.mock.calls[0];
expect(name).toBe('romans-8-schedule.ics');
expect(text.match(/BEGIN:VEVENT/g)).toHaveLength(2);
expect(text).toContain('DTSTART;VALUE=DATE:20261011');
expect(text).toContain('DTSTART;VALUE=DATE:20261018'); // a week later
});
test('a study with no chunks shows nothing', () => {
const { container } = render(<SessionSchedule project={{ title: 'x', chapters: [] }} />);
expect(container).toBeEmptyDOMElement();
});
+3 -1
View File
@@ -1,5 +1,6 @@
// Share an outline: a read-only link anyone can open (no account), or the plain text. // Share an outline: a read-only link anyone can open (no account), or the plain text.
import { useState } from 'react'; import { useState } from 'react';
import LinkRules from './LinkRules.jsx';
import Sheet from './reader/Sheet.jsx'; import Sheet from './reader/Sheet.jsx';
import { useToast } from './Toasts.jsx'; import { useToast } from './Toasts.jsx';
import { useOutlineShareLink } from '../hooks/useOutlineShareLink.js'; import { useOutlineShareLink } from '../hooks/useOutlineShareLink.js';
@@ -49,10 +50,11 @@ export default function ShareOutlineSheet({ open, onClose, projectId, outline })
<button type="button" onClick={shareLink} className={btn}>Share link…</button> <button type="button" onClick={shareLink} className={btn}>Share link…</button>
<button type="button" onClick={link.turnOff} disabled={link.busy} className="text-xs text-[var(--r-danger,#b42318)] hover:underline">{link.busy ? 'Turning off…' : 'Turn off link'}</button> <button type="button" onClick={link.turnOff} disabled={link.busy} className="text-xs text-[var(--r-danger,#b42318)] hover:underline">{link.busy ? 'Turning off…' : 'Turn off link'}</button>
</div> </div>
<LinkRules expiresAt={link.expiresAt} hasPasscode={link.hasPasscode} busy={link.busy} onSave={async (options) => { await link.create(options); return true; }} />
<p className="text-[11px] text-[var(--r-sub)]">People see the version that last synced to your account, and it updates as you edit. Turning the link off stops it working right away.</p> <p className="text-[11px] text-[var(--r-sub)]">People see the version that last synced to your account, and it updates as you edit. Turning the link off stops it working right away.</p>
</div> </div>
) : ( ) : (
<button type="button" onClick={link.create} disabled={link.busy} className="rounded-xl bg-[var(--r-accent)] px-4 py-2 text-sm font-medium text-[var(--r-on)] hover:opacity-90 disabled:opacity-50">{link.busy ? 'Creating…' : 'Create read-only link'}</button> <button type="button" onClick={() => link.create()} disabled={link.busy} className="rounded-xl bg-[var(--r-accent)] px-4 py-2 text-sm font-medium text-[var(--r-on)] hover:opacity-90 disabled:opacity-50">{link.busy ? 'Creating…' : 'Create read-only link'}</button>
))} ))}
{link.error && <p role="alert" className="mt-2 text-xs text-[var(--r-danger,#b42318)]">{link.error}</p>} {link.error && <p role="alert" className="mt-2 text-xs text-[var(--r-danger,#b42318)]">{link.error}</p>}
+17 -7
View File
@@ -1,8 +1,9 @@
// What someone sees when they open a read-only outline link: a calm page with the outline, a switcher // What someone sees when they open a read-only outline link: a calm page with the outline, a switcher
// when there is more than one, and Print / PDF. Nothing is editable. // when there is more than one, and Print / PDF. Nothing is editable.
import { useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { getSharedOutline } from '../syncService.js'; import { getSharedOutline } from '../syncService.js';
import { themeVars } from './reader/readerThemes.js'; import { themeVars } from './reader/readerThemes.js';
import SharedPasscodeForm from './SharedPasscodeForm.jsx';
import OutlineDocument from './OutlineDocument.jsx'; import OutlineDocument from './OutlineDocument.jsx';
import { printOutline } from '../utils/outlineExport.js'; import { printOutline } from '../utils/outlineExport.js';
import { flatten } from '../utils/outline.js'; import { flatten } from '../utils/outline.js';
@@ -17,18 +18,27 @@ export default function SharedOutlineView({ token }) {
const [data, setData] = useState(null); const [data, setData] = useState(null);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [scope, setScope] = useState('study'); const [scope, setScope] = useState('study');
const [needsPasscode, setNeedsPasscode] = useState(false);
const [passcodeError, setPasscodeError] = useState('');
const [checking, setChecking] = useState(false);
useEffect(() => { const load = useCallback((passcode) => {
let active = true; setChecking(true);
getSharedOutline(token).then((result) => { return getSharedOutline(token, passcode).then((result) => {
if (!active) return; setChecking(false);
if (result.ok) { setData(result.data); setScope(result.data.outlines[0]?.scope ?? 'study'); } else setError(result.error ?? 'This outline link is invalid or has been turned off.'); if (result.ok) { setData(result.data); setNeedsPasscode(false); setScope(result.data.outlines[0]?.scope ?? 'study'); return; }
if (result.data?.needsPasscode) { setNeedsPasscode(true); setPasscodeError(passcode ? result.error : ''); return; }
setNeedsPasscode(false);
setError(result.error ?? 'This outline link is invalid or has been turned off.');
}); });
return () => { active = false; };
}, [token]); }, [token]);
useEffect(() => { load(); }, [load]);
useEffect(() => { if (data?.title) document.title = `${data.title} — Outline`; }, [data]); useEffect(() => { if (data?.title) document.title = `${data.title} — Outline`; }, [data]);
if (needsPasscode) return <Frame><SharedPasscodeForm error={passcodeError} busy={checking} onSubmit={load} /></Frame>;
if (error) { if (error) {
return ( return (
<Frame> <Frame>
+20
View File
@@ -0,0 +1,20 @@
// What someone sees when a shared link needs a passcode.
import { useState } from 'react';
export default function SharedPasscodeForm({ error, busy, onSubmit, dark = false }) {
const [value, setValue] = useState('');
return (
<div className={`flex min-h-dvh items-center justify-center px-4 ${dark ? 'bg-slate-900' : 'bg-[var(--r-bg)] text-[var(--r-text)]'}`}>
<form onSubmit={(e) => { e.preventDefault(); if (value.trim()) onSubmit(value.trim()); }}
className="w-full max-w-sm rounded-3xl border border-black/10 bg-white p-8 text-center text-slate-900 shadow-lg">
<p className="text-xs uppercase tracking-[0.24em] text-slate-400">Bible Study Project</p>
<h1 className="mt-4 text-lg font-medium">This link has a passcode</h1>
<p className="mt-1 text-sm text-slate-500">Enter the passcode the person who shared it gave you.</p>
<input type="password" autoFocus autoComplete="off" aria-label="Passcode" value={value} onChange={(e) => setValue(e.target.value)}
className="mt-5 block w-full rounded-xl border border-slate-300 px-3 py-2.5 text-center text-sm" />
{error && <p role="alert" className="mt-3 text-sm text-rose-600">{error}</p>}
<button type="submit" disabled={busy || !value.trim()} className="mt-5 w-full rounded-full bg-slate-900 px-4 py-2.5 text-sm font-medium text-white hover:opacity-90 disabled:opacity-50">{busy ? 'Checking…' : 'Open'}</button>
</form>
</div>
);
}
+18
View File
@@ -3,6 +3,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import Sheet from './Sheet.jsx'; import Sheet from './Sheet.jsx';
import { CloseIcon } from './icons.jsx'; import { CloseIcon } from './icons.jsx';
import { buildMarksDocxBlob, buildMarksModel, downloadMarksFile, marksToMarkdown } from '../../utils/marksExport.js';
import { COLOR_NAMES, COLOR_SUGGESTIONS, HIGHLIGHT_COLORS, colorTitle } from '../../utils/highlightColors.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'}`; 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'}`;
@@ -20,6 +21,15 @@ export default function MarksSheet({
const shown = filter ? highlights.filter((h) => h.color === filter) : highlights; 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 // Colours in use that aren't one of the picker's (older data) still get listed
const total = highlights.length + bookmarks.length; const total = highlights.length + bookmarks.length;
const [exportError, setExportError] = useState('');
const exportAs = async (kind) => {
setExportError('');
const model = buildMarksModel({ highlights, bookmarks, colorLabels });
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 ( return (
<Sheet open={open} onClose={onClose} label="Bookmarks and highlights" title={`Bookmarks & highlights${total ? ` (${total})` : ''}`}> <Sheet open={open} onClose={onClose} label="Bookmarks and highlights" title={`Bookmarks & highlights${total ? ` (${total})` : ''}`}>
@@ -91,6 +101,14 @@ export default function MarksSheet({
</div> </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> </Sheet>
); );
} }
@@ -0,0 +1,29 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi, test, expect } from 'vitest';
vi.mock('../../utils/marksExport.js', async (orig) => ({ ...(await orig()), downloadMarksFile: vi.fn() }));
import MarksSheet from './MarksSheet.jsx';
import { downloadMarksFile } from '../../utils/marksExport.js';
const base = { open: true, onClose: () => {}, colorLabels: { '#bbf7d0': 'Promises' }, onSetLabel: () => {}, onJump: () => {}, onRemoveBookmark: () => {}, onRemoveHighlight: () => {} };
const highlights = [{ id: 'h', color: '#bbf7d0', book: 'JHN', bookName: 'John', bookIndex: 42, chapter: 3, verse: 16, startOffset: 0, endOffset: 5, text: 'For God' }];
test('highlights and bookmarks can be exported as Markdown, named by your colour names', async () => {
const user = userEvent.setup();
render(<MarksSheet {...base} bookmarks={[]} highlights={highlights} />);
await user.click(screen.getByRole('button', { name: 'Markdown' }));
const [blob, name] = downloadMarksFile.mock.calls[0];
expect(name).toBe('highlights-and-bookmarks.md');
expect(await blob.text()).toContain('## Promises (green)');
});
test('Word export is offered too, and nothing to export means no buttons', async () => {
const user = userEvent.setup();
const { rerender } = render(<MarksSheet {...base} bookmarks={[]} highlights={highlights} />);
await user.click(screen.getByRole('button', { name: 'Word (.docx)' }));
await vi.waitFor(() => expect(downloadMarksFile).toHaveBeenCalledTimes(2));
expect(downloadMarksFile.mock.calls[1][1]).toBe('highlights-and-bookmarks.docx');
rerender(<MarksSheet {...base} bookmarks={[]} highlights={[]} />);
expect(screen.queryByRole('group', { name: /export your marks/i })).toBeNull();
});
+33 -2
View File
@@ -1,7 +1,8 @@
// Observe / Interpret / Apply as an accordion (one open at a time), plus a "More" // Observe / Interpret / Apply as an accordion (one open at a time), plus a "More"
// area for background notes and tags so the page stays quiet. // area for background notes and tags so the page stays quiet.
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { templatePrompt } from '../../utils/studyTemplates.js'; import DictateButton, { appendSpoken } from '../DictateButton.jsx';
import { templateFromChunk, templatePrompt } from '../../utils/studyTemplates.js';
const SECTIONS = [ const SECTIONS = [
{ field: 'observation', label: 'Observe', hint: 'What does the text say?', { field: 'observation', label: 'Observe', hint: 'What does the text say?',
@@ -15,11 +16,13 @@ const SECTIONS = [
const areaCls = 'w-full resize-y rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3.5 py-3 font-serif text-[15px] leading-7 text-[var(--r-text)] placeholder:font-sans placeholder:text-[13px] placeholder:leading-5 placeholder:text-[var(--r-sub)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]'; const areaCls = 'w-full resize-y rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3.5 py-3 font-serif text-[15px] leading-7 text-[var(--r-text)] placeholder:font-sans placeholder:text-[13px] placeholder:leading-5 placeholder:text-[var(--r-sub)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]';
const words = (s) => (s ?? '').trim().split(/\s+/).filter(Boolean).length; const words = (s) => (s ?? '').trim().split(/\s+/).filter(Boolean).length;
export default function NotesPane({ chunk, updateChunk, allTags, templateId }) { export default function NotesPane({ chunk, updateChunk, allTags, templateId, saveTemplate, notify }) {
const [open, setOpen] = useState(() => { const [open, setOpen] = useState(() => {
try { return localStorage.getItem('studyNotesOpen') || 'observation'; } catch { return 'observation'; } try { return localStorage.getItem('studyNotesOpen') || 'observation'; } catch { return 'observation'; }
}); });
const [more, setMore] = useState(false); const [more, setMore] = useState(false);
const [tplName, setTplName] = useState('');
const [tplOpen, setTplOpen] = useState(false);
const [tagInput, setTagInput] = useState(''); const [tagInput, setTagInput] = useState('');
useEffect(() => { setTagInput(''); }, [chunk.id]); useEffect(() => { setTagInput(''); }, [chunk.id]);
useEffect(() => { try { localStorage.setItem('studyNotesOpen', open || ''); } catch { /* storage unavailable */ } }, [open]); useEffect(() => { try { localStorage.setItem('studyNotesOpen', open || ''); } catch { /* storage unavailable */ } }, [open]);
@@ -53,6 +56,7 @@ export default function NotesPane({ chunk, updateChunk, allTags, templateId }) {
<div className="px-4 pb-4"> <div className="px-4 pb-4">
<textarea aria-label={label} value={chunk[field] ?? ''} rows={7} placeholder={templatePrompt(templateId, field) ?? placeholder} <textarea aria-label={label} value={chunk[field] ?? ''} rows={7} placeholder={templatePrompt(templateId, field) ?? placeholder}
onChange={(e) => updateChunk(chunk.id, { [field]: e.target.value })} className={areaCls} /> onChange={(e) => updateChunk(chunk.id, { [field]: e.target.value })} className={areaCls} />
<div className="mt-1.5"><DictateButton label={`Dictate ${label.toLowerCase()}`} onText={(said) => updateChunk(chunk.id, { [field]: appendSpoken(chunk[field], said) })} /></div>
</div> </div>
)} )}
</div> </div>
@@ -71,7 +75,34 @@ export default function NotesPane({ chunk, updateChunk, allTags, templateId }) {
Background / general notes Background / general notes
<textarea value={chunk.generalNotes ?? ''} rows={5} onChange={(e) => updateChunk(chunk.id, { generalNotes: e.target.value })} <textarea value={chunk.generalNotes ?? ''} rows={5} onChange={(e) => updateChunk(chunk.id, { generalNotes: e.target.value })}
placeholder="e.g. author, date, audience, historical context, key themes…" className={`mt-1 ${areaCls}`} /> placeholder="e.g. author, date, audience, historical context, key themes…" className={`mt-1 ${areaCls}`} />
<span className="mt-1 block"><DictateButton label="Dictate background" onText={(said) => updateChunk(chunk.id, { generalNotes: appendSpoken(chunk.generalNotes, said) })} /></span>
</label> </label>
<label className="block text-xs text-[var(--r-sub)]">
Discussion questions <span className="opacity-70">(one per line; printed on the lesson handout)</span>
<textarea value={chunk.discussionQuestions ?? ''} rows={4} onChange={(e) => updateChunk(chunk.id, { discussionQuestions: e.target.value })}
placeholder={'What does this passage say about God?\nWhere do you see this in your own life?'} className={`mt-1 ${areaCls}`} />
</label>
{saveTemplate && (
<div className="rounded-xl border border-dashed border-[var(--r-line)] p-3">
{!tplOpen ? (
<button type="button" onClick={() => setTplOpen(true)} className="text-xs text-[var(--r-accent)] hover:underline">Save this chunk’s notes as a template…</button>
) : (
<div>
<p className="mb-2 text-xs text-[var(--r-sub)]">What’s written in this chunk’s note boxes becomes the starting text of each new chunk in studies made from the template, along with this study’s prompts.</p>
<div className="flex flex-wrap items-center gap-2">
<input type="text" aria-label="Template name" value={tplName} onChange={(e) => setTplName(e.target.value)} maxLength={40} placeholder="Template name"
className="min-w-0 flex-1 rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2.5 py-1.5 text-sm" />
<button type="button" onClick={() => {
const made = templateFromChunk({ name: tplName, chunk, template: templateId });
if (!made) { notify?.('Give it a name, and write something in a note box first.', 2600); return; }
saveTemplate(made); setTplName(''); setTplOpen(false); notify?.(`Saved the template “${made.name}”. Pick it when you start a new study.`, 3200);
}} className="rounded-lg border border-[var(--r-line)] px-3 py-1.5 text-xs hover:bg-black/5">Save template</button>
<button type="button" onClick={() => setTplOpen(false)} className="text-xs text-[var(--r-sub)] hover:underline">Cancel</button>
</div>
</div>
)}
</div>
)}
<div> <div>
<p className="mb-1.5 text-xs text-[var(--r-sub)]">Tags</p> <p className="mb-1.5 text-xs text-[var(--r-sub)]">Tags</p>
<div className="flex flex-wrap items-center gap-1.5"> <div className="flex flex-wrap items-center gap-1.5">
+15 -2
View File
@@ -165,6 +165,16 @@ export function useBibleReader(currentPage, translationOverride = null) {
return next; return next;
}); });
}; };
// Study templates you saved from your own notes; they follow the account like the other preferences.
const [userTemplates, setUserTemplatesState] = useState(() => {
try { const v = JSON.parse(localStorage.getItem('study-templates') || '[]'); return Array.isArray(v) ? v : []; } catch { return []; }
});
const setUserTemplates = (next) => {
setUserTemplatesState(next);
try { localStorage.setItem('study-templates', JSON.stringify(next)); } catch {}
};
const saveUserTemplate = (template) => setUserTemplates([...userTemplates, template].slice(-12));
const deleteUserTemplate = (id) => setUserTemplates(userTemplates.filter((t) => t.id !== id));
// Debounce timer ref so rapid stroke updates don't flood the server // Debounce timer ref so rapid stroke updates don't flood the server
const inkSaveTimerRef = useRef({}); const inkSaveTimerRef = useRef({});
@@ -523,7 +533,8 @@ export function useBibleReader(currentPage, translationOverride = null) {
// Signing out clears this account's ink from the device. // Signing out clears this account's ink from the device.
const stopInkSync = useCallback(() => { const stopInkSync = useCallback(() => {
Object.keys(inkSaveTimerRef.current).forEach((k) => clearTimeout(inkSaveTimerRef.current[k])); Object.keys(inkSaveTimerRef.current).forEach((k) => clearTimeout(inkSaveTimerRef.current[k]));
['readerInkByPage', 'readerInkDirty'].forEach((k) => { try { localStorage.removeItem(k); } catch {} }); ['readerInkByPage', 'readerInkDirty', 'study-templates'].forEach((k) => { try { localStorage.removeItem(k); } catch {} });
setUserTemplatesState([]);
setReaderInkByPage({}); setReaderInkByPage({});
}, []); }, []);
@@ -539,7 +550,7 @@ export function useBibleReader(currentPage, translationOverride = null) {
theme: readerTheme, translation: baseTranslation, layout: readerLayout, commentarySource: readerCommentary, theme: readerTheme, translation: baseTranslation, layout: readerLayout, commentarySource: readerCommentary,
planTranslation: planTranslationPref, startPage: startPagePref, planTranslation: planTranslationPref, startPage: startPagePref,
position: { book: readerBookAbbrev, chapter: readerChapter }, position: { book: readerBookAbbrev, chapter: readerChapter },
colorLabels: readerColorLabels, colorLabels: readerColorLabels, studyTemplates: userTemplates,
}, },
apply: (key, v) => { apply: (key, v) => {
if (key === 'theme') { setReaderThemeState(v); try { localStorage.setItem('reader-theme', v); } catch {} } if (key === 'theme') { setReaderThemeState(v); try { localStorage.setItem('reader-theme', v); } catch {} }
@@ -549,6 +560,7 @@ export function useBibleReader(currentPage, translationOverride = null) {
else if (key === 'planTranslation') { setPlanTranslationPrefState(v); try { localStorage.setItem('plan-translation', v); } catch {} } else if (key === 'planTranslation') { setPlanTranslationPrefState(v); try { localStorage.setItem('plan-translation', v); } catch {} }
else if (key === 'commentarySource') { setReaderCommentaryState(v); try { localStorage.setItem('reader-commentary', v); } catch {} } else if (key === 'commentarySource') { setReaderCommentaryState(v); try { localStorage.setItem('reader-commentary', v); } catch {} }
else if (key === 'colorLabels') { setReaderColorLabelsState(v); try { localStorage.setItem('reader-color-labels', JSON.stringify(v)); } catch {} } else if (key === 'colorLabels') { setReaderColorLabelsState(v); try { localStorage.setItem('reader-color-labels', JSON.stringify(v)); } catch {} }
else if (key === 'studyTemplates') { setUserTemplatesState(v); try { localStorage.setItem('study-templates', JSON.stringify(v)); } catch {} }
else if (key === 'position') { setReaderBookAbbrev(v.book); setReaderChapter(v.chapter); setReaderSelectedVerse(null); } else if (key === 'position') { setReaderBookAbbrev(v.book); setReaderChapter(v.chapter); setReaderSelectedVerse(null); }
}, },
canApplyPosition: () => pageRef.current !== 'reader', canApplyPosition: () => pageRef.current !== 'reader',
@@ -597,6 +609,7 @@ export function useBibleReader(currentPage, translationOverride = null) {
removeTextHighlight, removeTextHighlight,
restoreTextHighlight, restoreTextHighlight,
readerColorLabels, setReaderColorLabel, readerColorLabels, setReaderColorLabel,
userTemplates, saveUserTemplate, deleteUserTemplate,
bibleIndexStatusFor, bibleIndexStatusFor,
loadBibleIndex, loadBibleIndex,
_bibleIndexCacheRef, _bibleIndexCacheRef,
+11 -6
View File
@@ -102,7 +102,7 @@ function initialChapterIndex(project) {
return index >= 0 ? index : 0; return index >= 0 ? index : 0;
} }
export function useChunkBuilder({ project, setProject, updateProject, allChunks, setCurrentPage }) { export function useChunkBuilder({ project, setProject, updateProject, allChunks, setCurrentPage, userTemplates = [] }) {
const { toast } = useToast(); const { toast } = useToast();
const [setup, setSetup] = useState(() => initialSetupForm(project)); const [setup, setSetup] = useState(() => initialSetupForm(project));
const [titleEdited, setTitleEdited] = useState(false); const [titleEdited, setTitleEdited] = useState(false);
@@ -138,7 +138,10 @@ export function useChunkBuilder({ project, setProject, updateProject, allChunks,
const activeChapter = project?.chapters[activeChapterIndex] ?? null; const activeChapter = project?.chapters[activeChapterIndex] ?? null;
const handleSetupField = (field, value) => { const handleSetupField = (field, value) => {
if (field === 'book') { if (field === 'template') {
// a template of your own is copied into the study, so it keeps working wherever the study goes
setSetup((current) => ({ ...current, template: value, templateDef: userTemplates.find((t) => t.id === value) }));
} else if (field === 'book') {
const selected = bookOptions.find((book) => book.abbrev === value) ?? bookOptions[0]; const selected = bookOptions.find((book) => book.abbrev === value) ?? bookOptions[0];
setSetup((current) => ({ setSetup((current) => ({
...current, ...current,
@@ -195,6 +198,8 @@ export function useChunkBuilder({ project, setProject, updateProject, allChunks,
title: setup.title, title: setup.title,
translation: setup.translation, translation: setup.translation,
templateId: setup.template ?? 'blank', templateId: setup.template ?? 'blank',
...(setup.templateDef ? { templateDef: setup.templateDef } : {}), // a copy of your own template
lastEdited: Date.now(), lastEdited: Date.now(),
selectedChunkId: null, selectedChunkId: null,
chapters: [ chapters: [
@@ -237,7 +242,7 @@ export function useChunkBuilder({ project, setProject, updateProject, allChunks,
&& (c.spilloverEndVerse ?? null) === (spilloverEndVerse ?? null), && (c.spilloverEndVerse ?? null) === (spilloverEndVerse ?? null),
); );
if (overlap) return ch; if (overlap) return ch;
const newChunk = blankChunk(start, end, spilloverEndVerse, current.templateId); const newChunk = blankChunk(start, end, spilloverEndVerse, current.templateDef ?? current.templateId);
return { ...ch, chunks: [...ch.chunks, newChunk] }; return { ...ch, chunks: [...ch.chunks, newChunk] };
}); });
// Find the newly added chunk id // Find the newly added chunk id
@@ -286,7 +291,7 @@ export function useChunkBuilder({ project, setProject, updateProject, allChunks,
skippedCount += 1; skippedCount += 1;
return; return;
} }
const newChunk = blankChunk(start, end, spilloverEndVerse, current.templateId); const newChunk = blankChunk(start, end, spilloverEndVerse, current.templateDef ?? current.templateId);
existing.add(key); existing.add(key);
newChunks.push(newChunk); newChunks.push(newChunk);
addedCount += 1; addedCount += 1;
@@ -577,12 +582,12 @@ export function useChunkBuilder({ project, setProject, updateProject, allChunks,
if (i >= 0) { if (i >= 0) {
const c = chunks[i]; const c = chunks[i];
chunks[i] = { ...c, endVerse: n, spilloverEndVerse: null }; chunks[i] = { ...c, endVerse: n, spilloverEndVerse: null };
chunks.splice(i + 1, 0, blankChunk(n + 1, c.endVerse, c.spilloverEndVerse ?? null, current.templateId)); chunks.splice(i + 1, 0, blankChunk(n + 1, c.endVerse, c.spilloverEndVerse ?? null, current.templateDef ?? current.templateId));
return { ...ch, chunks }; return { ...ch, chunks };
} }
if (chunks.some((c) => c.startVerse <= n && n <= c.endVerse)) return ch; // already a boundary if (chunks.some((c) => c.startVerse <= n && n <= c.endVerse)) return ch; // already a boundary
const start = Math.max(0, ...chunks.filter((c) => c.endVerse < n).map((c) => c.endVerse)) + 1; const start = Math.max(0, ...chunks.filter((c) => c.endVerse < n).map((c) => c.endVerse)) + 1;
const created = blankChunk(start, n, null, current.templateId); const created = blankChunk(start, n, null, current.templateDef ?? current.templateId);
selected = created.id; selected = created.id;
const at = chunks.findIndex((c) => c.startVerse > n); const at = chunks.findIndex((c) => c.startVerse > n);
chunks.splice(at < 0 ? chunks.length : at, 0, created); chunks.splice(at < 0 ? chunks.length : at, 0, created);
+9 -6
View File
@@ -5,6 +5,8 @@ import { disableOutlineSharing, enableOutlineSharing, getOutlineShareStatus } fr
export function useOutlineShareLink(projectId, enabled = true) { export function useOutlineShareLink(projectId, enabled = true) {
const [token, setToken] = useState(null); const [token, setToken] = useState(null);
const [expiresAt, setExpiresAt] = useState(null);
const [hasPasscode, setHasPasscode] = useState(false);
const [state, setState] = useState('loading'); // 'loading' | 'ready' | 'unavailable' (no account / server) const [state, setState] = useState('loading'); // 'loading' | 'ready' | 'unavailable' (no account / server)
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
@@ -17,19 +19,20 @@ export function useOutlineShareLink(projectId, enabled = true) {
setState('loading'); setState('loading');
getOutlineShareStatus(projectId).then((result) => { getOutlineShareStatus(projectId).then((result) => {
if (!active) return; if (!active) return;
if (result.ok) { setToken(result.data.shareToken); setState('ready'); } else setState('unavailable'); if (result.ok) { setToken(result.data.shareToken); setExpiresAt(result.data.expiresAt ?? null); setHasPasscode(!!result.data.hasPasscode); setState('ready'); } else setState('unavailable');
}); });
return () => { active = false; }; return () => { active = false; };
}, [projectId, enabled]); }, [projectId, enabled]);
const url = token ? `${window.location.origin}${window.location.pathname}?outline=${token}` : ''; const url = token ? `${window.location.origin}${window.location.pathname}?outline=${token}` : '';
const create = async () => { // Creates the link, or changes its rules: { expiresInDays?: number | null, passcode?: string | null }
const create = async (options = {}) => {
setBusy(true); setBusy(true);
setError(''); setError('');
const result = await enableOutlineSharing(projectId); const result = await enableOutlineSharing(projectId, options);
setBusy(false); setBusy(false);
if (result.ok) setToken(result.data.shareToken); if (result.ok) { setToken(result.data.shareToken); setExpiresAt(result.data.expiresAt ?? null); setHasPasscode(!!result.data.hasPasscode); }
else setError(result.status === 404 ? 'Your study hasn’t synced to your account yet. Wait a moment and try again.' : 'Couldn’t create the link. Check your connection and try again.'); else setError(result.status === 404 ? 'Your study hasn’t synced to your account yet. Wait a moment and try again.' : 'Couldn’t create the link. Check your connection and try again.');
}; };
@@ -37,8 +40,8 @@ export function useOutlineShareLink(projectId, enabled = true) {
setBusy(true); setBusy(true);
const result = await disableOutlineSharing(projectId); const result = await disableOutlineSharing(projectId);
setBusy(false); setBusy(false);
if (result.ok) setToken(null); else setError('Couldn’t turn the link off. Try again.'); if (result.ok) { setToken(null); setExpiresAt(null); setHasPasscode(false); } else setError('Couldn’t turn the link off. Try again.');
}; };
return { token, url, state, busy, error, create, turnOff }; return { token, url, expiresAt, hasPasscode, state, busy, error, create, turnOff };
} }
+2 -2
View File
@@ -5,7 +5,7 @@
import { useCallback, useEffect, useRef } from 'react'; import { useCallback, useEffect, useRef } from 'react';
import { getRemotePrefs, saveRemotePrefs } from '../syncService.js'; import { getRemotePrefs, saveRemotePrefs } from '../syncService.js';
export const PREF_KEYS = ['theme', 'translation', 'layout', 'commentarySource', 'planTranslation', 'startPage', 'position', 'colorLabels']; export const PREF_KEYS = ['theme', 'translation', 'layout', 'commentarySource', 'planTranslation', 'startPage', 'position', 'colorLabels', 'studyTemplates'];
const META_KEY = 'prefs-meta'; // { key: timestamp of the last change made on this device } const META_KEY = 'prefs-meta'; // { key: timestamp of the last change made on this device }
const PUSH_DELAY_MS = 1000; const PUSH_DELAY_MS = 1000;
const FOCUS_PULL_MIN_MS = 30_000; const FOCUS_PULL_MIN_MS = 30_000;
@@ -58,7 +58,7 @@ export function usePrefsSync({ values, apply, canApplyPosition = () => true }) {
window.clearTimeout(timerRef.current); window.clearTimeout(timerRef.current);
timerRef.current = window.setTimeout(push, PUSH_DELAY_MS); timerRef.current = window.setTimeout(push, PUSH_DELAY_MS);
} }
}, [values.theme, values.translation, values.layout, values.commentarySource, values.planTranslation, values.startPage, values.position.book, values.position.chapter, values.colorLabels, push]); // eslint-disable-line react-hooks/exhaustive-deps }, [values.theme, values.translation, values.layout, values.commentarySource, values.planTranslation, values.startPage, values.position.book, values.position.chapter, values.colorLabels, values.studyTemplates, push]); // eslint-disable-line react-hooks/exhaustive-deps
const pullServerPrefs = useCallback(async () => { const pullServerPrefs = useCallback(async () => {
enabledRef.current = true; enabledRef.current = true;
+56
View File
@@ -29,6 +29,9 @@ export function useProjectLibrary({
const pendingDeletes = useRef(new Map()); // id -> timer const pendingDeletes = useRef(new Map()); // id -> timer
const [staleLocalProjects, setStaleLocalProjects] = useState([]); // projects where server is newer const [staleLocalProjects, setStaleLocalProjects] = useState([]); // projects where server is newer
const [autoRestoredCount, setAutoRestoredCount] = useState(null); // brief "synced from another device" toast const [autoRestoredCount, setAutoRestoredCount] = useState(null); // brief "synced from another device" toast
// Studies another account invited you to edit: id -> the owner's email (shown on the card)
const [sharedStudies, setSharedStudies] = useState({});
const sharedFrom = (list) => Object.fromEntries(list.filter((e) => e.shared).map((e) => [e.id, e.ownerEmail ?? '']));
// Studies saved here that the server hasn't accepted yet (saved offline, or the tab closed mid-sync). // Studies saved here that the server hasn't accepted yet (saved offline, or the tab closed mid-sync).
// A study another device changed meanwhile is left alone; opening it offers the keep-mine/keep-theirs choice. // A study another device changed meanwhile is left alone; opening it offers the keep-mine/keep-theirs choice.
@@ -76,6 +79,7 @@ export function useProjectLibrary({
const gone = new Set(pendingDeleteIds.list()); const gone = new Set(pendingDeleteIds.list());
await retryPendingDeletes(); await retryPendingDeletes();
const serverList = result.data.filter((e) => !gone.has(e.id)); const serverList = result.data.filter((e) => !gone.has(e.id));
setSharedStudies(sharedFrom(serverList));
const localMap = new Map(localIndex.map((e) => [e.id, e])); const localMap = new Map(localIndex.map((e) => [e.id, e]));
// What this device has seen of each study on the server: a save made on top of anything older is // What this device has seen of each study on the server: a save made on top of anything older is
// checked against it, so a newer copy from another device is never overwritten silently. // checked against it, so a newer copy from another device is never overwritten silently.
@@ -127,6 +131,29 @@ export function useProjectLibrary({
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [authUser]); }, [authUser]);
// Another device (or a co-teacher) may have saved a study since you opened this page: look again when you come
// back to the tab, and every couple of minutes while it's open. A newer copy is offered, never forced on you.
const refreshStale = async () => {
const result = await listRemoteProjects();
if (!result.ok) return;
const local = new Map(loadProjectIndex().map((e) => [e.id, e]));
const unsynced = new Set(unsyncedStudies.list());
setSharedStudies(sharedFrom(result.data));
setStaleLocalProjects(result.data.filter((e) => {
const mine = local.get(e.id);
return mine && (e.lastEdited ?? 0) > (mine.lastEdited ?? 0) && !unsynced.has(e.id);
}));
};
useEffect(() => {
if (!authUser) return undefined;
let last = Date.now();
const maybe = () => { if (document.visibilityState === 'visible' && Date.now() - last > 30_000) { last = Date.now(); refreshStale(); } };
document.addEventListener('visibilitychange', maybe);
const timer = window.setInterval(maybe, 120_000);
return () => { document.removeEventListener('visibilitychange', maybe); window.clearInterval(timer); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [authUser]);
// Back online: send what the server missed. // Back online: send what the server missed.
useEffect(() => { useEffect(() => {
const online = () => { if (authUser) { retryPendingDeletes(); pushPending(); } }; const online = () => { if (authUser) { retryPendingDeletes(); pushPending(); } };
@@ -165,6 +192,27 @@ export function useProjectLibrary({
} }
}; };
// Puts a study in a series ("Romans", "Advent 2026"), or takes it out (an empty name).
const setProjectSeries = (id, series) => {
const loaded = loadProjectById(id);
if (!loaded) return;
const name = String(series ?? '').trim().slice(0, 80);
if ((loaded.series ?? '') === name) return;
const updated = { ...loaded, series: name, lastEdited: Date.now() };
saveProjectToStorage(updated);
unsyncedStudies.add(id);
setProjectIndex(loadProjectIndex());
if (project?.id === id) setProject((current) => (current ? { ...current, series: name } : current));
saveRemoteProject(updated, { baseEdited: getSyncBase(id) }).then((result) => {
if (result.ok) {
const serverEdited = result.data?.lastEdited ?? updated.lastEdited;
setSyncBase(id, serverEdited);
unsyncedStudies.remove(id);
try { saveProjectToStorage({ ...updated, lastEdited: serverEdited }); setProjectIndex(loadProjectIndex()); } catch { /* already saved */ }
} else if (result.status === 409) toast('That study changed on another device. Open it to choose which version to keep.', { tone: 'error' });
});
};
// Deleting is immediate here, with an Undo for a few seconds; only after that is the server copy // Deleting is immediate here, with an Undo for a few seconds; only after that is the server copy
// removed (and straight away if the tab is closed first, so a deleted project never comes back). // removed (and straight away if the tab is closed first, so a deleted project never comes back).
const finishDelete = (id) => { const finishDelete = (id) => {
@@ -190,6 +238,11 @@ export function useProjectLibrary({
pendingDeleteIds.add(id); pendingDeleteIds.add(id);
setProjectIndex(loadProjectIndex()); setProjectIndex(loadProjectIndex());
pendingDeletes.current.set(id, window.setTimeout(() => finishDelete(id), 8000)); pendingDeletes.current.set(id, window.setTimeout(() => finishDelete(id), 8000));
if (sharedStudies[id] !== undefined) {
// A study someone else owns: this only removes you from it (they keep it). A new invite is needed to rejoin.
toast(`You left “${loaded.title}”. Ask for a new invite if you want back in.`);
return;
}
toast(`Deleted “${loaded.title}”`, { toast(`Deleted “${loaded.title}”`, {
action: { action: {
label: 'Undo', label: 'Undo',
@@ -218,10 +271,13 @@ export function useProjectLibrary({
}; };
return { return {
sharedStudies,
refreshLibrary: refreshStale,
staleLocalProjects, staleLocalProjects,
autoRestoredCount, autoRestoredCount,
resumeProject, resumeProject,
renameProjectInStorage, renameProjectInStorage,
setProjectSeries,
deleteProject, deleteProject,
pullLatestFromServer, pullLatestFromServer,
}; };
+72
View File
@@ -0,0 +1,72 @@
// Remembers which chapters you have read in the Reader, and keeps that in step with your account.
// A chapter counts as read after you have had it open for a little while (not just flicked past).
// It is saved on this device, and merged with the account's copy (never overwriting it) when you sign in
// and a moment after each new chapter, so two devices both add to the same history.
import { useCallback, useEffect, useRef, useState } from 'react';
import { getRemoteHistory, saveRemoteHistory } from '../syncService.js';
import { emptyHistory, historyEqual, mergeHistory, normalizeHistory, recordRead } from '../utils/readingHistory.js';
import { localDateString } from '../utils/readingPlans.js';
const KEY = 'reading-history';
export const DWELL_MS = 15_000;
const PUSH_DELAY_MS = 3000;
const load = () => { try { return normalizeHistory(JSON.parse(localStorage.getItem(KEY) || 'null')); } catch { return emptyHistory(); } };
const save = (h) => { try { localStorage.setItem(KEY, JSON.stringify(h)); } catch { /* storage unavailable */ } };
export function useReadingHistory({ active, book, chapter }) {
const [history, setHistory] = useState(load);
const historyRef = useRef(history);
historyRef.current = history;
const enabledRef = useRef(false);
const timerRef = useRef(null);
const apply = useCallback((next) => {
if (historyEqual(next, historyRef.current)) return;
historyRef.current = next;
save(next);
setHistory(next);
}, []);
const push = useCallback(async () => {
window.clearTimeout(timerRef.current);
if (!enabledRef.current) return;
const result = await saveRemoteHistory(historyRef.current);
if (result.ok) apply(mergeHistory(historyRef.current, result.data));
}, [apply]);
// A chapter on screen long enough counts as read.
useEffect(() => {
if (!active || !book || !chapter) return undefined;
const t = window.setTimeout(() => {
if (document.visibilityState === 'hidden') return;
const next = recordRead(historyRef.current, book, chapter, localDateString());
if (next === historyRef.current) return;
apply(next);
if (enabledRef.current) { window.clearTimeout(timerRef.current); timerRef.current = window.setTimeout(push, PUSH_DELAY_MS); }
}, DWELL_MS);
return () => window.clearTimeout(t);
}, [active, book, chapter, apply, push]);
// Signing in: combine this device's history with the account's, and send back anything the account lacks.
const pull = useCallback(async () => {
enabledRef.current = true;
const result = await getRemoteHistory();
if (!result.ok) return;
const merged = mergeHistory(historyRef.current, result.data);
apply(merged);
if (!historyEqual(merged, result.data)) await push();
}, [apply, push]);
// Signing out clears this device's copy, so the next person starts clean.
const stop = useCallback(() => {
enabledRef.current = false;
window.clearTimeout(timerRef.current);
try { localStorage.removeItem(KEY); } catch { /* storage unavailable */ }
historyRef.current = emptyHistory();
setHistory(emptyHistory());
}, []);
useEffect(() => () => window.clearTimeout(timerRef.current), []);
return { readingHistory: history, pullReadingHistory: pull, stopReadingHistory: stop };
}
+56
View File
@@ -0,0 +1,56 @@
import { renderHook, act } from '@testing-library/react';
import { vi, test, expect, beforeEach, afterEach } from 'vitest';
vi.mock('../syncService.js', () => ({ getRemoteHistory: vi.fn(), saveRemoteHistory: vi.fn() }));
import { useReadingHistory, DWELL_MS } from './useReadingHistory.js';
import { getRemoteHistory, saveRemoteHistory } from '../syncService.js';
beforeEach(() => {
localStorage.clear();
vi.clearAllMocks();
vi.useFakeTimers();
getRemoteHistory.mockResolvedValue({ ok: true, data: { chapters: { 'ROM.8': { n: 1, last: '2026-09-01' } }, days: ['2026-09-01'] } });
saveRemoteHistory.mockImplementation(async (h) => ({ ok: true, data: h }));
});
afterEach(() => vi.useRealTimers());
const setup = (props) => renderHook((p) => useReadingHistory(p), { initialProps: { active: true, book: 'JHN', chapter: 3, ...props } });
test('a chapter counts as read only after it has been open a while', () => {
const { result } = setup();
act(() => { vi.advanceTimersByTime(DWELL_MS - 1000); });
expect(Object.keys(result.current.readingHistory.chapters)).toEqual([]);
act(() => { vi.advanceTimersByTime(1500); });
expect(Object.keys(result.current.readingHistory.chapters)).toEqual(['JHN.3']);
expect(JSON.parse(localStorage.getItem('reading-history')).chapters['JHN.3'].n).toBe(1);
});
test('flicking past a chapter, or not being in the Reader, counts for nothing', () => {
const { result, rerender } = setup();
act(() => { vi.advanceTimersByTime(5000); });
rerender({ active: true, book: 'JHN', chapter: 4 }); // moved on after 5 seconds
act(() => { vi.advanceTimersByTime(DWELL_MS - 1000); });
expect(result.current.readingHistory.chapters).toEqual({});
rerender({ active: false, book: 'JHN', chapter: 4 });
act(() => { vi.advanceTimersByTime(DWELL_MS * 2); });
expect(result.current.readingHistory.chapters).toEqual({});
});
test('signing in merges with the account\'s history and sends back what it lacked', async () => {
const { result } = setup({ active: false });
localStorage.setItem('reading-history', JSON.stringify({ chapters: { 'GEN.1': { n: 1, last: '2026-10-01' } }, days: ['2026-10-01'] }));
const fresh = renderHook(() => useReadingHistory({ active: false, book: 'GEN', chapter: 1 }));
await act(async () => { await fresh.result.current.pullReadingHistory(); });
expect(Object.keys(fresh.result.current.readingHistory.chapters).sort()).toEqual(['GEN.1', 'ROM.8']);
expect(saveRemoteHistory).toHaveBeenCalled(); // the account didn't have GEN.1
expect(result.current.readingHistory).toBeTruthy();
});
test('signing out clears this device\'s history', async () => {
const { result } = setup();
act(() => { vi.advanceTimersByTime(DWELL_MS + 100); });
expect(Object.keys(result.current.readingHistory.chapters)).toHaveLength(1);
act(() => result.current.stopReadingHistory());
expect(result.current.readingHistory).toEqual({ chapters: {}, days: [] });
expect(localStorage.getItem('reading-history')).toBeNull();
});
+17 -9
View File
@@ -1,18 +1,23 @@
// Read-only share link for the open project: status, enable, revoke, copy. // Read-only share link for the open project: status, enable, change its rules (expiry, passcode), revoke, copy.
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { disableSharing, enableSharing, getShareStatus } from '../syncService.js'; import { disableSharing, enableSharing, getShareStatus } from '../syncService.js';
export function useShareLink(projectId) { export function useShareLink(projectId) {
const [shareToken, setShareToken] = useState(null); const [shareToken, setShareToken] = useState(null);
const [expiresAt, setExpiresAt] = useState(null);
const [hasPasscode, setHasPasscode] = useState(false);
const [shareBusy, setShareBusy] = useState(false); const [shareBusy, setShareBusy] = useState(false);
const [shareCopied, setShareCopied] = useState(false); const [shareCopied, setShareCopied] = useState(false);
const [shareError, setShareError] = useState('');
const apply = (data) => { setShareToken(data.shareToken ?? null); setExpiresAt(data.expiresAt ?? null); setHasPasscode(!!data.hasPasscode); };
useEffect(() => { useEffect(() => {
setShareToken(null); setShareToken(null); setExpiresAt(null); setHasPasscode(false); setShareError('');
if (!projectId) return; if (!projectId) return undefined;
let active = true; let active = true;
getShareStatus(projectId).then((result) => { getShareStatus(projectId).then((result) => {
if (active && result.ok) setShareToken(result.data.shareToken); if (active && result.ok) apply(result.data);
}); });
return () => { active = false; }; return () => { active = false; };
}, [projectId]); }, [projectId]);
@@ -21,11 +26,14 @@ export function useShareLink(projectId) {
? `${window.location.origin}${window.location.pathname}?share=${shareToken}` ? `${window.location.origin}${window.location.pathname}?share=${shareToken}`
: ''; : '';
const enable = async () => { // Turns the link on, or changes its rules: { expiresInDays?: number | null, passcode?: string | null }
const enable = async (options = {}) => {
setShareBusy(true); setShareBusy(true);
const result = await enableSharing(projectId); setShareError('');
const result = await enableSharing(projectId, options);
setShareBusy(false); setShareBusy(false);
if (result.ok) setShareToken(result.data.shareToken); if (result.ok) apply(result.data); else setShareError(result.error ?? 'Could not save the link settings.');
return result.ok;
}; };
const revoke = async () => { const revoke = async () => {
@@ -33,7 +41,7 @@ export function useShareLink(projectId) {
setShareBusy(true); setShareBusy(true);
const result = await disableSharing(projectId); const result = await disableSharing(projectId);
setShareBusy(false); setShareBusy(false);
if (result.ok) setShareToken(null); if (result.ok) apply({});
}; };
const copy = () => { const copy = () => {
@@ -44,5 +52,5 @@ export function useShareLink(projectId) {
}); });
}; };
return { shareToken, shareUrl, shareBusy, shareCopied, enable, revoke, copy }; return { shareToken, shareUrl, expiresAt, hasPasscode, shareBusy, shareCopied, shareError, enable, revoke, copy };
} }
+16 -8
View File
@@ -1,20 +1,28 @@
// A ?share=TOKEN URL shows a read-only view of someone else's project. No // A ?share=TOKEN URL shows a read-only view of someone else's project. No
// login is needed, so this runs independently of auth. // login is needed, so this runs independently of auth. A link may need a passcode.
import { useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { getSharedProject } from '../syncService.js'; import { getSharedProject } from '../syncService.js';
export function useSharedView() { export function useSharedView() {
const [token] = useState(() => new URLSearchParams(window.location.search).get('share')); const [token] = useState(() => new URLSearchParams(window.location.search).get('share'));
const [project, setProject] = useState(null); const [project, setProject] = useState(null);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [needsPasscode, setNeedsPasscode] = useState(false);
const [passcodeError, setPasscodeError] = useState('');
const [checking, setChecking] = useState(false);
useEffect(() => { const load = useCallback((passcode) => {
if (!token) return; setChecking(true);
getSharedProject(token).then((result) => { return getSharedProject(token, passcode).then((result) => {
if (result.ok) setProject(result.data); setChecking(false);
else setError(result.error ?? 'This share link is invalid or has been revoked.'); if (result.ok) { setProject(result.data); setNeedsPasscode(false); setPasscodeError(''); return; }
if (result.data?.needsPasscode) { setNeedsPasscode(true); setPasscodeError(passcode ? result.error : ''); return; }
setNeedsPasscode(false);
setError(result.error ?? 'This share link is invalid or has been revoked.');
}); });
}, [token]); }, [token]);
return { token, project, error }; useEffect(() => { if (token) load(); }, [token, load]);
return { token, project, error, needsPasscode, passcodeError, checking, submitPasscode: load };
} }
+8 -1
View File
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useApp } from '../context/AppContext.js'; import { useApp } from '../context/AppContext.js';
import AdminSignup from '../components/AdminSignup.jsx';
import PageShell from '../components/study/PageShell.jsx'; import PageShell from '../components/study/PageShell.jsx';
import { buildExportHtml } from '../utils/bibleUtils.jsx'; import { buildExportHtml } from '../utils/bibleUtils.jsx';
import { import {
@@ -13,7 +14,7 @@ import {
export default function AdminPage() { export default function AdminPage() {
const { authUser, goHome } = useApp(); const { authUser, goHome } = useApp();
const [adminTab, setAdminTab] = useState('users'); // 'users' | 'projects' const [adminTab, setAdminTab] = useState('users'); // 'users' | 'projects' | 'signup'
const [adminUsers, setAdminUsers] = useState([]); const [adminUsers, setAdminUsers] = useState([]);
const [adminProjects, setAdminProjects] = useState([]); const [adminProjects, setAdminProjects] = useState([]);
const [adminLoading, setAdminLoading] = useState(false); const [adminLoading, setAdminLoading] = useState(false);
@@ -79,8 +80,14 @@ export default function AdminPage() {
className={`rounded-full px-4 py-1.5 text-sm transition ${adminTab === 'projects' ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'border border-[var(--r-line)] text-[var(--r-sub)] hover:bg-black/5'}`}> className={`rounded-full px-4 py-1.5 text-sm transition ${adminTab === 'projects' ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'border border-[var(--r-line)] text-[var(--r-sub)] hover:bg-black/5'}`}>
Projects ({adminProjects.length}) Projects ({adminProjects.length})
</button> </button>
<button type="button" onClick={() => setAdminTab('signup')}
className={`rounded-full px-4 py-1.5 text-sm transition ${adminTab === 'signup' ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'border border-[var(--r-line)] text-[var(--r-sub)] hover:bg-black/5'}`}>
Sign-ups
</button>
</div> </div>
{adminTab === 'signup' && <AdminSignup />}
{adminLoading && <p className="text-sm text-[var(--r-sub)]">Loading…</p>} {adminLoading && <p className="text-sm text-[var(--r-sub)]">Loading…</p>}
{adminError && <p className="text-sm text-[var(--r-danger,#b42318)]">{adminError}</p>} {adminError && <p className="text-sm text-[var(--r-danger,#b42318)]">{adminError}</p>}
+23 -7
View File
@@ -1,8 +1,8 @@
import { useState } from 'react'; import { useEffect, useState } from 'react';
import { useApp } from '../context/AppContext.js'; import { useApp } from '../context/AppContext.js';
import { themeVars, READER_THEMES } from '../components/reader/readerThemes.js'; import { themeVars, READER_THEMES } from '../components/reader/readerThemes.js';
import ResetPasswordForm from '../components/ResetPasswordForm.jsx'; import ResetPasswordForm from '../components/ResetPasswordForm.jsx';
import { loginUser, registerUser, verifyMfaLogin, logoutUser } from '../syncService.js'; import { getSignupMode, loginUser, registerUser, verifyMfaLogin, logoutUser } from '../syncService.js';
// What the app does, in four groups. Everything listed here exists in the app. // What the app does, in four groups. Everything listed here exists in the app.
const FEATURES = [ const FEATURES = [
@@ -90,7 +90,16 @@ function ThemeSwitch() {
export default function AuthPage() { export default function AuthPage() {
const { signIn, readerTheme } = useApp(); const { signIn, readerTheme } = useApp();
const shell = { style: themeVars(readerTheme) }; const shell = { style: themeVars(readerTheme) };
const [authMode, setAuthMode] = useState('login'); // 'login' | 'register' | 'forgot' // An invite link (?invite=CODE) opens straight on "Create an account" with the code filled in
const linkInvite = new URLSearchParams(window.location.search).get('invite') ?? '';
const [authMode, setAuthMode] = useState(linkInvite ? 'register' : 'login'); // 'login' | 'register' | 'forgot'
const [signupMode, setSignupMode] = useState('open'); // what the server allows: 'open' | 'invite' | 'closed'
const [invite, setInvite] = useState(linkInvite);
useEffect(() => {
let live = true;
getSignupMode().then((r) => { if (live && r.ok && r.data?.mode) setSignupMode(r.data.mode); });
return () => { live = false; };
}, []);
const [authForm, setAuthForm] = useState({ email: '', password: '' }); const [authForm, setAuthForm] = useState({ email: '', password: '' });
const [authError, setAuthError] = useState(''); const [authError, setAuthError] = useState('');
const [authBusy, setAuthBusy] = useState(false); const [authBusy, setAuthBusy] = useState(false);
@@ -102,8 +111,7 @@ export default function AuthPage() {
e.preventDefault(); e.preventDefault();
setAuthError(''); setAuthError('');
setAuthBusy(true); setAuthBusy(true);
const action = authMode === 'login' ? loginUser : registerUser; const result = authMode === 'login' ? await loginUser(authForm.email.trim(), authForm.password) : await registerUser(authForm.email.trim(), authForm.password, invite.trim());
const result = await action(authForm.email.trim(), authForm.password);
setAuthBusy(false); setAuthBusy(false);
if (!result.ok) { if (!result.ok) {
setAuthError(result.error ?? 'Something went wrong.'); setAuthError(result.error ?? 'Something went wrong.');
@@ -240,6 +248,14 @@ export default function AuthPage() {
/> />
{authMode === 'register' && <span className="mt-1 block text-xs text-[var(--r-sub)]">At least 8 characters.</span>} {authMode === 'register' && <span className="mt-1 block text-xs text-[var(--r-sub)]">At least 8 characters.</span>}
</label> </label>
{authMode === 'register' && signupMode === 'invite' && (
<label className="block text-sm">
Invite code
<input type="text" required autoComplete="off" value={invite} onChange={(e) => setInvite(e.target.value)} placeholder="ABCD-EFGH-JKMN"
className="mt-1 block w-full rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2.5 font-mono text-sm uppercase tracking-wider text-[var(--r-text)]" />
<span className="mt-1 block text-xs text-[var(--r-sub)]">Sign-up here is by invitation. Ask the administrator for a code.</span>
</label>
)}
{authError && <p role="alert" className="text-sm text-[var(--r-danger,#b42318)]">{authError}</p>} {authError && <p role="alert" className="text-sm text-[var(--r-danger,#b42318)]">{authError}</p>}
<button <button
type="submit" type="submit"
@@ -253,7 +269,7 @@ export default function AuthPage() {
<button type="button" onClick={() => { setAuthMode('forgot'); setAuthError(''); }} <button type="button" onClick={() => { setAuthMode('forgot'); setAuthError(''); }}
className="mt-4 w-full text-center text-sm text-[var(--r-sub)] underline hover:text-[var(--r-text)]">Forgot your password?</button> className="mt-4 w-full text-center text-sm text-[var(--r-sub)] underline hover:text-[var(--r-text)]">Forgot your password?</button>
)} )}
<button {(signupMode !== 'closed' || authMode === 'register') && <button
type="button" type="button"
onClick={() => { onClick={() => {
setAuthMode((m) => (m === 'login' ? 'register' : 'login')); setAuthMode((m) => (m === 'login' ? 'register' : 'login'));
@@ -262,7 +278,7 @@ export default function AuthPage() {
className="mt-4 w-full text-center text-sm text-[var(--r-sub)] underline hover:text-[var(--r-text)]" className="mt-4 w-full text-center text-sm text-[var(--r-sub)] underline hover:text-[var(--r-text)]"
> >
{authMode === 'login' ? 'Need an account? Sign up' : 'Already have an account? Sign in'} {authMode === 'login' ? 'Need an account? Sign up' : 'Already have an account? Sign in'}
</button> </button>}
</> </>
)} )}
</div> </div>
+14 -2
View File
@@ -1,6 +1,10 @@
// The Export stage: take the finished study out as a file, or share it read-only. // The Export stage: take the finished study out as a file, or share it read-only.
import { useApp } from '../context/AppContext.js'; import { useApp } from '../context/AppContext.js';
import { useShareLink } from '../hooks/useShareLink.js'; import { useShareLink } from '../hooks/useShareLink.js';
import HandoutExport from '../components/HandoutExport.jsx';
import SessionSchedule from '../components/SessionSchedule.jsx';
import CoTeachers from '../components/CoTeachers.jsx';
import LinkRules from '../components/LinkRules.jsx';
import StudioShell from '../components/study/StudioShell.jsx'; import StudioShell from '../components/study/StudioShell.jsx';
import { exportProjectDocx, exportProjectHtml, exportProjectMarkdown, printProjectPdf } from '../utils/projectExport.js'; import { exportProjectDocx, exportProjectHtml, exportProjectMarkdown, printProjectPdf } from '../utils/projectExport.js';
@@ -17,7 +21,7 @@ function Row({ title, note, button, onClick }) {
} }
export default function ExportPage() { export default function ExportPage() {
const { project, allChunks, setCurrentPage } = useApp(); const { project, allChunks, setCurrentPage, sharedStudies, deleteProject } = useApp();
const share = useShareLink(project?.id); const share = useShareLink(project?.id);
const filled = allChunks.filter((c) => (c.finalScript ?? '').trim()).length; const filled = allChunks.filter((c) => (c.finalScript ?? '').trim()).length;
@@ -47,13 +51,21 @@ export default function ExportPage() {
className="min-w-0 flex-1 truncate rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2 text-xs text-[var(--r-sub)]" /> className="min-w-0 flex-1 truncate rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2 text-xs text-[var(--r-sub)]" />
<button type="button" onClick={share.copy} className="shrink-0 rounded-lg bg-[var(--r-accent)] px-4 py-2 text-xs font-medium text-[var(--r-on)]">{share.shareCopied ? 'Copied!' : 'Copy'}</button> <button type="button" onClick={share.copy} className="shrink-0 rounded-lg bg-[var(--r-accent)] px-4 py-2 text-xs font-medium text-[var(--r-on)]">{share.shareCopied ? 'Copied!' : 'Copy'}</button>
</div> </div>
<LinkRules expiresAt={share.expiresAt} hasPasscode={share.hasPasscode} busy={share.shareBusy} onSave={share.enable} />
{share.shareError && <p role="alert" className="text-xs text-[var(--r-danger,#b42318)]">{share.shareError}</p>}
<button type="button" onClick={share.revoke} disabled={share.shareBusy} className="text-xs text-[var(--r-danger,#b42318)] hover:underline">{share.shareBusy ? 'Revoking…' : 'Revoke link'}</button> <button type="button" onClick={share.revoke} disabled={share.shareBusy} className="text-xs text-[var(--r-danger,#b42318)] hover:underline">{share.shareBusy ? 'Revoking…' : 'Revoke link'}</button>
</div> </div>
) : ( ) : (
<button type="button" onClick={share.enable} disabled={share.shareBusy} className={btn}>{share.shareBusy ? 'Enabling…' : 'Enable sharing'}</button> <button type="button" onClick={() => share.enable()} disabled={share.shareBusy} className={btn}>{share.shareBusy ? 'Enabling…' : 'Enable sharing'}</button>
)} )}
</section> </section>
<HandoutExport project={project} />
<SessionSchedule project={project} />
<CoTeachers projectId={project?.id} sharedBy={project && sharedStudies?.[project.id]} onLeave={() => { deleteProject(project.id); setCurrentPage('home'); }} />
<section className={card} aria-label="Pronunciation"> <section className={card} aria-label="Pronunciation">
<Row title="Pronunciation guide" note="Every Greek and Hebrew word in this study, with transliterations." button="Open guide" onClick={() => setCurrentPage('pronunciation')} /> <Row title="Pronunciation guide" note="Every Greek and Hebrew word in this study, with transliterations." button="Open guide" onClick={() => setCurrentPage('pronunciation')} />
</section> </section>
+44 -4
View File
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { useApp } from '../context/AppContext.js'; import { useApp } from '../context/AppContext.js';
import { filterStudies, isTagQuery, tagCounts } from '../utils/studyFilter.js'; import { filterStudies, groupBySeries, isTagQuery, seriesList, tagCounts } from '../utils/studyFilter.js';
import BookCover from '../components/BookCover.jsx'; import BookCover from '../components/BookCover.jsx';
import IqDailyCard from '../components/iq/IqDailyCard.jsx'; import IqDailyCard from '../components/iq/IqDailyCard.jsx';
import IqNarratorOptions from '../components/iq/IqNarratorOptions.jsx'; import IqNarratorOptions from '../components/iq/IqNarratorOptions.jsx';
@@ -20,6 +20,7 @@ export default function HomePage() {
const { const {
autoRestoredCount, autoRestoredCount,
staleLocalProjects, staleLocalProjects,
sharedStudies,
projectIndex, projectIndex,
planProgress, planProgress,
readingActivity, readingActivity,
@@ -39,6 +40,7 @@ export default function HomePage() {
pullLatestFromServer, pullLatestFromServer,
resumeProject, resumeProject,
renameProjectInStorage, renameProjectInStorage,
setProjectSeries,
deleteProject, deleteProject,
handlePlayBookAudio, handlePlayBookAudio,
handleStopBookAudio, handleStopBookAudio,
@@ -53,6 +55,9 @@ export default function HomePage() {
}); // 'recent' | 'title' | 'passage' }); // 'recent' | 'title' | 'passage'
const [homeTags, setHomeTags] = useState([]); // tag chips switched on: a study must have all of them const [homeTags, setHomeTags] = useState([]); // tag chips switched on: a study must have all of them
const [showAllTags, setShowAllTags] = useState(false); const [showAllTags, setShowAllTags] = useState(false);
const [seriesFilter, setSeriesFilter] = useState(''); // '' = all, '__none' = not in a series, or a series name
const [seriesEditingId, setSeriesEditingId] = useState(null);
const [seriesValue, setSeriesValue] = useState('');
const toggleTag = (tag) => setHomeTags((cur) => (cur.includes(tag) ? cur.filter((t) => t !== tag) : [...cur, tag])); const toggleTag = (tag) => setHomeTags((cur) => (cur.includes(tag) ? cur.filter((t) => t !== tag) : [...cur, tag]));
const [homeView, setHomeView] = useState(() => { const [homeView, setHomeView] = useState(() => {
try { return localStorage.getItem('homeView') === 'list' ? 'list' : 'grid'; } catch { return 'grid'; } try { return localStorage.getItem('homeView') === 'list' ? 'list' : 'grid'; } catch { return 'grid'; }
@@ -103,12 +108,15 @@ export default function HomePage() {
const tagList = tagCounts(projectIndex); const tagList = tagCounts(projectIndex);
const shownTags = showAllTags ? tagList : tagList.slice(0, 12); const shownTags = showAllTags ? tagList : tagList.slice(0, 12);
const visible = filterStudies(projectIndex, { query: homeSearch, tags: homeTags }) const seriesNames = seriesList(projectIndex);
const visible = filterStudies(projectIndex, { query: homeSearch, tags: homeTags, series: seriesFilter })
.sort((a, b) => { .sort((a, b) => {
if (homeSort === 'title') return (a.title ?? '').localeCompare(b.title ?? ''); if (homeSort === 'title') return (a.title ?? '').localeCompare(b.title ?? '');
if (homeSort === 'passage') return (a.chapterSummary ?? '').localeCompare(b.chapterSummary ?? ''); if (homeSort === 'passage') return (a.chapterSummary ?? '').localeCompare(b.chapterSummary ?? '');
return (b.lastEdited ?? 0) - (a.lastEdited ?? 0); return (b.lastEdited ?? 0) - (a.lastEdited ?? 0);
}); });
// With series in use (and no single series chosen) the studies are grouped under their series name
const sections = seriesFilter === '' && seriesNames.length > 0 ? groupBySeries(visible) : [{ name: '', items: visible, flat: true }];
const fieldCls = 'rounded-lg border border-[var(--r-line)] bg-[var(--r-bar)] px-2.5 py-1.5 text-sm text-[var(--r-text)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]'; const fieldCls = 'rounded-lg border border-[var(--r-line)] bg-[var(--r-bar)] px-2.5 py-1.5 text-sm text-[var(--r-text)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]';
const linkBtn = 'rounded-lg px-3 py-1.5 text-sm text-[var(--r-sub)] hover:bg-black/5'; const linkBtn = 'rounded-lg px-3 py-1.5 text-sm text-[var(--r-sub)] hover:bg-black/5';
const notice = 'mb-4 rounded-xl border border-[var(--r-line)] bg-[var(--r-bar)] px-4 py-3 text-sm'; const notice = 'mb-4 rounded-xl border border-[var(--r-line)] bg-[var(--r-bar)] px-4 py-3 text-sm';
@@ -166,6 +174,15 @@ export default function HomePage() {
<option value="passage">Passage</option> <option value="passage">Passage</option>
</select> </select>
</label> </label>
{seriesNames.length > 0 && (
<label className="text-sm text-[var(--r-sub)]">Series{' '}
<select value={seriesFilter} onChange={(e) => setSeriesFilter(e.target.value)} className={`${fieldCls} ml-1`}>
<option value="">All studies</option>
{seriesNames.map((sr) => <option key={sr.name} value={sr.name}>{sr.name} ({sr.count})</option>)}
<option value="__none">Not in a series</option>
</select>
</label>
)}
<div role="group" aria-label="Studies layout" className="ml-auto flex overflow-hidden rounded-xl border border-[var(--r-line)] text-sm"> <div role="group" aria-label="Studies layout" className="ml-auto flex overflow-hidden rounded-xl border border-[var(--r-line)] text-sm">
{[['grid', 'Grid'], ['list', 'List']].map(([id, label]) => ( {[['grid', 'Grid'], ['list', 'List']].map(([id, label]) => (
<button key={id} type="button" aria-pressed={homeView === id} onClick={() => setHomeView(id)} <button key={id} type="button" aria-pressed={homeView === id} onClick={() => setHomeView(id)}
@@ -174,6 +191,7 @@ export default function HomePage() {
</div> </div>
</div> </div>
<datalist id="series-names">{seriesNames.map((sr) => <option key={sr.name} value={sr.name} />)}</datalist>
{tagList.length > 0 && ( {tagList.length > 0 && (
<div className="-mt-1 mb-4 flex flex-wrap items-center gap-1.5" role="group" aria-label="Filter by tag"> <div className="-mt-1 mb-4 flex flex-wrap items-center gap-1.5" role="group" aria-label="Filter by tag">
<span className="mr-1 text-xs text-[var(--r-sub)]">Tags</span> <span className="mr-1 text-xs text-[var(--r-sub)]">Tags</span>
@@ -226,8 +244,15 @@ export default function HomePage() {
</div> </div>
)} )}
{sections.map((section) => (
<div key={section.name || '__none'} className="mb-5 last:mb-0">
{!section.flat && (
<h3 className="mb-2 flex items-baseline gap-2 text-sm font-medium">
{section.name || 'Not in a series'} <span className="text-xs font-normal text-[var(--r-sub)]">{section.items.length}</span>
</h3>
)}
<div className={homeView === 'list' ? 'flex flex-col gap-2' : 'grid gap-3 sm:grid-cols-2'}> <div className={homeView === 'list' ? 'flex flex-col gap-2' : 'grid gap-3 sm:grid-cols-2'}>
{visible.map((entry) => ( {section.items.map((entry) => (
<div key={entry.id} className={homeView === 'list' <div key={entry.id} className={homeView === 'list'
? 'flex flex-col gap-3 rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-3 sm:flex-row sm:items-center sm:gap-4' ? 'flex flex-col gap-3 rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-3 sm:flex-row sm:items-center sm:gap-4'
: 'flex flex-col gap-3 rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5'}> : 'flex flex-col gap-3 rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-5'}>
@@ -249,7 +274,20 @@ export default function HomePage() {
) : ( ) : (
<div className={`flex items-start gap-2 ${homeView === 'list' ? 'flex-wrap items-baseline' : 'justify-between'}`}> <div className={`flex items-start gap-2 ${homeView === 'list' ? 'flex-wrap items-baseline' : 'justify-between'}`}>
<h2 className="text-base font-medium">{entry.title}</h2> <h2 className="text-base font-medium">{entry.title}</h2>
{sharedStudies?.[entry.id] !== undefined && (
<span className="rounded-full border border-[var(--r-line)] px-2 py-0.5 text-[11px] text-[var(--r-sub)]" title="Another account invited you to edit this study">Shared{sharedStudies[entry.id] ? ` by ${sharedStudies[entry.id]}` : ''}</span>
)}
<button type="button" onClick={() => { setRenamingId(entry.id); setRenameValue(entry.title ?? ''); }} className="shrink-0 text-xs text-[var(--r-sub)] hover:text-[var(--r-text)]" title="Rename project">✎ Rename</button> <button type="button" onClick={() => { setRenamingId(entry.id); setRenameValue(entry.title ?? ''); }} className="shrink-0 text-xs text-[var(--r-sub)] hover:text-[var(--r-text)]" title="Rename project">✎ Rename</button>
<button type="button" onClick={() => { setSeriesEditingId(entry.id); setSeriesValue(entry.series ?? ''); }} className="shrink-0 text-xs text-[var(--r-sub)] hover:text-[var(--r-text)]" title="Put this study in a series">{entry.series ? '⧉ Series' : '⧉ Add to series'}</button>
</div>
)}
{seriesEditingId === entry.id && (
<div className="mt-2 flex items-center gap-2">
<input type="text" autoFocus list="series-names" aria-label="Series name" placeholder="Series name (blank to remove)" value={seriesValue} onChange={(e) => setSeriesValue(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); setProjectSeries(entry.id, seriesValue); setSeriesEditingId(null); } else if (e.key === 'Escape') setSeriesEditingId(null); }}
className={`${fieldCls} flex-1`} />
<button type="button" onClick={() => { setProjectSeries(entry.id, seriesValue); setSeriesEditingId(null); }} className="text-sm text-[var(--r-accent)] hover:underline">Save</button>
<button type="button" onClick={() => setSeriesEditingId(null)} aria-label="Cancel" className="text-sm text-[var(--r-sub)]">×</button>
</div> </div>
)} )}
{entry.chapterSummary && <p className="mt-1 text-sm text-[var(--r-sub)]" title={entry.chapterSummary}>{compactPassages(entry.chapterSummary)}</p>} {entry.chapterSummary && <p className="mt-1 text-sm text-[var(--r-sub)]" title={entry.chapterSummary}>{compactPassages(entry.chapterSummary)}</p>}
@@ -267,11 +305,13 @@ export default function HomePage() {
</div> </div>
<div className={`flex items-center gap-2 ${homeView === 'list' ? 'sm:w-56 sm:shrink-0' : ''}`}> <div className={`flex items-center gap-2 ${homeView === 'list' ? 'sm:w-56 sm:shrink-0' : ''}`}>
<button type="button" onClick={() => resumeProject(entry.id)} className="flex-1 rounded-xl bg-[var(--r-text)] px-4 py-2 text-sm font-medium text-[var(--r-bg)] hover:opacity-90">Resume</button> <button type="button" onClick={() => resumeProject(entry.id)} className="flex-1 rounded-xl bg-[var(--r-text)] px-4 py-2 text-sm font-medium text-[var(--r-bg)] hover:opacity-90">Resume</button>
<button type="button" onClick={() => deleteProject(entry.id)} className="rounded-xl border border-[var(--r-line)] px-4 py-2 text-sm text-[var(--r-danger,#b42318)] hover:bg-black/5">Delete</button> <button type="button" onClick={() => deleteProject(entry.id)} className="rounded-xl border border-[var(--r-line)] px-4 py-2 text-sm text-[var(--r-danger,#b42318)] hover:bg-black/5">{sharedStudies?.[entry.id] !== undefined ? 'Leave' : 'Delete'}</button>
</div> </div>
</div> </div>
))} ))}
</div> </div>
</div>
))}
</> </>
)} )}
</section> </section>
+4
View File
@@ -10,6 +10,7 @@ import { formatChunkReference } from '../utils/bibleUtils.jsx';
import { buildChapterSummary } from '../utils/projectStorage.js'; import { buildChapterSummary } from '../utils/projectStorage.js';
import { compactPassages } from '../utils/passageSummary.js'; import { compactPassages } from '../utils/passageSummary.js';
import { attachableFromChunk, fetchPassageText } from '../utils/passageText.js'; import { attachableFromChunk, fetchPassageText } from '../utils/passageText.js';
import PresentMode from '../components/PresentMode.jsx';
import IqOutlineAttach from '../components/iq/IqOutlineAttach.jsx'; import IqOutlineAttach from '../components/iq/IqOutlineAttach.jsx';
import ShareOutlineSheet from '../components/ShareOutlineSheet.jsx'; import ShareOutlineSheet from '../components/ShareOutlineSheet.jsx';
import { downloadOutlineDocx, downloadOutlineMarkdown, printOutline } from '../utils/outlineExport.js'; import { downloadOutlineDocx, downloadOutlineMarkdown, printOutline } from '../utils/outlineExport.js';
@@ -47,6 +48,7 @@ export default function OutlinePage() {
const [pendingFocus, setPendingFocus] = useState(null); const [pendingFocus, setPendingFocus] = useState(null);
const [metaOpen, setMetaOpen] = useState(() => new Set()); // points whose detail fields are showing const [metaOpen, setMetaOpen] = useState(() => new Set()); // points whose detail fields are showing
const [shareOpen, setShareOpen] = useState(false); const [shareOpen, setShareOpen] = useState(false);
const [presenting, setPresenting] = useState(false);
const [lookup, setLookup] = useState({ id: null, busy: false, error: '' }); const [lookup, setLookup] = useState({ id: null, busy: false, error: '' });
// The one way every change is made, always from the latest outline (typing is fast). // The one way every change is made, always from the latest outline (typing is fast).
@@ -142,6 +144,7 @@ export default function OutlinePage() {
return ( return (
<StudioShell stage="outline"> <StudioShell stage="outline">
{presenting && <PresentMode mode="outline" title={title} subtitle={subtitle} tree={tree} onClose={() => setPresenting(false)} />}
<main className="mx-auto max-w-3xl px-4 py-8"> <main className="mx-auto max-w-3xl px-4 py-8">
<div className="mb-4"> <div className="mb-4">
<h1 className="text-xl font-medium">Outline</h1> <h1 className="text-xl font-medium">Outline</h1>
@@ -171,6 +174,7 @@ export default function OutlinePage() {
Build from my notes{fromNotes > 0 ? ` (${fromNotes})` : ''} Build from my notes{fromNotes > 0 ? ` (${fromNotes})` : ''}
</button> </button>
<span className="mx-1 hidden h-6 w-px bg-[var(--r-line)] sm:block" aria-hidden="true" /> <span className="mx-1 hidden h-6 w-px bg-[var(--r-line)] sm:block" aria-hidden="true" />
<button type="button" onClick={() => setPresenting(true)} disabled={tree.length === 0} className={btn} title="Step through the outline full-screen, with a timer and your planned minutes">Present</button>
<button type="button" onClick={() => setShareOpen(true)} disabled={tree.length === 0} className={btn}>Share</button> <button type="button" onClick={() => setShareOpen(true)} disabled={tree.length === 0} className={btn}>Share</button>
<button type="button" onClick={print} disabled={tree.length === 0} className={btn}>Print / PDF</button> <button type="button" onClick={print} disabled={tree.length === 0} className={btn}>Print / PDF</button>
<details className="relative"> <details className="relative">
+3
View File
@@ -1,6 +1,7 @@
// Reading plans home, styled to match the reader (paper / sepia / dark). One big // Reading plans home, styled to match the reader (paper / sepia / dark). One big
// "Continue reading" card, your plans in progress, and a browsable catalog. Details, // "Continue reading" card, your plans in progress, and a browsable catalog. Details,
// the custom-plan builder and everything else open as sheets. // the custom-plan builder and everything else open as sheets.
import ReadingProgress from '../components/ReadingProgress.jsx';
import IqPlanExport from '../components/iq/IqPlanExport.jsx'; import IqPlanExport from '../components/iq/IqPlanExport.jsx';
import { useState } from 'react'; import { useState } from 'react';
import { useApp } from '../context/AppContext.js'; import { useApp } from '../context/AppContext.js';
@@ -39,6 +40,7 @@ export default function ReadingPlansPage() {
openPlanReading, studyReadingPlanDay, studyBuildBusy, openPlanReading, studyReadingPlanDay, studyBuildBusy,
readingPlanSyncStatus, readingPlanSyncError, retryReadingPlanSync, readingPlanSyncStatus, readingPlanSyncError, retryReadingPlanSync,
readerTranslation, planTranslationPref, readerTranslation, planTranslationPref,
readingHistory, readReadingPlanDay,
} = useApp(); } = useApp();
const [detail, setDetail] = useState(null); // { planId, progressId? } const [detail, setDetail] = useState(null); // { planId, progressId? }
@@ -219,6 +221,7 @@ export default function ReadingPlansPage() {
</div> </div>
</> </>
)} )}
<div className="mt-8"><ReadingProgress history={readingHistory ?? { chapters: {}, days: [] }} planProgress={planProgress} activity={readingActivity} onOpenBook={(abbrev) => readReadingPlanDay(abbrev, 1)} /></div>
<div className="mt-8"><IqPlanExport mcheyne /></div> <div className="mt-8"><IqPlanExport mcheyne /></div>
</main> </main>
+6
View File
@@ -2,6 +2,8 @@
// write the final script beside your study notes. Claude prompts and the // write the final script beside your study notes. Claude prompts and the
// pronunciation guide live here because this is where they're needed. // pronunciation guide live here because this is where they're needed.
import { useState } from 'react'; import { useState } from 'react';
import PresentMode from '../components/PresentMode.jsx';
import DictateButton, { appendSpoken } from '../components/DictateButton.jsx';
import { useIqAvailable } from '../hooks/useIq.js'; import { useIqAvailable } from '../hooks/useIq.js';
import { chunkRefs, gatherIqContext } from '../utils/iqbible.js'; import { chunkRefs, gatherIqContext } from '../utils/iqbible.js';
import { useApp } from '../context/AppContext.js'; import { useApp } from '../context/AppContext.js';
@@ -20,6 +22,7 @@ export default function ScriptPage() {
const [notice, setNotice] = useState(''); const [notice, setNotice] = useState('');
const iq = useIqAvailable(); const iq = useIqAvailable();
const [gathering, setGathering] = useState(false); const [gathering, setGathering] = useState(false);
const [presenting, setPresenting] = useState(false);
const { toast } = useToast(); const { toast } = useToast();
const chunk = allChunks.find((c) => c.id === project?.selectedChunkId) ?? allChunks[0] ?? null; const chunk = allChunks.find((c) => c.id === project?.selectedChunkId) ?? allChunks[0] ?? null;
@@ -138,6 +141,8 @@ export default function ScriptPage() {
</button> </button>
)} )}
<button type="button" onClick={() => setCurrentPage('pronunciation')} className={btn}>Pronunciation guide</button> <button type="button" onClick={() => setCurrentPage('pronunciation')} className={btn}>Pronunciation guide</button>
<button type="button" onClick={() => setPresenting(true)} className={btn} title="Full-screen, large type, with a timer and auto-scroll">Present</button>
<DictateButton label="Dictate script" onText={(said) => set({ finalScript: appendSpoken(script, said) })} />
{notice && <span role="status" className="text-xs text-[var(--r-accent)]">{notice}</span>} {notice && <span role="status" className="text-xs text-[var(--r-accent)]">{notice}</span>}
</div> </div>
<textarea aria-label="Final script" value={script} onChange={(e) => set({ finalScript: e.target.value })} <textarea aria-label="Final script" value={script} onChange={(e) => set({ finalScript: e.target.value })}
@@ -165,6 +170,7 @@ export default function ScriptPage() {
)} )}
</aside> </aside>
</div> </div>
{presenting && <PresentMode mode="script" title={reference} subtitle={project.title} script={script} onClose={() => setPresenting(false)} />}
</StudioShell> </StudioShell>
); );
} }
+16
View File
@@ -85,6 +85,8 @@ export default function SettingsPage() {
ntfyUrl: authUser?.ntfyUrl ?? '', ntfyUrl: authUser?.ntfyUrl ?? '',
ntfyTopic: authUser?.ntfyTopic ?? '', ntfyTopic: authUser?.ntfyTopic ?? '',
reminderTime: authUser?.reminderTime ?? '07:00', reminderTime: authUser?.reminderTime ?? '07:00',
votdEnabled: authUser?.votdEnabled ?? false,
votdTime: authUser?.votdTime ?? '06:30',
}); });
const [reminderBusy, setReminderBusy] = useState(false); const [reminderBusy, setReminderBusy] = useState(false);
const [reminderError, setReminderError] = useState(''); const [reminderError, setReminderError] = useState('');
@@ -426,6 +428,20 @@ export default function SettingsPage() {
/> />
</label> </label>
</div> </div>
{authUser?.votdAvailable && (
<div className="rounded-xl border border-[var(--r-line)] p-3">
<label className="flex items-center gap-2 text-sm font-medium text-[var(--r-text)]">
<input type="checkbox" checked={reminderForm.votdEnabled} onChange={(e) => setReminderForm((f) => ({ ...f, votdEnabled: e.target.checked }))} className="h-4 w-4 accent-[var(--r-accent)]" />
Also send the verse of the day
</label>
<label className="mt-2 block max-w-xs text-sm font-medium text-[var(--r-text)]">
Send it at
<input type="time" value={reminderForm.votdTime} onChange={(e) => setReminderForm((f) => ({ ...f, votdTime: e.target.value }))}
className="mt-1 block w-full rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2 text-sm text-[var(--r-text)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]" />
</label>
<span className="mt-1 block text-xs text-[var(--r-sub)]">A short notification with today’s verse (KJV) to the same topic, even if the reading reminder is off.</span>
</div>
)}
<label className="block max-w-xs text-sm font-medium text-[var(--r-text)]"> <label className="block max-w-xs text-sm font-medium text-[var(--r-text)]">
ntfy server <span className="text-[var(--r-sub)]">(optional — defaults to ntfy.sh)</span> ntfy server <span className="text-[var(--r-sub)]">(optional — defaults to ntfy.sh)</span>
<input <input
+9 -5
View File
@@ -14,7 +14,7 @@ const primaryBtn = 'rounded-xl bg-[var(--r-accent)] px-5 py-2.5 text-sm font-med
// Band tints that alternate from chunk to chunk so neighbouring chunks are easy to tell apart. // Band tints that alternate from chunk to chunk so neighbouring chunks are easy to tell apart.
const BANDS = ['color-mix(in srgb, var(--r-accent) 13%, transparent)', 'color-mix(in srgb, var(--r-sub) 17%, transparent)']; const BANDS = ['color-mix(in srgb, var(--r-accent) 13%, transparent)', 'color-mix(in srgb, var(--r-sub) 17%, transparent)'];
function PassageForm({ setup, onField, onTitleChange, onLoad, loading, error, withTitle }) { function PassageForm({ setup, onField, onTitleChange, onLoad, loading, error, withTitle, userTemplates = [], onDeleteTemplate }) {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
@@ -35,8 +35,12 @@ function PassageForm({ setup, onField, onTitleChange, onLoad, loading, error, wi
<label className="block text-sm sm:col-span-2">Start from <label className="block text-sm sm:col-span-2">Start from
<select value={setup.template ?? 'blank'} onChange={(e) => onField('template', e.target.value)} className={inputCls}> <select value={setup.template ?? 'blank'} onChange={(e) => onField('template', e.target.value)} className={inputCls}>
{STUDY_TEMPLATES.map((t) => <option key={t.id} value={t.id}>{t.name}</option>)} {STUDY_TEMPLATES.map((t) => <option key={t.id} value={t.id}>{t.name}</option>)}
{userTemplates.length > 0 && <optgroup label="Your templates">{userTemplates.map((t) => <option key={t.id} value={t.id}>{t.name}</option>)}</optgroup>}
</select> </select>
<span className="mt-1 block text-xs text-[var(--r-sub)]">{getTemplate(setup.template).description}</span> <span className="mt-1 block text-xs text-[var(--r-sub)]">{getTemplate(setup.template, userTemplates).description}</span>
{setup.template?.startsWith('user-') && onDeleteTemplate && (
<button type="button" onClick={() => { onDeleteTemplate(setup.template); onField('template', 'blank'); }} className="mt-1 text-xs text-[var(--r-danger,#b42318)] hover:underline">Delete this template</button>
)}
</label> </label>
)} )}
</div> </div>
@@ -49,8 +53,8 @@ function PassageForm({ setup, onField, onTitleChange, onLoad, loading, error, wi
} }
export default function SetupPage() { export default function SetupPage() {
const { project, setProject, allChunks, updateProject, setCurrentPage } = useApp(); const { project, setProject, allChunks, updateProject, setCurrentPage, userTemplates, deleteUserTemplate } = useApp();
const b = useChunkBuilder({ project, setProject, updateProject, allChunks, setCurrentPage }); const b = useChunkBuilder({ project, setProject, updateProject, allChunks, setCurrentPage, userTemplates });
const { activeChapter, activeChapterIndex } = b; const { activeChapter, activeChapterIndex } = b;
const chapters = project?.chapters ?? []; const chapters = project?.chapters ?? [];
@@ -73,7 +77,7 @@ export default function SetupPage() {
<h1 className="mb-1 text-xl font-medium">New study</h1> <h1 className="mb-1 text-xl font-medium">New study</h1>
<p className="mb-5 text-sm text-[var(--r-sub)]">Pick a chapter, then cut it into chunks. You’ll study one chunk at a time.</p> <p className="mb-5 text-sm text-[var(--r-sub)]">Pick a chapter, then cut it into chunks. You’ll study one chunk at a time.</p>
<PassageForm setup={b.setup} onField={b.handleSetupField} onTitleChange={onTitleChange} onLoad={b.handleLoadChapter} <PassageForm setup={b.setup} onField={b.handleSetupField} onTitleChange={onTitleChange} onLoad={b.handleLoadChapter}
loading={b.loadingChapter} error={b.errorMessage} withTitle /> loading={b.loadingChapter} error={b.errorMessage} withTitle userTemplates={userTemplates} onDeleteTemplate={deleteUserTemplate} />
</section> </section>
)} )}
+2 -2
View File
@@ -16,7 +16,7 @@ import ResourcePane, { useResourceTab } from '../components/study/ResourcePane.j
import SuggestWordsSheet from '../components/study/SuggestWordsSheet.jsx'; import SuggestWordsSheet from '../components/study/SuggestWordsSheet.jsx';
export default function StudyPage() { export default function StudyPage() {
const { project, allChunks, projectIndex, updateProject, updateChunk, setCurrentPage, readerCommentary, setReaderCommentary } = useApp(); const { project, allChunks, projectIndex, updateProject, updateChunk, setCurrentPage, readerCommentary, setReaderCommentary, saveUserTemplate } = useApp();
// Short-lived status line (word suggestions, lookups, …) // Short-lived status line (word suggestions, lookups, …)
const [statusMessage, setStatusMessage] = useState(''); const [statusMessage, setStatusMessage] = useState('');
@@ -132,7 +132,7 @@ export default function StudyPage() {
<section aria-label="Notes" className="flex min-h-[22rem] flex-col overflow-hidden rounded-2xl border border-[var(--r-line)] bg-[var(--r-bg)] lg:min-h-0 lg:flex-[11]"> <section aria-label="Notes" className="flex min-h-[22rem] flex-col overflow-hidden rounded-2xl border border-[var(--r-line)] bg-[var(--r-bg)] lg:min-h-0 lg:flex-[11]">
<h2 className="shrink-0 border-b border-[var(--r-line)] px-4 py-2.5 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">Your notes</h2> <h2 className="shrink-0 border-b border-[var(--r-line)] px-4 py-2.5 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">Your notes</h2>
<div className="min-h-0 flex-1 overflow-y-auto p-3"> <div className="min-h-0 flex-1 overflow-y-auto p-3">
<NotesPane chunk={selectedChunk} updateChunk={updateChunk} allTags={allTags} templateId={project?.templateId} /> <NotesPane chunk={selectedChunk} updateChunk={updateChunk} allTags={allTags} templateId={project?.templateDef ?? project?.templateId} saveTemplate={saveUserTemplate} notify={notify} />
</div> </div>
</section> </section>
<section aria-label="Resources" className="flex min-h-[24rem] flex-col overflow-hidden rounded-2xl border border-[var(--r-line)] bg-[var(--r-bg)] lg:min-h-0 lg:flex-[10]"> <section aria-label="Resources" className="flex min-h-[24rem] flex-col overflow-hidden rounded-2xl border border-[var(--r-line)] bg-[var(--r-bg)] lg:min-h-0 lg:flex-[10]">
+34 -10
View File
@@ -94,8 +94,8 @@ export async function getShareStatus(id) {
} }
/** Enables sharing (or returns the existing token if already enabled). Returns { shareToken }. */ /** Enables sharing (or returns the existing token if already enabled). Returns { shareToken }. */
export async function enableSharing(id) { export async function enableSharing(id, options = {}) {
return request('POST', `/projects/${id}/share`); return request('POST', `/projects/${id}/share`, options);
} }
/** Revokes a project's share link. */ /** Revokes a project's share link. */
@@ -103,24 +103,32 @@ export async function disableSharing(id) {
return request('DELETE', `/projects/${id}/share`); return request('DELETE', `/projects/${id}/share`);
} }
// ---- co-teachers: other accounts that may edit a study ----
export async function getCollaborators(id) { return request('GET', `/projects/${id}/collaborators`); }
/** A single-use link (valid 7 days) that lets whoever opens it, signed in, edit this study. -> { token, expiresAt } */
export async function createEditInvite(id) { return request('POST', `/projects/${id}/collaborators/invite`); }
export async function removeCollaborator(id, userId) { return request('DELETE', `/projects/${id}/collaborators/${userId}`); }
/** Uses an invite link: -> { projectId, title } */
export async function joinStudy(token) { return request('POST', `/join/${encodeURIComponent(token)}`); }
/** The project's read-only outline link: { shareToken } (null when off). A separate link from the study share. */ /** The project's read-only outline link: { shareToken } (null when off). A separate link from the study share. */
export async function getOutlineShareStatus(id) { export async function getOutlineShareStatus(id) {
return request('GET', `/projects/${id}/outline-share`); return request('GET', `/projects/${id}/outline-share`);
} }
export async function enableOutlineSharing(id) { export async function enableOutlineSharing(id, options = {}) {
return request('POST', `/projects/${id}/outline-share`); return request('POST', `/projects/${id}/outline-share`, options);
} }
export async function disableOutlineSharing(id) { export async function disableOutlineSharing(id) {
return request('DELETE', `/projects/${id}/outline-share`); return request('DELETE', `/projects/${id}/outline-share`);
} }
/** Public lookup — no session. Returns { title, translation, outlines: [{ scope, label, tree }] }. */ /** Public lookup — no session. Returns { title, translation, outlines: [{ scope, label, tree }] }. */
export async function getSharedOutline(token) { export async function getSharedOutline(token, passcode) {
return request('GET', `/share-outline/${token}`); return request('GET', `/share-outline/${token}`, undefined, { headers: passcode ? { 'X-Share-Passcode': passcode } : {} });
} }
/** Public lookup — no session required. Returns the full project for a valid share token. */ /** Public lookup — no session required. Returns the full project for a valid share token. */
export async function getSharedProject(token) { export async function getSharedProject(token, passcode) {
return request('GET', `/share/${token}`); return request('GET', `/share/${token}`, undefined, { headers: passcode ? { 'X-Share-Passcode': passcode } : {} });
} }
/** /**
@@ -152,8 +160,13 @@ export async function getCurrentUser() {
return result; return result;
} }
export async function registerUser(email, password) { export async function registerUser(email, password, invite) {
return request('POST', '/auth/register', { email, password }); return request('POST', '/auth/register', { email, password, invite });
}
/** Who may sign up here: { mode: 'open' | 'invite' | 'closed' }. */
export async function getSignupMode() {
return request('GET', '/auth/signup-mode');
} }
export async function loginUser(email, password) { export async function loginUser(email, password) {
@@ -209,6 +222,11 @@ export async function saveRemoteMarks(marks) {
return request('PUT', '/reader/marks', marks); return request('PUT', '/reader/marks', marks);
} }
/** What the person has read: { chapters, days }. */
export async function getRemoteHistory() { return request('GET', '/reader/history'); }
/** Merges this device's reading history into the account's; returns the combined history. */
export async function saveRemoteHistory(history) { return request('PUT', '/reader/history', history); }
/** Sends a test notification to the given ntfy topic/server (or the saved ones). */ /** Sends a test notification to the given ntfy topic/server (or the saved ones). */
export async function sendReminderTest(settings) { export async function sendReminderTest(settings) {
return request('POST', '/auth/reminder-test', settings); return request('POST', '/auth/reminder-test', settings);
@@ -255,6 +273,12 @@ export async function adminResetPassword(id) {
return request('POST', `/admin/users/${id}/reset-password`); return request('POST', `/admin/users/${id}/reset-password`);
} }
/** The administrator's sign-up setting and invite codes. */
export async function adminGetSignup() { return request('GET', '/admin/signup'); }
export async function adminSetSignupMode(mode) { return request('PUT', '/admin/signup', { mode }); }
export async function adminCreateInvite({ note, days } = {}) { return request('POST', '/admin/invites', { note, days }); }
export async function adminDeleteInvite(code) { return request('DELETE', `/admin/invites/${encodeURIComponent(code)}`); }
export async function adminListProjects() { export async function adminListProjects() {
return request('GET', '/admin/projects'); return request('GET', '/admin/projects');
} }
+16
View File
@@ -66,6 +66,22 @@ describe('Home page', () => {
expect(titles()).toHaveLength(3); expect(titles()).toHaveLength(3);
}); });
test('a study can be put in a series; the dashboard groups by series and can show just one', async () => {
seedProject({ id: 'p1', title: 'Titus Notes' });
seedProject({ id: 'p2', title: 'New Birth' });
const user = userEvent.setup();
await renderApp();
const card = (title) => screen.getByRole('heading', { name: title }).closest('div.rounded-2xl');
await user.click(within(card('Titus Notes')).getByRole('button', { name: /add to series/i }));
await user.type(screen.getByLabelText('Series name'), 'Pastoral letters{Enter}');
expect(await screen.findByRole('heading', { level: 3, name: /Pastoral letters/ })).toBeInTheDocument();
expect(screen.getByRole('heading', { level: 3, name: /Not in a series/ })).toBeInTheDocument();
expect(JSON.parse(localStorage.getItem('bible-study-project-p1')).series).toBe('Pastoral letters');
await user.selectOptions(screen.getByLabelText(/^Series/), 'Pastoral letters');
expect(screen.queryByRole('heading', { name: 'New Birth' })).toBeNull();
expect(screen.getByRole('heading', { name: 'Titus Notes' })).toBeInTheDocument();
});
test('studies can be shown as a grid or a list, and the choice is remembered', async () => { test('studies can be shown as a grid or a list, and the choice is remembered', async () => {
seedProject(); seedProject();
seedProject({ id: 'p2', title: 'Second Study' }); seedProject({ id: 'p2', title: 'Second Study' });
+85
View File
@@ -0,0 +1,85 @@
// A one-page-per-session handout for a group: the passage, the outline, discussion questions and room to write.
// It opens as a print-friendly page ("Save as PDF" in the print dialog).
import { formatChunkReference, getChunkVerseEntries } from './bibleUtils.jsx';
import { flatten } from './outline.js';
const esc = (s) => String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
const lines = (text) => String(text ?? '').split('\n').map((l) => l.replace(/^\s*(?:[-*•–]|\d{1,2}[.)])\s+/, '').trim()).filter(Boolean);
// Every session (chunk) of the study, in order, with where it sits.
export function sessionsFor(project) {
const out = [];
(project?.chapters ?? []).forEach((chapter, chapterIndex) => {
for (const chunk of chapter.chunks ?? []) out.push({ chunk, chapterIndex, number: out.length + 1, reference: formatChunkReference(project, chapterIndex, chunk, '–') });
});
return out;
}
// The outline points for a session: its own outline, else the study outline's points linked to it.
export function outlineForSession(project, chunk) {
if ((chunk.outline ?? []).length) return chunk.outline;
const linked = (project.outline ?? []).filter((n) => n.chunkId === chunk.id);
return linked.length ? linked : [];
}
// The questions to print: the ones written for the session, else its Application notes as "Think about" prompts.
export function questionsFor(chunk) {
const own = lines(chunk.discussionQuestions);
if (own.length) return { kind: 'questions', items: own };
const fallback = lines(chunk.application);
return fallback.length ? { kind: 'think', items: fallback } : { kind: 'none', items: [] };
}
function sessionHtml(project, session, { answerLines }) {
const { chunk, chapterIndex, number, reference } = session;
const series = (project.series ?? '').trim();
const title = (chunk.episodeTitle ?? '').trim();
const num = (chunk.episodeNumber ?? '').toString().trim() || number;
const verses = getChunkVerseEntries(project, chapterIndex, chunk);
const outline = flatten(outlineForSession(project, chunk));
const q = questionsFor(chunk);
const blank = (n) => Array.from({ length: n }, () => '<div class="rule"></div>').join('');
return `<section class="page">
<header>
<p class="kicker">${esc(series || project.title)}</p>
<h1>Session ${esc(num)}${title ? `: ${esc(title)}` : ''}</h1>
<p class="ref">${esc(reference)} <span>(${esc(project.translation ?? 'BSB')})</span></p>
<p class="meta">Name: ____________________________ &nbsp;&nbsp; Date: ________________</p>
</header>
${verses.length ? `<div class="passage">${verses.map((v) => `<p><sup>${esc(v.number)}</sup> ${esc(v.text)}</p>`).join('')}</div>` : ''}
${outline.length ? `<h2>Outline</h2><ul class="outline">${outline.map(({ node, depth, label }) => `<li style="margin-left:${depth * 1.2}em"><b>${esc(label)}</b> ${esc(node.text || '')}${node.ref ? ` <i>(${esc(node.ref)})</i>` : ''}</li>`).join('')}</ul>` : ''}
${q.items.length ? `<h2>${q.kind === 'questions' ? 'Discussion questions' : 'Think about it'}</h2><ol class="questions">${q.items.map((item) => `<li>${esc(item)}${answerLines ? blank(2) : ''}</li>`).join('')}</ol>` : ''}
${answerLines ? `<h2>Notes</h2>${blank(q.items.length ? 4 : 10)}` : ''}
<footer>${esc(project.title)}</footer>
</section>`;
}
// ids: the chunk ids to include, or null for every session
export function buildHandoutHtml(project, ids = null, { answerLines = true } = {}) {
const sessions = sessionsFor(project).filter((s) => !ids || ids.includes(s.chunk.id));
return `<!doctype html><html><head><meta charset="utf-8"><title>${esc(project.title)} — handout</title><style>
@page { margin: 18mm 16mm; }
body { font-family: Georgia, 'Times New Roman', serif; color: #1f1b16; line-height: 1.5; margin: 0; font-size: 12.5pt; }
.page { page-break-after: always; min-height: 95vh; position: relative; }
.page:last-child { page-break-after: auto; }
.kicker { margin: 0; font: 600 9.5pt -apple-system, Helvetica, Arial, sans-serif; letter-spacing: .14em; text-transform: uppercase; color: #8a4a2a; }
h1 { margin: .15em 0 0; font-size: 22pt; font-weight: 600; }
.ref { margin: .2em 0 .4em; font-size: 13pt; color: #8a4a2a; font-weight: 600; } .ref span { color: #6b6458; font-weight: 400; font-size: 10pt; }
.meta { color: #6b6458; font: 10pt -apple-system, Helvetica, Arial, sans-serif; margin: .2em 0 1em; }
h2 { font: 600 10pt -apple-system, Helvetica, Arial, sans-serif; letter-spacing: .12em; text-transform: uppercase; color: #6b6458; border-bottom: 1px solid #d8cfbb; padding-bottom: 3px; margin: 1.3em 0 .5em; }
.passage { background: #f6f1e6; border-left: 3px solid #a84e27; padding: .4em 1em; } .passage p { margin: .35em 0; } sup { color: #a84e27; font-size: 70%; font-family: -apple-system, Helvetica, Arial, sans-serif; }
.outline { list-style: none; padding: 0; margin: 0; } .outline li { margin-bottom: .25em; } .outline b { color: #a84e27; }
.questions { padding-left: 1.4em; } .questions li { margin-bottom: .7em; }
.rule { border-bottom: 1px solid #c9bfa8; height: 1.7em; }
footer { position: absolute; bottom: 0; left: 0; right: 0; text-align: center; color: #9b9484; font: 9pt -apple-system, Helvetica, Arial, sans-serif; }
</style></head><body>${sessions.map((s) => sessionHtml(project, s, { answerLines })).join('')}</body></html>`;
}
export function printHandout(project, ids = null, options) {
const win = window.open('', '_blank');
if (!win) return false;
win.document.write(buildHandoutHtml(project, ids, options));
win.document.close();
win.onload = () => { win.focus(); win.print(); };
return true;
}
+48
View File
@@ -0,0 +1,48 @@
import { test, expect } from 'vitest';
import { buildHandoutHtml, outlineForSession, questionsFor, sessionsFor } from './handout.js';
const chunk = (id, s, e, x = {}) => ({ id, startVerse: s, endVerse: e, observation: '', interpretation: '', application: '', ...x });
const project = {
title: 'Romans 8 <study>', series: 'Romans', translation: 'BSB',
outline: [{ id: 'o1', text: 'No condemnation', ref: '', chunkId: 'a', children: [{ id: 'o2', text: 'The Spirit gives life', ref: 'Rom 8:2', chunkId: null, children: [] }] }],
chapters: [{ book: 'Romans', bookAbbrev: 'ROM', chapter: '8', verses: [{ number: 1, text: 'There is now no condemnation.' }, { number: 2, text: 'For the law of the Spirit of life.' }, { number: 12, text: 'So then, brothers.' }], chunks: [
chunk('a', 1, 2, { episodeNumber: '1', episodeTitle: 'Free at last', discussionQuestions: '1. What is condemnation?\n- Why does it end?', application: 'Rest in grace' }),
chunk('b', 12, 12, { application: '- Walk by the Spirit\n- Put sin to death' }),
] }],
};
test('sessions are numbered in order with their passages', () => {
expect(sessionsFor(project).map((s) => [s.number, s.reference])).toEqual([[1, 'Romans 8:1–2'], [2, 'Romans 8:12']]);
});
test('questions written for a session are used; otherwise the Application notes become prompts', () => {
expect(questionsFor(project.chapters[0].chunks[0])).toEqual({ kind: 'questions', items: ['What is condemnation?', 'Why does it end?'] });
expect(questionsFor(project.chapters[0].chunks[1])).toEqual({ kind: 'think', items: ['Walk by the Spirit', 'Put sin to death'] });
expect(questionsFor(chunk('z', 1, 1))).toEqual({ kind: 'none', items: [] });
});
test('a session uses the study outline points linked to it', () => {
expect(outlineForSession(project, project.chapters[0].chunks[0])).toHaveLength(1);
expect(outlineForSession(project, project.chapters[0].chunks[1])).toEqual([]);
expect(outlineForSession(project, { id: 'a', outline: [{ id: 'x', text: 'Own', children: [] }] })[0].id).toBe('x');
});
test('the handout has a page per session with passage, outline and questions, and escapes text', () => {
const html = buildHandoutHtml(project);
expect(html.match(/class="page"/g)).toHaveLength(2);
expect(html).toContain('Session 1: Free at last');
expect(html).toContain('<sup>1</sup> There is now no condemnation.');
expect(html).toContain('The Spirit gives life');
expect(html).toContain('Discussion questions');
expect(html).toContain('Think about it');
expect(html).toContain('Romans 8 &lt;study&gt;'); // no raw markup from the title
expect(html).not.toContain('<study>');
expect(html).toContain('class="rule"');
});
test('one session only, and no writing lines when switched off', () => {
const html = buildHandoutHtml(project, ['b'], { answerLines: false });
expect(html.match(/class="page"/g)).toHaveLength(1);
expect(html).toContain('Session 2');
expect(html).not.toContain('class="rule"');
});
+52
View File
@@ -0,0 +1,52 @@
// A calendar file (.ics) any calendar app can import: one event per entry. Dates are plain calendar days
// (all-day events) unless a time is given, in which case the event is at that clock time wherever the file is opened.
const pad = (n) => String(n).padStart(2, '0');
const day = (d) => `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}`;
const stamp = (d) => `${day(d)}T${pad(d.getHours())}${pad(d.getMinutes())}00`;
const utcStamp = (d) => `${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}T${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}Z`;
// Escapes text for an ICS field, and folds long lines the way the format requires (75 characters).
export const icsText = (s) => String(s ?? '').replace(/\\/g, '\\\\').replace(/;/g, '\\;').replace(/,/g, '\\,').replace(/\r?\n/g, '\\n');
export function fold(line) {
const out = [];
let rest = line;
while (rest.length > 75) { out.push(rest.slice(0, 75)); rest = ` ${rest.slice(75)}`; }
out.push(rest);
return out.join('\r\n');
}
// events: [{ date: Date, title, description?, time?: 'HH:MM', minutes?: number }] -> the file's text
export function buildIcs(events, { name = 'Bible study', now = new Date() } = {}) {
const lines = ['BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//Bible Study Project//EN', 'CALSCALE:GREGORIAN', `X-WR-CALNAME:${icsText(name)}`];
events.forEach((e, i) => {
lines.push('BEGIN:VEVENT', `UID:${utcStamp(now)}-${i}@bible-study-project`, `DTSTAMP:${utcStamp(now)}`);
const m = /^(\d{1,2}):(\d{2})$/.exec(e.time ?? '');
if (m) {
const start = new Date(e.date.getFullYear(), e.date.getMonth(), e.date.getDate(), Number(m[1]), Number(m[2]));
const end = new Date(start.getTime() + (e.minutes ?? 60) * 60_000);
lines.push(`DTSTART:${stamp(start)}`, `DTEND:${stamp(end)}`);
} else {
const next = new Date(e.date.getFullYear(), e.date.getMonth(), e.date.getDate() + 1);
lines.push(`DTSTART;VALUE=DATE:${day(e.date)}`, `DTEND;VALUE=DATE:${day(next)}`);
}
lines.push(`SUMMARY:${icsText(e.title)}`);
if (e.description) lines.push(`DESCRIPTION:${icsText(e.description)}`);
lines.push('END:VEVENT');
});
lines.push('END:VCALENDAR');
return `${lines.map(fold).join('\r\n')}\r\n`;
}
// The date of each of `count` sessions: the first on `start`, then every `everyDays` days.
export function sessionDates(start, count, everyDays = 7) {
return Array.from({ length: count }, (_, i) => new Date(start.getFullYear(), start.getMonth(), start.getDate() + i * everyDays));
}
export function downloadIcs(text, fileName) {
const url = URL.createObjectURL(new Blob([text], { type: 'text/calendar;charset=utf-8' }));
const a = document.createElement('a');
a.href = url; a.download = fileName;
document.body.appendChild(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
+33
View File
@@ -0,0 +1,33 @@
import { test, expect } from 'vitest';
import { buildIcs, fold, icsText, sessionDates } from './icsFile.js';
const NOW = new Date(Date.UTC(2026, 9, 6, 12, 0, 0));
test('sessions fall on the start date and then every N days', () => {
const d = sessionDates(new Date(2026, 9, 11), 3, 7);
expect(d.map((x) => `${x.getMonth() + 1}/${x.getDate()}`)).toEqual(['10/11', '10/18', '10/25']);
expect(sessionDates(new Date(2026, 11, 28), 2, 7).map((x) => `${x.getFullYear()}-${x.getMonth() + 1}-${x.getDate()}`)).toEqual(['2026-12-28', '2027-1-4']); // across the new year
});
test('all-day events end the next day', () => {
const ics = buildIcs([{ date: new Date(2026, 9, 11), title: 'Romans — Ep 1: Grace' }], { name: 'Romans', now: NOW });
expect(ics.startsWith('BEGIN:VCALENDAR\r\n')).toBe(true);
expect(ics).toContain('DTSTART;VALUE=DATE:20261011');
expect(ics).toContain('DTEND;VALUE=DATE:20261012');
expect(ics).toContain('SUMMARY:Romans — Ep 1: Grace');
expect(ics).toContain('X-WR-CALNAME:Romans');
expect(ics.endsWith('END:VCALENDAR\r\n')).toBe(true);
});
test('a time makes a timed event with a length', () => {
const ics = buildIcs([{ date: new Date(2026, 9, 11), title: 'Lesson', time: '09:30', minutes: 45 }], { now: NOW });
expect(ics).toContain('DTSTART:20261011T093000');
expect(ics).toContain('DTEND:20261011T101500');
});
test('special characters are escaped and long lines are folded', () => {
expect(icsText('A, B; C\nD\\E')).toBe('A\\, B\\; C\\nD\\\\E');
const folded = fold(`DESCRIPTION:${'x'.repeat(200)}`);
expect(folded.split('\r\n').every((l) => l.length <= 76)).toBe(true);
expect(folded.replace(/\r\n /g, '')).toBe(`DESCRIPTION:${'x'.repeat(200)}`);
});
+66
View File
@@ -0,0 +1,66 @@
// Your highlights and bookmarks as a file: grouped by colour (under the names you gave the colours), each in
// Bible order with the words you marked. Markdown for notes apps, Word for printing.
import { COLOR_NAMES, HIGHLIGHT_COLORS, colorTitle } from './highlightColors.js';
// highlights: [{ bookName, bookIndex, chapter, verse, startOffset, color, text, translation }]; bookmarks: [{ bookName, bookIndex, chapter, verse, color }]
export function buildMarksModel({ highlights = [], bookmarks = [], colorLabels = {} }) {
const order = (a, b) => a.bookIndex - b.bookIndex || a.chapter - b.chapter || a.verse - b.verse || (a.startOffset ?? 0) - (b.startOffset ?? 0);
const colors = [...new Set([...HIGHLIGHT_COLORS, ...highlights.map((h) => h.color)])];
const groups = colors
.map((color) => ({
color,
title: colorTitle(color, colorLabels),
items: highlights.filter((h) => h.color === color).sort(order).map((h) => ({ ref: `${h.bookName} ${h.chapter}:${h.verse}`, text: (h.text ?? '').trim(), translation: h.translation ?? '' })),
}))
.filter((g) => g.items.length > 0);
const marks = bookmarks.slice().sort(order).map((b) => ({ ref: `${b.bookName} ${b.chapter}:${b.verse}`, color: COLOR_NAMES[b.color] ?? '' }));
return { groups, bookmarks: marks };
}
export function marksToMarkdown(model, { title = 'My highlights and bookmarks', date = new Date() } = {}) {
const out = [`# ${title}`, '', `*${date.toLocaleDateString()}*`, ''];
for (const g of model.groups) {
out.push(`## ${g.title}`, '');
for (const item of g.items) out.push(`- **${item.ref}**${item.translation ? ` (${item.translation})` : ''}${item.text ? ` — “${item.text.replace(/\s+/g, ' ')}”` : ''}`);
out.push('');
}
if (model.bookmarks.length) {
out.push('## Bookmarks', '');
for (const b of model.bookmarks) out.push(`- ${b.ref}${b.color ? ` (${b.color.toLowerCase()})` : ''}`);
out.push('');
}
if (!model.groups.length && !model.bookmarks.length) out.push('Nothing marked yet.', '');
return out.join('\n');
}
export async function buildMarksDocxBlob(model, { title = 'My highlights and bookmarks', date = new Date() } = {}) {
const { Document, HeadingLevel, Packer, Paragraph, TextRun } = await import('docx'); // the Word library only loads when used
const heading = (text) => new Paragraph({ text, heading: HeadingLevel.HEADING_2, spacing: { before: 280, after: 100 } });
const children = [
new Paragraph({ text: title, heading: HeadingLevel.TITLE }),
new Paragraph({ children: [new TextRun({ text: date.toLocaleDateString(), color: '7A7060', size: 21 })], spacing: { after: 160 } }),
];
for (const g of model.groups) {
children.push(heading(g.title));
for (const item of g.items) {
children.push(new Paragraph({
bullet: { level: 0 },
children: [new TextRun({ text: item.ref, bold: true }), ...(item.translation ? [new TextRun({ text: ` (${item.translation})`, color: '7A7060' })] : []), ...(item.text ? [new TextRun({ text: ` — “${item.text.replace(/\s+/g, ' ')}”` })] : [])],
spacing: { after: 60 },
}));
}
}
if (model.bookmarks.length) {
children.push(heading('Bookmarks'));
for (const b of model.bookmarks) children.push(new Paragraph({ bullet: { level: 0 }, children: [new TextRun({ text: b.ref }), ...(b.color ? [new TextRun({ text: ` (${b.color.toLowerCase()})`, color: '7A7060' })] : [])], spacing: { after: 40 } }));
}
return Packer.toBlob(new Document({ sections: [{ children }] }));
}
export function downloadMarksFile(blob, fileName) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = fileName;
document.body.appendChild(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
+33
View File
@@ -0,0 +1,33 @@
import { test, expect } from 'vitest';
import { buildMarksDocxBlob, buildMarksModel, marksToMarkdown } from './marksExport.js';
const hl = (id, color, bookIndex, bookName, chapter, verse, text, extra = {}) => ({ id, color, bookIndex, bookName, chapter, verse, text, startOffset: 0, ...extra });
const highlights = [
hl('1', '#bbf7d0', 43, 'John', 3, 16, 'For God so loved the world', { translation: 'BSB' }),
hl('2', '#bbf7d0', 44, 'Acts', 1, 8, 'you will receive power'),
hl('3', '#fef08a', 44, 'Romans', 8, 28, 'all things work together'),
hl('4', '#bbf7d0', 42, 'Luke', 1, 37, 'nothing is impossible with God'),
];
const bookmarks = [{ bookName: 'Psalms', bookIndex: 18, chapter: 23, verse: 1, color: '#bfdbfe' }, { bookName: 'Genesis', bookIndex: 0, chapter: 1, verse: 1, color: '#fef08a' }];
test('highlights are grouped by colour under the names you gave them, in Bible order', () => {
const model = buildMarksModel({ highlights, bookmarks, colorLabels: { '#bbf7d0': 'Promises' } });
expect(model.groups.map((g) => g.title)).toEqual(['Yellow', 'Promises (green)']); // yellow comes first in the picker order
expect(model.groups[1].items.map((i) => i.ref)).toEqual(['Luke 1:37', 'John 3:16', 'Acts 1:8']);
expect(model.bookmarks.map((b) => b.ref)).toEqual(['Genesis 1:1', 'Psalms 23:1']);
});
test('the Markdown lists each mark with its words and the translation', () => {
const md = marksToMarkdown(buildMarksModel({ highlights, bookmarks, colorLabels: {} }), { title: 'Mine', date: new Date(2026, 9, 6) });
expect(md).toContain('# Mine');
expect(md).toContain('## Green');
expect(md).toContain('- **John 3:16** (BSB) — “For God so loved the world”');
expect(md).toContain('## Bookmarks');
expect(md).toContain('- Psalms 23:1 (blue)');
expect(marksToMarkdown({ groups: [], bookmarks: [] })).toContain('Nothing marked yet.');
});
test('the Word file is made', async () => {
const blob = await buildMarksDocxBlob(buildMarksModel({ highlights, bookmarks, colorLabels: {} }));
expect(blob.size).toBeGreaterThan(1000);
});
+43
View File
@@ -0,0 +1,43 @@
// Helpers for present mode: a clock, and how the talk is going against the minutes planned in the outline.
import { flatten } from './outline.js';
export function formatClock(totalSeconds) {
const s = Math.max(0, Math.round(totalSeconds));
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
const mm = String(m).padStart(h ? 2 : 1, '0');
return `${h ? `${h}:` : ''}${mm}:${String(sec).padStart(2, '0')}`;
}
// The outline as a list of steps to move through, with the minutes planned so far at the *end* of each step.
export function outlineSteps(tree) {
let planned = 0;
return flatten(tree).map(({ node, depth, label }) => {
const minutes = Number.isFinite(node.minutes) && node.minutes > 0 ? node.minutes : 0;
planned += minutes;
return { id: node.id, label, depth, text: node.text ?? '', ref: node.ref ?? '', note: node.note ?? '', kind: node.kind ?? '', scripture: node.scripture ?? null, attachments: node.attachments ?? [], minutes, plannedEnd: planned };
});
}
// How the talk is going. Each point has a window in the plan: it should start when the earlier points' minutes are
// used up and end when its own are. Being on a point before its window starts means you are ahead; still on it
// after its window ends means you are behind; anywhere in the window is on pace.
// -> { plannedSeconds (when it should start), endSeconds, totalSeconds, diffSeconds (+ ahead, - behind) } or null when no minutes are planned.
export function pacing(steps, index, elapsedSeconds) {
const total = steps.length ? steps[steps.length - 1].plannedEnd : 0;
if (!total) return null;
const startSeconds = (index > 0 ? steps[index - 1].plannedEnd : 0) * 60;
const endSeconds = (steps[index]?.plannedEnd ?? total) * 60;
let diff = 0;
if (elapsedSeconds < startSeconds) diff = startSeconds - elapsedSeconds;
else if (elapsedSeconds > endSeconds) diff = endSeconds - elapsedSeconds;
return { plannedSeconds: startSeconds, endSeconds, totalSeconds: total * 60, diffSeconds: diff };
}
export const paceText = (p) => {
if (!p) return '';
const d = Math.round(p.diffSeconds);
if (Math.abs(d) < 20) return 'On pace';
return d > 0 ? `${formatClock(d)} ahead` : `${formatClock(-d)} behind`;
};
+30
View File
@@ -0,0 +1,30 @@
import { test, expect } from 'vitest';
import { formatClock, outlineSteps, paceText, pacing } from './present.js';
const n = (id, text, minutes = null, children = []) => ({ id, text, ref: '', chunkId: null, minutes, note: '', kind: '', scripture: null, attachments: [], children });
const tree = [n('a', 'Intro', 5, [n('a1', 'Hook', 2)]), n('b', 'Body', 15), n('c', 'Close', 5)];
test('the clock reads like a clock', () => {
expect(formatClock(0)).toBe('0:00');
expect(formatClock(65)).toBe('1:05');
expect(formatClock(3725)).toBe('1:02:05');
expect(formatClock(-4)).toBe('0:00');
});
test('the outline becomes steps in reading order with minutes planned so far', () => {
const steps = outlineSteps(tree);
expect(steps.map((s) => `${s.label}${s.text}`)).toEqual(['I.Intro', 'A.Hook', 'II.Body', 'III.Close']);
expect(steps.map((s) => s.plannedEnd)).toEqual([5, 7, 22, 27]);
});
test('pace: ahead before a point\'s window, behind after it, on pace inside it', () => {
const steps = outlineSteps(tree); // Body is planned from 7:00 to 22:00
expect(pacing(steps, 0, 0)).toMatchObject({ plannedSeconds: 0, totalSeconds: 27 * 60 });
expect(pacing(steps, 2, 6 * 60).diffSeconds).toBe(60);
expect(paceText(pacing(steps, 2, 6 * 60))).toBe('1:00 ahead');
expect(paceText(pacing(steps, 2, 9 * 60))).toBe('On pace'); // part-way through Body
expect(paceText(pacing(steps, 2, 25 * 60))).toBe('3:00 behind'); // still on Body after it should have ended
expect(paceText(pacing(steps, 0, 65))).toBe('On pace'); // a minute into a 5-minute intro
expect(pacing(outlineSteps([n('x', 'No minutes')]), 0, 10)).toBeNull();
expect(paceText(null)).toBe('');
});
+1
View File
@@ -93,6 +93,7 @@ export function saveProjectToStorage(project) {
title: updated.title, title: updated.title,
lastEdited: updated.lastEdited, lastEdited: updated.lastEdited,
chapterSummary: buildChapterSummary(updated), chapterSummary: buildChapterSummary(updated),
series: (updated.series ?? '').trim(),
tags, tags,
}; };
if (existing >= 0) { if (existing >= 0) {
+79
View File
@@ -0,0 +1,79 @@
// What has been read in the Reader: { chapters: { "JHN.3": { n: times read, last: "2026-10-06" } }, days: ["2026-10-06", ...] }.
// Pure helpers for recording it, merging two copies, and turning it into the numbers and pictures on the progress map.
import { bookOptions, CHAPTER_COUNTS } from './bibleUtils.jsx';
export const emptyHistory = () => ({ chapters: {}, days: [] });
const DATE = /^\d{4}-\d{2}-\d{2}$/;
export const chapterKey = (book, chapter) => `${book}.${chapter}`;
export function normalizeHistory(raw) {
const chapters = {};
if (raw?.chapters && typeof raw.chapters === 'object' && !Array.isArray(raw.chapters)) {
for (const [k, v] of Object.entries(raw.chapters)) {
if (/^[1-3A-Z]{3}\.\d{1,3}$/.test(k) && v && typeof v === 'object') chapters[k] = { n: Number.isInteger(v.n) && v.n > 0 ? v.n : 1, last: DATE.test(v.last ?? '') ? v.last : '' };
}
}
const days = Array.isArray(raw?.days) ? [...new Set(raw.days.filter((d) => typeof d === 'string' && DATE.test(d)))].sort() : [];
return { chapters, days };
}
export function mergeHistory(a, b) {
const x = normalizeHistory(a);
const y = normalizeHistory(b);
const chapters = { ...x.chapters };
for (const [k, v] of Object.entries(y.chapters)) {
const mine = chapters[k];
chapters[k] = mine ? { n: Math.max(mine.n, v.n), last: mine.last > v.last ? mine.last : v.last } : v;
}
return { chapters, days: [...new Set([...x.days, ...y.days])].sort() };
}
export const historyEqual = (a, b) => JSON.stringify(normalizeHistory(a)) === JSON.stringify(normalizeHistory(b));
// A chapter read on `date`: counted once per day however long you stay, and the day is marked as a reading day.
export function recordRead(history, book, chapter, date) {
const key = chapterKey(book, chapter);
const was = history.chapters[key];
const alreadyToday = was?.last === date;
const chapters = alreadyToday ? history.chapters : { ...history.chapters, [key]: { n: (was?.n ?? 0) + 1, last: date } };
const days = history.days.includes(date) ? history.days : [...history.days, date].sort();
return chapters === history.chapters && days === history.days ? history : { chapters, days };
}
// Chapters finished in reading plans count as read too: -> Set of "JHN.3" keys
export function planChapterKeys(planProgress, getSchedule) {
const keys = new Set();
for (const p of planProgress ?? []) {
for (const list of Object.values(p.chaptersRead ?? {})) for (const k of list ?? []) keys.add(String(k).toUpperCase());
let schedule;
try { schedule = getSchedule(p.planId) ?? []; } catch { schedule = []; }
for (const day of p.completedDays ?? []) for (const r of schedule[day] ?? []) if (r?.book && r?.chapter) keys.add(chapterKey(r.book, r.chapter));
}
return keys;
}
// -> { books: [{ abbrev, name, read, total, pct, testament }], read, total, pct, ot: {read,total}, nt: {read,total} }
export function progressSummary(history, extraKeys = new Set()) {
const have = new Set([...Object.keys(history.chapters), ...extraKeys]);
const books = bookOptions.map((b, i) => {
const total = CHAPTER_COUNTS[b.abbrev] ?? 0;
let read = 0;
for (let c = 1; c <= total; c += 1) if (have.has(chapterKey(b.abbrev, c))) read += 1;
return { abbrev: b.abbrev, name: b.name, read, total, pct: total ? read / total : 0, testament: i < 39 ? 'ot' : 'nt' };
});
const sum = (list) => list.reduce((acc, b) => ({ read: acc.read + b.read, total: acc.total + b.total }), { read: 0, total: 0 });
const all = sum(books);
return { books, read: all.read, total: all.total, pct: all.total ? all.read / all.total : 0, ot: sum(books.filter((b) => b.testament === 'ot')), nt: sum(books.filter((b) => b.testament === 'nt')) };
}
const iso = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
// The last `weeks` weeks as columns of 7 days (Sunday first), ending with the week of `today`.
// -> [[{ date, on, future }]]
export function heatmapWeeks(daySet, today = new Date(), weeks = 26) {
const end = new Date(today.getFullYear(), today.getMonth(), today.getDate());
const startOfThisWeek = new Date(end.getFullYear(), end.getMonth(), end.getDate() - end.getDay());
return Array.from({ length: weeks }, (_, w) => Array.from({ length: 7 }, (__, d) => {
const date = new Date(startOfThisWeek.getFullYear(), startOfThisWeek.getMonth(), startOfThisWeek.getDate() - (weeks - 1 - w) * 7 + d);
return { date: iso(date), on: daySet.has(iso(date)), future: date > end };
}));
}
+55
View File
@@ -0,0 +1,55 @@
import { test, expect } from 'vitest';
import { emptyHistory, heatmapWeeks, historyEqual, mergeHistory, normalizeHistory, planChapterKeys, progressSummary, recordRead } from './readingHistory.js';
test('a chapter is counted once a day, and the day is marked', () => {
let h = recordRead(emptyHistory(), 'JHN', 3, '2026-10-05');
expect(h).toEqual({ chapters: { 'JHN.3': { n: 1, last: '2026-10-05' } }, days: ['2026-10-05'] });
const same = recordRead(h, 'JHN', 3, '2026-10-05');
expect(same).toBe(h); // staying on the page changes nothing
h = recordRead(h, 'JHN', 3, '2026-10-06');
expect(h.chapters['JHN.3']).toEqual({ n: 2, last: '2026-10-06' });
h = recordRead(h, 'ROM', 8, '2026-10-06');
expect(h.days).toEqual(['2026-10-05', '2026-10-06']);
});
test('two copies merge without losing anything', () => {
const a = { chapters: { 'JHN.3': { n: 3, last: '2026-10-01' } }, days: ['2026-10-01'] };
const b = { chapters: { 'JHN.3': { n: 1, last: '2026-10-09' }, 'GEN.1': { n: 1, last: '2026-10-02' } }, days: ['2026-10-02'] };
const m = mergeHistory(a, b);
expect(m.chapters['JHN.3']).toEqual({ n: 3, last: '2026-10-09' });
expect(m.days).toEqual(['2026-10-01', '2026-10-02']);
expect(historyEqual(m, mergeHistory(b, a))).toBe(true);
expect(normalizeHistory({ chapters: { bad: {} }, days: ['x'] })).toEqual(emptyHistory());
});
test('progress is counted per book and for the whole Bible, with plan chapters included', () => {
const h = { chapters: { 'GEN.1': { n: 1, last: '' }, 'GEN.2': { n: 1, last: '' }, 'JHN.3': { n: 1, last: '' } }, days: [] };
const sum = progressSummary(h, new Set(['GEN.3', 'JHN.3', 'REV.22']));
const gen = sum.books.find((b) => b.abbrev === 'GEN');
expect(gen).toMatchObject({ read: 3, total: 50, testament: 'ot' });
expect(sum.books.find((b) => b.abbrev === 'JHN')).toMatchObject({ read: 1, total: 21, testament: 'nt' });
expect(sum.books).toHaveLength(66);
expect(sum.total).toBe(1189);
expect(sum.read).toBe(5); // JHN.3 is in both and counts once
expect(sum.ot.total + sum.nt.total).toBe(1189);
expect(sum.nt.total).toBe(260);
});
test('chapters finished in reading plans come from ticked chapters and completed days', () => {
const schedule = { x: [[{ book: 'JHN', chapter: 1 }, { book: 'JHN', chapter: 2 }], [{ book: 'JHN', chapter: 3 }]] };
const keys = planChapterKeys([{ planId: 'x', completedDays: [0], chaptersRead: { 1: ['JHN.3'] } }], (id) => schedule[id]);
expect([...keys].sort()).toEqual(['JHN.1', 'JHN.2', 'JHN.3']);
expect(planChapterKeys([{ planId: 'gone', completedDays: [0] }], () => { throw new Error('unknown plan'); }).size).toBe(0);
});
test('the calendar is 26 weeks of 7 days ending this week, marking the days read', () => {
const weeks = heatmapWeeks(new Set(['2026-10-06', '2026-10-01']), new Date(2026, 9, 6), 26); // Tuesday
expect(weeks).toHaveLength(26);
expect(weeks.every((w) => w.length === 7)).toBe(true);
const last = weeks.at(-1);
expect(last[0].date).toBe('2026-10-04'); // that week's Sunday
expect(last[2]).toMatchObject({ date: '2026-10-06', on: true, future: false });
expect(last[3].future).toBe(true);
expect(weeks.flat().filter((d) => d.on).map((d) => d.date).sort()).toEqual(['2026-10-01', '2026-10-06']);
expect(weeks[0][0].date).toBe('2026-04-12'); // 25 weeks before
});
+22 -2
View File
@@ -11,15 +11,35 @@ export function tagCounts(index) {
return [...counts].map(([tag, count]) => ({ tag, count })).sort((a, b) => b.count - a.count || a.tag.localeCompare(b.tag)); return [...counts].map(([tag, count]) => ({ tag, count })).sort((a, b) => b.count - a.count || a.tag.localeCompare(b.tag));
} }
export function filterStudies(index, { query = '', tags = [] } = {}) { // The series names in use, A to Z, with how many studies each has.
export function seriesList(index) {
const counts = new Map();
for (const e of index) { const name = (e.series ?? '').trim(); if (name) counts.set(name, (counts.get(name) ?? 0) + 1); }
return [...counts].map(([name, count]) => ({ name, count })).sort((a, b) => a.name.localeCompare(b.name));
}
// Splits studies into one group per series (A to Z), with the ones in no series last. Keeps their order within a group.
export function groupBySeries(entries) {
const groups = new Map();
for (const e of entries) {
const name = (e.series ?? '').trim();
if (!groups.has(name)) groups.set(name, []);
groups.get(name).push(e);
}
const named = [...groups].filter(([n]) => n).sort(([a], [b]) => a.localeCompare(b)).map(([name, items]) => ({ name, items }));
return groups.has('') ? [...named, { name: '', items: groups.get('') }] : named;
}
export function filterStudies(index, { query = '', tags = [], series = '' } = {}) {
const raw = String(query).trim().toLowerCase(); const raw = String(query).trim().toLowerCase();
const tagOnly = raw.startsWith('#'); const tagOnly = raw.startsWith('#');
const q = tagOnly ? raw.slice(1).trim() : raw; const q = tagOnly ? raw.slice(1).trim() : raw;
return index.filter((entry) => { return index.filter((entry) => {
if (series === '__none') { if ((entry.series ?? '').trim()) return false; } else if (series && (entry.series ?? '').trim() !== series) return false;
const own = (entry.tags ?? []).map((t) => t.toLowerCase()); const own = (entry.tags ?? []).map((t) => t.toLowerCase());
if (tags.length && !tags.every((t) => own.includes(t.toLowerCase()))) return false; if (tags.length && !tags.every((t) => own.includes(t.toLowerCase()))) return false;
if (!q) return true; if (!q) return true;
if (own.some((t) => t.includes(q))) return true; if (own.some((t) => t.includes(q))) return true;
return !tagOnly && ((entry.title ?? '').toLowerCase().includes(q) || (entry.chapterSummary ?? '').toLowerCase().includes(q)); return !tagOnly && ((entry.title ?? '').toLowerCase().includes(q) || (entry.chapterSummary ?? '').toLowerCase().includes(q) || (entry.series ?? '').toLowerCase().includes(q));
}); });
} }
+19
View File
@@ -33,3 +33,22 @@ test('chosen tags must all be present, and combine with the search', () => {
test('tags are counted, most used first', () => { test('tags are counted, most used first', () => {
expect(tagCounts(index)).toEqual([{ tag: 'hope', count: 2 }, { tag: 'grace', count: 1 }, { tag: 'leadership', count: 1 }]); expect(tagCounts(index)).toEqual([{ tag: 'hope', count: 2 }, { tag: 'grace', count: 1 }, { tag: 'leadership', count: 1 }]);
}); });
import { groupBySeries, seriesList } from './studyFilter.js';
test('series are listed and studies grouped under them, with unsorted ones last', () => {
const idx = [
{ id: 'a', title: 'A', series: 'Romans' }, { id: 'b', title: 'B', series: '' },
{ id: 'c', title: 'C', series: 'Advent' }, { id: 'd', title: 'D', series: 'Romans' },
];
expect(seriesList(idx)).toEqual([{ name: 'Advent', count: 1 }, { name: 'Romans', count: 2 }]);
expect(groupBySeries(idx).map((g) => `${g.name || '(none)'}:${g.items.map((e) => e.id).join('')}`)).toEqual(['Advent:c', 'Romans:ad', '(none):b']);
expect(groupBySeries([{ id: 'x' }]).map((g) => g.name)).toEqual(['']);
});
test('a series can be chosen as a filter, and the search finds studies by series name', () => {
const idx = [{ id: 'a', title: 'Ch 8', series: 'Romans' }, { id: 'b', title: 'Ch 1' }, { id: 'c', title: 'Gen', series: 'Advent' }];
expect(filterStudies(idx, { series: 'Romans' }).map((e) => e.id)).toEqual(['a']);
expect(filterStudies(idx, { series: '__none' }).map((e) => e.id)).toEqual(['b']);
expect(filterStudies(idx, { query: 'advent' }).map((e) => e.id)).toEqual(['c']);
});
+29 -6
View File
@@ -38,13 +38,36 @@ export const STUDY_TEMPLATES = [
}, },
]; ];
export const getTemplate = (id) => STUDY_TEMPLATES.find((t) => t.id === id) ?? STUDY_TEMPLATES[0]; export const getTemplate = (id, userTemplates = []) => STUDY_TEMPLATES.find((t) => t.id === id) ?? userTemplates.find((t) => t.id === id) ?? STUDY_TEMPLATES[0];
// The prompt for one note box in a study built from `templateId`, or undefined for the default one. // A template can be given as an id of a built-in, or as the template itself (a study keeps a copy of the one
export const templatePrompt = (templateId, field) => getTemplate(templateId).prompts?.[field]; // it was made from, so it still works for a co-teacher or on another device that doesn't have it).
const asTemplate = (t) => (t && typeof t === 'object' ? t : getTemplate(t));
// The prompt for one note box in a study built from this template, or undefined for the default one.
export const templatePrompt = (template, field) => asTemplate(template).prompts?.[field];
export const STARTER_FIELDS = ['generalNotes', 'observation', 'interpretation', 'application'];
// Fields a new chunk starts with under this template. // Fields a new chunk starts with under this template.
export const templateStarter = (templateId) => { export const templateStarter = (template) => {
const starter = getTemplate(templateId).starter; const t = asTemplate(template);
return starter ? { generalNotes: starter } : {}; const starters = { ...(t.starter ? { generalNotes: t.starter } : {}), ...(t.starters ?? {}) };
return Object.fromEntries(STARTER_FIELDS.filter((f) => typeof starters[f] === 'string' && starters[f].trim()).map((f) => [f, starters[f]]));
}; };
// A template made from the notes written in a chunk: what it asks (the study's current prompts) and the scaffold
// text in each note box. Returns null when there is nothing in the chunk to keep.
export function templateFromChunk({ name, chunk, template }) {
const starters = Object.fromEntries(STARTER_FIELDS.filter((f) => (chunk[f] ?? '').trim()).map((f) => [f, chunk[f].slice(0, 2000)]));
const t = asTemplate(template);
const prompts = Object.fromEntries(['observation', 'interpretation', 'application'].filter((f) => t.prompts?.[f]).map((f) => [f, t.prompts[f]]));
if (!name.trim() || (!Object.keys(starters).length && !Object.keys(prompts).length)) return null;
return {
id: `user-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`,
name: name.trim().slice(0, 40),
description: 'Your own template',
...(Object.keys(prompts).length ? { prompts } : {}),
...(Object.keys(starters).length ? { starters } : {}),
};
}
+25
View File
@@ -16,3 +16,28 @@ test('templates change the prompts and may pre-fill background notes', () => {
expect(blankChunk(1, 5).generalNotes).toBe(''); expect(blankChunk(1, 5).generalNotes).toBe('');
expect(blankChunk(1, 5, null, 'devotional').generalNotes).toBe(''); expect(blankChunk(1, 5, null, 'devotional').generalNotes).toBe('');
}); });
import { templateFromChunk } from './studyTemplates.js';
test('a template of your own can be made from what is written in a chunk', () => {
const chunk = { observation: 'Who: \nWhat: ', interpretation: '', application: '', generalNotes: 'Context:\n' };
const made = templateFromChunk({ name: ' My lesson ', chunk, template: 'inductive' });
expect(made.id).toMatch(/^user-/);
expect(made.name).toBe('My lesson');
expect(made.starters).toEqual({ observation: 'Who: \nWhat: ', generalNotes: 'Context:\n' }); // empty boxes are left out
expect(made.prompts.observation).toMatch(/Look before you leap/); // keeps the prompts of the template it came from
expect(templateFromChunk({ name: '', chunk, template: 'blank' })).toBeNull();
expect(templateFromChunk({ name: 'x', chunk: { observation: '', interpretation: '', application: '', generalNotes: '' }, template: 'blank' })).toBeNull();
});
test('new chunks start from your template\'s text in every box it filled', () => {
const def = { id: 'user-1', name: 'Mine', starters: { observation: 'Who:', generalNotes: 'Context:' }, prompts: { application: 'So what?' } };
const c = blankChunk(1, 3, null, def);
expect(c.observation).toBe('Who:');
expect(c.generalNotes).toBe('Context:');
expect(c.interpretation).toBe('');
expect(templatePrompt(def, 'application')).toBe('So what?');
expect(templatePrompt(def, 'observation')).toBeUndefined();
expect(getTemplate('user-1', [def]).name).toBe('Mine');
expect(getTemplate('user-9', [def]).id).toBe('blank');
});