Pinboard / index.html
SolarumAsteridion's picture
Update index.html
3f72fc7 verified
<!DOCTYPE html>
<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 &amp; 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 &amp; 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>