testsAndMisc/articles/index.html

234 lines
13 KiB
HTML

<!doctype html>
<html lang="en">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Mini Articles</title>
<style>
*{box-sizing:border-box}html,body{height:100%}body{margin:0;font:16px/1.4 system-ui,-apple-system,Segoe UI,Roboto,Ubuntu,Cantarell,'Helvetica Neue',Arial,sans-serif;color:#222;background:#fff}
header{display:flex;gap:.5rem;align-items:center;justify-content:space-between;padding:.5rem .75rem;border-bottom:1px solid #ddd;position:sticky;top:0;background:#fff}
header h1{margin:0;font-size:1.1rem}
header nav{display:flex;gap:.5rem}
button, input[type=file]{font:inherit}
main{max-width:960px;margin:0 auto;padding:1rem}
.hidden{display:none!important}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:.75rem}
.card{border:1px solid #ddd;border-radius:.5rem;overflow:hidden;background:#fff;cursor:pointer;display:flex;flex-direction:column}
.card img{width:100%;height:120px;object-fit:cover;display:block}
.card h3{margin:.5rem;font-size:1rem}
#editor label{display:block;margin:.5rem 0 .25rem}
input[type=text]{width:100%;padding:.5rem;border:1px solid #ccc;border-radius:.4rem}
.drop{border:1.5px dashed #bbb;border-radius:.5rem;padding:.5rem;text-align:center;color:#666}
.drop img{max-width:100%;max-height:200px;display:block;margin:.25rem auto}
#content{min-height:200px;padding:.5rem;border:1px solid #ccc;border-radius:.5rem}
#content:empty:before{content:attr(data-ph);color:#999}
article img, article video{max-width:100%;height:auto;display:block;margin:.5rem 0}
article h1{margin:.25rem 0 .5rem;font-size:1.6rem}
.controls{display:flex;gap:.5rem;flex-wrap:wrap;margin:.5rem 0}
.small{font-size:.85rem;color:#666}
#loading{display:flex;align-items:center;gap:.5rem;margin:0 0 1rem}
.spinner{width:16px;height:16px;border:2px solid #ccc;border-top-color:#333;border-radius:50%;display:inline-block;animation:spin 1s linear infinite}
@keyframes spin{to{transform:rotate(360deg)}}
.reading header nav{display:none}
.reading #delBtn{display:none}
</style>
<header>
<h1>Mini Articles</h1>
<nav>
<button id="toList" title="All articles">List</button>
<button id="toNew" title="Create article">New</button>
</nav>
</header>
<main>
<section id="loading" class="hidden">
<div class="small"><span class="spinner"></span><span class="msg">Loading…</span></div>
</section>
<section id="listV" class="grid"></section>
<section id="readV" class="hidden">
<div class="controls">
<button id="backBtn">← Back</button>
<button id="delBtn">Delete</button>
<span class="small" id="dateInfo"></span>
</div>
<article id="readA">
<img id="readThumb"/>
<h1 id="readTitle"></h1>
<div id="readBody"></div>
</article>
</section>
<section id="editor" class="hidden">
<label>Title</label>
<input id="title" type="text" placeholder="Article title"/>
<label>Author</label>
<input id="author" type="text" placeholder="Author (optional)"/>
<label>Thumbnail</label>
<div id="thumbDrop" class="drop">Choose image…<br><img id="thumbPrev"/></div>
<input id="thumbFile" type="file"/>
<label>Body</label>
<div class="controls">
<button id="insImg">Insert image</button>
<button id="insVid">Insert video</button>
</div>
<div id="content" contenteditable="true" data-ph="Write here, anything html-able should work"></div>
<div class="controls">
<button id="saveBtn">Save</button>
<button id="cancelBtn">Cancel</button>
</div>
<input id="imgFile" type="file" hidden>
<input id="vidFile" type="file" hidden>
</section>
</main>
<script>
(function(){
const $=s=>document.querySelector(s);
const listV=$('#listV'), readV=$('#readV'), editor=$('#editor');
const loading=$('#loading');
const rT=$('#readTitle'), rB=$('#readBody'), rTh=$('#readThumb'), dt=$('#dateInfo');
const toList=$('#toList'), toNew=$('#toNew'), back=$('#backBtn'), del=$('#delBtn');
const title=$('#title'), author=$('#author'), content=$('#content');
const tDrop=$('#thumbDrop'), tPrev=$('#thumbPrev'), tFile=$('#thumbFile');
const insImg=$('#insImg'), insVid=$('#insVid'), imgFile=$('#imgFile'), vidFile=$('#vidFile');
const saveBtn=$('#saveBtn'), cancelBtn=$('#cancelBtn');
const KEY='articles14k';
const PH="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 180 120'%3E%3Crect width='100%25' height='100%25' fill='%23eee'/%3E%3C/svg%3E";
let selId=null; let API=null; let preList; // cache first list to avoid duplicate initial fetches
const AC={};
function setLoading(on,msg){ if(!loading) return; if(msg) loading.querySelector('.msg').textContent=msg; loading.classList.toggle('hidden',!on); }
async function uploadBlobAndGetURL(blob){
try{
const ext = blob.type.startsWith('image/')? (blob.type.split('/')[1]||'bin') : 'bin';
const r = await fetch(`/api/upload?ext=${encodeURIComponent(ext)}`, { method:'POST', headers:{'Content-Type': blob.type || 'application/octet-stream'}, body: blob });
if(!r.ok) throw new Error('upload failed');
const j = await r.json();
return j && j.url ? j.url : '';
}catch(e){ return ''; }
}
function imgEl(src){ const img=new Image(); img.loading='lazy'; img.decoding='async'; img.src=src; return img; }
function videoEl(src){ const v=document.createElement('video'); v.controls=true; v.src=src; v.style.maxWidth='100%'; return v; }
// API client with detection
async function detectAPI(){
// Try server once; cache the result to reuse for initial render
try{
const r=await fetch('/api/articles');
if(r.ok){
preList = await r.json();
return {
async list(){ return (await fetch('/api/articles')).json() },
async get(id){const r=await fetch('/api/articles/'+id); if(!r.ok) return null; return r.json()},
async create(a){const r=await fetch('/api/articles',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(a)}); return r.json()},
async update(id,patch){const r=await fetch('/api/articles/'+id,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(patch)}); return r.ok? r.json():null},
async remove(id){await fetch('/api/articles/'+id,{method:'DELETE'})}
};
}
}catch(e){}
// Fallback to localStorage
const S={get(){try{return JSON.parse(localStorage.getItem(KEY)||'[]')}catch(e){return []}},set(a){localStorage.setItem(KEY,JSON.stringify(a))}};
const uid=()=>Date.now().toString(36)+Math.random().toString(36).slice(2,6);
return {
async list(){return S.get()},
async get(id){return S.get().find(x=>x.id===id)||null},
async create(a){const arr=S.get(); const obj=Object.assign({id:uid(),createdAt:Date.now()},a); arr.unshift(obj); S.set(arr); return obj},
async update(id,patch){const arr=S.get(); const i=arr.findIndex(x=>x.id===id); if(i<0) return null; arr[i]=Object.assign(arr[i],patch,{updatedAt:Date.now()}); S.set(arr); return arr[i]},
async remove(id){const arr=S.get().filter(x=>x.id!==id); S.set(arr)}
};
}
function show(v){[listV,readV,editor].forEach(x=>x.classList.add('hidden')); v.classList.remove('hidden'); document.documentElement.classList.toggle('reading', v===readV)}
async function renderList(items){
let a;
if(items!==undefined){
a = items;
} else {
setLoading(true,'Loading articles…');
try{ a = await API.list(); }
finally{ setLoading(false); }
}
if(!a.length){listV.innerHTML='<div class="small">No articles yet. Click New to create one.</div>';return}
listV.innerHTML=a.map(x=>`<div class="card" data-id="${x.id}"><img loading="lazy" decoding="async" src="${x.thumb||PH}" alt="thumb"><h3>${esc(x.title)}</h3></div>`).join('');
}
function esc(s){return (s||'').replace(/[&<>]/g,m=>({"&":"&amp;","<":"&lt;",">":"&gt;"}[m]))}
function pick(el,cb){el.onchange=e=>{const f=e.target.files&&e.target.files[0]; if(f) fileToDataURL(f).then(cb)}; el.click()}
function fileToDataURL(f){return new Promise((res,rej)=>{const r=new FileReader(); r.onload=()=>res(r.result); r.onerror=rej; r.readAsDataURL(f)})}
function dataURLToBlob(dataURL){ try{ const [h,b]=dataURL.split(','); const m=h.match(/data:([^;]+)(;base64)?/); if(!m) return null; const mime=m[1]; const isB64=!!m[2]; const raw=isB64? atob(b): decodeURIComponent(b); const arr=new Uint8Array(raw.length); for(let i=0;i<raw.length;i++) arr[i]=raw.charCodeAt(i); return new Blob([arr], {type:mime}); }catch(e){ return null; } }
function insertAtSel(node){const s=window.getSelection(); if(!s||!s.rangeCount){content.appendChild(node); return} const r=s.getRangeAt(0); r.deleteContents(); r.insertNode(node); r.setStartAfter(node); r.setEndAfter(node); s.removeAllRanges(); s.addRange(r)}
// Thumbnail via file chooser only
tFile.addEventListener('change',e=>{const f=e.target.files[0]; if(f) fileToDataURL(f).then(u=>tPrev.src=u)});
tDrop.addEventListener('click',()=>tFile.click());
content.addEventListener('paste',e=>{const it=(e.clipboardData||{}).items||[]; for(const x of it){const f=x.getAsFile&&x.getAsFile(); if(!f) continue; e.preventDefault(); if(f.type.startsWith('image/')) {
// Insert a placeholder while uploading
const ph=imgEl(''); ph.alt='image'; ph.style.minHeight='120px'; ph.style.background='#f0f0f0'; insertAtSel(ph);
(async()=>{ const url=await uploadBlobAndGetURL(f); if(url){ ph.removeAttribute('style'); ph.src=url; } else { fileToDataURL(f).then(u=>{ ph.removeAttribute('style'); ph.src=u; }); } })();
} else if(f.type.startsWith('video/')) {
fileToDataURL(f).then(u=>{const v=videoEl(u); insertAtSel(v)});
} } });
// Insert buttons
insImg.onclick=()=>{ imgFile.onchange=async e=>{ const f=e.target.files&&e.target.files[0]; if(!f) return; const ph=imgEl(''); ph.alt='image'; ph.style.minHeight='120px'; ph.style.background='#f0f0f0'; insertAtSel(ph); const url=await uploadBlobAndGetURL(f); if(url){ ph.removeAttribute('style'); ph.src=url; } else { fileToDataURL(f).then(u=>{ ph.removeAttribute('style'); ph.src=u; }); } }; imgFile.click(); };
insVid.onclick=()=>pick(vidFile, u=>{const v=videoEl(u); insertAtSel(v)});
// Save article
saveBtn.onclick=async()=>{
// Remove any empty placeholder images prior to saving
content.querySelectorAll('img[src=""], img:not([src])').forEach(n=>n.remove());
let th=tPrev.src||''; if(!th){const fi=content.querySelector('img'); if(fi) th=fi.src}
// If thumbnail is data URL, try uploading to separate file so it loads separately
if(th && th.startsWith('data:')){ const blob=dataURLToBlob(th); if(blob){ const u=await uploadBlobAndGetURL(blob); if(u) th=u; } }
const obj={title:title.value.trim()||'Untitled', author:author.value.trim(), body:content.innerHTML, thumb:th||''};
setLoading(true,'Saving…');
await API.create(obj);
selId=null; clearEditor();
await renderList();
setLoading(false);
show(listV);
};
function clearEditor(){ title.value=''; author.value=''; content.innerHTML=''; tPrev.removeAttribute('src') }
cancelBtn.onclick=()=>{selId=null; clearEditor(); show(listV)};
// List click -> read (ensure full focus by switching view)
listV.addEventListener('click',async e=>{const card=e.target.closest('.card'); if(!card) return; const id=card.getAttribute('data-id'); const c=AC[id]; if(c){ openRead(c); return; } setLoading(true,'Loading article…'); try{ const a=await API.get(id); if(!a) return; AC[id]=a; openRead(a); } finally { setLoading(false); }});
function openRead(a){ selId=a.id; rTh.loading='lazy'; rTh.decoding='async'; rTh.src=a.thumb||''; rT.textContent=a.title||''; rB.innerHTML=a.body||''; rB.querySelectorAll('img').forEach(im=>{ im.loading='lazy'; im.decoding='async'; }); const d=new Date(a.createdAt||Date.now()).toLocaleString(); dt.textContent=(a.author? a.author+' · ': '')+d; show(readV) }
// Delete
del.onclick=async()=>{ if(!selId) return; setLoading(true,'Deleting…'); await API.remove(selId); delete AC[selId]; selId=null; await renderList(); setLoading(false); show(listV) };
// Nav
toList.onclick=async()=>{ setLoading(true,'Loading articles…'); await renderList(); setLoading(false); show(listV)};
toNew.onclick=()=>{selId=null; clearEditor(); show(editor)};
back.onclick=()=>{show(listV)};
// No deep linking; keep UI simple and focused
(async function init(){
setLoading(true,'Loading articles…');
API=await detectAPI();
let items;
if(typeof preList!== 'undefined'){ items = preList; preList = undefined; }
else { items = await API.list(); }
if(!items.length){
const created = await API.create({title:'Welcome to Mini Articles', body:'<p>Edit or create your first article. Drop images or videos right into the text.</p>', thumb:''});
items = [created];
}
await renderList(items);
show(listV);
setLoading(false);
})();
navigator.serviceWorker&&navigator.serviceWorker.register('/sw.js').catch(()=>{});
})();
</script>
</html>