mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-07-04 13:03:13 +02:00
- Move puzzle_solver/, poker_modifier_app/, articles/, tests/ into python_pkg/ - Move moviepy_showcase.py and _moviepy_*.py into python_pkg/moviepy_showcase/ - Update all imports to use python_pkg. prefix - Update pyproject.toml per-file-ignores and pytest testpaths - Add pre-commit hook to enforce Python files under python_pkg/
234 lines
13 KiB
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=>({"&":"&","<":"<",">":">"}[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>
|