2025-09-07 21:26:55 +02:00
<!doctype html>
< html lang = "en" >
< meta charset = "utf-8" >
< meta name = "viewport" content = "width=device-width,initial-scale=1" >
2025-09-07 22:09:22 +02:00
< title > Mini Articles< / title >
2025-09-07 21:26:55 +02:00
< 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}
2025-09-08 07:41:15 +02:00
#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)}}
2025-09-07 21:26:55 +02:00
.reading header nav{display:none}
2025-09-08 12:06:31 +02:00
.reading #delBtn{display:none}
2025-09-07 21:26:55 +02:00
< / style >
< header >
2025-09-07 22:09:22 +02:00
< h1 > Mini Articles< / h1 >
2025-09-07 21:26:55 +02:00
< nav >
< button id = "toList" title = "All articles" > List< / button >
< button id = "toNew" title = "Create article" > New< / button >
< / nav >
< / header >
< main >
2025-09-08 08:04:07 +02:00
< section id = "loading" class = "hidden" >
2025-09-08 07:41:15 +02:00
< div class = "small" > < span class = "spinner" > < / span > < span class = "msg" > Loading…< / span > < / div >
< / section >
2025-09-07 21:26:55 +02:00
< section id = "listV" class = "grid" > < / section >
< section id = "readV" class = "hidden" >
< div class = "controls" >
< button id = "backBtn" > ← Back< / button >
2025-09-08 08:04:07 +02:00
< button id = "delBtn" > Delete< / button >
2025-09-07 21:26:55 +02:00
< span class = "small" id = "dateInfo" > < / span >
< / div >
< article id = "readA" >
2025-09-08 08:04:07 +02:00
< img id = "readThumb" / >
2025-09-07 21:26:55 +02:00
< h1 id = "readTitle" > < / h1 >
< div id = "readBody" > < / div >
< / article >
< / section >
< section id = "editor" class = "hidden" >
< label > Title< / label >
2025-09-08 08:04:07 +02:00
< input id = "title" type = "text" placeholder = "Article title" / >
2025-09-07 21:26:55 +02:00
2025-09-08 12:06:31 +02:00
< label > Author< / label >
< input id = "author" type = "text" placeholder = "Author (optional)" / >
2025-09-07 21:26:55 +02:00
< label > Thumbnail< / label >
2025-09-08 12:06:31 +02:00
< div id = "thumbDrop" class = "drop" > Choose image…< br > < img id = "thumbPrev" / > < / div >
2025-09-08 08:04:07 +02:00
< input id = "thumbFile" type = "file" / >
2025-09-07 21:26:55 +02:00
< label > Body< / label >
< div class = "controls" >
< button id = "insImg" > Insert image< / button >
< button id = "insVid" > Insert video< / button >
< / div >
2025-09-08 12:08:10 +02:00
< div id = "content" contenteditable = "true" data-ph = "Write here, anything html-able should work" > < / div >
2025-09-07 21:26:55 +02:00
< div class = "controls" >
< button id = "saveBtn" > Save< / button >
< button id = "cancelBtn" > Cancel< / button >
< / div >
2025-09-08 08:04:07 +02:00
< input id = "imgFile" type = "file" hidden >
< input id = "vidFile" type = "file" hidden >
2025-09-07 21:26:55 +02:00
< / section >
< / main >
< script >
(function(){
const $=s=>document.querySelector(s);
const listV=$('#listV'), readV=$('#readV'), editor=$('#editor');
2025-09-08 07:41:15 +02:00
const loading=$('#loading');
2025-09-07 21:26:55 +02:00
const rT=$('#readTitle'), rB=$('#readBody'), rTh=$('#readThumb'), dt=$('#dateInfo');
const toList=$('#toList'), toNew=$('#toNew'), back=$('#backBtn'), del=$('#delBtn');
2025-09-08 12:06:31 +02:00
const title=$('#title'), author=$('#author'), content=$('#content');
2025-09-07 21:26:55 +02:00
const tDrop=$('#thumbDrop'), tPrev=$('#thumbPrev'), tFile=$('#thumbFile');
const insImg=$('#insImg'), insVid=$('#insVid'), imgFile=$('#imgFile'), vidFile=$('#vidFile');
const saveBtn=$('#saveBtn'), cancelBtn=$('#cancelBtn');
const KEY='articles14k';
2025-09-08 12:06:31 +02:00
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";
2025-09-08 07:41:15 +02:00
let selId=null; let API=null; let preList; // cache first list to avoid duplicate initial fetches
2025-09-08 08:04:07 +02:00
const AC={};
2025-09-08 07:41:15 +02:00
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; }
2025-09-07 21:26:55 +02:00
// API client with detection
async function detectAPI(){
2025-09-08 07:41:15 +02:00
// 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){}
2025-09-07 21:26:55 +02:00
// 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)}
2025-09-08 07:41:15 +02:00
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}
2025-09-08 12:06:31 +02:00
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('');
2025-09-07 21:26:55 +02:00
}
function esc(s){return (s||'').replace(/[&<>]/g,m=>({"&":"& ","< ":"< ",">":"> "}[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)})}
2025-09-08 07:41:15 +02:00
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 ; } }
2025-09-07 21:26:55 +02:00
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)}
2025-09-08 12:06:31 +02:00
// Thumbnail via file chooser only
2025-09-07 21:26:55 +02:00
tFile.addEventListener('change',e=>{const f=e.target.files[0]; if(f) fileToDataURL(f).then(u=>tPrev.src=u)});
tDrop.addEventListener('click',()=>tFile.click());
2025-09-08 07:41:15 +02:00
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)});
} } });
2025-09-07 21:26:55 +02:00
// Insert buttons
2025-09-08 07:41:15 +02:00
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)});
2025-09-07 21:26:55 +02:00
// Save article
saveBtn.onclick=async()=>{
2025-09-08 07:41:15 +02:00
// Remove any empty placeholder images prior to saving
content.querySelectorAll('img[src=""], img:not([src])').forEach(n=>n.remove());
2025-09-07 21:26:55 +02:00
let th=tPrev.src||''; if(!th){const fi=content.querySelector('img'); if(fi) th=fi.src}
2025-09-08 07:41:15 +02:00
// 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; } }
2025-09-08 12:06:31 +02:00
const obj={title:title.value.trim()||'Untitled', author:author.value.trim(), body:content.innerHTML, thumb:th||''};
2025-09-08 07:41:15 +02:00
setLoading(true,'Saving…');
await API.create(obj);
selId=null; clearEditor();
await renderList();
setLoading(false);
show(listV);
2025-09-07 21:26:55 +02:00
};
2025-09-08 12:06:31 +02:00
function clearEditor(){ title.value=''; author.value=''; content.innerHTML=''; tPrev.removeAttribute('src') }
2025-09-07 21:26:55 +02:00
cancelBtn.onclick=()=>{selId=null; clearEditor(); show(listV)};
// List click -> read (ensure full focus by switching view)
2025-09-08 07:54:18 +02:00
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); }});
2025-09-08 12:06:31 +02:00
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) }
2025-09-07 21:26:55 +02:00
// Delete
2025-09-08 07:54:18 +02:00
del.onclick=async()=>{ if(!selId) return; setLoading(true,'Deleting…'); await API.remove(selId); delete AC[selId]; selId=null; await renderList(); setLoading(false); show(listV) };
2025-09-07 21:26:55 +02:00
// Nav
2025-09-08 07:41:15 +02:00
toList.onclick=async()=>{ setLoading(true,'Loading articles…'); await renderList(); setLoading(false); show(listV)};
2025-09-07 21:26:55 +02:00
toNew.onclick=()=>{selId=null; clearEditor(); show(editor)};
back.onclick=()=>{show(listV)};
// No deep linking; keep UI simple and focused
(async function init(){
2025-09-08 07:41:15 +02:00
setLoading(true,'Loading articles…');
2025-09-07 21:26:55 +02:00
API=await detectAPI();
2025-09-08 07:41:15 +02:00
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);
2025-09-07 21:26:55 +02:00
show(listV);
2025-09-08 07:41:15 +02:00
setLoading(false);
2025-09-07 21:26:55 +02:00
})();
2025-09-08 07:46:49 +02:00
navigator.serviceWorker&&navigator.serviceWorker.register('/sw.js').catch(()=>{});
2025-09-07 21:26:55 +02:00
})();
< / script >
< / html >