Spaces:
Running
Running
<html lang="en"> | |
<head> | |
<meta charset="UTF-8" /> | |
<meta name="viewport" content="width=device-width,initial-scale=1" /> | |
<title>Question Sticky Board</title> | |
<!-- Google fonts --> | |
<link | |
href="https://fonts.googleapis.com/css2?family=Crimson+Text:wght@400;600&family=Libre+Baskerville:wght@400;700&family=PT+Mono&display=swap" | |
rel="stylesheet" | |
/> | |
<style> | |
:root{ | |
--desk-bg:#fbf9f5; --desk-dot:#e2dccd; | |
--paper-bg:#fffefa; --paper-text:#222; --shadow:rgba(0,0,0,.35); | |
--perforation:#d0c6b7; --board-line:rgba(201,190,170,.18); | |
--note-yellow:#fff6a8; --note-mint:#d8f7e6; --note-blush:#ffe3e0; --note-blue:#e6f0ff; | |
--note-border:rgba(0,0,0,.08); --note-bar:rgba(0,0,0,.04); --note-text:#2b2b2b; | |
--btn-fg:#444; --btn-fg-dim:#666; --btn-bg-hover:rgba(0,0,0,.08); | |
} | |
body.dark{ | |
--desk-bg:#2c2a27; --desk-dot:#3a3733; | |
--paper-bg:#302e2b; --paper-text:#e9e7e2; --shadow:rgba(0,0,0,.55); | |
--perforation:#6d6456; --board-line:rgba(110,103,94,.28); | |
--note-yellow:#6b6434; --note-mint:#3f5a50; --note-blush:#5b3f3c; --note-blue:#3e4c63; | |
--note-border:rgba(0,0,0,.25); --note-bar:rgba(255,255,255,.05); --note-text:#f1efe9; | |
--btn-fg:#ddd; --btn-fg-dim:#aaa; --btn-bg-hover:rgba(255,255,255,.1); | |
} | |
html,body{height:100%} | |
body{ | |
margin:0; background:var(--desk-bg); | |
background-image:radial-gradient(var(--desk-dot) 1px,transparent 1px); | |
background-size:14px 14px; | |
font-family:'Crimson Text','Times New Roman',serif; color:var(--paper-text); | |
-webkit-font-smoothing:antialiased; | |
} | |
/* paper */ | |
.container{ | |
max-width:1100px; margin:40px auto; padding:34px 34px 40px 50px; | |
background:var(--paper-bg); color:var(--paper-text); | |
border:1px solid rgba(0,0,0,.05); border-radius:12px 12px 10px 10px; position:relative; | |
box-shadow:0 18px 40px -22px var(--shadow), inset 0 2px 6px rgba(0,0,0,.06); | |
} | |
.container::before{ | |
content:''; position:absolute; top:26px; bottom:26px; left:30px; width:9px; | |
background-image:radial-gradient(circle var(--perforation) 0%,var(--perforation) 2px,transparent 3px); | |
background-size:9px 28px; background-repeat:repeat-y; pointer-events:none; | |
} | |
.container::after{ | |
content:''; position:absolute; top:0; right:0; width:110px; height:110px; | |
background: | |
linear-gradient(135deg,rgba(0,0,0,.08) 0%,rgba(0,0,0,0) 42%), | |
linear-gradient(135deg,var(--paper-bg) 0%,var(--paper-bg) 50%,rgba(255,255,255,0) 51%); | |
border-bottom-left-radius:12px; pointer-events:none; | |
} | |
/* theme toggle */ | |
#themeToggle{ position:absolute; top:12px; right:14px; z-index:10; | |
font-size:20px; background:none; border:none; cursor:pointer; transition:transform .25s; user-select:none; | |
} | |
#themeToggle:hover{ transform:rotate(20deg) scale(1.15) } | |
/* header */ | |
.header{text-align:center; margin-bottom:18px; padding-bottom:14px; border-bottom:1px solid rgba(0,0,0,.05)} | |
h1{font-family:'Libre Baskerville',serif; margin:0; font-size:28px; letter-spacing:.4px} | |
.subtitle{font-family:'PT Mono',monospace; font-size:14px; color:#666; margin-top:6px; letter-spacing:1px} | |
/* board */ | |
#board{ | |
min-height:520px; position:relative; padding:18px 6px 10px 6px; | |
display:grid; gap:16px; overflow:visible; | |
align-items:start; /* prevents tall stretching of short images */ | |
/* dynamic columns driven by JS vars */ | |
grid-template-columns: repeat(var(--cols, auto-fit), minmax(var(--minCol, 220px), 1fr)); | |
} | |
#board::before{ | |
content:''; position:absolute; inset:0 6px; | |
background:repeating-linear-gradient(0deg, transparent,transparent 2.8em, var(--board-line) 2.8em,var(--board-line) 2.85em); | |
pointer-events:none; z-index:1; border-radius:8px; | |
} | |
#board > *{ position:relative; z-index:2 } | |
/* placeholder */ | |
.placeholder{ color:#888; font-style:italic; text-align:center; padding:120px 20px; user-select:none; grid-column:1/-1 } | |
/* sticky */ | |
.sticky{ | |
position:relative; border:1px solid var(--note-border); border-radius:10px; color:var(--note-text); | |
box-shadow:0 10px 26px -16px var(--shadow), inset 0 1px 0 rgba(255,255,255,.35); | |
transform:rotate(var(--tilt,0deg)) scale(1); | |
transition:transform .12s ease, box-shadow .12s ease, opacity .16s ease; | |
animation:pop .18s ease-out; align-self:start; | |
} | |
.sticky:hover{ transform:rotate(var(--tilt,0deg)) translateY(-2px) scale(1.01); | |
box-shadow:0 14px 32px -18px var(--shadow), inset 0 1px 0 rgba(255,255,255,.45); | |
} | |
@keyframes pop{from{transform:scale(.96);opacity:0}to{transform:scale(1);opacity:1}} | |
.sticky.note-yellow{background:linear-gradient(180deg,var(--note-yellow),color-mix(in oklab,var(--note-yellow)85%,#000 15%))} | |
.sticky.note-mint {background:linear-gradient(180deg,var(--note-mint), color-mix(in oklab,var(--note-mint) 85%,#000 15%))} | |
.sticky.note-blush {background:linear-gradient(180deg,var(--note-blush), color-mix(in oklab,var(--note-blush) 85%,#000 15%))} | |
.sticky.note-blue {background:linear-gradient(180deg,var(--note-blue), color-mix(in oklab,var(--note-blue) 85%,#000 15%))} | |
.sticky .bar{ | |
display:flex; align-items:center; justify-content:space-between; | |
padding:6px 8px 4px 10px; background:var(--note-bar); | |
border-top-left-radius:10px; border-top-right-radius:10px; | |
font-family:'PT Mono',monospace; font-size:12px; letter-spacing:.3px | |
} | |
.badge{ background:rgba(0,0,0,.08); padding:2px 6px; border-radius:999px; font-weight:600 } | |
body.dark .badge{background:rgba(255,255,255,.12)} | |
.sticky img{ | |
width:100%; height:auto; display:block; | |
border-bottom-left-radius:10px; border-bottom-right-radius:10px; | |
background:linear-gradient(180deg,rgba(255,255,255,.25),rgba(255,255,255,0)); | |
max-height:80vh; /* keep ultra-tall screenshots sane */ | |
} | |
/* delete button */ | |
.btn-del{ | |
position:absolute; top:6px; right:6px; width:26px; height:26px; border-radius:50%; | |
border:1px solid var(--note-border); background:rgba(255,255,255,.35); color:var(--btn-fg); | |
font-weight:700; line-height:24px; text-align:center; cursor:pointer; backdrop-filter:saturate(120%) blur(2px); | |
display:flex; align-items:center; justify-content:center; opacity:.85; transition:background .12s, transform .12s, opacity .12s; | |
} | |
.btn-del:hover{background:var(--btn-bg-hover); transform:scale(1.06); opacity:1} | |
.btn-del:active{transform:scale(.96)} | |
/* processing badge */ | |
.processing{ | |
position:fixed; top:20px; right:20px; background:#333; color:#fff; | |
padding:10px 20px; border-radius:6px; font-family:'PT Mono',monospace; | |
font-size:14px; opacity:0; transition:opacity .25s; z-index:2000 | |
} | |
.processing.show{opacity:.9} | |
/* instructions */ | |
.instructions{ text-align:center; font-family:'PT Mono',monospace; font-size:14px; color:#666; font-style:italic; margin-top:16px } | |
/* responsive */ | |
@media(max-width:768px){ | |
.container{margin:20px 16px; padding:24px} | |
h1{font-size:24px} | |
#board{ grid-template-columns: repeat(var(--cols, auto-fit), minmax(180px,1fr)) } | |
} | |
/* print */ | |
@media print{ | |
body{background:#fff} | |
.container{box-shadow:none; border:none} | |
.header,.processing,.instructions,#themeToggle{display:none} | |
} | |
</style> | |
</head> | |
<body> | |
<div class="container"> | |
<button id="themeToggle" title="Toggle dark / light">🌙</button> | |
<div class="header"> | |
<h1>Question Sticky Board</h1> | |
<div class="subtitle">press ctrl+v anywhere to paste images of questions</div> | |
</div> | |
<div id="board"> | |
<div class="placeholder"> | |
Press <kbd>Ctrl</kbd>+<kbd>V</kbd> (or <kbd>⌘</kbd>+<kbd>V</kbd>) to paste screenshots/images.<br/> | |
You can also drag & drop images here. | |
</div> | |
</div> | |
<div class="instructions">Tip: each image becomes a sticky. Click the tiny × to delete.</div> | |
</div> | |
<div class="processing">Processing…</div> | |
<script> | |
/* ======= DOM refs ======= */ | |
const board = document.getElementById('board'); | |
const processingNode = document.querySelector('.processing'); | |
const themeBtn = document.getElementById('themeToggle'); | |
let noteCount = 0; | |
/* ======= UI helpers ======= */ | |
function showProcessing(text){ if(text) processingNode.textContent=text; processingNode.classList.add('show') } | |
function hideProcessing(){ setTimeout(()=>{processingNode.classList.remove('show'); processingNode.textContent='Processing…'},250) } | |
function removePlaceholder(){ | |
const ph = board.querySelector('.placeholder'); | |
if(ph) ph.remove(); | |
} | |
function ensurePlaceholder(){ | |
if(!board.querySelector('.sticky') && !board.querySelector('.placeholder')){ | |
const ph = document.createElement('div'); | |
ph.className = 'placeholder'; | |
ph.innerHTML = `Press <kbd>Ctrl</kbd>+<kbd>V</kbd> (or <kbd>⌘</kbd>+<kbd>V</kbd>) to paste screenshots/images.<br/>You can also drag & drop images here.`; | |
board.appendChild(ph); | |
} | |
} | |
/* ======= SMART LAYOUT by aspect ratio ======= */ | |
/* Tune thresholds here */ | |
const WIDE_AR = 1.35; // >= this is considered landscape | |
const TALL_AR = 0.85; // <= this is considered portrait | |
function setLayout(cols, min){ | |
board.style.setProperty('--cols', String(cols)); | |
board.style.setProperty('--minCol', min); | |
} | |
function decideOrientation(imgs){ | |
let loaded=0, wide=0, tall=0, sum=0; | |
for(const img of imgs){ | |
const w=img.naturalWidth, h=img.naturalHeight; | |
if(!w || !h) continue; | |
loaded++; | |
const r = w/h; sum += r; | |
if(r >= WIDE_AR) wide++; | |
else if(r <= TALL_AR) tall++; | |
} | |
if(loaded === 0) return 'unknown'; | |
const avg = sum/loaded; | |
if(wide >= tall && avg >= 1.20) return 'landscape'; | |
if(tall > wide && avg <= 0.95) return 'portrait'; | |
return 'mixed'; | |
} | |
function updateLayout(){ | |
const stickies = [...board.querySelectorAll('.sticky')]; | |
const count = stickies.length; | |
if(count === 0){ | |
setLayout('auto-fit','220px'); | |
return; | |
} | |
if(count === 1){ | |
setLayout(1,'0px'); // full width | |
return; | |
} | |
const imgs = stickies.map(s=>s.querySelector('img')); | |
const mode = decideOrientation(imgs); | |
if(mode === 'landscape'){ | |
// Mostly wide → stack vertically (top to bottom) | |
setLayout(1,'0px'); | |
}else if(mode === 'portrait'){ | |
// Mostly tall → left to right (responsive columns) | |
if(count === 2) setLayout(2,'0px'); else setLayout('auto-fit','260px'); | |
}else{ | |
// Mixed or unknown → reasonable responsive default | |
if(count === 2) setLayout(2,'0px'); else setLayout('auto-fit','220px'); | |
} | |
} | |
/* ======= random styling ======= */ | |
function randTilt(){ return (Math.random()*4.4 - 2.2).toFixed(2)+'deg'; } | |
function pickColorClass(){ | |
const choices = ['note-yellow','note-mint','note-blush','note-blue']; | |
return choices[Math.floor(Math.random()*choices.length)]; | |
} | |
function uuid(){ return (crypto?.randomUUID?.() || ('id-'+Math.random().toString(16).slice(2)+Date.now())); } | |
/* ======= PERSISTENCE (IndexedDB with localStorage fallback) ======= */ | |
const PERSIST = { mode: 'idb' }; | |
const DB_NAME = 'question-board'; | |
const STORE = 'stickies'; | |
let dbConn = null; | |
function initDB(){ | |
return new Promise((resolve,reject)=>{ | |
const req = indexedDB.open(DB_NAME, 1); | |
req.onupgradeneeded = () => { | |
const db = req.result; | |
if(!db.objectStoreNames.contains(STORE)){ | |
const store = db.createObjectStore(STORE, { keyPath:'id' }); | |
store.createIndex('createdAt','createdAt'); | |
} | |
}; | |
req.onsuccess = ()=> resolve(req.result); | |
req.onerror = ()=> reject(req.error); | |
}); | |
} | |
async function initPersistence(){ | |
if(!('indexedDB' in window)){ PERSIST.mode='ls'; return; } | |
try{ dbConn = await initDB(); PERSIST.mode='idb'; } | |
catch(e){ console.warn('IndexedDB unavailable, fallback to localStorage', e); PERSIST.mode='ls'; } | |
} | |
/* IDB ops */ | |
function idbAdd(rec){ return new Promise((res,rej)=>{ const tx=dbConn.transaction(STORE,'readwrite'); tx.objectStore(STORE).put(rec); tx.oncomplete=()=>res(); tx.onerror=()=>rej(tx.error); }); } | |
function idbGetAll(){ return new Promise((res,rej)=>{ const tx=dbConn.transaction(STORE,'readonly'); const idx=tx.objectStore(STORE).index('createdAt'); const req=idx.getAll(); req.onsuccess=()=>{ const arr=req.result||[]; arr.sort((a,b)=>a.createdAt-b.createdAt); res(arr); }; req.onerror=()=>rej(req.error); }); } | |
function idbDelete(id){ return new Promise((res,rej)=>{ const tx=dbConn.transaction(STORE,'readwrite'); tx.objectStore(STORE).delete(id); tx.oncomplete=()=>res(); tx.onerror=()=>rej(tx.error); }); } | |
/* localStorage fallback */ | |
function lsMeta(){ try{ return JSON.parse(localStorage.getItem('stickies-meta')||'[]'); }catch{ return []; } } | |
function lsSetMeta(arr){ localStorage.setItem('stickies-meta', JSON.stringify(arr)); } | |
function fileToDataURL(file){ return new Promise((res,rej)=>{ const r=new FileReader(); r.onload=()=>res(r.result); r.onerror=rej; r.readAsDataURL(file); }); } | |
function lsAdd(rec){ const meta=lsMeta(); meta.push({id:rec.id,createdAt:rec.createdAt,color:rec.color,tilt:rec.tilt}); lsSetMeta(meta); localStorage.setItem('sticky-img-'+rec.id, rec.dataUrl); } | |
function lsGetAll(){ const meta=lsMeta().sort((a,b)=>a.createdAt-b.createdAt); return meta.map(m=>({id:m.id,createdAt:m.createdAt,color:m.color,tilt:m.tilt,dataUrl:localStorage.getItem('sticky-img-'+m.id)})).filter(r=>!!r.dataUrl); } | |
function lsDelete(id){ const meta=lsMeta().filter(m=>m.id!==id); lsSetMeta(meta); localStorage.removeItem('sticky-img-'+id); } | |
async function persistFile(file, color, tilt){ | |
const rec = { id: uuid(), createdAt: Date.now(), color, tilt }; | |
if(PERSIST.mode==='idb'){ await idbAdd({ ...rec, blob:file }); return { ...rec, blob:file }; } | |
const dataUrl = await fileToDataURL(file); lsAdd({ ...rec, dataUrl }); return { ...rec, dataUrl }; | |
} | |
async function loadPersisted(){ return PERSIST.mode==='idb' ? idbGetAll() : lsGetAll(); } | |
async function deletePersisted(id){ return PERSIST.mode==='idb' ? idbDelete(id) : lsDelete(id); } | |
/* ======= sticky creation ======= */ | |
function createSticky(src, { isBlobUrl=false, id=null, colorClass=null, tilt=null } = {}){ | |
removePlaceholder(); | |
noteCount++; | |
const wrap = document.createElement('div'); | |
const color = colorClass || pickColorClass(); | |
const tlt = tilt || randTilt(); | |
wrap.className = `sticky ${color}`; | |
wrap.style.setProperty('--tilt', tlt); | |
if(id) wrap.dataset.id = id; | |
const bar = document.createElement('div'); | |
bar.className = 'bar'; | |
bar.innerHTML = `<span class="badge">Q${noteCount}</span><span style="opacity:.7">saved</span>`; | |
const img = document.createElement('img'); | |
img.alt = `Question ${noteCount}`; | |
img.src = src; | |
const btn = document.createElement('button'); | |
btn.className = 'btn-del'; | |
btn.setAttribute('aria-label','Delete sticky'); | |
btn.textContent = '×'; | |
btn.addEventListener('click', async ()=>{ | |
wrap.style.opacity='0'; wrap.style.transform='scale(.96)'; | |
try{ if(wrap.dataset.id) await deletePersisted(wrap.dataset.id); }catch(e){ console.warn('Delete failed', e); } | |
setTimeout(()=>{ | |
if(isBlobUrl) URL.revokeObjectURL(src); | |
wrap.remove(); | |
ensurePlaceholder(); | |
updateLayout(); | |
},140); | |
}); | |
// When the image finishes loading, recompute layout (so aspect ratio is known) | |
img.addEventListener('load', updateLayout); | |
wrap.appendChild(bar); | |
wrap.appendChild(img); | |
wrap.appendChild(btn); | |
board.prepend(wrap); | |
} | |
/* ======= paste / drop ======= */ | |
function filesFromClipboard(dataTransfer){ | |
const items = dataTransfer?.items || []; | |
const files = []; | |
for(const it of items){ | |
if(it.kind==='file' && it.type.startsWith('image/')){ | |
const f = it.getAsFile(); if(f) files.push(f); | |
} | |
} | |
return files; | |
} | |
async function handleImages(files){ | |
if(!files.length){ showProcessing('No images found'); hideProcessing(); return; } | |
showProcessing('Saving…'); | |
for(const file of files){ | |
const color = pickColorClass(), tilt = randTilt(); | |
const rec = await persistFile(file, color, tilt); | |
if(PERSIST.mode==='idb'){ | |
const url = URL.createObjectURL(file); | |
createSticky(url, { isBlobUrl:true, id:rec.id, colorClass:color, tilt }); | |
}else{ | |
createSticky(rec.dataUrl, { id:rec.id, colorClass:color, tilt }); | |
} | |
} | |
hideProcessing(); | |
} | |
document.addEventListener('paste', e=>{ | |
const files = filesFromClipboard(e.clipboardData); | |
if(files.length){ e.preventDefault(); handleImages(files); } | |
}); | |
board.addEventListener('dragover', e=>{ e.preventDefault(); board.style.outline='2px dashed rgba(0,0,0,.2)'; }); | |
board.addEventListener('dragleave', ()=>{ board.style.outline='none'; }); | |
board.addEventListener('drop', e=>{ | |
e.preventDefault(); board.style.outline='none'; | |
const dtFiles = [...(e.dataTransfer?.files || [])].filter(f=>f.type.startsWith('image/')); | |
handleImages(dtFiles); | |
}); | |
/* ======= theme ======= */ | |
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)'); | |
const savedTheme = localStorage.getItem('note-theme'); | |
function initTheme(){ | |
if(savedTheme){ document.body.classList.toggle('dark', savedTheme==='dark'); } | |
else if(prefersDark.matches){ document.body.classList.add('dark'); } | |
updateIcon(); | |
} | |
themeBtn.addEventListener('click',()=>{ | |
document.body.classList.toggle('dark'); updateIcon(); | |
localStorage.setItem('note-theme', document.body.classList.contains('dark') ? 'dark' : 'light'); | |
}); | |
function updateIcon(){ themeBtn.textContent = document.body.classList.contains('dark') ? '☀️' : '🌙'; } | |
/* ======= boot ======= */ | |
document.addEventListener('DOMContentLoaded', async ()=>{ | |
const sheet=document.querySelector('.container'); | |
sheet.style.opacity='0'; | |
setTimeout(()=>{ sheet.style.transition='opacity .6s ease'; sheet.style.opacity='1' },80); | |
initTheme(); | |
showProcessing('Loading saved…'); | |
await initPersistence(); | |
try{ | |
const saved = await loadPersisted(); | |
for(const rec of saved){ | |
if(PERSIST.mode==='idb'){ | |
const url = URL.createObjectURL(rec.blob); | |
createSticky(url, { isBlobUrl:true, id:rec.id, colorClass:rec.color, tilt:rec.tilt }); | |
}else{ | |
createSticky(rec.dataUrl, { id:rec.id, colorClass:rec.color, tilt:rec.tilt }); | |
} | |
} | |
}catch(e){ console.warn('Load failed', e); } | |
updateLayout(); // initial | |
hideProcessing(); | |
}); | |
</script> | |
</body> | |
</html> |