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> | |
/* ───────────────────────────────────────── | |
COLOR SYSTEM (light / dark via variables) | |
───────────────────────────────────────── */ | |
: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); | |
/* sticky colors */ | |
--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); | |
} | |
/* ───────────────────────────────────────── | |
GLOBAL “DESK” BACKGROUND | |
───────────────────────────────────────── */ | |
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 | |
───────────────────────────────────────── */ | |
.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); | |
} | |
/* perforation holes */ | |
.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; | |
} | |
/* curled corner */ | |
.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; | |
/* prevent short images from becoming tall boxes */ | |
align-items:start; | |
/* dynamic columns via CSS vars set from JS */ | |
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 notes */ | |
.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; /* don't stretch tile height */ | |
} | |
.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 & print */ | |
@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)); | |
} | |
} | |
@media print{ | |
body{background:#fff} | |
.container{box-shadow:none;border:none} | |
.header,.processing,.instructions,#themeToggle{display:none} | |
} | |
</style> | |
</head> | |
<body> | |
<div class="container"> | |
<!-- theme icon --> | |
<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; | |
/* ======= processing UI ======= */ | |
function showProcessing(text){ | |
if(text) processingNode.textContent = text; | |
processingNode.classList.add('show'); | |
} | |
function hideProcessing(){ | |
setTimeout(()=>{ | |
processingNode.classList.remove('show'); | |
processingNode.textContent = 'Processing…'; | |
},250); | |
} | |
/* ======= placeholder helpers ======= */ | |
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); | |
} | |
} | |
/* ======= layout: 1 image full width, 2 images 50/50, 3+ responsive ======= */ | |
function updateLayout(){ | |
const count = board.querySelectorAll('.sticky').length; | |
if(count === 1){ | |
board.style.setProperty('--cols', '1'); | |
board.style.setProperty('--minCol', '0px'); | |
}else if(count === 2){ | |
board.style.setProperty('--cols', '2'); | |
board.style.setProperty('--minCol', '0px'); | |
}else{ | |
board.style.setProperty('--cols', 'auto-fit'); | |
board.style.setProperty('--minCol', '220px'); | |
} | |
} | |
/* ======= random style bits ======= */ | |
function randTilt(){ | |
const d = (Math.random()*4.4 - 2.2).toFixed(2); | |
return d + '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, falling back to localStorage', e); | |
PERSIST.mode = 'ls'; | |
} | |
} | |
/* --- IndexedDB ops --- */ | |
function idbAdd(rec){ | |
return new Promise((resolve,reject)=>{ | |
const tx = dbConn.transaction(STORE,'readwrite'); | |
tx.objectStore(STORE).put(rec); | |
tx.oncomplete = ()=> resolve(); | |
tx.onerror = ()=> reject(tx.error); | |
}); | |
} | |
function idbGetAll(){ | |
return new Promise((resolve,reject)=>{ | |
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); | |
resolve(arr); | |
}; | |
req.onerror = ()=> reject(req.error); | |
}); | |
} | |
function idbDelete(id){ | |
return new Promise((resolve,reject)=>{ | |
const tx = dbConn.transaction(STORE,'readwrite'); | |
tx.objectStore(STORE).delete(id); | |
tx.oncomplete = ()=> resolve(); | |
tx.onerror = ()=> reject(tx.error); | |
}); | |
} | |
/* --- localStorage ops (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((resolve,reject)=>{ | |
const r = new FileReader(); | |
r.onload = ()=> resolve(r.result); | |
r.onerror = reject; | |
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); | |
} | |
/* --- persist a new file and return its record --- */ | |
async function persistFile(file, color, tilt){ | |
const rec = { id: uuid(), createdAt: Date.now(), color, tilt }; | |
if(PERSIST.mode === 'idb'){ | |
// store blob directly | |
await idbAdd({ ...rec, blob: file }); | |
return { ...rec, blob: file }; | |
}else{ | |
// fallback: store base64 (size-limited) | |
const dataUrl = await fileToDataURL(file); | |
lsAdd({ ...rec, dataUrl }); | |
return { ...rec, dataUrl }; | |
} | |
} | |
async function loadPersisted(){ | |
if(PERSIST.mode === 'idb'){ | |
return await idbGetAll(); | |
}else{ | |
return lsGetAll(); | |
} | |
} | |
async function deletePersisted(id){ | |
if(PERSIST.mode === 'idb'){ | |
await idbDelete(id); | |
}else{ | |
lsDelete(id); | |
} | |
} | |
/* ======= create sticky DOM ======= */ | |
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)'; | |
const stickyId = wrap.dataset.id; | |
try{ if(stickyId) await deletePersisted(stickyId); }catch(e){ console.warn('Delete failed', e); } | |
setTimeout(()=>{ | |
if(isBlobUrl) URL.revokeObjectURL(src); | |
wrap.remove(); | |
ensurePlaceholder(); | |
updateLayout(); | |
},140); | |
}); | |
wrap.appendChild(bar); | |
wrap.appendChild(img); | |
wrap.appendChild(btn); | |
board.prepend(wrap); // newest on top | |
} | |
/* ======= handle pasted or dropped images ======= */ | |
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 in clipboard'); | |
hideProcessing(); | |
return; | |
} | |
showProcessing('Saving…'); | |
for(const file of files){ | |
const color = pickColorClass(); | |
const tilt = randTilt(); | |
// persist first to get an id | |
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, { isBlobUrl:false, id: rec.id, colorClass: color, tilt }); | |
} | |
} | |
updateLayout(); | |
hideProcessing(); | |
} | |
/* ======= paste listener ======= */ | |
document.addEventListener('paste', e=>{ | |
const files = filesFromClipboard(e.clipboardData); | |
if(files.length){ e.preventDefault(); handleImages(files); } | |
}); | |
/* ======= drag & drop ======= */ | |
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 toggler ======= */ | |
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)'); | |
const savedTheme = localStorage.getItem('note-theme'); | |
initTheme(); | |
themeBtn.addEventListener('click',()=>{ | |
document.body.classList.toggle('dark'); | |
updateIcon(); | |
localStorage.setItem('note-theme', document.body.classList.contains('dark') ? 'dark' : 'light'); | |
}); | |
function initTheme(){ | |
if(savedTheme){ | |
document.body.classList.toggle('dark', savedTheme==='dark'); | |
}else if(prefersDark.matches){ | |
document.body.classList.add('dark'); | |
} | |
updateIcon(); | |
} | |
function updateIcon(){ | |
themeBtn.textContent = document.body.classList.contains('dark') ? '☀️' : '🌙'; | |
} | |
/* ======= boot: fade in and load saved stickies ======= */ | |
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); | |
showProcessing('Loading saved…'); | |
await initPersistence(); | |
try{ | |
const saved = await loadPersisted(); // ascending by createdAt | |
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, { isBlobUrl:false, id: rec.id, colorClass: rec.color, tilt: rec.tilt }); | |
} | |
} | |
}catch(e){ | |
console.warn('Load failed', e); | |
} | |
updateLayout(); | |
hideProcessing(); | |
}); | |
</script> | |
</body> | |
</html> |