Files
percoandClaude Sonnet 4.6 3e98de9c18
Deploy HouseHub / deploy (push) Successful in 2s
fix(memo): supprime loading=lazy sur les images en vue note
loading="lazy" dans un conteneur parent avec overflow:hidden empêche
le browser de détecter les images comme visibles → elles ne chargent pas.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 17:23:38 +02:00

411 lines
19 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// HouseHub — Memo module
const API = '/modules/memo/api.php';
// ─── Helpers ─────────────────────────────────────────────────────────────────
function escHtml(s){ const d=document.createElement('div');d.textContent=String(s??'');return d.innerHTML; }
function fmtDate(d){ if(!d)return'--'; try{return new Date(d).toLocaleDateString('fr-FR',{day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit'});}catch{return d;} }
function fmtSize(b){ if(!b)return''; if(b<1024)return b+'o'; if(b<1048576)return Math.round(b/1024)+'ko'; return (b/1048576).toFixed(1)+'Mo'; }
function toast(msg,type='success'){
const c=document.getElementById('memo-toasts')||document.body;
const t=document.createElement('div');t.className='memo-toast'+(type==='error'?' error':'');t.textContent=msg;
c.appendChild(t);setTimeout(()=>t.remove(),3000);
}
async function api(action,method='GET',data=null,extra=''){
const opts={method,headers:{}};
if(data&&!(data instanceof FormData)){opts.headers['Content-Type']='application/json';opts.body=JSON.stringify(data);}
else if(data instanceof FormData){opts.body=data;}
const r=await fetch(API+'?action='+action+extra,opts);
const j=await r.json();
if(!j.ok)throw new Error(j.error||'Erreur API');
return j.data;
}
// ─── State ───────────────────────────────────────────────────────────────────
let currentNoteId = null;
let currentPage = 'list';
let currentQ = '';
let currentTag = '';
let allTagsList = [];
let pendingFiles = [];
let pendingUrls = [];
// ─── Navigation ──────────────────────────────────────────────────────────────
function showPage(name){
document.querySelectorAll('.memo-page').forEach(p=>p.classList.remove('active'));
const el=document.getElementById('memo-page-'+name);
if(el)el.classList.add('active');
currentPage=name;
}
// ─── Sidebar ─────────────────────────────────────────────────────────────────
async function loadSidebar(){
try{
allTagsList=await api('tags');
const el=document.getElementById('memo-tags-list');
if(!el)return;
const allActive=currentTag===''&&currentQ==='';
let html=`<div class="memo-tag-item${allActive?' active':''}" onclick="filterTag('')">
📝 Toutes les notes <span class="memo-tag-count" id="memo-all-count">…</span></div>`;
allTagsList.forEach(t=>{
const act=currentTag===t.tag?' active':'';
html+=`<div class="memo-tag-item${act}" onclick="filterTag('${escHtml(t.tag)}')">
<span># ${escHtml(t.tag)}</span><span class="memo-tag-count">${t.count}</span></div>`;
});
el.innerHTML=html;
}catch(e){}
}
// ─── Note list ────────────────────────────────────────────────────────────────
async function loadList(q='',tag='',page=1){
currentQ=q;currentTag=tag;showPage('list');
loadSidebar();
try{
let extra='&page='+page;
if(q)extra+='&q='+encodeURIComponent(q);
if(tag)extra+='&tag='+encodeURIComponent(tag);
const data=await api('notes','GET',null,extra);
const el=document.getElementById('memo-notes-grid');
const count=document.getElementById('memo-all-count');
const title=document.getElementById('memo-list-title');
const subtitle=document.getElementById('memo-list-subtitle');
if(count)count.textContent=data.total;
if(title){
title.textContent = q?`🔍 "${q}"` : tag?`# ${tag}` : '📝 Notes';
}
if(subtitle)subtitle.textContent=data.total+' note'+(data.total!==1?'s':'');
if(!data.notes.length){
el.innerHTML='<div class="memo-empty"><div class="icon">📝</div><p>'+(q||tag?'Aucune note trouvée.':'Aucune note. Créez-en une !')+'</p></div>';
return;
}
el.innerHTML=data.notes.map(n=>noteCardHtml(n)).join('');
// Pagination
const total_pages=Math.ceil(data.total/data.per_page);
const pag=document.getElementById('memo-pagination');
if(pag){
pag.innerHTML=total_pages>1?
(page>1?`<button class="btn btn-secondary btn-sm" onclick="loadList('${escHtml(q)}','${escHtml(tag)}',${page-1})">← Précédent</button>`:'<span></span>')+
`<span style="color:var(--text-muted);font-size:.82rem">Page ${page} / ${total_pages}</span>`+
(page<total_pages?`<button class="btn btn-secondary btn-sm" onclick="loadList('${escHtml(q)}','${escHtml(tag)}',${page+1})">Suivant →</button>`:'<span></span>')
: '';
}
}catch(e){toast(e.message,'error');}
}
function noteCardHtml(n){
const tags=(n.tags||'').split(',').filter(t=>t.trim()).map(t=>
`<span class="note-tag-chip" onclick="event.stopPropagation();filterTag('${escHtml(t.trim())}')">#${escHtml(t.trim())}</span>`).join('');
const snippet=n.snippet?(escHtml(n.snippet.replace(/\n/g,' '))):'';
return `<div class="note-card" onclick="viewNote(${n.id})">
<div class="note-card-title">${escHtml(n.title)}</div>
${snippet?`<div class="note-card-snippet">${snippet}</div>`:''}
<div class="note-card-meta">${tags}<span class="note-card-date">${fmtDate(n.updated_at)}</span></div>
</div>`;
}
function filterTag(tag){
currentTag=tag;currentQ='';
document.getElementById('memo-search-input')?.value!=null&&(document.getElementById('memo-search-input').value='');
loadList('',tag);
}
function doSearch(){
const q=document.getElementById('memo-search-input')?.value.trim()||'';
loadList(q,'');
}
// ─── Note view ────────────────────────────────────────────────────────────────
async function viewNote(id){
currentNoteId=id;showPage('view');
try{
const note=await api('notes','GET',null,'&id='+id);
document.getElementById('view-title').textContent=note.title;
document.getElementById('view-date').textContent='Modifié '+fmtDate(note.updated_at);
// Tags
const tagEl=document.getElementById('view-tags');
tagEl.innerHTML=(note.tags||'').split(',').filter(t=>t.trim()).map(t=>
`<span class="note-tag-chip" onclick="filterTag('${escHtml(t.trim())}')">#${escHtml(t.trim())}</span>`).join('');
// Markdown rendering
const mdEl=document.getElementById('view-md');
if(note.content&&window.marked){
mdEl.innerHTML=marked.parse(note.content);
mdEl.classList.remove('empty');
} else if(note.content){
mdEl.textContent=note.content;mdEl.classList.remove('empty');
} else {
mdEl.innerHTML='<em>Aucun contenu.</em>';mdEl.classList.add('empty');
}
// Attachments
renderAttachmentsView(note.attachments||[],id);
}catch(e){toast(e.message,'error');}
}
function renderAttachmentsView(atts,noteId){
const images=atts.filter(a=>a.type==='image');
const files =atts.filter(a=>a.type==='file');
const urls =atts.filter(a=>a.type==='url');
const el=document.getElementById('view-attachments');
el.innerHTML='';
if(!atts.length){el.style.display='none';return;}
el.style.display='block';
if(images.length){
el.innerHTML+=`<div class="memo-att-section">
<div class="memo-att-title">🖼️ Images</div>
<div class="att-img-grid">${images.map(a=>`
<div class="att-img-item" onclick="openLightbox('${API}?action=file&id=${a.id}')">
<img src="${API}?action=file&id=${a.id}" alt="${escHtml(a.original_name||'')}">
<button class="att-del" onclick="event.stopPropagation();deleteAttachment(${a.id},${noteId})" title="Supprimer">✕</button>
</div>`).join('')}</div></div>`;
}
if(files.length){
el.innerHTML+=`<div class="memo-att-section">
<div class="memo-att-title">📎 Fichiers</div>
<div class="att-file-list">${files.map(a=>`
<div class="att-file-item">
<a href="${API}?action=file&id=${a.id}" target="_blank">${escHtml(a.original_name||a.filename)}</a>
<span class="att-file-size">${fmtSize(a.size)}</span>
<button class="btn btn-danger btn-sm" onclick="deleteAttachment(${a.id},${noteId})">✕</button>
</div>`).join('')}</div></div>`;
}
if(urls.length){
el.innerHTML+=`<div class="memo-att-section">
<div class="memo-att-title">🔗 Liens</div>
<div class="att-url-list">${urls.map(a=>`
<div class="att-url-item">
<a href="${escHtml(a.url)}" target="_blank" rel="noopener">${escHtml(a.label||a.url)}</a>
<button class="btn btn-danger btn-sm" onclick="deleteAttachment(${a.id},${noteId})">✕</button>
</div>`).join('')}</div></div>`;
}
}
async function deleteAttachment(attId,noteId){
if(!confirm('Supprimer cet attachement ?'))return;
try{ await api('attachments','DELETE',null,'&id='+attId); toast('Supprimé'); viewNote(noteId); }
catch(e){toast(e.message,'error');}
}
// Lightbox
function openLightbox(src){
const lb=document.getElementById('memo-lightbox');
const img=lb.querySelector('img');
img.src=src;lb.classList.add('show');
}
function closeLightbox(){ document.getElementById('memo-lightbox')?.classList.remove('show'); }
// ─── Edit / Create ────────────────────────────────────────────────────────────
let editingId=null;
let editTags=[];
let tagAutocomplete=[];
function openCreate(){
editingId=null;pendingFiles=[];pendingUrls=[];editTags=[];
document.getElementById('edit-page-title').textContent='Nouvelle note';
document.getElementById('edit-title').value='';
document.getElementById('edit-content').value='';
document.getElementById('md-live-preview').innerHTML='';
document.getElementById('edit-tags-chips').innerHTML='';
document.getElementById('edit-tags-input').value='';
document.getElementById('edit-attach-preview').innerHTML='';
document.getElementById('edit-url-rows').innerHTML=addUrlRowHtml();
renderTagChips();
showPage('edit');
document.getElementById('edit-title')?.focus();
}
async function openEdit(id){
editingId=id;pendingFiles=[];pendingUrls=[];editTags=[];
try{
const note=await api('notes','GET',null,'&id='+id);
document.getElementById('edit-page-title').textContent='Modifier la note';
document.getElementById('edit-title').value=note.title;
document.getElementById('edit-content').value=note.content||'';
editTags=(note.tags||'').split(',').filter(t=>t.trim());
renderTagChips();
updatePreview();
// existing attachments shown below form
renderAttachmentsEdit(note.attachments||[]);
document.getElementById('edit-url-rows').innerHTML=addUrlRowHtml();
showPage('edit');
}catch(e){toast(e.message,'error');}
}
function renderAttachmentsEdit(atts){
const el=document.getElementById('edit-existing-attachments');
if(!atts.length){el.innerHTML='';return;}
el.innerHTML=`<div class="memo-att-title" style="margin-bottom:.5rem">Attachements existants</div>`+
atts.map(a=>`<div class="att-file-item">
${a.type==='url'?
`<a href="${escHtml(a.url)}" target="_blank">${escHtml(a.label||a.url)}</a>`:
`<a href="${API}?action=file&id=${a.id}" target="_blank">${escHtml(a.original_name||a.filename)}</a>
<span class="att-file-size">${fmtSize(a.size)}</span>`}
<button class="btn btn-danger btn-sm" onclick="deleteAttachment(${a.id},${editingId})">✕</button>
</div>`).join('');
}
function updatePreview(){
const content=document.getElementById('edit-content')?.value||'';
const el=document.getElementById('md-live-preview');
if(!el)return;
el.innerHTML=window.marked?marked.parse(content):'<em style="color:var(--text-muted)">Preview chargement…</em>';
}
// Tags
function renderTagChips(){
const wrap=document.getElementById('edit-tags-chips');
wrap.innerHTML=editTags.map(t=>
`<span class="tag-chip">#${escHtml(t)}<button type="button" onclick="removeTag('${escHtml(t)}')" title="Retirer">×</button></span>`
).join('');
}
function removeTag(t){ editTags=editTags.filter(x=>x!==t);renderTagChips(); }
function addTagFromInput(){
const inp=document.getElementById('edit-tags-input');
const val=inp.value.trim().replace(/^#/,'').toLowerCase();
if(val&&!editTags.includes(val)){editTags.push(val);renderTagChips();}
inp.value='';
document.getElementById('tags-suggestions')?.remove();
}
function onTagKeydown(e){
if(e.key==='Enter'||e.key===','||e.key===' '){e.preventDefault();addTagFromInput();}
else if(e.key==='Backspace'&&!e.target.value&&editTags.length){
editTags.pop();renderTagChips();
} else showTagSuggestions(e.target.value);
}
function showTagSuggestions(q){
document.getElementById('tags-suggestions')?.remove();
if(!q||!allTagsList.length)return;
const matches=allTagsList.filter(t=>t.tag.includes(q.toLowerCase())).slice(0,6);
if(!matches.length)return;
const inp=document.getElementById('edit-tags-input');
const box=document.createElement('div');
box.id='tags-suggestions';box.className='tags-suggestions';
box.style.cssText=`position:absolute;top:${inp.offsetTop+inp.offsetHeight+4}px;left:${inp.offsetLeft}px`;
matches.forEach(t=>{const d=document.createElement('div');d.textContent='#'+t.tag;d.onclick=()=>{
if(!editTags.includes(t.tag)){editTags.push(t.tag);renderTagChips();}
inp.value='';box.remove();
};box.appendChild(d);});
inp.closest('.tags-input-wrap').style.position='relative';
inp.closest('.tags-input-wrap').appendChild(box);
}
// File drop
function initDropZone(){
const zone=document.getElementById('edit-drop-zone');
const inp=document.getElementById('edit-file-input');
if(!zone)return;
zone.addEventListener('click',()=>inp?.click());
zone.addEventListener('dragover',e=>{e.preventDefault();zone.classList.add('drag-over');});
zone.addEventListener('dragleave',()=>zone.classList.remove('drag-over'));
zone.addEventListener('drop',e=>{e.preventDefault();zone.classList.remove('drag-over');handleFileSelect(e.dataTransfer.files);});
inp?.addEventListener('change',()=>handleFileSelect(inp.files));
document.getElementById('edit-content')?.addEventListener('paste',e=>{
const files=[...e.clipboardData.files].filter(f=>f.type.startsWith('image/'));
if(files.length){e.preventDefault();handleFileSelect(files);}
});
}
function handleFileSelect(files){
const preview=document.getElementById('edit-attach-preview');
[...files].forEach(f=>{
pendingFiles.push(f);
const item=document.createElement('div');item.className='attach-preview-item';
if(f.type.startsWith('image/')){
const img=document.createElement('img');
const reader=new FileReader();
reader.onload=ev=>{img.src=ev.target.result;};
reader.readAsDataURL(f);
item.appendChild(img);
}else{
item.innerHTML=`<div style="background:#f1f5f9;padding:.3rem .5rem;border-radius:6px;font-size:.75rem;max-width:100px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escHtml(f.name)}</div>`;
}
const rm=document.createElement('button');rm.className='rm';rm.textContent='✕';
rm.onclick=()=>{pendingFiles=pendingFiles.filter(x=>x!==f);item.remove();};
item.appendChild(rm);
preview.appendChild(item);
});
}
// URL rows
function addUrlRowHtml(){
return `<div class="url-row" style="margin-bottom:.4rem">
<input type="text" class="form-control url-input" placeholder="https://..." style="flex:1.5">
<input type="text" class="form-control url-label" placeholder="Libellé (optionnel)" style="flex:1">
<button type="button" class="btn btn-secondary btn-sm" onclick="addUrlRow()">+</button>
</div>`;
}
function addUrlRow(){
const wrap=document.getElementById('edit-url-rows');
wrap.insertAdjacentHTML('beforeend',addUrlRowHtml());
}
// Save
async function saveNote(){
const title=document.getElementById('edit-title')?.value.trim();
if(!title){toast('Titre requis','error');return;}
const content=document.getElementById('edit-content')?.value||'';
const tags=editTags.join(',');
// Collect URLs
const urlRows=document.querySelectorAll('#edit-url-rows .url-row');
const urls=[];
urlRows.forEach(row=>{
const u=row.querySelector('.url-input')?.value.trim();
const l=row.querySelector('.url-label')?.value.trim()||'';
if(u)urls.push({url:u,label:l});
});
try{
let noteId;
if(editingId){
await api('notes','PUT',{title,content,tags},'&id='+editingId);
noteId=editingId;toast('Note mise à jour');
}else{
const r=await api('notes','POST',{title,content,tags,urls});
noteId=r.id;toast('Note créée');
}
// Upload pending files
for(const f of pendingFiles){
const fd=new FormData();fd.append('file',f);fd.append('note_id',noteId);
try{await api('attachments','POST',fd);}catch(e){toast('Erreur upload: '+f.name,'error');}
}
// Add URLs (for edit mode)
if(editingId){
for(const u of urls){
const fd=new FormData();fd.append('note_id',noteId);fd.append('url',u.url);fd.append('label',u.label);
try{await api('attachments','POST',fd);}catch{}
}
}
loadList(currentQ,currentTag);
viewNote(noteId);
}catch(e){toast(e.message,'error');}
}
async function deleteNote(id){
if(!confirm('Supprimer cette note définitivement ?'))return;
try{
await api('notes','DELETE',null,'&id='+id);
toast('Note supprimée');loadList(currentQ,currentTag);
}catch(e){toast(e.message,'error');}
}
// ─── Init ─────────────────────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded',()=>{
loadList();
loadSidebar();
initDropZone();
// Search
const sinp=document.getElementById('memo-search-input');
if(sinp){
let t;sinp.addEventListener('input',()=>{clearTimeout(t);t=setTimeout(()=>doSearch(),300);});
sinp.addEventListener('keydown',e=>{if(e.key==='Enter')doSearch();});
}
// Content live preview
document.getElementById('edit-content')?.addEventListener('input',updatePreview);
// Lightbox close
document.getElementById('memo-lightbox')?.addEventListener('click',closeLightbox);
// Tags input
document.getElementById('edit-tags-input')?.addEventListener('keydown',onTagKeydown);
document.getElementById('edit-tags-input')?.addEventListener('blur',()=>setTimeout(()=>document.getElementById('tags-suggestions')?.remove(),150));
document.addEventListener('click',e=>{if(!e.target.closest('.tags-input-wrap'))document.getElementById('tags-suggestions')?.remove();});
});