📷 Photos
+ Add photo
JSON.parse(hygieneClean),
() => JSON.parse(hygieneClean.match(/\{[^{}]*\}/)?.[0] || ''),
() => JSON.parse(hygieneClean.replace(/[\x00-\x1F\x7F]/g,' ').match(/\{[^{}]*\}/)?.[0] || ''),
]) {
try { r = attempt(); if (r) break; } catch(e) { dbgWarn('AI-REVIEW', 'Parse failed:', e.message); }
}
if (!r) throw new Error('Could not parse hygiene response. Raw: ' + hygieneClean.substring(0,100));
const cleanSuggest = (v) => {
if (!v) return '';
const l = v.toString().toLowerCase().trim();
if (['empty','none','n/a','not applicable','unknown','unclear','not mentioned'].includes(l)) return '';
return v.trim();
};
r.spelling = { ok: !!r.spelling_ok, fixed: '', notes: r.spelling_notes || '' };
r.grammar = { ok: !!r.grammar_ok, notes: r.grammar_notes || '' };
r.length = { ok: !!r.length_ok, notes: r.length_notes || '' };
r.voice = { ok: !!r.voice_ok, notes: r.voice_notes || '' };
r.suggestTitle = cleanSuggest(r.suggest_title);
r.suggestChapter = cleanSuggest(r.suggest_chapter);
r.suggestEra = cleanSuggest(r.suggest_era);
r.suggestPeople = cleanSuggest(r.suggest_people);
r.suggestLocation = cleanSuggest(r.suggest_location);
r.suggestReference = cleanSuggest(r.suggest_reference);
if (!r.spelling.ok) {
const fixPrompt =
'Fix ONLY the spelling errors in this text. Preserve every word, fact and the person\'s exact voice. ' +
'Return ONLY the corrected text, nothing else:\n\n' + text;
try {
r.spelling.fixed = (await callAI(fixPrompt, { max_tokens: 600 })).trim();
} catch(e) { dbgWarn('AI-REVIEW', 'Spelling fix failed:', e.message); }
}
const reconPrompt =
'Rewrite this memoir entry as a clean, coherent paragraph. ' +
'Preserve ALL facts and the person\'s warm personal voice exactly. ' +
'Fix rambling, repetition, and speech-to-text errors. ' +
'If the original is already clean and clear, reply with exactly: CLEAN\n\n' +
'ENTRY:\n' + text;
try {
const reconRaw = (await callAI(reconPrompt, { max_tokens: 600 })).trim();
r.reconstruction = reconRaw === 'CLEAN' ? '' : reconRaw;
} catch(e) {
dbgWarn('AI-REVIEW', 'Reconstruction failed:', e.message);
r.reconstruction = '';
}
let html = '
';
html += '
HYGIENE CHECK
';
const cleanNote = (note) => {
if (!note) return '';
const lower = note.toLowerCase().trim();
if (['none','n/a','empty','no issues','ok','good','clear','fine','looks good','no problems','not applicable'].includes(lower)) return '';
return note;
};
const row = (ok, label, detail, warn) => {
const colour = ok ? '#4caf72' : warn ? 'var(--amber, #e8a020)' : 'var(--rose)';
const icon = ok ? '✓' : warn ? '⚠' : '✗';
const d = cleanNote(detail);
return '
' +
'' + icon + ' ' +
'' + label + ' ' + (d ? ' — ' + d + ' ' : '') + ' ' +
'
';
};
const dateTagVal = tags.date || '';
const eraTagVal = tags.year || '';
if (dateTagVal) {
const dateYear = parseInt(dateTagVal.split('-')[0], 10);
const thisYear = new Date().getFullYear();
const chapterCtx = (tags.chapter || '').toLowerCase();
const isChildhood = /child|early|school|family|birth|grow|youth|young/.test(chapterCtx);
if (isChildhood && dateYear >= thisYear - 1) {
html += row(false, 'Date', 'Date looks recent (' + dateYear + ') for a childhood memory — is this right?', true);
} else if (eraTagVal) {
const eraYear = parseInt(eraTagVal.replace(/s$/,''), 10);
if (!isNaN(eraYear) && Math.abs(dateYear - eraYear) > 15) {
html += row(false, "Date", "Date (" + dateYear + ") does not match Era (" + eraTagVal + ") — one may be wrong.", true);
}
}
}
html += row(r.spelling?.ok, 'Spelling', r.spelling?.notes);
html += row(r.grammar?.ok, 'Grammar', r.grammar?.notes);
html += row(r.length?.ok, 'Length', r.length?.notes);
html += row(r.voice?.ok, 'Clarity', r.voice?.notes);
html += row(hasTitle, 'Title', !hasTitle ? (r.suggestTitle ? 'Suggested: ' + r.suggestTitle : 'Not set — give this memory a title') : '', false);
html += row(hasChapter, 'Chapter', !hasChapter ? (r.suggestChapter ? 'Suggested: ' + r.suggestChapter : 'Not set — broad life chapter e.g. Family') : '', false);
html += row(hasEra, 'Era', !hasEra ? (r.suggestEra ? 'Suggested: ' + r.suggestEra : 'Not set — when did this happen?') : '', false);
html += row(hasPeople, 'People', !hasPeople ? (r.suggestPeople ? 'Suggested: ' + r.suggestPeople : 'Not set — who was there?') : '', !hasPeople);
html += row(hasLocation, 'Location', !hasLocation ? (r.suggestLocation ? 'Suggested: ' + r.suggestLocation : 'Not set — where did this happen?') : '', !hasLocation);
if (r.suggestReference || hasRef) html += row(hasRef, 'Reference', !hasRef && r.suggestReference ? 'Suggested: ' + r.suggestReference : '', !hasRef);
html += '
';
const sugTags = {};
if (!hasTitle && r.suggestTitle) sugTags.title = r.suggestTitle;
if (!hasChapter && r.suggestChapter) sugTags.chapter = r.suggestChapter;
if (!hasEra && r.suggestEra) sugTags.year = r.suggestEra;
if (!hasPeople && r.suggestPeople) sugTags.people = r.suggestPeople;
if (!hasLocation && r.suggestLocation) sugTags.location = r.suggestLocation;
if (!hasRef && r.suggestReference) sugTags.reference = r.suggestReference;
if (Object.keys(sugTags).length) {
html += '
SUGGESTED TAGS — click to accept
';
html += '
';
Object.entries(sugTags).forEach(([k,v]) => {
const single = JSON.stringify({[k]:v}).replace(/"/g,'"');
const eid = entryId ? "'" + entryId + "'" : 'null';
html += `+ ${k}: ${v} `;
});
html += '
';
}
if (!r.spelling?.ok && r.spelling?.fixed) {
html += '
';
html += '
SPELLING FIX
';
html += '
' + text + '
';
html += '
' + r.spelling.fixed + '
';
html += '
✓ Accept spelling fix ';
html += '
';
panel.dataset.fixed = r.spelling.fixed;
}
if (r.reconstruction && r.reconstruction.trim()) {
html += '
';
html += '
AI RECONSTRUCTION
';
html += '
Your entry, cleaned up — all your facts, just tidier
';
html += '
' + r.reconstruction + '
';
html += '
';
html += '✓ Use this version ';
html += '
';
panel.dataset.reconstruction = r.reconstruction;
}
panel.innerHTML = html;
} catch(err) {
panel.innerHTML = '
Could not review — try again. ';
dbgErr('AI-REVIEW', err.message);
}
}
async function acceptSpellFix(entryId) {
const panel = entryId
? (document.getElementById('improve-' + entryId) || document.getElementById('cdimprove-' + entryId))
: document.getElementById('memSpellPanel');
const fixed = panel?.dataset.fixed;
if (!fixed) return;
if (entryId) {
await fetch(DB_URL + '?id=eq.' + entryId + '&user_id=eq.' + currentUser.id, {
method: 'PATCH',
headers: { ...getAuthHeaders(), 'Prefer': 'return=minimal', 'Content-Type': 'application/json' },
body: JSON.stringify({ answer: fixed })
});
const e = entries.find(e => e.id === entryId);
if (e) e.answer = fixed;
toast('Spelling fixed & saved ✓');
panel.style.display = 'none';
if (document.getElementById('panel-entries').classList.contains('active')) renderReviewEntries(); if (document.getElementById('panel-dashboard').classList.contains('active')) renderDashboard();
} else {
document.getElementById('memAnswer').value = fixed;
panel.style.display = 'none';
draftSave(); // snapshot fixed text so autosave ticker can't revert it
toast('Spelling fixed ✓');
memCheckButtons();
}
}
async function acceptReconstruction(entryId) {
const panel = entryId
? (document.getElementById('improve-' + entryId) || document.getElementById('cdimprove-' + entryId))
: document.getElementById('memSpellPanel');
const text = panel?.dataset.reconstruction;
if (!text) return;
if (entryId) {
await fetch(DB_URL + '?id=eq.' + entryId + '&user_id=eq.' + currentUser.id, {
method: 'PATCH',
headers: { ...getAuthHeaders(), 'Prefer': 'return=minimal', 'Content-Type': 'application/json' },
body: JSON.stringify({ answer: text })
});
const e = entries.find(e => e.id === entryId);
if (e) e.answer = text;
toast('Entry updated & saved ✓');
panel.style.display = 'none';
if (document.getElementById('panel-entries').classList.contains('active')) renderReviewEntries(); if (document.getElementById('panel-dashboard').classList.contains('active')) renderDashboard();
} else {
document.getElementById('memAnswer').value = text;
panel.style.display = 'none';
draftSave(); // snapshot accepted text into localStorage right now
toast('Entry updated ✓');
memCheckButtons();
}
}
async function applyAIReviewTags(tagsJson, entryId, btn) {
let tags;
try { tags = typeof tagsJson === 'string' ? JSON.parse(tagsJson.replace(/"/g,'"')) : tagsJson; }
catch(e) { return; }
if (btn) { btn.style.opacity = '0.4'; btn.disabled = true; btn.textContent = '✓ ' + btn.textContent.replace('+ ',''); }
if (entryId) {
const patch = {};
if (tags.title) patch.title = tags.title;
if (tags.chapter) patch.chapter = tags.chapter;
if (tags.year) patch.era = tags.year;
if (tags.people) patch.people = tags.people;
if (tags.location) patch.location = tags.location;
if (tags.reference) patch.reference = tags.reference;
await fetch(DB_URL + '?id=eq.' + entryId + '&user_id=eq.' + currentUser.id, {
method: 'PATCH',
headers: { ...getAuthHeaders(), 'Prefer': 'return=minimal', 'Content-Type': 'application/json' },
body: JSON.stringify(patch)
});
const e = entries.find(e => e.id === entryId);
if (e) Object.assign(e, patch);
toast('Tags saved ✓');
if (e) {
const updatedTags = getEntryTags(e);
const newChipsHtml = buildTagChips(updatedTags);
for (const pfx of ['rev-', 'dcd-']) {
const card = document.getElementById(pfx + entryId.replace(/[:.+]/g,'-'));
if (card) {
const existingChips = card.querySelector('.tag-chips-container');
if (existingChips) {
existingChips.outerHTML = newChipsHtml || '';
} else if (newChipsHtml) {
const textEl = card.querySelector('.entry-text');
if (textEl) textEl.insertAdjacentHTML('afterend', newChipsHtml);
}
break;
}
}
}
if (document.getElementById('panel-dashboard').classList.contains('active')) renderDashboard();
} else {
if (tags.title) {
const memTitleEl = document.getElementById('memTitle');
if (memTitleEl) memTitleEl.value = tags.title;
const { title: _t, ...restTags } = tags;
applyTagDefaults('rec', restTags);
} else {
applyTagDefaults('rec', tags);
}
draftSave(); // snapshot title change so autosave ticker preserves it
toast('Tags applied ✓');
}
}
async function memSaveAndNew() {
if (!currentUser) return toast('Please sign in first');
setMemStatus('idle', 'Saving...');
const eid = await memSaveCurrentEntry();
if (eid) {
toast('Saved ✓ — ready for next memory');
recLastParentId = null;
recCurQuestion = '';
document.getElementById('aiQuestionBox').style.display = 'none';
memResetForm(false); // false = also clear tags for a truly fresh entry
if (memMicActive) { stopMemMic(); startMemMic(); } // restart mic fresh
}
}
function memCancel() {
memResetForm(false);
}
function memSetInterviewBtn(active) {
const btn = document.getElementById('memAITopicBtn');
if (!btn) return;
btn.textContent = active ? '💬 Continue interview' : '💬 Interview me';
}
async function memInterviewMe() {
if (!currentUser) return toast('Please sign in first');
logActivity('interview_started', null, 'info', 'AI');
const { title, answer } = memReadEntry();
if (window.speechSynthesis) {
const primer = new SpeechSynthesisUtterance('');
speechSynthesis.speak(primer);
speechSynthesis.cancel();
}
if (answer || title) {
var fullContext = (title ? 'Title: ' + title + '\n' : '') + answer;
var contextLines = fullContext.split('\n');
var qCount = contextLines.filter(function(l){ return /^AI Question:/.test(l); }).length;
if (qCount > 7) {
var found = 0, startIdx = 0;
for (var ci = contextLines.length - 1; ci >= 0; ci--) {
if (/^AI Question:/.test(contextLines[ci])) {
found++;
if (found === 7) { startIdx = ci; break; }
}
}
fullContext = contextLines.slice(startIdx).join('\n');
}
recDeepContext = fullContext;
if (memMicActive) stopMemMic();
setMemStatus('speaking', 'Gethro is thinking...');
await memAskFollowUp();
} else {
recActive = true;
await memAskQuestion('new');
}
interviewActive = true;
memSetInterviewBtn(true);
}
async function memAIConversation() {
if (!currentUser) return toast('Please sign in first');
const { title, answer } = memReadEntry();
if (window.speechSynthesis) {
const primer = new SpeechSynthesisUtterance('');
speechSynthesis.speak(primer);
speechSynthesis.cancel();
}
if (!answer && !title) {
await memAINewTopic();
return;
}
setMemStatus('speaking', 'Saving and thinking of a follow-up...');
const parentId = await memSaveCurrentEntry();
recLastParentId = parentId || null;
recDeepContext = (title ? 'Title: ' + title + '\n' : '') + 'A: ' + (answer || title);
document.getElementById('memAnswer').value = '';
document.getElementById('memTitle').value = '';
recTranscript = '';
recPhotos.forEach(p => URL.revokeObjectURL(p.preview));
recPhotos = []; renderRecPhotoStrip();
if (memMicActive) stopMemMic();
await memAskFollowUp();
}
function memAppendQuestion(question) {
const ta = document.getElementById('memAnswer');
const rawName = (userProfile && userProfile.name) ? userProfile.name : 'Your response';
const firstName = rawName.split(' ')[0];
const firstNameUpper = firstName.toUpperCase();
const sep = ta.value.trim() ? '\n\n' : '';
ta.value += sep + 'AI Question:\n' + question + '\n\n' + firstNameUpper + ' Answer:\n';
ta.scrollTop = ta.scrollHeight;
ta.focus();
ta.setSelectionRange(ta.value.length, ta.value.length);
}
async function memAskFollowUp() {
if (!recDeepContext) return;
try {
const prompt =
'You are a warm, curious biographer in conversation with someone recording their life story.\n\n' +
'WHAT THEY JUST SHARED:\n' + recDeepContext + '\n\n' +
'Ask ONE specific follow-up question (max 15 words) that digs into the most interesting detail. ' +
'Be warm and show genuine curiosity. Return ONLY the question.';
const question = await callAI(prompt);
recCurQuestion = question;
memAppendQuestion(question);
document.getElementById('aiQuestionBox').style.display = 'none';
if (memMicActive) stopMemMic();
setMemStatus('speaking', 'Gethro is thinking...');
gethroSpeak(question).then(function() {
setMemStatus('idle', '');
if (userMicPref) { setTimeout(function() { startMemMic(); }, 300); }
});
} catch(err) {
dbgErr('MEM', 'Follow-up failed:', err.message);
setMemStatus('idle', '');
}
}
async function memAINewTopic() {
if (!currentUser) return toast('Please sign in first');
if (window.speechSynthesis) {
const primer = new SpeechSynthesisUtterance('');
speechSynthesis.speak(primer);
speechSynthesis.cancel();
}
const { answer, title } = memReadEntry();
if (answer || title || recPhotos.length) {
setMemStatus('idle', 'Saving...');
await memSaveCurrentEntry();
toast('Saved ✓');
}
recActive = true;
document.getElementById('memAnswer').value = '';
document.getElementById('memTitle').value = '';
recPhotos.forEach(p => URL.revokeObjectURL(p.preview));
recPhotos = []; renderRecPhotoStrip();
await memAskQuestion('new');
}
async function memConvNext() {
const answer = document.getElementById('memAnswer').value.trim();
if (answer) {
setMemStatus('idle', 'Saving...');
await memSaveCurrentEntry();
toast('Saved ✓');
}
document.getElementById('memAnswer').value = '';
document.getElementById('memTitle').value = '';
recPhotos.forEach(p => URL.revokeObjectURL(p.preview));
recPhotos = []; renderRecPhotoStrip();
resetTagFields('rec');
await memAskQuestion('new');
}
async function memConvDeeper() {
const answer = document.getElementById('memAnswer').value.trim();
if (answer) {
recDeepContext = (recCurQuestion ? 'Q: ' + recCurQuestion + '\nA: ' : 'A: ') + answer;
setMemStatus('idle', 'Saving...');
await memSaveCurrentEntry();
toast('Saved ✓');
}
document.getElementById('memAnswer').value = '';
document.getElementById('memTitle').value = '';
recPhotos.forEach(p => URL.revokeObjectURL(p.preview));
recPhotos = []; renderRecPhotoStrip();
resetTagFields('rec');
await memAskQuestion('deep');
}
function memConvEnd() {
recActive = false;
stopListening();
gethroCancel();
document.getElementById('aiQuestionBox').style.display = 'none';
document.getElementById('memAnswer').value = '';
document.getElementById('memTitle').value = '';
recPhotos.forEach(p => URL.revokeObjectURL(p.preview));
recPhotos = []; renderRecPhotoStrip();
setMemStatus('idle', '');
recCurQuestion = '';
applyTagDefaults('rec', recLastTags);
memCheckButtons();
toast('Conversation saved');
}
async function memAskQuestion(direction) {
if (!recActive) return;
const isDeep = direction === 'deep';
setMemStatus('speaking', isDeep ? 'Gethro is thinking...' : 'Gethro is choosing a topic...');
document.getElementById('aiQuestionBox').style.display = 'none';
try {
const realEntries = entries.filter(e => !e.category.startsWith('_profile'));
let prompt;
if (isDeep) {
const context = recDeepContext || realEntries.slice(-3)
.map(e => (e.question ? 'Q: ' + e.question + '\n' : '') + 'A: ' + e.answer).join('\n\n');
prompt =
'You are a warm, curious biographer in conversation with someone recording their life story.\n\n' +
'THE MOST RECENT EXCHANGE:\n' + context + '\n\n' +
'Ask ONE specific follow-up question (max 15 words). Focus on the most interesting or emotional detail. Show genuine curiosity. Do NOT introduce a new topic. Return ONLY the question.';
} else {
const coveredQuestions = realEntries.map(e => e.question).filter(Boolean).concat(recCurQuestion ? [recCurQuestion] : []).join('\n');
const coveredText = realEntries.slice(-40).map(e => (e.question || '') + ' ' + e.answer.slice(0,60)).join(' ').toLowerCase();
const lifeAreas = ['early childhood','parents','siblings','primary school','high school',
'first job','romance','marriage','children','career','places lived',
'travel','hobbies','proudest moment','regrets','hardest time',
'mentor','beliefs','funniest memory','advice to younger self'];
const uncovered = lifeAreas.filter(a => !a.split(' ').some(w => w.length > 3 && coveredText.includes(w)));
prompt =
'You are a warm biographer helping someone record their life story.\n\n' +
'QUESTIONS ALREADY ASKED — DO NOT REPEAT:\n' + (coveredQuestions || 'None yet.') + '\n\n' +
'UNCOVERED AREAS TO CHOOSE FROM:\n' + (uncovered.length ? uncovered.join(', ') : lifeAreas.slice(-8).join(', ')) + '\n\n' +
'Ask ONE short warm question about one uncovered area. Max 12 words. Return ONLY the question.';
}
const question = await callAI(prompt);
if (!recActive) return;
recCurQuestion = question;
memAppendQuestion(question);
document.getElementById('aiQuestionBox').style.display = 'none';
applyTagDefaults('rec', recLastTags);
await speakAndWait(question);
if (!recActive) return;
setMemStatus('idle', '');
if (memMicActive) stopMemMic();
if (userMicPref) {
await new Promise(r => setTimeout(r, 150));
startMemMic();
}
} catch(err) {
dbgErr('MEM', err.message);
setMemStatus('idle', 'Could not reach AI');
}
}
function handleRecPhotos(input) {
Array.from(input.files).forEach(file => {
if (file.size > 10 * 1024 * 1024) { toast(file.name + ' too large — max 10MB'); return; }
recPhotos.push({ file, preview: URL.createObjectURL(file) });
});
input.value = '';
renderRecPhotoStrip();
memCheckButtons();
}
function renderRecPhotoStrip() {
const strip = document.getElementById('recPhotoStrip');
if (!strip) return;
const addBtn = '
' +
'+ Add photo ';
if (!recPhotos.length) { strip.innerHTML = addBtn; return; }
strip.innerHTML = recPhotos.map((p, i) =>
'
' +
'
' +
'
' +
'
✕ ' +
'
' +
'
' +
'
'
).join('') + addBtn;
memCheckButtons();
}
function removeRecPhoto(idx) {
URL.revokeObjectURL(recPhotos[idx].preview);
recPhotos.splice(idx, 1);
renderRecPhotoStrip();
memCheckButtons();
}
let composerPhotos = []; // [{file, preview}] in the photos composer
function handlePhotosSelected(input) {
if (!currentUser) return toast('Please sign in first');
const files = Array.from(input.files);
files.forEach(file => {
if (file.size > 10 * 1024 * 1024) { toast(file.name + ' is too large — max 10MB'); return; }
composerPhotos.push({ file, preview: URL.createObjectURL(file) });
});
input.value = '';
renderComposerStrip();
}
function handlePhotoSelected(input) {
const file = input.files[0];
if (!file) return;
if (!currentUser) return toast('Please sign in first');
if (file.size > 10 * 1024 * 1024) { input.value = ''; return toast('Photo too large — max 10MB'); }
composerPhotos = [{ file, preview: URL.createObjectURL(file) }];
document.getElementById('photoPreview').src = composerPhotos[0].preview;
document.getElementById('photoRecordingSection').style.display = 'block';
document.getElementById('photoStoryText').value = '';
resetTagFields('photo');
input.value = '';
}
function renderComposerStrip() {
const strip = document.getElementById('photoPreviewStrip');
const items = document.getElementById('photoPreviewItems');
if (!strip || !items) return;
if (!composerPhotos.length) { strip.style.display = 'none'; return; }
strip.style.display = 'block';
items.innerHTML = composerPhotos.map((p, i) =>
'
' +
'
' +
'
✕ ' +
'
'
).join('');
}
function removeComposerPhoto(idx) {
URL.revokeObjectURL(composerPhotos[idx].preview);
composerPhotos.splice(idx, 1);
renderComposerStrip();
}
function cancelPhotoEntry() {
composerPhotos.forEach(p => URL.revokeObjectURL(p.preview));
composerPhotos = [];
renderComposerStrip();
document.getElementById('photoStoryText').value = '';
document.getElementById('photoFileInput').value = '';
document.getElementById('photoCat').value = '';
document.getElementById('photoRecordStatus').textContent = '';
document.getElementById('photoRecordBtn').textContent = '🎙 Record Instead';
document.getElementById('photoRecordBtn').className = 'btn btn-danger';
if (photoRecRec) { try { photoRecRec.stop(); } catch(e){} photoRecRec = null; }
photoRecActive = false;
resetTagFields('photo');
}
function togglePhotoRecord() { photoRecActive ? stopPhotoRecord() : startPhotoRecord(); }
function startPhotoRecord() {
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
if (!SR) return toast('Speech not supported — type instead');
photoRecRec = new SR();
photoRecRec.lang = 'en-AU'; photoRecRec.interimResults = true; photoRecRec.continuous = true;
photoRecText = '';
photoRecRec.onresult = (e) => {
let t = ''; for (let i = 0; i < e.results.length; i++) t += e.results[i][0].transcript;
photoRecText = t; document.getElementById('photoStoryText').value = t;
};
photoRecRec.onerror = (e) => { if (e.error !== 'aborted') toast('Mic error: ' + e.error); };
photoRecRec.start();
photoRecActive = true;
document.getElementById('photoRecordBtn').textContent = '⏹ Stop Recording';
document.getElementById('photoRecordBtn').className = 'btn btn-ghost';
document.getElementById('photoRecordStatus').textContent = '🔴 Recording...';
}
function stopPhotoRecord() {
if (photoRecRec) { try { photoRecRec.stop(); } catch(e){} photoRecRec = null; }
photoRecActive = false;
document.getElementById('photoRecordBtn').textContent = '🎙 Record Again';
document.getElementById('photoRecordBtn').className = 'btn btn-danger';
document.getElementById('photoRecordStatus').textContent = 'Recording stopped';
}
async function describePhoto(file) {
return new Promise((resolve) => {
const reader = new FileReader();
reader.onload = async (e) => {
const base64 = e.target.result.split(',')[1];
try {
const res = await fetch(AI_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + SUPABASE_ANON, 'apikey': SUPABASE_ANON },
body: JSON.stringify({ vision: true, imageBase64: base64, imageMediaType: file.type || 'image/jpeg',
prompt: 'Describe this photo in rich, vivid detail for use in a personal memoir. Note the people, setting, mood, era, clothing, expressions, and any details that tell a story. Be warm and observational. 3-5 sentences.' })
});
const data = await res.json();
resolve(data?.choices?.[0]?.message?.content?.trim() ?? null);
} catch(err) { resolve(null); }
};
reader.readAsDataURL(file);
});
}
async function savePhotoEntry() {
if (!currentUser) return toast('Please sign in first');
if (!composerPhotos.length) return toast('Please add at least one photo');
const story = document.getElementById('photoStoryText').value.trim() || photoRecText.trim();
if (!story) return toast('Please tell the story behind these photos');
const cat = document.getElementById('photoCat').value || 'Unspecified';
const tags = readTags('photo');
toast('Saving entry...');
try {
const firstPhoto = composerPhotos[0];
toast('Uploading photo 1 of ' + composerPhotos.length + '...');
const firstUp = await uploadPhotoFile(firstPhoto.file);
if (!firstUp) return toast('Photo upload failed');
const parentId = await addEntry(cat, story, null, firstUp.path, firstUp.desc, tags);
if (!parentId) return toast('Could not save entry');
for (let i = 1; i < composerPhotos.length; i++) {
toast('Uploading photo ' + (i+1) + ' of ' + composerPhotos.length + '...');
const up = await uploadPhotoFile(composerPhotos[i].file);
if (up) await addEntry(cat, 'Additional photo', null, up.path, up.desc, tags, parentId);
}
toast('Saved ✓');
await loadEntries();
} catch(err) { dbgErr('PHOTO', 'savePhotoEntry failed:', err.message); toast('Error: ' + err.message); }
}
async function getSignedUrl(path) {
try {
const res = await fetch(STORAGE_SIGN + '/' + path, {
method: 'POST',
headers: { ...getAuthHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify({ expiresIn: 3600 })
});
const data = await res.json();
if (data.signedURL) return SUPABASE_URL + '/storage/v1' + data.signedURL;
return null;
} catch(err) { return null; }
}
async function renderPhotoGrid() {
const allPhotos = entries.filter(e => e.photo_url && !e.category.startsWith('_profile'));
const grid = document.getElementById('photoGrid');
if (!allPhotos.length) {
grid.innerHTML = '
📷
No photos yet — add one above!
';
return;
}
const parents = allPhotos.filter(e => !e.parent_entry_id);
const orphans = allPhotos.filter(e => e.parent_entry_id && !parents.find(p => p.id === e.parent_entry_id));
const toShow = [...parents, ...orphans];
grid.innerHTML = toShow.map(() =>
'
'
).join('');
const withUrls = await Promise.all(toShow.map(async e => ({ ...e, signedUrl: await getSignedUrl(e.photo_url) })));
grid.innerHTML = withUrls.map(e => {
const children = entries.filter(c => c.parent_entry_id === e.id && c.photo_url);
return '
' +
(e.signedUrl ? '
' : '
📷
') +
(children.length ? '
+' + children.length + ' more photo' + (children.length>1?'s':'') + '
' : '') +
'
' + e.answer + '
' +
(e.photo_ai_description ? '
✨ ' + e.photo_ai_description.substring(0,120) + (e.photo_ai_description.length > 120 ? '...' : '') + '
' : '') +
'
' + new Date(e.timestamp).toLocaleDateString('en-AU',{day:'numeric',month:'short',year:'numeric'}) + '
' +
'
';
}).join('');
}
var _bookSettings = { title: '', author: '', blurb: '', dedication: '', coverStyle: 'classic' };
var _bookDetailsDirty = false;
var _bookDetailsSaveTimer = null;
async function loadBookSettings() {
if (!currentUser) return;
try {
var res = await fetch(DB_SETTINGS_URL + '?user_id=eq.' + currentUser.id + '&key=in.(book_title,book_author,book_blurb,book_dedication,book_cover_style)', {
headers: getAuthHeaders()
});
if (!res.ok) { dbgWarn('BOOKSETTINGS', 'Load failed:', res.status); return; }
var rows = await res.json();
rows.forEach(function(row) {
if (row.key === 'book_title') _bookSettings.title = row.value || '';
if (row.key === 'book_author') _bookSettings.author = row.value || '';
if (row.key === 'book_blurb') _bookSettings.blurb = row.value || '';
if (row.key === 'book_dedication') _bookSettings.dedication = row.value || '';
if (row.key === 'book_cover_style')_bookSettings.coverStyle = row.value || 'classic';
});
} catch(err) { dbgWarn('BOOKSETTINGS', 'Load error:', err.message); }
}
async function saveBookSettingKey(key, value) {
if (!currentUser) return;
try {
var res = await fetch(DB_SETTINGS_URL, {
method: 'POST',
headers: { ...getAuthHeaders(), 'Prefer': 'resolution=merge-duplicates,return=minimal', 'Content-Type': 'application/json' },
body: JSON.stringify({ user_id: currentUser.id, key: key, value: value, updated_at: new Date().toISOString() })
});
if (!res.ok) { dbgWarn('BOOKSETTINGS', 'Save failed for', key, res.status); }
} catch(err) { dbgWarn('BOOKSETTINGS', 'Save error:', err.message); }
}
async function saveBookDetails() {
var title = (document.getElementById('bdTitle')?.value || '').trim();
var author = (document.getElementById('bdAuthor')?.value || '').trim();
var blurb = (document.getElementById('bdBlurb')?.value || '').trim();
var dedication = (document.getElementById('bdDedication')?.value || '').trim();
_bookSettings.title = title;
_bookSettings.author = author;
_bookSettings.blurb = blurb;
_bookSettings.dedication = dedication;
var status = document.getElementById('bdSaveStatus');
if (status) { status.textContent = '⏳ Saving…'; }
await Promise.all([
saveBookSettingKey('book_title', title),
saveBookSettingKey('book_author', author),
saveBookSettingKey('book_blurb', blurb),
saveBookSettingKey('book_dedication', dedication),
saveBookSettingKey('book_cover_style',_bookSettings.coverStyle)
]);
_bookDetailsDirty = false;
if (status) { status.textContent = '✓ Saved'; setTimeout(function(){ if (status) status.textContent = ''; }, 2500); }
syncBookDetailsToPreview();
}
function bookDetailsChanged() {
_bookDetailsDirty = true;
var status = document.getElementById('bdSaveStatus');
if (status) status.textContent = 'Unsaved changes';
}
function renderBookDetails() {
var titleEl = document.getElementById('bdTitle');
var authorEl = document.getElementById('bdAuthor');
var blurbEl = document.getElementById('bdBlurb');
var dedicationEl = document.getElementById('bdDedication');
if (!titleEl) return;
if (!titleEl.value) titleEl.value = _bookSettings.title || (userProfile.name ? userProfile.name + "'s Life Story" : '');
if (!authorEl.value) authorEl.value = _bookSettings.author || userProfile.name || '';
if (!blurbEl.value) blurbEl.value = _bookSettings.blurb || '';
if (!dedicationEl.value) dedicationEl.value = _bookSettings.dedication || '';
}
function toggleBookDetails() {
var body = document.getElementById('bookDetailsBody');
var chevron = document.getElementById('bookDetailsChevron');
if (!body) return;
var open = body.style.display !== 'none';
body.style.display = open ? 'none' : 'block';
if (chevron) chevron.style.transform = open ? '' : 'rotate(180deg)';
if (!open) renderBookDetails();
}
function syncBookDetailsToPreview() {
var overlay = document.getElementById('printPreviewOverlay');
if (!overlay || !overlay.classList.contains('visible')) return;
var ppoTitle = document.getElementById('ppoBookTitle');
var ppoAuthor = document.getElementById('ppoAuthorName');
var ppoDedic = document.getElementById('ppoDedication');
var bdTitle = document.getElementById('bdTitle');
var bdAuthor = document.getElementById('bdAuthor');
var bdDedic = document.getElementById('bdDedication');
if (ppoTitle && bdTitle && bdTitle.value) ppoTitle.value = bdTitle.value;
if (ppoAuthor && bdAuthor && bdAuthor.value) ppoAuthor.value = bdAuthor.value;
if (ppoDedic && bdDedic && bdDedic.value) ppoDedic.value = bdDedic.value;
}
async function suggestBlurb(btn) {
var real = entries.filter(function(e){ return !e.category.startsWith('_profile'); });
if (real.length < 3) return toast('Add more memories first — AI needs at least 3 entries to suggest a blurb');
if (btn) { btn.textContent = '⏳ Thinking…'; btn.disabled = true; }
var sample = real.slice(0, 20).map(function(e){ return e.answer; }).join(' ').substring(0, 3000);
var name = userProfile.name || 'the author';
var prompt = 'Write a compelling back-cover blurb (2-3 sentences, max 60 words) for a personal memoir by ' + name + '. '
+ 'Draw on these memoir entries to make it specific and warm. '
+ 'Write in third person. Return only the blurb text, no quotes or preamble. '
+ 'MEMOIR ENTRIES: ' + sample;
try {
var blurb = await callAI(prompt, { max_tokens: 200 });
var el = document.getElementById('bdBlurb');
if (el) { el.value = blurb.trim(); bookDetailsChanged(); }
toast('✨ Blurb suggested — edit to taste, then Save');
} catch(err) {
dbgErr('BOOKSETTINGS', 'suggestBlurb failed:', err.message);
toast('Could not suggest blurb — try again');
} finally {
if (btn) { btn.textContent = '✨ Suggest'; btn.disabled = false; }
}
}
function toggleFunny() {
funnyMode = !funnyMode;
const btn = document.getElementById('funnyToggleBtn');
const lbl = document.getElementById('funnyToggleLabel');
lbl.textContent = funnyMode ? 'Funny: On' : 'Funny: Off';
btn.style.borderColor = funnyMode ? 'var(--gold)' : 'var(--border)';
btn.style.background = funnyMode ? 'rgba(201,169,110,0.12)' : 'var(--surface)';
btn.style.color = funnyMode ? 'var(--gold-dark)' : 'var(--text)';
}
async function generateStory(mode) {
if (!currentUser) return toast('Please sign in first');
const realEntries = entries.filter(e => !e.category.startsWith('_profile'));
if (!realEntries.length) return toast('Add some entries first!');
logActivity('story_generated', mode, 'info', 'AI');
const out = document.getElementById('storyOutput');
const skeleton = document.getElementById('storySkeletonLoader');
out.style.display = 'none';
skeleton.style.display = 'block';
try {
let profileContext = '';
if (userProfile.name || userProfile.dob || userProfile.sex) {
profileContext = 'ABOUT THIS PERSON:\n';
if (userProfile.name) profileContext += '- Name: ' + userProfile.name + '\n';
if (userProfile.sex) profileContext += '- Sex: ' + userProfile.sex + '\n';
if (userProfile.dob) {
const dob = new Date(userProfile.dob);
const year = dob.getFullYear();
const age = new Date().getFullYear() - year;
const dobFmt = dob.toLocaleDateString('en-AU',{day:'numeric',month:'long',year:'numeric'});
profileContext += '- Date of birth: ' + dobFmt + ' (age ' + age + ')\n';
profileContext += '- Birth year: ' + year + ' — weave in world events, music, fashion of that era.\n';
}
profileContext += '\n';
}
const photoEntries = realEntries.filter(e => e.photo_url);
const entriesText = realEntries.map((e,i) => {
let line = (i+1) + '. [' + e.category + ']';
if (e.title) line += ' [Title: ' + e.title + ']';
if (e.era) line += ' [Era: ' + e.era + ']';
if (e.location) line += ' [Location: ' + e.location + ']';
if (e.people) line += ' [People: ' + e.people + ']';
if (e.chapter) line += ' [Chapter: ' + e.chapter + ']';
if (e.photo_url) { line += ' [PHOTO'; if (e.photo_ai_description) line += ' — AI sees: ' + e.photo_ai_description; line += ']'; }
if (e.question && e.question !== 'Tell me about this photo') line += ' Q: ' + cleanQuestion(e.question) + ' —';
line += ' ' + e.answer;
return line;
}).join('\n');
const byChapter = {};
realEntries.forEach(e => {
const ch = e.chapter || e.category || 'Unspecified';
if (!byChapter[ch]) byChapter[ch] = [];
byChapter[ch].push(e);
});
const chapterText = Object.entries(byChapter).map(([ch, ents]) =>
'=== ' + ch.toUpperCase() + ' ===\n' +
ents.map((e,i) => {
let line = (i+1) + '.';
if (e.title) line += ' [' + e.title + ']';
if (e.question && e.question !== 'Tell me about this photo') line += ' Q: ' + cleanQuestion(e.question) + ' —';
line += ' ' + e.answer;
return line;
}).join('\n')
).join('\n\n');
const base = profileContext;
const funny = funnyMode; // read toggle state
const funnyPrefix = funny
? 'You are a brilliant comedy writer. Write in a warm, affectionate roast style — funny but never mean. Poke gentle fun, exaggerate for comic effect, use wit and irony. Include funny observations about the era they grew up in. '
: '';
let prompt, maxTok;
const wordCount = realEntries.reduce((s,e) => s + (e.answer||'').split(/\s+/).filter(Boolean).length, 0);
const strictNote = funny ? '' :
'CRITICAL RULES FOR FACTUAL WRITING:\n' +
'- Write ONLY what is stated in the entries. Do NOT invent, embellish or assume any facts.\n' +
'- If a detail is not in the entries, do not include it.\n' +
'- You may use warm, literary language but every fact must come directly from the entries.\n\n';
if (mode === 'blurb') {
prompt = base + funnyPrefix + strictNote +
(funny
? 'Write a single hilariously funny paragraph (150–200 words) — the roast back-cover of this person\'s life. Third person. Return only the paragraph.\n\nLIFE ENTRIES:\n' + entriesText
: 'Write a single evocative paragraph (150–200 words) — the back-cover blurb of this person\'s life. Capture their essence based only on what is written. Third person, warm. Return only the paragraph.\n\nLIFE ENTRIES:\n' + entriesText);
maxTok = 500;
} else if (mode === 'short') {
if (!funny && wordCount < 50) {
skeleton.style.display = 'none';
out.style.display = 'block'; out.className = '';
out.innerHTML = `
📝
Not enough memories yet
The Short Version needs at least a few memories to work with. Add more in the Memories tab, or try the Funny version which works great even with just a few entries!
➕ Add more memories
`; return;
}
prompt = base + funnyPrefix + strictNote +
'Write a ' + (funny ? 'funny roast-style' : 'warm factual') + ' 3–5 paragraph memoir hitting the most meaningful highlights. ' +
'Flowing prose, no headings. Third person. Return only the story.\n\nLIFE ENTRIES:\n' + entriesText;
maxTok = 1500;
} else if (mode === 'extended') {
if (!funny && wordCount < 150) {
skeleton.style.display = 'none';
out.style.display = 'block'; out.className = '';
out.innerHTML = `
📖
More memories needed for The Full Story
The Full Story covers every memory in depth — you currently have ~${wordCount} words of memories. Aim for at least 150 words across your entries for a satisfying read. Keep adding to the Memories tab!
➕ Add more memories
😄 Try a Funny Short Version
`; return;
}
prompt = base + funnyPrefix + strictNote +
'Write a ' + (funny ? 'hilarious roast-style' : 'full factual') + ' memoir covering EVERY entry — do not skip or summarise briefly. ' +
'Give each memory its own space. Flowing prose, no headings. Third person. ' +
'Aim for at least one substantial paragraph per memory. Return only the story.\n\nLIFE ENTRIES:\n' + entriesText;
maxTok = 8000;
} else if (mode === 'chapters') {
if (!funny && wordCount < 150) {
skeleton.style.display = 'none';
out.style.display = 'block'; out.className = '';
out.innerHTML = `
📚
More memories needed for the Book Edition
The Book Edition organises your memories into chapters — you need enough entries across multiple chapters to make it worthwhile. Keep adding memories and tagging them with chapters!
➕ Add more memories
`; return;
}
prompt = base + funnyPrefix + strictNote +
'Write a ' + (funny ? 'funny roast-style' : 'factual') + ' memoir organised as a chapter book. Entries are grouped by chapter below. ' +
'Write each chapter as a named section — put the chapter heading on its own line prefixed with ##. ' +
'Within each chapter, flowing prose covering every entry — facts only, nothing invented. No bullet points. Third person. ' +
'Return only the story.\n\nENTRIES BY CHAPTER:\n' + chapterText;
maxTok = 8000;
} else if (mode === 'journal') {
skeleton.style.display = 'none';
const sorted = realEntries.slice().sort((a, b) => {
const da = a.date ? new Date(a.date) : new Date(a.timestamp);
const db = b.date ? new Date(b.date) : new Date(b.timestamp);
return da - db;
});
let html = '
';
let lastChapter = null;
sorted.forEach(e => {
const ch = e.chapter || '';
if (ch && ch !== lastChapter) {
html += '
' + escHtml(ch) + ' ';
lastChapter = ch;
}
html += '
';
const dateLabel = e.date ? (function(d){ try { return new Date(d).toLocaleDateString('en-AU',{day:'numeric',month:'long',year:'numeric'}); } catch(x){ return d; } })(e.date) : (e.era || null);
if (dateLabel) html += '
' + escHtml(dateLabel) + '
';
if (e.title) html += '
' + escHtml(e.title) + '
';
if (e.photo_url) html += '
';
html += '
' + escHtml(e.answer || '') + '
';
const peopleTxt = Array.isArray(e.people) ? e.people.join(', ') : (e.people || '');
const tagParts = [];
if (e.location) tagParts.push('📍\u00a0' + escHtml(e.location));
if (peopleTxt) tagParts.push('👥\u00a0' + escHtml(peopleTxt));
if (tagParts.length) html += '
' + tagParts.map(t => '' + t + ' ').join('') + '
';
html += '
';
});
html += '
';
out.style.display = 'block'; out.className = '';
out.innerHTML = html;
currentStory = { text: sorted.map(e => (e.title ? e.title + '\n' : '') + e.answer).join('\n\n---\n\n'), mode: 'journal', funny: false, funnyCaptions: {} };
document.getElementById('exportPdfBtn').style.display = 'inline-block';
document.getElementById('printBookBtn').style.display = 'inline-block';
const nudge = document.getElementById('storyNudge');
if (nudge) nudge.style.display = 'none';
return;
}
const story = await callAI(prompt, { max_tokens: maxTok });
let funnyCaptions = {};
if (funny && photoEntries.length) {
await Promise.all(photoEntries.map(async (e) => {
try {
const cap = await callAI(
'Write a witty one-liner caption for a photo in a funny memoir. Warm, not mean. 2 sentences max. ' +
(e.photo_ai_description ? 'Photo shows: ' + e.photo_ai_description + '\n' : '') +
'Person says: "' + e.answer + '"\nReturn only the caption.',
{ max_tokens: 100 }
);
funnyCaptions[e.timestamp] = cap;
} catch(err) { dbgWarn('STORY','Caption failed'); }
}));
}
currentStory = { text: story, mode, funny, funnyCaptions };
skeleton.style.display = 'none';
await renderStory(story, photoEntries, mode, funnyCaptions, funny);
document.getElementById('exportPdfBtn').style.display = 'inline-block';
document.getElementById('printBookBtn').style.display = 'inline-block';
const nudge = document.getElementById('storyNudge');
if (nudge) nudge.style.display = 'none';
} catch (err) {
dbgErr('STORY', err);
skeleton.style.display = 'none';
out.style.display = 'block';
out.innerHTML = '
Could not generate story — please try again.
';
}
}
async function renderStory(story, photoEntries, mode, funnyCaptions, funnyOverride) {
const out = document.getElementById('storyOutput');
out.style.display = 'block';
out.className = '';
const photos = photoEntries || entries.filter(e => e.photo_url);
const caps = funnyCaptions || {};
const funny = funnyOverride !== undefined ? funnyOverride : (mode === 'funny');
const name = userProfile.name || 'Their';
const photosWithUrls = await Promise.all(photos.map(async e => ({
...e, signedUrl: await getSignedUrl(e.photo_url)
})));
const processedStory = story.replace(/^##\s*(.+)$/gm, '\x00CHAPTERHEAD:$1\x00');
const paragraphs = processedStory.split('\n').filter(p => p.trim());
const titleHtml =
'
' + (name !== 'Their' ? name + '\'s ' : '') + (funny ? 'Life Story (The Comedy Edition)' : 'Life Story') + '
' +
'
' + (funny ? '😄 A loving roast' : '✨ A personal memoir') + ' · ' + new Date().toLocaleDateString('en-AU', {day:'numeric',month:'long',year:'numeric'}) + '
';
const essentialsHtml = (mode === 'extended' || mode === 'chapters') ? buildEssentialsPage() : '';
function renderPara(p) {
if (p.startsWith('\x00CHAPTERHEAD:') && p.endsWith('\x00')) {
return '
' + p.slice(13,-1) + ' ';
}
return '
' + p + '
';
}
if (!photosWithUrls.length) {
let bodyHtml = '';
paragraphs.forEach((p, i) => {
bodyHtml += renderPara(p);
if (i === 2 && paragraphs.length > 5) {
const sentences = p.split('. ');
const pullSentence = sentences[Math.floor(sentences.length / 2)] || sentences[0];
if (pullSentence.length > 40) {
bodyHtml += '
"' + pullSentence.trim().replace(/[.,"]+$/, '') + '"
';
}
}
if (i === Math.floor(paragraphs.length / 2) && i > 3) {
bodyHtml += '
— ✦ —
';
}
});
out.innerHTML = titleHtml + essentialsHtml + '
' + bodyHtml + '
';
return;
}
const buildPhotoBlock = (photo) => {
if (!photo.signedUrl) return '';
const caption = caps[photo.timestamp];
const userCapt = photo.answer.substring(0,80) + (photo.answer.length > 80 ? '...' : '');
return '
' +
'
' +
(funny && caption
? '
😄 ' + caption + '
'
: '
' + userCapt + '
'
) +
'
';
};
let photoIndex = 0;
const spacing = Math.max(1, Math.floor(paragraphs.length / photosWithUrls.length));
let bodyHtml = '';
paragraphs.forEach((para, i) => {
if (photoIndex < photosWithUrls.length && i > 0 && i % spacing === 0) {
bodyHtml += buildPhotoBlock(photosWithUrls[photoIndex++]);
}
bodyHtml += renderPara(para);
if (i === 2 && paragraphs.length > 5) {
const sentences = para.split('. ');
const pullSentence = sentences[Math.floor(sentences.length / 2)] || sentences[0];
if (pullSentence.length > 40) {
bodyHtml += '
"' + pullSentence.trim().replace(/[.,"]+$/, '') + '"
';
}
}
if (i === Math.floor(paragraphs.length / 2) && i > 3) {
bodyHtml += '
— ✦ —
';
}
});
while (photoIndex < photosWithUrls.length) {
const photo = photosWithUrls[photoIndex++];
bodyHtml += '
' +
(photo.signedUrl ? '
' : '') +
(funny && caps[photo.timestamp]
? '
😄 ' + caps[photo.timestamp] + '
'
: '
' + photo.answer.substring(0,80) + '
'
) + '
';
}
out.innerHTML = titleHtml + essentialsHtml + '
' + bodyHtml + '
';
}
const TAG_DEFAULTS = {
year: ['1950s','1960s','1970s','1980s','1990s','2000s','2010s','2020s'],
location: ['Home','School','Work','Overseas','Hospital','Church','Beach','Countryside'],
people: ['Mum','Dad','Partner','Best friend','Brother','Sister','Grandparent','Colleague'],
chapter: ['Early Childhood','School Years','Young Adult','Career','Relationships','Family Life','Travel & Adventure','Later Life','Reflections']
};
function getTagValues() {
const result = {
year: new Set(TAG_DEFAULTS.year),
location: new Set(TAG_DEFAULTS.location),
people: new Set(TAG_DEFAULTS.people),
chapter: new Set(TAG_DEFAULTS.chapter)
};
entries.forEach(e => {
if (e.era) result.year.add(e.era);
if (e.location) result.location.add(e.location);
if (e.people) result.people.add(e.people);
if (e.chapter) result.chapter.add(e.chapter);
if (e.question) {
const m = e.question.match(/\[tags: (.+?)\]/);
if (m) m[1].split(' | ').forEach(part => {
const idx = part.indexOf(':');
if (idx > -1) {
const k = part.substring(0, idx), v = part.substring(idx + 1);
if (k === 'year') result.year.add(v);
if (k === 'location') result.location.add(v);
if (k === 'people') result.people.add(v);
if (k === 'chapter') result.chapter.add(v);
}
});
}
});
return result;
}
function populateTagDropdowns(prefix) {
const vals = getTagValues();
[['Year','year'],['Location','location'],['Chapter','chapter']].forEach(([label, key]) => {
const sel = document.getElementById(prefix + 'Tag' + label);
if (!sel) return;
const cur = sel.value;
sel.innerHTML = '
Not specified ' +
[...vals[key]].sort().map(v => '
' + v + ' ').join('') +
'
+ Add new... ';
if (cur) sel.value = cur;
});
}
function handleNewTagOption(sel, customInputId) {
const inp = document.getElementById(customInputId);
if (!inp) return;
if (sel.value === '__new__') {
inp.style.display = 'block'; inp.focus(); sel.value = '';
} else {
inp.style.display = 'none';
}
}
function readTags(prefix) {
const fields = ['Year','Location','Chapter'];
const keys = ['year','location','chapter'];
const tags = {};
const titleEl = document.getElementById(prefix + 'TagTitle');
if (titleEl && titleEl.value.trim()) tags.title = titleEl.value.trim();
const dateEl = document.getElementById(prefix + 'TagDate');
if (dateEl && dateEl.value) tags.date = dateEl.value;
const peopleEl = document.getElementById(prefix + 'TagPeople');
if (peopleEl && peopleEl.value.trim()) tags.people = peopleEl.value.trim();
const refEl = document.getElementById(prefix + 'TagRef');
const refUrlEl = document.getElementById(prefix + 'TagRefUrl');
if (refEl && refEl.value.trim()) tags.reference = refEl.value.trim();
if (refUrlEl && refUrlEl.value.trim()) tags.reference_url = refUrlEl.value.trim();
fields.forEach((f, i) => {
const sel = document.getElementById(prefix + 'Tag' + f);
const inp = document.getElementById(prefix + 'Tag' + f + 'Custom');
const val = (inp && inp.style.display !== 'none' && inp.value.trim()) ? inp.value.trim() : (sel ? sel.value : '');
if (val && val !== '__new__') tags[keys[i]] = val;
});
return tags;
}
let _tagMgr = { type: null, prefix: null, entryId: null, items: [] };
function openTagManager(type, prefix, entryId) {
_tagMgr = { type, prefix, entryId: entryId || null, items: [] };
const overlay = document.getElementById('tagManagerOverlay');
const title = document.getElementById('tagManagerTitle');
const input = document.getElementById('tagManagerInput');
const urlRow = document.getElementById('tagManagerUrlRow');
if (type === 'people') {
title.textContent = '👥 Manage People';
input.placeholder = 'Add a name e.g. Mum, Dad...';
urlRow.style.display = 'none';
const hiddenId = entryId ? prefix + '_' + entryId + 'TagPeople' : prefix + 'TagPeople';
const existing = document.getElementById(hiddenId)?.value || '';
_tagMgr.items = existing.split(',').map(s=>s.trim()).filter(Boolean).map(s=>({label:s,url:''}));
} else {
title.textContent = '🔗 Manage References';
input.placeholder = 'Add a reference e.g. World War 2...';
urlRow.style.display = 'block';
const hiddenId = entryId ? prefix + '_' + entryId + 'TagRef' : prefix + 'TagRef';
const urlId = entryId ? prefix + '_' + entryId + 'TagRefUrl' : prefix + 'TagRefUrl';
const existing = document.getElementById(hiddenId)?.value || '';
const existingUrls = document.getElementById(urlId)?.value || '';
const labels = existing ? existing.split('|||') : [];
const urls = existingUrls ? existingUrls.split('|||') : [];
_tagMgr.items = labels.map((label, i) => ({ label, url: urls[i] || '' }));
}
input.value = '';
document.getElementById('tagManagerUrlInput').value = '';
renderTagManagerChips();
overlay.style.display = 'flex';
setTimeout(() => input.focus(), 100);
}
function closeTagManager() {
document.getElementById('tagManagerOverlay').style.display = 'none';
const { type, prefix, entryId } = _tagMgr;
if (type === 'people') {
const val = _tagMgr.items.map(i=>i.label).join(', ');
const hiddenId = entryId ? prefix + '_' + entryId + 'TagPeople' : prefix + 'TagPeople';
const hidden = document.getElementById(hiddenId);
if (hidden) hidden.value = val;
const chipsId = entryId ? prefix + 'PeopleChips' + entryId : prefix + 'PeopleChips';
renderTagChipManager(chipsId, _tagMgr.items.map(i=>i.label), type, prefix, entryId);
} else {
const labels = _tagMgr.items.map(i=>i.label).join('|||');
const urls = _tagMgr.items.map(i=>i.url||'').join('|||');
const hiddenId = entryId ? prefix + '_' + entryId + 'TagRef' : prefix + 'TagRef';
const urlId = entryId ? prefix + '_' + entryId + 'TagRefUrl' : prefix + 'TagRefUrl';
const hRef = document.getElementById(hiddenId);
const hUrl = document.getElementById(urlId);
if (hRef) hRef.value = labels;
if (hUrl) hUrl.value = urls;
const chipsId = entryId ? prefix + 'RefChips' + entryId : prefix + 'RefChips';
renderRefChipManager(chipsId, _tagMgr.items, prefix, entryId);
}
}
function addTagManagerItem() {
const label = document.getElementById('tagManagerInput').value.trim();
if (!label) return;
const url = (_tagMgr.type === 'ref') ? (document.getElementById('tagManagerUrlInput').value.trim() || '') : '';
if (_tagMgr.items.some(i => i.label.toLowerCase() === label.toLowerCase())) {
toast('Already added'); return;
}
_tagMgr.items.push({ label, url });
document.getElementById('tagManagerInput').value = '';
document.getElementById('tagManagerUrlInput').value = '';
document.getElementById('tagManagerInput').focus();
renderTagManagerChips();
}
function removeTagManagerItem(idx) {
_tagMgr.items.splice(idx, 1);
renderTagManagerChips();
}
function renderTagManagerChips() {
const container = document.getElementById('tagManagerChips');
if (!container) return;
if (!_tagMgr.items.length) {
container.innerHTML = '
None added yet ';
return;
}
container.innerHTML = _tagMgr.items.map((item, i) =>
'
' +
(item.url ? '' + item.label + ' ↗ ' : item.label) +
'✕ ' +
' '
).join('');
}
function renderTagChipManager(containerId, items, type, prefix, entryId) {
const el = document.getElementById(containerId);
if (!el) return;
const btnOnclick = entryId
? `openTagManager('people','${prefix}','${entryId}')`
: `openTagManager('people','${prefix}')`;
const addBtn = `
+ Tag person `;
if (!items || !items.length) { el.innerHTML = addBtn; return; }
el.innerHTML = items.map(item =>
'
' +
item +
' '
).join('') + addBtn;
}
function renderRefChipManager(containerId, items, prefix, entryId) {
const el = document.getElementById(containerId);
if (!el) return;
const btnOnclick = entryId
? `openTagManager('ref','${prefix}','${entryId}')`
: `openTagManager('ref','${prefix}')`;
const addBtn = `
+ Add reference `;
if (!items || !items.length) { el.innerHTML = addBtn; return; }
el.innerHTML = items.map(item => {
const href = item.url && !/^https?:\/\//i.test(item.url) ? 'https://' + item.url : item.url;
return '
' +
(href ? '' + item.label + ' ↗ ' : item.label) +
' ';
}).join('') + addBtn;
}
function toggleRefFields() {
const fields = document.getElementById('recRefFields');
const btn = document.getElementById('recRefToggleBtn');
if (!fields) return;
const showing = fields.style.display !== 'none';
fields.style.display = showing ? 'none' : 'block';
btn.textContent = showing ? '🔗 Add reference context' : '🔗 Remove reference';
}
function setTagDateToday() {
const d = document.getElementById('recTagDate');
if (d) { d.value = new Date().toISOString().split('T')[0]; onTagDateChange(); }
}
function clearTagDate() {
const d = document.getElementById('recTagDate');
if (d) d.value = '';
}
function onTagDateChange() {
var d = document.getElementById('recTagDate');
if (!d || !d.value) return;
var yearSel = document.getElementById('recTagYear');
var yearCustom = document.getElementById('recTagYearCustom');
if (yearSel && !yearSel.value && yearCustom && !yearCustom.value) {
var year = d.value.split('-')[0];
var decade = year.slice(0,3) + '0s';
var found = false;
for (var i = 0; i < yearSel.options.length; i++) {
if (yearSel.options[i].value === decade) { yearSel.value = decade; found = true; break; }
}
if (!found) { yearCustom.value = year; yearCustom.style.display = 'block'; }
}
flagDateWarning(d.value);
}
function flagDateWarning(dateVal) {
var existing = document.getElementById('recDateWarning');
if (existing) existing.remove();
if (!dateVal) return;
var year = parseInt(dateVal.split('-')[0], 10);
var today = new Date();
var thisYear = today.getFullYear();
var chapter = (document.getElementById('recTagChapter')?.value || '').toLowerCase();
var category = (document.getElementById('recCategory')?.value || '').toLowerCase();
var context = chapter + ' ' + category;
var warning = null;
var isChildhoodContext = /child|early|school|family|birth|grow|youth|young/.test(context);
var isRecentDate = year >= thisYear - 1;
var isVeryOldDate = year < 1900;
if (isChildhoodContext && isRecentDate) {
warning = 'This date looks recent — is this a childhood memory? Check the year.';
} else if (isVeryOldDate) {
warning = 'Date is before 1900 — double-check this is correct.';
} else {
var eraVal = document.getElementById('recTagYear')?.value ||
document.getElementById('recTagYearCustom')?.value || '';
if (eraVal) {
var eraYear = parseInt(eraVal.replace(/s$/,''), 10);
if (!isNaN(eraYear) && Math.abs(year - eraYear) > 15) {
warning = "Date (" + year + ") does not match Era (" + eraVal + ") — one may be wrong.";
}
}
}
if (warning) {
var dateField = document.getElementById('recTagDate');
if (!dateField) return;
var w = document.createElement('div');
w.id = 'recDateWarning';
w.style.cssText = 'margin-top:4px;font-size:0.75rem;color:var(--rose);display:flex;gap:4px;align-items:flex-start';
w.innerHTML = '
⚠️ ' + warning + ' ';
dateField.parentNode.insertBefore(w, dateField.nextSibling);
}
}
function resetTagFields(prefix) {
const titleEl = document.getElementById(prefix + 'TagTitle');
if (titleEl) titleEl.value = '';
const dateEl = document.getElementById(prefix + 'TagDate');
if (dateEl) dateEl.value = '';
const peopleEl = document.getElementById(prefix + 'TagPeople');
if (peopleEl) peopleEl.value = '';
const peopleChips = document.getElementById(prefix + 'PeopleChips');
if (peopleChips) renderTagChipManager(prefix + 'PeopleChips', [], 'people', prefix, null);
const refEl = document.getElementById(prefix + 'TagRef');
const refUrlEl = document.getElementById(prefix + 'TagRefUrl');
if (refEl) refEl.value = '';
if (refUrlEl) refUrlEl.value = '';
const refChips = document.getElementById(prefix + 'RefChips');
if (refChips) renderRefChipManager(prefix + 'RefChips', [], prefix, null);
if (prefix === 'rec') renderRecPhotoStrip();
['Year','Location','Chapter'].forEach(f => {
const sel = document.getElementById(prefix + 'Tag' + f);
const inp = document.getElementById(prefix + 'Tag' + f + 'Custom');
if (sel) sel.value = '';
if (inp) { inp.value = ''; inp.style.display = 'none'; }
});
}
function applyTagDefaults(prefix, tags, entryId) {
if (!tags || !Object.keys(tags).length) return;
const titleEl = document.getElementById(prefix + 'TagTitle');
if (titleEl && tags.title) titleEl.value = tags.title;
const dateEl = document.getElementById(prefix + 'TagDate');
if (dateEl && tags.date) dateEl.value = tags.date;
if (tags.people) {
const hidden = document.getElementById(prefix + (entryId ? '_' + entryId : '') + 'TagPeople') ||
document.getElementById(prefix + 'TagPeople');
if (hidden) hidden.value = tags.people;
const chipsId = entryId ? prefix + 'PeopleChips' + entryId : prefix + 'PeopleChips';
renderTagChipManager(chipsId, tags.people.split(',').map(s=>s.trim()).filter(Boolean), 'people', prefix, entryId||null);
}
if (tags.reference) {
const hidden = document.getElementById(prefix + (entryId ? '_' + entryId : '') + 'TagRef') ||
document.getElementById(prefix + 'TagRef');
if (hidden) hidden.value = tags.reference;
const chipsId = entryId ? prefix + 'RefChips' + entryId : prefix + 'RefChips';
const urls = tags.reference_url ? tags.reference_url.split('|||') : [];
const items = tags.reference.split('|||').map((label,i) => ({ label, url: urls[i]||'' }));
renderRefChipManager(chipsId, items, prefix, entryId||null);
}
const map = { year:'Year', location:'Location', chapter:'Chapter' };
Object.entries(tags).forEach(([k, v]) => {
if (!v) return;
const label = map[k];
if (!label) return;
const idSuffix = entryId ? prefix + '_' + entryId + 'Tag' + label : prefix + 'Tag' + label;
const sel = document.getElementById(idSuffix);
if (!sel) return;
const exists = [...sel.options].some(o => o.value === v);
if (exists) { sel.value = v; }
else {
const inp = document.getElementById(idSuffix + 'Custom');
if (inp) { inp.value = v; inp.style.display = 'block'; }
}
});
}
async function saveEntryTags(entryId, prefix) {
if (!entryId) return toast('Cannot update this entry — missing id');
const pfx = (prefix || 'edit') + '_' + entryId;
const tags = readTags(pfx);
try {
const res = await fetch(DB_URL + '?id=eq.' + entryId + '&user_id=eq.' + currentUser.id, {
method: 'PATCH',
headers: { ...getAuthHeaders(), 'Prefer': 'return=minimal' },
body: JSON.stringify({
era: tags.year || null,
location: tags.location || null,
people: tags.people || null,
chapter: tags.chapter || null,
title: tags.title || null,
date: tags.date || null,
reference: tags.reference || null,
reference_url: tags.reference_url || null
})
});
if (!res.ok) { dbgErr('DB', 'saveEntryTags failed:', res.status, 'entryId:', entryId); toast('Could not save tags'); return; }
const idx = entries.findIndex(e => e.id === entryId);
if (idx > -1) {
entries[idx].era = tags.year || null;
entries[idx].location = tags.location || null;
entries[idx].people = tags.people || null;
entries[idx].chapter = tags.chapter || null;
entries[idx].title = tags.title || null;
entries[idx].date = tags.date || null;
entries[idx].reference = tags.reference || null;
entries[idx].reference_url = tags.reference_url || null;
}
toast('Tags saved ✓');
const queued = _editPanelPhotos[entryId] || [];
if (queued.length) {
toast('Uploading ' + queued.length + ' photo' + (queued.length>1?'s':'') + '...');
const entry = entries.find(e => e.id === entryId);
for (const p of queued) {
const up = await uploadPhotoFile(p.file);
if (up) await addEntry(entry?.chapter || 'Unspecified', p.caption || 'Photo', null, up.path, up.desc, getEntryTags(entry) || {}, entryId);
}
_editPanelPhotos[entryId] = [];
await loadEntries();
toast('Photos saved ✓');
}
const panel = document.getElementById('edit-' + entryId) || document.getElementById('redit-' + entryId) || document.getElementById('cdedit-' + entryId);
if (panel) panel.style.display = 'none';
if (document.getElementById('panel-entries').classList.contains('active')) renderReviewEntries(); if (document.getElementById('panel-dashboard').classList.contains('active')) renderDashboard();
else renderTimeline();
} catch(err) { dbgErr('DB', 'saveEntryTags threw:', err.message); toast('Error: ' + err.message); }
}
const _entryTagsRegistry = {};
const _chapterRegistry = {}; // chapter name -> entries array
function registerEntryTags(entryId, tags) {
_entryTagsRegistry[entryId] = tags || {};
}
function toggleEditTags(entryId, inReview) {
const panelId = inReview ? 'redit-' + entryId : 'edit-' + entryId;
const prefix = inReview ? 'redit_' + entryId : 'edit_' + entryId;
const panel = document.getElementById(panelId);
if (!panel) return;
if (panel.style.display === 'none' || !panel.style.display) {
const currentTags = _entryTagsRegistry[entryId] || {};
populateTagDropdowns(prefix);
applyTagDefaults(prefix, currentTags);
panel.style.display = 'grid';
panel.style.gridTemplateColumns = '1fr 1fr 1fr';
loadEditPanelPhotoThumbs(entryId);
} else {
panel.style.display = 'none';
}
}
function getEntryTags(e) {
const tags = {};
if (e.title) tags.title = e.title;
if (e.era) tags.year = e.era;
if (e.location) tags.location = e.location;
if (e.people) tags.people = e.people;
if (e.chapter) tags.chapter = e.chapter;
if (e.date) tags.date = e.date;
if (e.reference) tags.reference = e.reference;
if (e.reference_url) tags.reference_url = e.reference_url;
if (!Object.keys(tags).length && e.question) {
const m = e.question.match(/\[tags: (.+?)\]/);
if (m) m[1].split(' | ').forEach(part => {
const idx = part.indexOf(':');
if (idx > -1) tags[part.substring(0,idx)] = part.substring(idx+1);
});
}
return tags;
}
function buildTagChips(tags) {
if (!tags || !Object.keys(tags).length) return '';
const icons = { title:'🏷️', year:'📅', location:'📍', people:'👥', chapter:'📚', date:'🗓️', reference:'🔗' };
const skip = ['reference_url']; // handled inline with reference
const chips = [];
Object.entries(tags).forEach(([k, v]) => {
if (skip.includes(k)) return;
if (k === 'reference') {
const labels = v.split('|||');
const urls = tags.reference_url ? tags.reference_url.split('|||') : [];
labels.forEach((label, i) => {
const url = urls[i] ? urls[i].trim() : '';
const href = url && !/^https?:\/\//i.test(url) ? 'https://' + url : url;
chips.push(href
? `
🔗 ${label.trim()} ↗ `
: `
🔗 ${label.trim()} `
);
});
} else {
chips.push(`
${icons[k]||''} ${v} `);
}
});
return chips.length ? '
' + chips.join('') + '
' : '';
}
function buildEditPanel(entryId, prefix) {
if (!entryId) return '';
const photoChildren = entries.filter(e => e.parent_entry_id === entryId && e.photo_url);
const photosHtml = photoChildren.length
? photoChildren.map((p, i) =>
`
${p.answer?.substring(0,12)||'Photo'}
`
).join('')
: '';
return `
✏️ Edit tags & photos
🏷️ Title
📚 Chapter
Not specified
📅 Era
Not specified
🗓️ Date
📍 Location
Not specified
🔗 References
+ Add reference
📷 Photos
${photosHtml}
+ Add photo
]+>/g, '').trim();
if (!storyText || storyText.length < 50) throw new Error('Story is empty — please generate a story first');
const name = userProfile.name || 'My';
const title = currentStory.funny ? name + "\'s Life Story (Comedy Edition)" : name + "\'s Life Story";
let pageCount = Math.max(20, Math.ceil(storyText.length / 1800));
const { jsPDF } = window.jspdf;
const doc = new jsPDF({ unit: 'mm', format: 'a5', orientation: 'portrait' });
const pageW = doc.internal.pageSize.getWidth();
const pageH = doc.internal.pageSize.getHeight();
const marginL = 20, marginR = 15, marginT = 20, marginB = 20;
const textW = pageW - marginL - marginR;
let y = marginT;
function addPageIfNeeded(lineH) {
if (y + lineH > pageH - marginB) { doc.addPage(); y = marginT; }
}
doc.setFont('helvetica', 'bold');
doc.setFontSize(18);
const titleLines = doc.splitTextToSize(title, textW);
titleLines.forEach(line => { addPageIfNeeded(9); doc.text(line, pageW/2, y, { align: 'center' }); y += 9; });
y += 4;
doc.setFont('helvetica', 'italic');
doc.setFontSize(9);
doc.setTextColor(139, 115, 85);
const dateStr = 'Generated by LifeTold · ' + new Date().toLocaleDateString('en-AU', {day:'numeric',month:'long',year:'numeric'});
doc.text(dateStr, pageW/2, y, { align: 'center' });
y += 4;
doc.setDrawColor(232, 220, 200);
doc.line(marginL, y, pageW - marginR, y);
y += 8;
doc.setTextColor(44, 36, 22);
const bodyHtml = storyHtml
.replace(/
]*>(.*?)<\/h2>/gi, '\n\n##$1\n\n')
.replace(/<\/p>/gi, '\n')
.replace(/ /gi, '\n')
.replace(/<[^>]+>/g, '')
.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/ /g,' ').replace(/'/g,"'").replace(/"/g,'"');
const paragraphs = bodyHtml.split('\n').map(p => p.trim()).filter(p => p.length > 0);
paragraphs.forEach(para => {
if (para.startsWith('##')) {
y += 4;
addPageIfNeeded(10);
doc.setFont('helvetica', 'bold');
doc.setFontSize(13);
doc.setTextColor(44, 36, 22);
const heading = para.replace(/^##\s*/, '');
const hLines = doc.splitTextToSize(heading, textW);
hLines.forEach(line => { addPageIfNeeded(7); doc.text(line, marginL, y); y += 7; });
doc.setDrawColor(201, 169, 110);
doc.line(marginL, y, pageW - marginR, y);
y += 6;
} else {
doc.setFont('helvetica', 'normal');
doc.setFontSize(10);
doc.setTextColor(44, 36, 22);
const lines = doc.splitTextToSize(para, textW);
lines.forEach(line => { addPageIfNeeded(5.5); doc.text(line, marginL, y); y += 5.5; });
y += 3;
}
});
addPageIfNeeded(10);
y += 4;
doc.setDrawColor(232, 220, 200);
doc.line(marginL, y, pageW - marginR, y);
y += 5;
doc.setFont('helvetica', 'italic');
doc.setFontSize(8);
doc.setTextColor(201, 169, 110);
doc.text('Created with LifeTold ♥', pageW/2, y, { align: 'center' });
const PEECHO_MIN_PAGES = 20;
const currentPages = doc.internal.pages.length - 1;
if (currentPages < PEECHO_MIN_PAGES) {
const blanksNeeded = PEECHO_MIN_PAGES - currentPages;
for (let i = 0; i < blanksNeeded; i++) doc.addPage();
}
const blob = doc.output('blob');
pageCount = doc.internal.pages.length - 1; // actual page count from jsPDF
if (blob.size < 5000) throw new Error('PDF generation failed — output too small (' + blob.size + ' bytes)');
if (printBookBtn) printBookBtn.textContent = '☁️ Uploading…';
if (printBookBtn) printBookBtn.textContent = '🖼️ Generating preview…';
let thumbnailUrl = null;
try {
const canvas = document.createElement('canvas');
canvas.width = 420; canvas.height = 594;
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#fff';
ctx.fillRect(0, 0, 420, 594);
ctx.fillStyle = '#c9a96e';
ctx.fillRect(0, 0, 420, 8);
ctx.fillRect(0, 586, 420, 8);
ctx.fillStyle = '#2c2416';
ctx.font = 'bold 24px Georgia, serif';
ctx.textAlign = 'center';
const thumbTitle = title.length > 35 ? title.substring(0, 33) + '…' : title;
ctx.fillText(thumbTitle, 210, 210);
ctx.font = 'italic 13px Georgia, serif';
ctx.fillStyle = '#8b7355';
ctx.fillText('A personal memoir', 210, 245);
ctx.strokeStyle = '#c9a96e';
ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(60, 268); ctx.lineTo(360, 268); ctx.stroke();
ctx.font = '11px Georgia, serif';
ctx.fillStyle = '#6b5a3e';
ctx.textAlign = 'center';
const preview = storyText.substring(0, 150).replace(/\s+/g, ' ');
const previewWords = preview.split(' ');
let pLine = ''; let thumbY = 295;
previewWords.forEach(word => {
const test = pLine + (pLine ? ' ' : '') + word;
if (ctx.measureText(test).width > 290 && pLine) {
ctx.fillText(pLine, 210, thumbY); thumbY += 18; pLine = word;
} else { pLine = test; }
});
if (pLine) ctx.fillText(pLine + '…', 210, thumbY);
ctx.font = '10px Georgia, serif';
ctx.fillStyle = '#c9a96e';
ctx.fillText('LifeTold', 210, 560);
const thumbBlob = await new Promise(res => canvas.toBlob(res, 'image/jpeg', 0.85));
const thumbName = currentUser.id + '/print-thumb-' + Date.now() + '.jpg';
const thumbRes = await fetch(STORAGE_URL + '/' + thumbName, {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + sessionToken, 'apikey': SUPABASE_ANON,
'Content-Type': 'image/jpeg', 'x-upsert': 'true' },
body: thumbBlob
});
if (thumbRes.ok) {
const signRes = await fetch(STORAGE_SIGN + '/' + thumbName, {
method: 'POST',
headers: { ...getAuthHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify({ expiresIn: 604800 }) // 7 days
});
if (signRes.ok) {
const signData = await signRes.json();
if (signData.signedURL) {
thumbnailUrl = SUPABASE_URL + '/storage/v1' + signData.signedURL;
}
}
}
} catch(thumbErr) {
dbgWarn('PEECHO', 'Thumbnail generation failed (non-fatal):', thumbErr.message);
}
const pdfBlob = blob;
const fileName = currentUser.id + '/print-story-' + Date.now() + '.pdf';
const uploadRes = await fetch(STORAGE_URL + '/' + fileName, {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + sessionToken, 'apikey': SUPABASE_ANON, 'Content-Type': 'application/pdf', 'x-upsert': 'true' },
body: blob
});
if (!uploadRes.ok) throw new Error('Upload failed: ' + await uploadRes.text());
if (!sessionToken) throw new Error('Not signed in — please sign out and back in');
if (printBookBtn) printBookBtn.textContent = '🔗 Getting print link…';
const regRes = await fetch(PEECHO_PROXY_URL, {
method: 'POST',
headers: { ...getAuthHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'registerPrintFile', storagePath: fileName, ttlSeconds: 7200 })
});
if (!regRes.ok) throw new Error('Print link failed: ' + await regRes.text());
const regData = await regRes.json();
if (!regData.proxyUrl) throw new Error('No proxy URL: ' + JSON.stringify(regData));
fetch(PEECHO_PROXY_URL, {
method: 'POST',
headers: { ...getAuthHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'cleanupUserPrintFiles', keepToken: regData.token })
}).then(() => dbg('PEECHO', 'Old print files cleaned up'))
.catch(e => dbgWarn('PEECHO', 'Cleanup failed (non-fatal):', e.message));
const btn = document.getElementById('peechoPrintBtn');
btn.setAttribute('data-src', regData.proxyUrl);
btn.setAttribute('data-pages', String(pageCount));
if (thumbnailUrl) {
btn.setAttribute('data-thumbnail', thumbnailUrl);
} else {
btn.removeAttribute('data-thumbnail');
}
if (window.peecho && window.peecho.attach) {
window.peecho.attach(btn);
} else {
dbgWarn('PEECHO', 'window.peecho not ready — button shown anyway, script may load before click');
}
btn.style.display = 'inline-block';
if (printBookBtn) { printBookBtn.disabled = false; printBookBtn.textContent = '🖨️ Print as Book'; }
} catch(err) {
dbgErr('PEECHO', err.message);
toast('❌ ' + err.message);
if (printBookBtn) { printBookBtn.disabled = false; printBookBtn.textContent = '🖨️ Print as Book'; }
} finally {
}
}
function showReminder() {
const prompts = [
"✨ What's one memory you'd love your grandchildren to know?",
"💭 What was a turning point in your life?",
"📝 Don't forget to add to your life story today!",
"🌟 What's a lesson you'd want to pass on to the next generation?"
];
const r = document.getElementById('reminder');
r.textContent = prompts[Math.floor(Math.random() * prompts.length)];
r.style.display = 'block';
}
var _pwaDeferred = null; // holds the beforeinstallprompt event for Android
var PWA_KEY = 'mls_pwa_dismissed';
function pwaAlreadyHandled() {
try { return !!localStorage.getItem(PWA_KEY); } catch(e) { return true; }
}
function pwaDismiss() {
try { localStorage.setItem(PWA_KEY, '1'); } catch(e) {}
var ios = document.getElementById('pwaIosOverlay');
var and = document.getElementById('pwaAndroidBanner');
if (ios) ios.style.display = 'none';
if (and) and.style.display = 'none';
}
function pwaAndroidInstall() {
if (!_pwaDeferred) return;
_pwaDeferred.prompt();
_pwaDeferred.userChoice.then(function() { pwaDismiss(); });
}
window.addEventListener('beforeinstallprompt', function(e) {
e.preventDefault();
_pwaDeferred = e;
if (pwaAlreadyHandled()) return;
setTimeout(function() {
if (pwaAlreadyHandled()) return;
var banner = document.getElementById('pwaAndroidBanner');
if (banner) banner.style.display = 'block';
}, 4000);
});
(function() {
var isIos = /iphone|ipad|ipod/i.test(navigator.userAgent);
var isInStandaloneMode = window.navigator.standalone === true;
if (isIos && !isInStandaloneMode && !pwaAlreadyHandled()) {
setTimeout(function() {
if (pwaAlreadyHandled()) return;
var overlay = document.getElementById('pwaIosOverlay');
if (overlay) overlay.style.display = 'block';
}, 30000); // 30s delay — user should be engaged before we ask
}
})();
function toast(msg, duration = 3000) {
const t = document.getElementById('toast');
t.textContent = msg; t.classList.add('show');
setTimeout(() => t.classList.remove('show'), duration);
}
init();
function updateOnlineStatus() {
const banner = document.getElementById('offlineBanner');
if (!banner) return;
if (!navigator.onLine) {
banner.style.display = 'block';
dbgWarn('NET', 'Device went offline');
} else {
banner.style.display = 'none';
}
}
window.addEventListener('online', updateOnlineStatus);
window.addEventListener('offline', updateOnlineStatus);
updateOnlineStatus(); // check on load in case already offline
(function() {
var manifest = {
name: 'LifeTold',
short_name: 'LifeTold',
description: 'Every life should be told',
start_url: '/',
display: 'standalone',
background_color: '#faf7f2',
theme_color: '#c9a96e',
orientation: 'portrait-primary',
icons: [
{ src: 'https://lifetold.app/icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any maskable' },
{ src: 'https://lifetold.app/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any maskable' }
]
};
try {
var blob = new Blob([JSON.stringify(manifest)], { type: 'application/json' });
var url = URL.createObjectURL(blob);
var link = document.getElementById('pwaManifest');
if (link) link.href = url;
} catch(e) { }
})();
if ('serviceWorker' in navigator) {
var swCode = [
"const CACHE = 'lifetold-v1';",
"const SHELL = ['/'];",
"self.addEventListener('install', e => {",
" e.waitUntil(caches.open(CACHE).then(c => c.addAll(SHELL)));",
" self.skipWaiting();",
"});",
"self.addEventListener('activate', e => {",
" e.waitUntil(caches.keys().then(keys => Promise.all(",
" keys.filter(k => k !== CACHE).map(k => caches.delete(k))",
" )));",
" self.clients.claim();",
"});",
"self.addEventListener('fetch', e => {",
" if (e.request.method !== 'GET') return;",
" e.respondWith(",
" fetch(e.request).then(r => {",
" var clone = r.clone();",
" caches.open(CACHE).then(c => c.put(e.request, clone));",
" return r;",
" }).catch(() => caches.match(e.request))",
" );",
"});",
].join('\n');
try {
var swBlob = new Blob([swCode], { type: 'application/javascript' });
var swUrl = URL.createObjectURL(swBlob);
navigator.serviceWorker.register(swUrl, { scope: '/' })
.then(function(reg) { dbg('PWA', 'Service worker registered, scope: ' + reg.scope); })
.catch(function(err) { dbgWarn('PWA', 'Service worker registration failed: ' + err); });
} catch(e) { dbgWarn('PWA', 'Service worker blob failed: ' + e); }
}
document.addEventListener('DOMContentLoaded', () => {
['memTitle','memAnswer'].forEach(id => {
const el = document.getElementById(id);
if (el) {
el.addEventListener('input', memCheckButtons);
el.addEventListener('input', () => { _userHasTyped = true; });
}
});
});