Pinboard / index.html
SolarumAsteridion's picture
Update index.html
20a3962 verified
raw
history blame
19.2 kB
<!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>
/* ─────────────────────────────────────────
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 &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;
/* ======= 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 &amp; 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>