Enhance text note functionality: add API for creating and managing written notes, implement draft saving, and improve UI for note capture modes
This commit is contained in:
+143
-15
@@ -1,10 +1,12 @@
|
||||
const DB_NAME = 'faerro-kb-local';
|
||||
const STORE_NAME = 'recordings';
|
||||
const TEXT_STORE_NAME = 'text-notes';
|
||||
const DRAFT_ID = 'current-draft';
|
||||
const MAX_RECORDING_MS = 30 * 60 * 1000;
|
||||
const elements = Object.fromEntries([
|
||||
'record-button', 'record-title', 'record-help', 'record-time', 'record-meter-fill', 'audio-level', 'audio-level-fill', 'capture-message', 'audio-file-input',
|
||||
'queue-list', 'queue-empty', 'queue-count', 'sync-button', 'archive-list', 'archive-empty',
|
||||
'network-state', 'storage-status',
|
||||
'network-state', 'storage-status', 'voice-mode', 'write-mode', 'voice-panel', 'write-panel', 'written-note', 'draft-status', 'save-written-note',
|
||||
].map((id) => [id, document.getElementById(id)]));
|
||||
|
||||
let databasePromise;
|
||||
@@ -16,12 +18,17 @@ let recordingChunks = [];
|
||||
let recordingStartedAt = 0;
|
||||
let recordingTimer;
|
||||
let syncing = false;
|
||||
let draftSaveTimer;
|
||||
|
||||
function openDatabase() {
|
||||
if (databasePromise) return databasePromise;
|
||||
databasePromise = new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME, 1);
|
||||
request.onupgradeneeded = () => request.result.createObjectStore(STORE_NAME, { keyPath: 'id' });
|
||||
const request = indexedDB.open(DB_NAME, 2);
|
||||
request.onupgradeneeded = () => {
|
||||
const db = request.result;
|
||||
if (!db.objectStoreNames.contains(STORE_NAME)) db.createObjectStore(STORE_NAME, { keyPath: 'id' });
|
||||
if (!db.objectStoreNames.contains(TEXT_STORE_NAME)) db.createObjectStore(TEXT_STORE_NAME, { keyPath: 'id' });
|
||||
};
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
@@ -44,6 +51,92 @@ const getQueued = () => queueTransaction('readonly', (store) => store.getAll());
|
||||
const putQueued = (recording) => queueTransaction('readwrite', (store) => store.put(recording));
|
||||
const deleteQueued = (id) => queueTransaction('readwrite', (store) => store.delete(id));
|
||||
|
||||
async function textTransaction(mode, operation) {
|
||||
const db = await openDatabase();
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction(TEXT_STORE_NAME, mode);
|
||||
const request = operation(transaction.objectStore(TEXT_STORE_NAME));
|
||||
transaction.oncomplete = () => resolve(request?.result);
|
||||
transaction.onerror = () => reject(transaction.error);
|
||||
transaction.onabort = () => reject(transaction.error);
|
||||
});
|
||||
}
|
||||
|
||||
const getTextRecords = () => textTransaction('readonly', (store) => store.getAll());
|
||||
const putTextRecord = (record) => textTransaction('readwrite', (store) => store.put(record));
|
||||
const deleteTextRecord = (id) => textTransaction('readwrite', (store) => store.delete(id));
|
||||
|
||||
function setCaptureMode(mode) {
|
||||
const writing = mode === 'write';
|
||||
elements['voice-mode'].classList.toggle('active', !writing);
|
||||
elements['write-mode'].classList.toggle('active', writing);
|
||||
elements['voice-mode'].setAttribute('aria-selected', String(!writing));
|
||||
elements['write-mode'].setAttribute('aria-selected', String(writing));
|
||||
elements['voice-panel'].hidden = writing;
|
||||
elements['write-panel'].hidden = !writing;
|
||||
}
|
||||
|
||||
async function saveDraft() {
|
||||
const text = elements['written-note'].value;
|
||||
try {
|
||||
await putTextRecord({ id: DRAFT_ID, text, updatedAt: Date.now() });
|
||||
elements['draft-status'].textContent = 'Draft saved on this device.';
|
||||
elements['save-written-note'].disabled = !text.trim();
|
||||
} catch {
|
||||
elements['draft-status'].textContent = 'Could not save draft in browser storage. Keep this page open.';
|
||||
elements['save-written-note'].disabled = !text.trim();
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleDraftSave() {
|
||||
elements['save-written-note'].disabled = !elements['written-note'].value.trim();
|
||||
elements['draft-status'].textContent = 'Saving draft…';
|
||||
clearTimeout(draftSaveTimer);
|
||||
draftSaveTimer = window.setTimeout(saveDraft, 250);
|
||||
}
|
||||
|
||||
async function queueWrittenNote() {
|
||||
const text = elements['written-note'].value.trim();
|
||||
if (!text || elements['save-written-note'].disabled) return;
|
||||
clearTimeout(draftSaveTimer);
|
||||
const createdAt = new Date().toISOString();
|
||||
const note = { id: crypto.randomUUID(), text, createdAt, createdAtMs: Date.now(), filename: 'Written note' };
|
||||
try {
|
||||
const db = await openDatabase();
|
||||
await new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction(TEXT_STORE_NAME, 'readwrite');
|
||||
const store = transaction.objectStore(TEXT_STORE_NAME);
|
||||
store.put(note);
|
||||
store.delete(DRAFT_ID);
|
||||
transaction.oncomplete = resolve;
|
||||
transaction.onerror = () => reject(transaction.error);
|
||||
transaction.onabort = () => reject(transaction.error);
|
||||
});
|
||||
elements['written-note'].value = '';
|
||||
elements['draft-status'].textContent = navigator.onLine ? 'Saved on this device. Sending to the server…' : 'Saved on this device. It will send when you reconnect.';
|
||||
elements['save-written-note'].disabled = true;
|
||||
await renderQueue();
|
||||
await syncQueue();
|
||||
} catch {
|
||||
elements['draft-status'].textContent = 'Could not queue this note. Your draft is still here; try again.';
|
||||
}
|
||||
}
|
||||
|
||||
async function restoreDraft() {
|
||||
try {
|
||||
const records = await getTextRecords();
|
||||
const draft = records.find((record) => record.id === DRAFT_ID);
|
||||
if (draft?.text) {
|
||||
elements['written-note'].value = draft.text;
|
||||
elements['draft-status'].textContent = 'Draft restored from this device.';
|
||||
elements['save-written-note'].disabled = false;
|
||||
setCaptureMode('write');
|
||||
}
|
||||
} catch {
|
||||
elements['draft-status'].textContent = 'Browser storage is unavailable. This draft may not survive leaving the page.';
|
||||
}
|
||||
}
|
||||
|
||||
function setMessage(message, isError = false, isSuccess = false) {
|
||||
elements['capture-message'].textContent = message;
|
||||
elements['capture-message'].style.color = isError ? 'var(--coral)' : '';
|
||||
@@ -237,6 +330,22 @@ async function syncQueue() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
const textNotes = (await getTextRecords()).filter((record) => record.id !== DRAFT_ID).sort((a, b) => a.createdAtMs - b.createdAtMs);
|
||||
for (const note of textNotes) {
|
||||
try {
|
||||
const response = await fetch('/api/text-notes', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id: note.id, text: note.text, created_at: note.createdAt }),
|
||||
});
|
||||
if (!response.ok) throw new Error(`Save failed: ${response.status}`);
|
||||
await deleteTextRecord(note.id);
|
||||
elements['draft-status'].textContent = 'Note saved on the server.';
|
||||
} catch {
|
||||
elements['draft-status'].textContent = 'Upload paused. Your note is still saved on this device.';
|
||||
break;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
syncing = false;
|
||||
elements['sync-button'].disabled = false;
|
||||
@@ -250,7 +359,9 @@ function prettyDate(value) {
|
||||
}
|
||||
|
||||
async function renderQueue() {
|
||||
const recordings = (await getQueued()).sort((a, b) => b.createdAtMs - a.createdAtMs);
|
||||
const audioRecords = await getQueued();
|
||||
const textRecords = (await getTextRecords()).filter((record) => record.id !== DRAFT_ID);
|
||||
const recordings = [...audioRecords, ...textRecords].sort((a, b) => b.createdAtMs - a.createdAtMs);
|
||||
elements['queue-list'].replaceChildren();
|
||||
elements['queue-count'].textContent = recordings.length;
|
||||
elements['queue-empty'].hidden = recordings.length > 0;
|
||||
@@ -259,7 +370,8 @@ async function renderQueue() {
|
||||
item.className = 'queue-item';
|
||||
const icon = document.createElement('span');
|
||||
icon.className = 'queue-icon';
|
||||
icon.textContent = '◖';
|
||||
const isTextNote = 'text' in recording;
|
||||
icon.textContent = isTextNote ? 'T' : '◖';
|
||||
const body = document.createElement('div');
|
||||
body.className = 'item-body';
|
||||
const top = document.createElement('div');
|
||||
@@ -279,7 +391,8 @@ async function renderQueue() {
|
||||
if (syncing) return;
|
||||
remove.disabled = true;
|
||||
try {
|
||||
await deleteQueued(recording.id);
|
||||
if (isTextNote) await deleteTextRecord(recording.id);
|
||||
else await deleteQueued(recording.id);
|
||||
await renderQueue();
|
||||
} catch {
|
||||
setMessage('Could not remove the queued recording.', true);
|
||||
@@ -306,8 +419,8 @@ function createDeleteButton(label) {
|
||||
function renderArchive(notes) {
|
||||
elements['archive-list'].replaceChildren();
|
||||
elements['archive-empty'].hidden = notes.length > 0;
|
||||
if (!navigator.onLine) elements['archive-empty'].textContent = 'Reconnect to view notes awaiting transcription.';
|
||||
else elements['archive-empty'].textContent = 'No notes awaiting transcription.';
|
||||
if (!navigator.onLine) elements['archive-empty'].textContent = 'Reconnect to view saved notes.';
|
||||
else elements['archive-empty'].textContent = 'No saved notes yet.';
|
||||
for (const note of notes) {
|
||||
const item = document.createElement('article');
|
||||
item.className = 'archive-item';
|
||||
@@ -319,15 +432,16 @@ function renderArchive(notes) {
|
||||
const name = document.createElement('div');
|
||||
name.className = 'item-name';
|
||||
name.textContent = note.filename;
|
||||
const isWrittenNote = note.status === 'written';
|
||||
const transcript = document.createElement('textarea');
|
||||
transcript.className = 'archive-transcript';
|
||||
transcript.placeholder = note.status === 'transcribing' ? 'Transcription in progress…' : 'Transcript not available yet. Add or correct it here.';
|
||||
transcript.placeholder = isWrittenNote ? 'Write or edit your note…' : note.status === 'transcribing' ? 'Transcription in progress…' : 'Transcript not available yet. Add or correct it here.';
|
||||
transcript.value = note.transcript;
|
||||
transcript.setAttribute('aria-label', `Transcript for ${note.filename}`);
|
||||
transcript.setAttribute('aria-label', `${isWrittenNote ? 'Note' : 'Transcript'} for ${note.filename}`);
|
||||
const save = document.createElement('button');
|
||||
save.className = 'save-transcript';
|
||||
save.type = 'button';
|
||||
save.textContent = 'SAVE TRANSCRIPT';
|
||||
save.textContent = isWrittenNote ? 'SAVE NOTE' : 'SAVE TRANSCRIPT';
|
||||
save.addEventListener('click', async () => {
|
||||
save.disabled = true;
|
||||
try {
|
||||
@@ -344,7 +458,7 @@ function renderArchive(notes) {
|
||||
});
|
||||
const status = document.createElement('div');
|
||||
status.className = 'archive-status';
|
||||
status.textContent = 'AWAITING TRANSCRIPTION';
|
||||
status.textContent = isWrittenNote ? 'WRITTEN NOTE' : note.status === 'transcribing' ? 'TRANSCRIBING' : note.status === 'transcribed' ? 'TRANSCRIPT SAVED' : 'AWAITING TRANSCRIPTION';
|
||||
const remove = createDeleteButton(`Delete note ${note.filename}`);
|
||||
remove.addEventListener('click', async () => {
|
||||
remove.disabled = true;
|
||||
@@ -369,9 +483,14 @@ async function loadAwaitingNotes() {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await fetch('/api/notes?status=awaiting_transcription');
|
||||
if (!response.ok) throw new Error('Archive unavailable');
|
||||
renderArchive(await response.json());
|
||||
const responses = await Promise.all([
|
||||
fetch('/api/notes?status=awaiting_transcription'),
|
||||
fetch('/api/notes?status=written'),
|
||||
]);
|
||||
if (responses.some((response) => !response.ok)) throw new Error('Archive unavailable');
|
||||
const notes = (await Promise.all(responses.map((response) => response.json()))).flat();
|
||||
notes.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
|
||||
renderArchive(notes);
|
||||
} catch {
|
||||
elements['archive-list'].replaceChildren();
|
||||
elements['archive-empty'].hidden = false;
|
||||
@@ -380,6 +499,14 @@ async function loadAwaitingNotes() {
|
||||
}
|
||||
|
||||
elements['record-button'].addEventListener('click', () => mediaRecorder?.state === 'recording' ? stopRecording() : startRecording());
|
||||
elements['voice-mode'].addEventListener('click', () => setCaptureMode('voice'));
|
||||
elements['write-mode'].addEventListener('click', () => setCaptureMode('write'));
|
||||
elements['written-note'].addEventListener('input', scheduleDraftSave);
|
||||
elements['save-written-note'].addEventListener('click', queueWrittenNote);
|
||||
window.addEventListener('pagehide', () => {
|
||||
clearTimeout(draftSaveTimer);
|
||||
saveDraft();
|
||||
});
|
||||
elements['audio-file-input'].addEventListener('change', async (event) => {
|
||||
for (const file of event.target.files) await queueAudioFile(file);
|
||||
event.target.value = '';
|
||||
@@ -393,5 +520,6 @@ document.addEventListener('visibilitychange', () => { if (document.visibilitySta
|
||||
|
||||
updateNetwork();
|
||||
renderQueue().then(syncQueue).catch(() => setMessage('Browser storage is unavailable. Check Safari storage settings.', true));
|
||||
restoreDraft();
|
||||
loadAwaitingNotes();
|
||||
if ('serviceWorker' in navigator) navigator.serviceWorker.register('/sw.js').catch(() => {});
|
||||
Reference in New Issue
Block a user