Release v0.1.29: media previews, clean composer, regional profiles and cloud retention

This commit is contained in:
Codex
2026-10-04 20:03:44 +08:00
parent 67709832c5
commit 693dd2a10a
17 changed files with 359 additions and 46 deletions
+78 -21
View File
@@ -1,13 +1,47 @@
'use strict';
pageInfo.journal=['个人记事','一言一照,都是阅历。私藏或分享,由自己决定。'];
let journalGeneration=0,journalData=null,journalPage=0,journalUpload=false,journalRequest=null,journalRecorder=null,journalTracks=null,journalVoice=[],journalVoiceTimer=null,journalConfigRevision=0;
const journalImages=new Map();let journalImageBytes=0;
const journalDraftMedia=new Set(),journalSelectedMedia=new Set();
const journalId=()=>[...crypto.getRandomValues(new Uint8Array(16))].map(b=>b.toString(16).padStart(2,'0')).join('');
const journalToday=()=>{const d=new Date();return [d.getFullYear(),String(d.getMonth()+1).padStart(2,'0'),String(d.getDate()).padStart(2,'0')].join('-');};
function journalBusy(value){journalUpload=value;$('#journal-save').disabled=value;$('#journal-new').disabled=value;for(const control of $('#journal-filter').elements)control.disabled=value;$('#journal-prev').disabled=value||!journalData?.page;$('#journal-next').disabled=value||!journalData||(journalData.page+1)*20>=journalData.total;$('#journal-record').disabled=value||!journalData?.connection.connected||!window.isSecureContext||!navigator.mediaDevices?.getUserMedia||!window.MediaRecorder;}
function journalMessage(text){$('#journal-status').textContent=text;}
function journalValid(g){return g===journalGeneration&&loggedIn&&activePage==='journal';}
function journalMediaUrl(id,download=false){return '/media/journal?'+new URLSearchParams({id,family:state.familyId,...(download?{download:'1'}:{})});}
function releaseJournalMedia(root){
root.querySelectorAll('audio,video').forEach(v=>{v.pause();v.removeAttribute('src');v.load();});
root.querySelectorAll('img').forEach(img=>img.removeAttribute('src'));
}
function journalMediaView(m){
const name=esc(m.name),id=esc(m.id),src=esc(journalMediaUrl(m.id));
if(m.mime.startsWith('image/'))return `<figure class="journal-attachment"><button type="button" class="journal-photo" data-preview-media="${id}" aria-label="放大预览 ${name}"><img src="${src}" alt="${name}" loading="lazy" decoding="async"><span class="journal-photo-hint">点击放大</span></button><span class="journal-media-state" role="status">照片加载中…</span><button type="button" class="quiet" data-retry-media hidden>重新加载照片</button></figure>`;
const tag=m.mime.startsWith('audio/')?'audio':'video';
return `<figure class="journal-attachment journal-${tag}">${tag==='audio'?'<div class="journal-audio-label"><span aria-hidden="true">♪</span> 语音与音频</div>':''}<${tag} controls preload="metadata" playsinline src="${src}" aria-label="${name}"></${tag}><span class="journal-media-state" role="status">正在加载${tag==='audio'?'音频':'视频'}…</span><button type="button" class="quiet" data-retry-media hidden>重新加载</button></figure>`;
}
function bindJournalMedia(root){
root.querySelectorAll('.journal-attachment').forEach(box=>{
const media=box.querySelector('img,audio,video'),message=box.querySelector('.journal-media-state'),retry=box.querySelector('[data-retry-media]');
const ready=()=>{message.textContent='';retry.hidden=true;box.classList.remove('media-error');};
media.addEventListener(media.tagName==='IMG'?'load':'loadedmetadata',ready);
media.addEventListener('error',()=>{message.textContent=media.tagName==='IMG'?'照片暂未加载,请重试。':'暂时无法播放,请重试或下载原文件。';retry.hidden=false;box.classList.add('media-error');});
if(media.tagName==='IMG'&&media.complete&&media.naturalWidth)ready();
if(media.tagName!=='IMG'){
if(media.readyState>=1)ready();
media.addEventListener('waiting',()=>{message.textContent='正在缓冲…';});
media.addEventListener('playing',ready);media.addEventListener('canplay',ready);
media.addEventListener('play',()=>{$$('#journal-list audio,#journal-list video,#journal-upload-list audio,#journal-upload-list video,#journal-library-list audio,#journal-library-list video,#journal-preview audio,#journal-preview video').forEach(other=>{if(other!==media)other.pause();});});
}
});
}
function previewJournalMedia(id){
const all=[...(journalData?.uploads||[]),...(journalData?.trash||[]),...(journalData?.items||[]).flatMap(e=>e.media)];
const media=all.find(m=>m.id===id&&m.status==='ready');if(!media)return;
const dialog=$('#journal-preview');releaseJournalMedia($('#journal-preview-content'));
$('#journal-preview-title').textContent=media.name;$('#journal-preview-download').href=journalMediaUrl(id,true);
$('#journal-preview-content').innerHTML=journalMediaView(media);bindJournalMedia($('#journal-preview-content'));if(!dialog.open)dialog.showModal();
}
$('#journal-preview-close').onclick=()=>$('#journal-preview').close();
$('#journal-preview').addEventListener('close',()=>{releaseJournalMedia($('#journal-preview-content'));$('#journal-preview-content').replaceChildren();$('#journal-preview-download').removeAttribute('href');});
function stopJournalVoice(discard=true){
if(discard)journalVoice=[];if(journalRecorder&&journalRecorder.state!=='inactive')journalRecorder.stop();
journalTracks?.getTracks().forEach(t=>t.stop());journalTracks=null;clearTimeout(journalVoiceTimer);journalVoiceTimer=null;
@@ -15,13 +49,13 @@ function stopJournalVoice(discard=true){
}
function leaveJournal(){
journalGeneration++;journalRequest?.abort();journalRequest=null;journalBusy(false);stopJournalVoice();
$$('#journal-list video,#journal-list audio').forEach(v=>{v.pause();v.removeAttribute('src');v.load();});
for(const image of journalImages.values())URL.revokeObjectURL(image.url);journalImages.clear();journalImageBytes=0;
for(const id of ['journal-list','journal-upload-list','journal-library-list','journal-preview-content'])releaseJournalMedia($('#'+id));
$('#journal-preview').close();
$('#journal-list').replaceChildren();$('#journal-files').disabled=false;$('#journal-save').disabled=false;
}
function clearJournalForm(){const f=$('#journal-form');f.reset();for(const key of ['id','revision','clientId'])f.elements[key].value='';}
function resetJournal(){leaveJournal();journalData=null;journalPage=0;clearJournalForm();$('#journal-filter').reset();$('#journal-upload-list').replaceChildren();$('#journal-status').textContent='';$('#journal-person').replaceChildren();$('#journal-files').disabled=true;$('#journal-record').disabled=true;$('#baidu-config').reset();$('#baidu-status').textContent='';$('#baidu-config-status').textContent='';}
function newJournal(){clearJournalForm();const f=$('#journal-form');for(const input of $('#journal-upload-list').querySelectorAll('input[type=checkbox]'))input.checked=false;f.elements.clientId.value=journalId();f.elements.occurredOn.value=journalToday();$('#journal-save').textContent='保存记事';journalMessage('默认仅自己可见。选择“当前家庭/场所”后才会进入动态。');renderJournalUploads();}
function resetJournal(){leaveJournal();journalDraftMedia.clear();journalSelectedMedia.clear();$('#journal-library-list').replaceChildren();$('#journal-trash-list').replaceChildren();journalData=null;journalPage=0;clearJournalForm();$('#journal-filter').reset();$('#journal-upload-list').replaceChildren();$('#journal-status').textContent='';$('#journal-person').replaceChildren();$('#journal-files').disabled=true;$('#journal-record').disabled=true;$('#baidu-config').reset();$('#baidu-status').textContent='';$('#baidu-config-status').textContent='';}
function newJournal(){clearJournalForm();stopJournalVoice();journalDraftMedia.clear();journalSelectedMedia.clear();$('#journal-library').open=false;const f=$('#journal-form');for(const input of $('#journal-upload-list').querySelectorAll('input[type=checkbox]'))input.checked=false;f.elements.clientId.value=journalId();f.elements.occurredOn.value=journalToday();$('#journal-save').textContent='保存记事';journalMessage('默认仅自己可见。选择“当前家庭/场所”后才会进入动态。');renderJournalUploads();}
async function loadJournal(){
const g=++journalGeneration;if(!loggedIn)return;
if(!$('#journal-form').elements.clientId.value)newJournal();
@@ -39,15 +73,29 @@ async function loadJournal(){
renderJournalUploads();renderJournalEntries();
}catch(e){if(!e.stale&&journalValid(g))$('#journal-query-status').textContent=e.message;}
}
function journalUploadCard(m,library=false){
const id=esc(m.id),ready=m.status==='ready';
return `<div class="journal-upload-card" data-upload-card="${id}"><${library?'div':'label'} class="journal-upload-select">${library?'':`<input type="checkbox" value="${id}" ${ready?'':'disabled'} ${journalSelectedMedia.has(m.id)?'checked':''}>`}<span>${esc(m.name)}<small>${Math.ceil(m.size/1024)} KB · ${ready?'已存百度网盘':'等待继续上传'}</small></span></${library?'div':'label'}>${ready?journalMediaView(m):`<p class="description">${esc(m.error||'上传尚未完成,请选择原文件继续。')}</p>`}<div class="journal-card-actions">${ready?`<button type="button" class="quiet" data-preview-media="${id}">预览</button>${library?`<button type="button" class="quiet" data-use-upload="${id}">用于本条</button>`:`<button type="button" class="quiet" data-detach-upload="${id}">从本条移除</button>`}`:`<label class="quiet journal-resume">继续上传<input type="file" data-journal-resume="${id}"></label>`}<button type="button" class="quiet" data-remove-upload="${id}">删除</button></div></div>`;
}
function renderJournalUploads(){
const selected=new Set([...$('#journal-upload-list').querySelectorAll('input:checked')].map(e=>e.value));
const editing=journalData?.items.find(e=>e.id===$('#journal-form').elements.id.value);
const available=[...(journalData?.uploads||[])];for(const media of editing?.media||[])if(!available.some(m=>m.id===media.id))available.push(media);
$('#journal-upload-list').innerHTML=available.map(m=>`<div class="journal-upload"><label><input type="checkbox" value="${esc(m.id)}" ${m.status==='ready'?'':'disabled'} ${selected.has(m.id)?'checked':''}>${esc(m.name)} <small>${Math.ceil(m.size/1024)} KB · ${m.status==='ready'?'已存百度网盘':m.status==='receiving'?'等待继续上传':'已失效'}</small></label>${m.status==='receiving'?`<label class="quiet journal-resume">选择原文件继续<input type="file" data-journal-resume="${esc(m.id)}"></label>`:''}<button type="button" class="quiet" data-remove-upload="${esc(m.id)}">移出列表</button></div>`).join('');
releaseJournalMedia($('#journal-upload-list'));releaseJournalMedia($('#journal-library-list'));
$('#journal-upload-list').innerHTML=available.filter(m=>journalDraftMedia.has(m.id)).map(m=>journalUploadCard(m)).join('');
const library=(journalData?.uploads||[]).filter(m=>!journalDraftMedia.has(m.id));
$('#journal-library-count').textContent=String(library.length);
$('#journal-library-list').innerHTML=$('#journal-library').open?(library.map(m=>journalUploadCard(m,true)).join('')||'<p class="description">暂无其他附件。</p>'):'';
const trash=journalData?.trash||[];$('#journal-trash').hidden=!trash.length;
$('#journal-trash-count').textContent=String(trash.length);
$('#journal-trash-list').innerHTML=trash.map(m=>`<div class="journal-trash-row"><span>${esc(m.name)}</span><button type="button" class="quiet" data-restore-upload="${esc(m.id)}">恢复</button></div>`).join('');
bindJournalMedia($('#journal-upload-list'));bindJournalMedia($('#journal-library-list'));
}
$('#journal-library').addEventListener('toggle',renderJournalUploads);
$('#journal-upload-list').addEventListener('change',e=>{if(e.target.type==='checkbox'){if(e.target.checked)journalSelectedMedia.add(e.target.value);else journalSelectedMedia.delete(e.target.value);}});
function renderJournalEntries(){
for(const value of journalImages.values())URL.revokeObjectURL(value.url);journalImages.clear();journalImageBytes=0;
$('#journal-list').innerHTML=journalData.items.map(e=>`<article class="panel journal-entry" data-entry="${esc(e.id)}"><div class="section-head"><div><button class="quiet journal-author" data-journal-person="${esc(e.ownerId)}">${esc(e.authorName)}</button><small>${esc(e.occurredOn)} · ${esc(e.category)} · ${e.visibility==='private'?'仅自己可见':'当前家庭/场所'}</small></div>${e.canEdit?`<div><button class="quiet" data-journal-edit="${esc(e.id)}">编辑</button><button class="quiet" data-journal-delete="${esc(e.id)}">删除</button></div>`:''}</div><p class="journal-text">${esc(e.text)}</p><div class="journal-media">${e.media.map(m=>m.mime.startsWith('image/')?`<figure><button class="journal-image-load quiet" data-load-photo="${esc(m.id)}" data-size="${m.size}">查看照片 · ${esc(m.name)}</button><img hidden alt="${esc(m.name)}" loading="lazy"></figure>`:`<figure><${m.mime.startsWith('audio/')?'audio':'video'} controls preload="none" playsinline src="${esc(journalMediaUrl(m.id))}"></${m.mime.startsWith('audio/')?'audio':'video'}><figcaption>${esc(m.name)} <a href="${esc(journalMediaUrl(m.id,true))}">下载原文件</a></figcaption><small>点击播放后缓冲;编码不兼容时可下载原文件。</small></figure>`).join('')}</div><div class="journal-reactions"><button class="quiet" data-journal-like="${esc(e.id)}" aria-pressed="${e.liked}">${e.liked?'♥ 已赞':'♡ 点赞'} · ${e.likes}</button><button class="quiet" data-journal-person="${esc(e.ownerId)}">查看个人阅历</button></div><details class="journal-comments"><summary>评论 <span data-comment-count>${e.commentCount}</span></summary><div data-comments>${e.comments.map(c=>journalComment(e,c)).join('')}</div><form data-journal-comment="${esc(e.id)}"><label>写评论<input name="text" maxlength="500" required autocomplete="off"></label><button type="submit">发送</button><span class="form-error" role="status"></span></form></details></article>`).join('');
releaseJournalMedia($('#journal-list'));
$('#journal-list').innerHTML=journalData.items.map(e=>`<article class="panel journal-entry" data-entry="${esc(e.id)}"><div class="section-head"><div><button class="quiet journal-author" data-journal-person="${esc(e.ownerId)}">${esc(e.authorName)}</button><small>${esc(e.occurredOn)} · ${esc(e.category)} · ${e.visibility==='private'?'仅自己可见':'当前家庭/场所'}</small></div>${e.canEdit?`<div><button class="quiet" data-journal-edit="${esc(e.id)}">编辑</button><button class="quiet" data-journal-delete="${esc(e.id)}">删除</button></div>`:''}</div><p class="journal-text">${esc(e.text)}</p><div class="journal-media">${e.media.map(m=>`<div>${journalMediaView(m)}<p class="journal-media-caption">${esc(m.name)} <a href="${esc(journalMediaUrl(m.id,true))}">下载原文件</a></p></div>`).join('')}</div><div class="journal-reactions"><button class="quiet" data-journal-like="${esc(e.id)}" aria-pressed="${e.liked}">${e.liked?'♥ 已赞':'♡ 点赞'} · ${e.likes}</button><button class="quiet" data-journal-person="${esc(e.ownerId)}">查看个人阅历</button></div><details class="journal-comments"><summary>评论 <span data-comment-count>${e.commentCount}</span></summary><div data-comments>${e.comments.map(c=>journalComment(e,c)).join('')}</div><form data-journal-comment="${esc(e.id)}"><label>写评论<input name="text" maxlength="500" required autocomplete="off"></label><button type="submit">发送</button><span class="form-error" role="status"></span></form></details></article>`).join('');
bindJournalMedia($('#journal-list'));
}
function journalComment(entry,c){return `<p class="journal-comment"><strong>${esc(c.authorName)}</strong> ${esc(c.text)} <small>${esc(new Date(c.createdAt).toLocaleString())}</small>${c.canDelete?` <button class="quiet" data-delete-comment="${esc(c.id)}" data-journal-entry="${esc(entry.id)}">删除评论</button>`:''}</p>`;}
$('#journal-form').addEventListener('submit',async event=>{
@@ -77,7 +125,7 @@ async function uploadJournalFile(file,resume=''){
if(result.received<=offset)throw Error('上传进度未前进,请选择原文件继续');offset=result.received;journalMessage('正在上传百度网盘:'+Math.round(offset/file.size*95)+'%');
}
await api('/api/journal/finish',{id:task.id});if(!journalValid(g)||!journalUpload)return;
journalUpload=false;await loadJournal();const input=$('#journal-upload-list').querySelector(`input[value="${task.id}"]`);if(input)input.checked=true;journalMessage('附件已保存到百度网盘。填写内容和可见范围后保存记事。');
journalUpload=false;journalDraftMedia.add(task.id);journalSelectedMedia.add(task.id);await loadJournal();journalMessage('附件已保存到百度网盘。填写内容和可见范围后保存记事。');
}catch(e){if(!e.stale&&journalValid(g)){journalMessage(e.name==='AbortError'?'已暂停上传,可选择原文件继续。':e.message);journalUpload=false;const message=$('#journal-status').textContent;await loadJournal();journalMessage(message);}}
finally{journalBusy(false);journalRequest=null;if(activePage==='journal'){$('#journal-save').disabled=false;$('#journal-files').disabled=!journalData?.connection.connected;$('#journal-cancel-upload').hidden=true;}}
}
@@ -100,20 +148,29 @@ document.addEventListener('change',e=>{if(e.target.dataset.journalResume){upload
document.addEventListener('click',async event=>{
const b=event.target.closest('button');if(!b)return;const g=journalGeneration;
if(b.dataset.journalPerson){$('#journal-mode').value='person';$('#journal-person-label').hidden=false;$('#journal-person').value=b.dataset.journalPerson;journalPage=0;loadJournal();return;}
if(b.dataset.journalEdit){const e=journalData.items.find(e=>e.id===b.dataset.journalEdit);if(!e)return;const f=$('#journal-form');clearJournalForm();for(const key of ['id','revision','clientId','text','occurredOn','category','visibility'])f.elements[key].value=e[key];renderJournalUploads();$('#journal-save').textContent='保存修改';for(const input of $('#journal-upload-list').querySelectorAll('input[type=checkbox]'))input.checked=e.mediaIds.includes(input.value);f.scrollIntoView({behavior:'smooth'});return;}
if(b.dataset.loadPhoto){
b.disabled=true;try{
const id=b.dataset.loadPhoto;let cached=journalImages.get(id);
if(!cached){if(Number(b.dataset.size)>20*1024*1024||journalImageBytes+Number(b.dataset.size)>20*1024*1024)throw Error('照片较大,请下载原图查看。');
journalImageBytes+=Number(b.dataset.size);b.textContent='照片缓冲中…';const response=await fetch(journalMediaUrl(id));if(!response.ok)throw Error('照片读取失败,请稍后重试');const blob=await response.blob();if(!journalValid(g))return;
cached={url:URL.createObjectURL(blob),size:blob.size};journalImages.set(id,cached);}
const img=b.parentElement.querySelector('img');img.src=cached.url;img.hidden=false;b.hidden=true;
}catch(e){if(journalValid(g)){if(b.textContent==='照片缓冲中…')journalImageBytes=Math.max(0,journalImageBytes-Number(b.dataset.size));b.textContent=e.message;b.disabled=false;const link=document.createElement('a');link.href=journalMediaUrl(b.dataset.loadPhoto,true);link.textContent='下载照片';b.after(link);}}return;
if(b.dataset.journalEdit){const e=journalData.items.find(e=>e.id===b.dataset.journalEdit);if(!e)return;const f=$('#journal-form');clearJournalForm();for(const key of ['id','revision','clientId','text','occurredOn','category','visibility'])f.elements[key].value=e[key];journalDraftMedia.clear();journalSelectedMedia.clear();e.mediaIds.forEach(id=>{journalDraftMedia.add(id);journalSelectedMedia.add(id);});renderJournalUploads();$('#journal-save').textContent='保存修改';for(const input of $('#journal-upload-list').querySelectorAll('input[type=checkbox]'))input.checked=e.mediaIds.includes(input.value);f.scrollIntoView({behavior:'smooth'});return;}
if(b.dataset.useUpload){journalDraftMedia.add(b.dataset.useUpload);journalSelectedMedia.add(b.dataset.useUpload);renderJournalUploads();return;}
if(b.dataset.detachUpload){journalDraftMedia.delete(b.dataset.detachUpload);journalSelectedMedia.delete(b.dataset.detachUpload);renderJournalUploads();return;}
if(b.dataset.previewMedia){previewJournalMedia(b.dataset.previewMedia);return;}
if(b.hasAttribute('data-retry-media')){
const box=b.closest('.journal-attachment'),media=box.querySelector('img,audio,video');
b.hidden=true;box.classList.remove('media-error');box.querySelector('.journal-media-state').textContent='正在重新加载…';
if(media.tagName==='IMG'){const src=media.src;media.removeAttribute('src');media.src=src;}else media.load();return;
}
const id=b.dataset.journalLike||b.dataset.journalDelete||b.dataset.journalEntry;
if(!id&&!b.dataset.removeUpload)return;b.disabled=true;
if(!id&&!b.dataset.removeUpload&&!b.dataset.restoreUpload)return;b.disabled=true;
try{
if(b.dataset.removeUpload){await api('/api/journal/remove-upload',{id:b.dataset.removeUpload});if(journalValid(g))await loadJournal();return;}
if(b.dataset.removeUpload||b.dataset.restoreUpload){
if(journalUpload){journalMessage('请等待当前上传完成,或先暂停上传。');return;}
const restore=!!b.dataset.restoreUpload;
await api(restore?'/api/journal/restore-upload':'/api/journal/remove-upload',{id:b.dataset.restoreUpload||b.dataset.removeUpload});
if(!journalValid(g))return;
const result=await api('/api/journal?mode=mine');if(!journalValid(g))return;
journalData.uploads=result.uploads;journalData.trash=result.trash;if(!restore){journalDraftMedia.delete(b.dataset.removeUpload);journalSelectedMedia.delete(b.dataset.removeUpload);}
// Keep an editor's existing references current without replacing its draft or feed players.
for(const entry of journalData.items)for(const media of entry.media){const current=[...result.uploads,...result.trash].find(m=>m.id===media.id);if(current)Object.assign(media,current);}
renderJournalUploads();journalMessage(restore?'附件已恢复,可重新勾选使用。':'已移到“已删除附件”,可以恢复。已发布的记事和网盘原文件保留。');return;
}
const entry=journalData.items.find(e=>e.id===id);if(!entry)return;
if(b.dataset.journalLike){await api('/api/journal/action',{id,action:'like',liked:!entry.liked});if(!journalValid(g))return;entry.liked=!entry.liked;entry.likes+=entry.liked?1:-1;b.textContent=(entry.liked?'♥ 已赞':'♡ 点赞')+' · '+entry.likes;b.setAttribute('aria-pressed',String(entry.liked));}
if(b.dataset.journalDelete){if(!confirm('删除这条记事?网盘原文件保留。'))return;await api('/api/journal/action',{id,action:'delete',revision:entry.revision});if(journalValid(g))loadJournal();}