🌐
Gpt5.html
Back
📝 Html ⚡ Executable Ctrl+S: Save • Ctrl+R: Run • Ctrl+F: Find
<!doctype html> <html lang="en"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <title>PicoDesk — Extensible HTML/JS Dashboard</title> <style> /* ------------------------------------------------------ PicoDesk — minimal, fast, no-build dashboard you can extend by dropping in small JS "plugins" (below). Single file; works fully offline; no frameworks. ------------------------------------------------------ */:root { --bg: #0b0e11; /* dark theme default */ --panel: #11151a; --panel-2: #161b22; --text: #e6edf3; --muted: #9fb0c0; --accent: #7aa2f7; --ok: #3fb950; --warn: #d29922; --danger: #f85149; --shadow: 0 6px 24px rgba(0,0,0,.35), 0 2px 8px rgba(0,0,0,.25); --radius: 16px; --radius-sm: 12px; } :root[data-theme="light"] { --bg: #f6f8fa; --panel: #ffffff; --panel-2: #f0f2f4; --text: #0b0e11; --muted: #495a6a; --accent: #2563eb; --ok: #16a34a; --warn: #b45309; --danger: #dc2626; --shadow: 0 6px 24px rgba(2,8,23,.12), 0 2px 8px rgba(2,8,23,.08); } * { box-sizing: border-box } html, body { height: 100% } body { margin: 0; font: 14px/1.4 system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, "Noto Sans", "Helvetica Neue", Arial, "Apple Color Emoji", "Segoe UI Emoji"; background: var(--bg); color: var(--text); } /* Top bar */ header { position: sticky; top: 0; z-index: 5; display: grid; grid-template-columns: 1fr auto auto; gap: 12px; align-items: center; padding: 14px clamp(12px, 3vw, 24px); background: linear-gradient(0deg, transparent, rgba(0,0,0,.1)); backdrop-filter: blur(6px); } .brand { display: flex; align-items: center; gap: 10px; font-weight: 700; letter-spacing: .2px; } .brand svg { width: 22px; height: 22px } .brand .sub { opacity: .7; font-weight: 500; margin-left: 8px; font-size: 12px } .search { display: flex; align-items: center; gap: 8px; max-width: 680px; width: 100%; margin: 0 auto; background: var(--panel); border: 1px solid var(--panel-2); border-radius: 999px; box-shadow: var(--shadow); padding: 8px 12px 8px 12px; } .search svg { width: 16px; height: 16px; opacity: .7 } .search input { border: 0; outline: none; background: transparent; color: var(--text); width: 100% } .search kbd { background: var(--panel-2); padding: 2px 6px; border-radius: 6px; font-size: 11px; opacity: .8 } .top-actions { display: flex; gap: 8px } .btn { display: inline-flex; align-items: center; gap: 8px; cursor: pointer; background: var(--panel); color: var(--text); border: 1px solid var(--panel-2); border-radius: 10px; padding: 10px 12px; box-shadow: var(--shadow); transition: transform .06s ease, background .2s ease, border-color .2s ease; } .btn:hover { transform: translateY(-1px) } .btn svg { width: 16px; height: 16px } /* Grid */ main { padding: 10px clamp(12px, 3vw, 24px) 40px } .grid { display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); grid-auto-rows: minmax(160px, auto); } /* Cards / widgets */ .card { background: var(--panel); border: 1px solid var(--panel-2); border-radius: var(--radius); box-shadow: var(--shadow); overflow: clip; display: flex; flex-direction: column; min-height: 160px; } .card.dragging { opacity: .6 } .card header { position: relative; display: flex; justify-content: space-between; align-items: center; gap: 8px; padding: 10px 12px; background: transparent; box-shadow: none } .title { display: flex; align-items: center; gap: 8px; font-weight: 700 } .title svg { width: 18px; height: 18px; opacity: .85 } .card .tools { display: flex; gap: 6px } .icon-btn { background: var(--panel-2); border: 1px solid transparent; color: var(--text); border-radius: 8px; padding: 6px; display: inline-flex; align-items: center; cursor: pointer } .icon-btn:hover { border-color: rgba(255,255,255,.12) } .icon-btn svg { width: 16px; height: 16px; opacity: .9 } .content { padding: 12px; flex: 1; min-height: 120px } .content.scroll { overflow: auto } .pill { display:inline-flex; align-items:center; gap:6px; border-radius:999px; padding:4px 8px; font-size:12px; background: var(--panel-2); border: 1px solid rgba(255,255,255,.08) } /* Modal / overlay */ .overlay { position: fixed; inset: 0; background: rgba(0,0,0,.45); display: none; place-items: center; padding: 20px; z-index: 50 } .overlay.show { display: grid } .modal { width: min(900px, 96vw); max-height: 86vh; overflow: auto; background: var(--panel); border:1px solid var(--panel-2); border-radius: 18px; box-shadow: var(--shadow) } .modal header { padding: 14px 16px; border-bottom: 1px solid var(--panel-2) } .modal .body { padding: 16px } .modal .grid { grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)) } .plugin-card { padding: 12px; border-radius: 12px; border: 1px solid var(--panel-2); background: var(--panel) } .plugin-card h4 { margin: 6px 0 } .plugin-card p { margin: 6px 0 12px; color: var(--muted) } /* Settings form */ .form { display: grid; gap: 10px } .form label { display: grid; gap: 6px; font-size: 13px } .form input[type="text"], .form input[type="number"], .form input[type="time"], .form textarea, .form select { background: var(--panel-2); color: var(--text); border: 1px solid rgba(255,255,255,.08); border-radius: 10px; padding: 8px 10px; outline: none } /* Command palette */ .palette { position: fixed; inset: 0; display: none; place-items: start center; padding-top: 12vh; background: rgba(0,0,0,.45); z-index: 60 } .palette.show { display: grid } .palette .box { width: min(720px, 96vw); background: var(--panel); border: 1px solid var(--panel-2); border-radius: 16px; box-shadow: var(--shadow); overflow: clip } .palette input { width: 100%; border: 0; outline: none; background: transparent; color: var(--text); padding: 12px 14px; font-size: 15px } .palette ul { list-style: none; margin: 0; padding: 0; max-height: 50vh; overflow: auto } .palette li { padding: 10px 14px; border-top: 1px solid var(--panel-2); display: flex; align-items: center; gap: 10px; cursor: pointer } .palette li:hover { background: var(--panel-2) } /* Small helpers */ .row { display: flex; gap: 8px; align-items: center } .spacer { flex: 1 } .muted { color: var(--muted) } .hint { color: var(--muted); font-size: 12px } .danger { color: var(--danger) } .ok { color: var(--ok) } .warn { color: var(--warn) } .hidden { display: none } /* Checkboxes */ .check { display: inline-flex; align-items: center; gap: 8px; user-select: none; cursor: pointer } .check input { width: 16px; height: 16px } /* Tables */ table { width: 100%; border-collapse: collapse } th, td { padding: 8px 6px; border-bottom: 1px solid var(--panel-2) } /* Drag handle cursor */ .drag-handle { cursor: grab } /* Toasts */ #toasts { position: fixed; right: 16px; bottom: 16px; display: grid; gap: 8px; z-index: 70 } .toast { background: var(--panel); border: 1px solid var(--panel-2); color: var(--text); box-shadow: var(--shadow); padding: 10px 12px; border-radius: 10px } /* Links */ a { color: var(--accent); text-decoration: none } /* ---------- Visual polish upgrades ---------- */ body { /* layered glow background for depth */ background: radial-gradient(1200px 600px at 20% -10%, rgba(122,162,247,.12), transparent 60%), radial-gradient(1000px 500px at 90% 10%, rgba(63,185,80,.08), transparent 60%), var(--bg); } .card { transition: transform .12s ease, box-shadow .2s ease, border-color .2s ease } .card:hover { transform: translateY(-2px); box-shadow: 0 16px 40px rgba(0,0,0,.35) } .btn { position: relative; overflow: hidden } .btn::after { content: ""; position: absolute; inset: 0; background: linear-gradient(90deg, transparent, rgba(255,255,255,.12), transparent); transform: translateX(-120%); transition: transform .4s ease } .btn:hover::after { transform: translateX(120%) } .pill { border: 1px solid rgba(255,255,255,.12) } .title span { text-shadow: 0 1px 0 rgba(0,0,0,.25) } /* Kanban visuals */ .kanban { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px,1fr)); gap: 10px } .kanban .col { background: var(--panel-2); border: 1px solid rgba(255,255,255,.08); border-radius: var(--radius-sm); padding: 8px; min-height: 180px; display: flex; flex-direction: column; gap: 8px } .kanban .col.dragover { outline: 2px dashed var(--accent) } .kanban .task { background: var(--panel); border: 1px solid rgba(255,255,255,.08); border-radius: 10px; padding: 8px; cursor: grab; user-select: none; box-shadow: var(--shadow) } /* Sketch canvas */ .sketch-toolbar { display: flex; gap: 8px; align-items: center; margin-bottom: 8px } .sketch-canvas { width: 100%; height: 320px; border-radius: 12px; border: 1px solid var(--panel-2); background: var(--panel-2); box-shadow: inset 0 0 0 1px rgba(255,255,255,.04) } /* ---------- Mobile & compact polish (v2) ---------- */ .desktop-only { display: inline-flex } .row-wrap { flex-wrap: wrap } @media (max-width: 720px) { header { grid-template-columns: 1fr auto; gap: 8px; padding: 10px 12px } .brand .sub { display: none } .search.desktop-only { display: none } .desktop-only { display: none } main { padding: 8px 12px 28px } .grid { gap: 8px; grid-template-columns: 1fr; } .card { min-height: 140px } .btn { padding: 8px 10px } .kanban .col { min-height: 160px } } :root.compact .grid { gap: 8px } :root.compact .card { border-radius: 12px } :root.compact .card header { padding: 8px 10px } :root.compact .content { padding: 10px } :root.compact .btn { padding: 8px 10px; border-radius: 8px } :root.compact .pill { padding: 3px 6px; font-size: 11px } :root.compact .kanban .task { padding: 6px; font-size: 13px } /* Tasks mobile layout */ .tasks-add > * { flex: 1 1 120px; min-width: 120px } @media (max-width: 720px) { .tasks-add input[type="date"], .tasks-add select { flex: 0 1 140px } } </style> </head> <body> <header> <div class="brand"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><path d="M4 12a8 8 0 1 0 16 0"/><path d="M12 4a8 8 0 0 0 0 16"/></svg> PicoDesk <span class="sub">extensible HTML/JS dashboard</span> </div><div class="search" title="Search data or press Ctrl/Cmd+K for the command palette"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/></svg> <input id="searchInput" placeholder="Search notes, tasks & widgets…" /> <kbd>Ctrl</kbd><kbd>K</kbd> </div> <div class="top-actions"> <button id="addBtn" class="btn" title="Add widget"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><path d="M12 5v14M5 12h14"/></svg> Add Widget </button> <button id="themeBtn" class="btn" title="Toggle theme"> <svg id="themeIcon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><path d="M12 3a9 9 0 1 0 9 9 7 7 0 0 1-9-9Z"/></svg> Theme </button> </div> </header> <main> <div id="grid" class="grid"></div> </main> <!-- Widgets catalog modal --> <div id="catalog" class="overlay" role="dialog" aria-modal="true"> <div class="modal"> <header> <div class="row"> <strong style="font-size:16px">Add a widget</strong> <span class="spacer"></span> <button class="icon-btn" data-close-catalog title="Close"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M6 6l12 12M18 6l-12 12"/></svg> </button> </div> </header> <div class="body"> <div class="grid" id="catalogGrid"></div> </div> </div> </div> <!-- Settings modal (reused by widgets) --> <div id="settings" class="overlay" role="dialog" aria-modal="true"> <div class="modal" style="width:min(720px,96vw)"> <header> <div class="row"> <strong style="font-size:16px">Widget settings</strong> <span id="settingsTitle" class="muted"></span> <span class="spacer"></span> <button class="icon-btn" data-close-settings title="Close"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M6 6l12 12M18 6l-12 12"/></svg> </button> </div> </header> <div class="body"> <form id="settingsForm" class="form"></form> <div class="row" style="margin-top:8px"> <span class="spacer"></span> <button id="settingsSave" class="btn">Save</button> </div> </div> </div> </div> <!-- Command palette --> <div id="palette" class="palette" role="dialog" aria-modal="true"> <div class="box"> <input id="paletteInput" placeholder="Type a command or search…" autofocus /> <ul id="paletteList"></ul> </div> </div> <div id="toasts"></div> <script> // ========================================================== // PicoDesk Core — state, registry, layout, persistence // ========================================================== (function() { const el = sel => document.querySelector(sel); const els = sel => Array.from(document.querySelectorAll(sel)); // ---------- Persistence ---------- const STORE_KEY = 'picodesk.v1'; const THEME_KEY = 'picodesk.theme'; function uid() { return Math.random().toString(36).slice(2, 10) } function save(state) { localStorage.setItem(STORE_KEY, JSON.stringify(state)); } function load() { try { return JSON.parse(localStorage.getItem(STORE_KEY)) } catch(e) { return null } } // ---------- Theme ---------- const setTheme = (theme) => { document.documentElement.setAttribute('data-theme', theme); localStorage.setItem(THEME_KEY, theme); el('#themeIcon').innerHTML = theme === 'light' ? '<circle cx="12" cy="12" r="5"/><path d="M12 1v3M12 20v3M4.22 4.22l2.12 2.12M17.66 17.66l2.12 2.12M1 12h3M20 12h3M4.22 19.78l2.12-2.12M17.66 6.34l2.12-2.12"/>' : '<path d="M12 3a9 9 0 1 0 9 9 7 7 0 0 1-9-9Z"/>'; } const toggleTheme = () => setTheme((document.documentElement.getAttribute('data-theme') === 'light') ? 'dark' : 'light'); // ---------- Toasts ---------- function toast(msg, ms = 1800) { const t = document.createElement('div'); t.className = 'toast'; t.textContent = msg; el('#toasts').appendChild(t); setTimeout(() => t.remove(), ms); } // ---------- Registry ---------- const Registry = { plugins: {}, register(p) { if (!p || !p.id) throw new Error('Plugin missing id'); if (this.plugins[p.id]) throw new Error('Duplicate plugin id: ' + p.id); this.plugins[p.id] = p; }, list() { return Object.values(this.plugins) } }; // ---------- App State ---------- const App = { state: { widgets: [] // {id, pluginId, title, settings, internalState} }, addWidget(pluginId, opts = {}) { const plugin = Registry.plugins[pluginId]; if (!plugin) return toast('Unknown plugin: ' + pluginId); const w = { id: uid(), pluginId, title: opts.title || plugin.name, settings: JSON.parse(JSON.stringify(plugin.defaultSettings || {})), internalState: plugin.createState ? plugin.createState() : {} }; this.state.widgets.push(w); save(this.state); renderGrid(); toast(`Added “${plugin.name}”`); }, removeWidget(id) { const i = this.state.widgets.findIndex(w => w.id === id); if (i >= 0) { const plugin = Registry.plugins[this.state.widgets[i].pluginId]; this.state.widgets.splice(i, 1); save(this.state); renderGrid(); toast(`Removed “${plugin?.name || 'widget'}”`); } }, moveWidget(fromIdx, toIdx) { const a = this.state.widgets; const [w] = a.splice(fromIdx, 1); a.splice(toIdx, 0, w); save(this.state); renderGrid(); } }; // ---------- Drag & Drop reorder ---------- function enableDrag(card, idx) { card.setAttribute('draggable', 'true'); const handle = card.querySelector('.drag-handle'); let startIdx = idx; card.addEventListener('dragstart', (e) => { if (e.target !== card && !handle.contains(e.target)) { e.preventDefault(); return; } card.classList.add('dragging'); e.dataTransfer.effectAllowed = 'move'; startIdx = els('.card').indexOf(card); }); card.addEventListener('dragend', () => card.classList.remove('dragging')); card.addEventListener('dragover', (e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move' }); card.addEventListener('drop', (e) => { e.preventDefault(); const cards = els('.card'); const toIdx = cards.indexOf(card); if (startIdx !== toIdx && startIdx >= 0 && toIdx >= 0) App.moveWidget(startIdx, toIdx); }); } // ---------- Rendering ---------- function renderGrid() { const grid = el('#grid'); grid.innerHTML = ''; App.state.widgets.forEach((w, idx) => { const plugin = Registry.plugins[w.pluginId]; const card = document.createElement('div'); card.className = 'card'; card.dataset.id = w.id; card.innerHTML = ` <header> <div class="title drag-handle" title="Drag to reorder"> ${svg(plugin.icon || defaultIcon)} <span>${w.title}</span> </div> <div class="tools"> <button class="icon-btn" data-action="settings" title="Settings">${svg(icons.cog)}</button> <button class="icon-btn" data-action="remove" title="Remove">${svg(icons.trash)}</button> </div> </header> <div class="content scroll"></div> `; enableDrag(card, idx); grid.appendChild(card); // Bind actions card.querySelector('[data-action="remove"]').onclick = () => App.removeWidget(w.id); card.querySelector('[data-action="settings"]').onclick = () => openSettings(w, plugin); // Render plugin UI const container = card.querySelector('.content'); const ctx = makeContext(w, plugin, container); plugin.render({ el: container, state: w.internalState, setState: ctx.setState, settings: w.settings, setSettings: ctx.setSettings, context: ctx }); }); } function makeContext(w, plugin, container) { return { widgetId: w.id, pluginId: w.pluginId, find: (sel) => container.querySelector(sel), el: container, setState(patch) { Object.assign(w.internalState, (typeof patch === 'function') ? patch(w.internalState) : patch); save(App.state); }, setSettings(patch) { Object.assign(w.settings, (typeof patch === 'function') ? patch(w.settings) : patch); save(App.state); }, rerender() { renderGrid() }, toast, actions: [], // plugins can push actions, collected for palette }; } // ---------- Settings ---------- function openSettings(widget, plugin) { const overlay = el('#settings'); const form = el('#settingsForm'); el('#settingsTitle').textContent = '— ' + (widget.title || plugin.name); form.innerHTML = ''; // Title field form.appendChild(labelWrap('Widget title', input('text', widget.title, v => widget.title = v))); // Plugin-defined settings schema (very small DSL) // Each entry: { key, type: 'text'|'number'|'select'|'checkbox'|'textarea', label, options?, min?, max?, step? } (plugin.settingsSchema || []).forEach(s => { const current = widget.settings[s.key]; let control; if (s.type === 'text') control = input('text', current, v => widget.settings[s.key] = v); if (s.type === 'number') control = input('number', current, v => widget.settings[s.key] = Number(v), { min: s.min, max: s.max, step: s.step || 1 }); if (s.type === 'textarea') control = textarea(current, v => widget.settings[s.key] = v); if (s.type === 'select') control = select(s.options || [], current, v => widget.settings[s.key] = v); if (s.type === 'checkbox') control = checkbox(Boolean(current), v => widget.settings[s.key] = v); form.appendChild(labelWrap(s.label || s.key, control, s.hint)); }); // Save el('#settingsSave').onclick = (e) => { e.preventDefault(); save(App.state); overlay.classList.remove('show'); renderGrid(); toast('Settings saved'); }; // Open overlay.classList.add('show'); } function input(type, value, oninput, attrs={}) { const i = document.createElement('input'); i.type = type; i.value = value; Object.entries(attrs).forEach(([k,v]) => v!=null && i.setAttribute(k, v)); i.oninput = () => oninput(i.value); return i; } function textarea(value, oninput) { const t = document.createElement('textarea'); t.rows = 6; t.value = value || ''; t.oninput = () => oninput(t.value); return t } function select(options, value, onchange) { const s = document.createElement('select'); options.forEach(o => { const opt = document.createElement('option'); const txt = (typeof o === 'string') ? o : o.label; const val = (typeof o === 'string') ? o : o.value; opt.textContent = txt; opt.value = val; if (val === value) opt.selected = true; s.appendChild(opt) }); s.onchange = () => onchange(s.value); return s; } function checkbox(checked, onchange) { const wrap = document.createElement('label'); wrap.className = 'check'; const c = document.createElement('input'); c.type = 'checkbox'; c.checked = checked; c.onchange = () => onchange(c.checked); wrap.appendChild(c); wrap.appendChild(document.createTextNode('Enabled')); return wrap; } function labelWrap(label, control, hint) { const l = document.createElement('label'); l.innerHTML = `<span>${label}</span>`; l.appendChild(control); if (hint) { const small = document.createElement('div'); small.className = 'hint'; small.textContent = hint; l.appendChild(small) } return l; } // ---------- Catalog ---------- function openCatalog() { const grid = el('#catalogGrid'); grid.innerHTML = ''; Registry.list().forEach(p => { const card = document.createElement('div'); card.className = 'plugin-card'; card.innerHTML = ` <div class="row"><span class="pill">${svg(p.icon || defaultIcon)} ${p.name}</span><span class="spacer"></span><span class="muted">${p.size || 'M'}</span></div> <h4>${p.name}</h4> <p>${p.description || ''}</p> <div class="row"> <button class="btn">Add</button> <span class="spacer"></span> <span class="hint">id: <code>${p.id}</code></span> </div> `; card.querySelector('.btn').onclick = () => { App.addWidget(p.id); }; grid.appendChild(card); }); el('#catalog').classList.add('show'); } // ---------- Search ---------- function searchAll(query) { query = query.trim().toLowerCase(); if (!query) return []; const results = []; App.state.widgets.forEach(w => { const plugin = Registry.plugins[w.pluginId]; if (plugin.searchIndex) { const items = plugin.searchIndex({ state: w.internalState, settings: w.settings }) || []; items.forEach(({ text, href }) => { if ((text || '').toLowerCase().includes(query)) results.push({ widget: w, plugin, text, href }); }); } }); return results; } // ---------- Command Palette ---------- const Palette = { open() { el('#palette').classList.add('show'); el('#paletteInput').value = ''; this.refresh(); }, close() { el('#palette').classList.remove('show') }, refresh() { const q = el('#paletteInput').value.trim().toLowerCase(); const actions = this.collectActions().filter(a => a.label.toLowerCase().includes(q)); const ul = el('#paletteList'); ul.innerHTML = ''; actions.forEach(a => { const li = document.createElement('li'); li.innerHTML = `${svg(a.icon || icons.bolt)}<div><div>${a.label}</div><div class="hint">${a.hint || ''}</div></div>`; li.onclick = () => { this.close(); a.run() }; ul.appendChild(li); }); }, collectActions() { const actions = [ { label: 'Add widget…', icon: icons.plus, hint: 'Open catalog', run: openCatalog }, { label: 'Toggle theme', icon: icons.sun, hint: 'Light/Dark', run: toggleTheme }, { label: 'Export data (JSON)', icon: icons.download, hint: 'Download your PicoDesk data', run: exportData }, { label: 'Import data (JSON)…', icon: icons.upload, hint: 'Load data from a file', run: importData }, ]; // plugin actions App.state.widgets.forEach(w => { const plugin = Registry.plugins[w.pluginId]; (plugin.actions || []).forEach(a => { actions.push({ label: `${plugin.name}: ${a.label}`, icon: a.icon, hint: a.hint, run: () => a.run({ widget: w, plugin, app: App }) }); }); }); return actions; } }; function exportData() { const blob = new Blob([JSON.stringify(App.state, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'picodesk-data.json'; a.click(); URL.revokeObjectURL(url); } function importData() { const input = document.createElement('input'); input.type = 'file'; input.accept = 'application/json'; input.onchange = () => { const file = input.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = () => { try { const data = JSON.parse(reader.result); if (Array.isArray(data.widgets)) { App.state = data; save(App.state); renderGrid(); toast('Imported data'); } else toast('Invalid file'); } catch(e) { toast('Invalid JSON') } }; reader.readAsText(file); }; input.click(); } // ---------- Icons ---------- function svg(path) { return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" aria-hidden="true">${path}</svg>` } const defaultIcon = '<circle cx="12" cy="12" r="8"/>'; const icons = { cog:'<path d="M9.5 2.8 8.8 5.3a6.8 6.8 0 0 0-1.9 1.1L4.5 6.1l-1.6 2.8 1.7 1.3a6.8 6.8 0 0 0 0 2.4L2.9 14l1.6 2.8 2.4-.3a6.8 6.8 0 0 0 1.9 1.1l.7 2.5h3.2l.7-2.5a6.8 6.8 0 0 0 1.9-1.1l2.4.3 1.6-2.8-1.7-1.3a6.8 6.8 0 0 0 0-2.4l1.7-1.3-1.6-2.8-2.4.3a6.8 6.8 0 0 0-1.9-1.1l-.7-2.5H9.5Zm2.5 6.2a3 3 0 1 1 0 6 3 3 0 0 1 0-6Z"/>', trash:'<path d="M4 7h16M6 7l1 12a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2l1-12M9 7V4a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v3"/>', plus:'<path d="M12 5v14M5 12h14"/>', sun:'<circle cx="12" cy="12" r="5"/><path d="M12 1v3M12 20v3M4.22 4.22l2.12 2.12M17.66 17.66l2.12 2.12M1 12h3M20 12h3M4.22 19.78l2.12-2.12M17.66 6.34l2.12-2.12"/>', bolt:'<path d="M13 2 3 14h7l-1 8 10-12h-7l1-8Z"/>', note:'<path d="M3 6a2 2 0 0 1 2-2h9l5 5v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"/><path d="M14 4v4a2 2 0 0 0 2 2h4"/>', list:'<path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/>', timer:'<path d="M10 2h4M12 14V8"/><circle cx="12" cy="14" r="8"/>', habit:'<rect x="3" y="4" width="18" height="16" rx="2"/><path d="M7 8v8M12 8v8M17 8v8"/>', link:'<path d="M10 13a5 5 0 0 1 7 0l2 2a5 5 0 0 1-7 7l-2-2M14 11a5 5 0 0 1-7 0l-2-2a5 5 0 0 1 7-7l2 2"/>', download:'<path d="M12 3v12m0 0 4-4m-4 4-4-4M3 21h18"/>', upload:'<path d="M12 21V9m0 0-4 4m4-4 4 4M3 3h18"/>', bookmark:'<path d="M6 3h12a1 1 0 0 1 1 1v17l-7-4-7 4V4a1 1 0 0 1 1-1z"/>', }; // ========================================================== // Built-in Plugins // Each plugin registers with Registry.register({ id, name, icon, // description, defaultSettings, settingsSchema, createState, render, // searchIndex, actions }) // ========================================================== // --- Notes Plugin ------------------------------------------------ Registry.register({ id: 'notes', name: 'Notes', icon: icons.note, size: 'M', description: 'A simple, fast note card with autosave & search.', defaultSettings: { title: 'Notes', monospace: false, placeholder: 'Type notes here…' }, settingsSchema: [ { key: 'monospace', type: 'checkbox', label: 'Monospace font' }, { key: 'placeholder', type: 'text', label: 'Placeholder' } ], createState: () => ({ text: '' }), render({ el, state, setState, settings }) { el.innerHTML = ''; const ta = document.createElement('textarea'); ta.value = state.text || ''; ta.placeholder = settings.placeholder || 'Type…'; ta.style.width = '100%'; ta.style.minHeight = '220px'; ta.style.resize = 'vertical'; ta.style.fontFamily = settings.monospace ? 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace' : 'inherit'; ta.oninput = () => setState({ text: ta.value }); el.appendChild(ta); }, searchIndex({ state }) { return [{ text: state.text }] }, actions: [ { label: 'Create new note card', icon: icons.plus, run: ({ app }) => app.addWidget('notes') } ] }); // --- Tasks Plugin ------------------------------------------------ Registry.register({ id: 'tasks', name: 'Tasks', icon: icons.list, size: 'M', description: 'Small but mighty task list with priorities & due dates.', defaultSettings: { title: 'Tasks', showCompleted: true }, settingsSchema: [ { key: 'showCompleted', type: 'checkbox', label: 'Show completed tasks' } ], createState: () => ({ items: [] }), render({ el, state, setState, settings }) { el.innerHTML = ''; // Add row — responsive, wraps nicely on mobile const addRow = document.createElement('div'); addRow.className = 'row row-wrap tasks-add'; const input = document.createElement('input'); input.placeholder = 'Add a task'; input.style.flex = '1'; const due = document.createElement('input'); due.type = 'date'; due.title = 'Due date'; const pri = document.createElement('select'); ['None','Low','Med','High'].forEach(p => { const o = document.createElement('option'); o.textContent = p; pri.appendChild(o) }); const addBtn = document.createElement('button'); addBtn.className = 'btn'; addBtn.textContent = 'Add'; addRow.append(input, due, pri, addBtn); el.appendChild(addRow); // List — mobile friendly (no wide table) const list = document.createElement('div'); list.className = 'tasks-list'; el.appendChild(list); function draw(){ list.innerHTML = ''; const items = state.items.slice().sort((a,b) => Number(b.priority||0)-Number(a.priority||0)); items.forEach((t, idx) => { if (t.done && !settings.showCompleted) return; const row = document.createElement('div'); row.className = 'taskrow row'; const chk = document.createElement('input'); chk.type='checkbox'; chk.checked=!!t.done; chk.onchange=()=>{ t.done=!t.done; setState({ items: state.items }); draw(); }; const text = document.createElement('div'); text.className='tasktext'; text.style.flex='1'; text.textContent=t.text; text.title='Tap to edit'; text.onclick = () => { const v = prompt('Edit task', t.text); if (v!=null) { t.text = v; setState({ items: state.items }); draw(); } }; const meta = document.createElement('div'); meta.className='hint'; meta.textContent = `${t.due||''}${t.due && t.priority? ' · ':''}${['','Low','Med','High'][t.priority||0]||''}`; const del = document.createElement('button'); del.className='icon-btn'; del.title='Delete'; del.textContent='🗑'; del.onclick=()=>{ state.items.splice(state.items.indexOf(t),1); setState({ items: state.items }); draw(); }; row.append(chk, text, meta, del); list.appendChild(row); }); } draw(); addBtn.onclick = () => { const text = input.value.trim(); if (!text) return; const item = { text, done: false, due: due.value || '', priority: pri.selectedIndex }; state.items.push(item); setState({ items: state.items }); input.value=''; due.value=''; pri.selectedIndex=0; draw(); }; }, searchIndex({ state }) { return state.items.map(t => ({ text: t.text })) }, actions: [ { label: 'New task…', icon: icons.plus, run: ({ app }) => app.addWidget('tasks') } ] }); } renderRows(); addBtn.onclick = () => { const text = input.value.trim(); if (!text) return; const item = { text, done: false, due: due.value || '', priority: pri.selectedIndex }; state.items.push(item); setState({ items: state.items }); input.value=''; due.value=''; pri.selectedIndex=0; renderRows(); }; }, searchIndex({ state }) { return state.items.map(t => ({ text: t.text })) }, actions: [ { label: 'New task…', icon: icons.plus, run: ({ app }) => app.addWidget('tasks') } ] }); // --- Pomodoro Plugin -------------------------------------------- Registry.register({ id: 'pomodoro', name: 'Pomodoro', icon: icons.timer, size: 'S', description: 'Focused timer with work/break cycles.', defaultSettings: { title: 'Focus Timer', workMins: 25, breakMins: 5, longBreakMins: 15, cycles: 4, autoStart: false }, settingsSchema: [ { key: 'workMins', type: 'number', label: 'Work minutes', min: 5, max: 120 }, { key: 'breakMins', type: 'number', label: 'Short break minutes', min: 1, max: 60 }, { key: 'longBreakMins', type: 'number', label: 'Long break minutes', min: 5, max: 60 }, { key: 'cycles', type: 'number', label: 'Work blocks per set', min: 1, max: 12 }, { key: 'autoStart', type: 'checkbox', label: 'Auto-start next block' } ], createState: () => ({ mode: 'work', secondsLeft: 25*60, running: false, cycle: 1, int: null }), render({ el, state, setState, settings, context }) { el.innerHTML = ''; const title = document.createElement('div'); title.className = 'row'; title.innerHTML = `<span class="pill">Mode: <strong>${state.mode}</strong></span><span class="spacer"></span><span class="muted">Cycle ${state.cycle}/${settings.cycles}</span>`; const big = document.createElement('div'); big.style.fontSize='42px'; big.style.fontWeight='800'; big.style.letterSpacing='.5px'; big.style.margin='6px 0 12px'; const controls = document.createElement('div'); controls.className='row'; const start = btn('Start'); const stop = btn('Stop'); const reset = btn('Reset'); controls.append(start, stop, reset); const next = btn('Next Block'); next.style.marginLeft='auto'; controls.append(next); el.append(title, big, controls); function fmt(s){ const m=Math.floor(s/60).toString().padStart(2,'0'); const ss=(s%60).toString().padStart(2,'0'); return `${m}:${ss}` } function update(){ big.textContent = fmt(state.secondsLeft) } function setTimer(seconds) { state.secondsLeft = seconds; setState({ secondsLeft: state.secondsLeft }); update() } function tick(){ if (!state.running) return; if (state.secondsLeft>0) { state.secondsLeft--; update() } else { ring(); nextBlock() } } function startTick(){ if(state.int) clearInterval(state.int); state.running=true; state.int=setInterval(tick,1000); setState({ running:true, int: state.int }); } function stopTick(){ if(state.int) clearInterval(state.int); state.running=false; setState({ running:false, int:null }); } function ring(){ try { new AudioContext().resume(); } catch(e){} toast('⏱️ Time!'); } function nextBlock(){ if (state.mode === 'work') { if (state.cycle >= settings.cycles) { state.mode = 'long'; setTimer((settings.longBreakMins||15)*60); state.cycle = 1 } else { state.mode = 'break'; setTimer((settings.breakMins||5)*60); state.cycle++ } } else { state.mode = 'work'; setTimer((settings.workMins||25)*60) } title.innerHTML = `<span class="pill">Mode: <strong>${state.mode}</strong></span><span class="spacer"></span><span class="muted">Cycle ${state.cycle}/${settings.cycles}</span>`; if (settings.autoStart) startTick(); else stopTick(); } // Initialize if (!state._init) { setTimer((settings.workMins||25)*60); setState({ _init: true }) } update(); start.onclick = startTick; stop.onclick = stopTick; reset.onclick = () => { stopTick(); state.mode='work'; state.cycle=1; setTimer((settings.workMins||25)*60) }; next.onclick = nextBlock; function btn(label){ const b=document.createElement('button'); b.className='btn'; b.textContent=label; return b } }, searchIndex(){ return [] }, actions: [ { label: 'Start timer', icon: icons.timer, run: ({ app }) => toast('Open the Pomodoro widget to start.') } ] }); // --- Habits Plugin ---------------------------------------------- Registry.register({ id: 'habits', name: 'Habits', icon: icons.habit, size: 'M', description: 'Tiny weekly habit tracker with streaks.', defaultSettings: { title: 'Habits', habits: 'Water,Walk,Read', weekStartsOn: 1 }, settingsSchema: [ { key: 'habits', type: 'text', label: 'Habit names (comma-separated)' }, { key: 'weekStartsOn', type: 'select', label: 'Week starts on', options: [{label:'Sunday', value:0},{label:'Monday', value:1}] } ], createState: () => ({ marks: {} }), render({ el, state, setState, settings }) { el.innerHTML=''; const habits = settings.habits.split(',').map(s=>s.trim()).filter(Boolean); const header = document.createElement('div'); header.className='row'; const prev = btn('◀'); const next = btn('▶'); const label = document.createElement('div'); label.className='pill'; header.append(prev, label, next); el.appendChild(header); const table = document.createElement('table'); el.appendChild(table); let monday = startOfWeek(new Date(), Number(settings.weekStartsOn)||1); function render(){ const days = Array.from({length:7}, (_,i)=>addDays(monday, i)); label.textContent = `${fmt(days[0])} – ${fmt(days[6])}`; table.innerHTML = ''; const thead=document.createElement('thead'); const trh=document.createElement('tr'); trh.innerHTML = `<th>Habit</th>${days.map(d=>`<th>${wday(d)}</th>`).join('')}`; thead.appendChild(trh); table.appendChild(thead); const tbody=document.createElement('tbody'); table.appendChild(tbody); habits.forEach(h => { const tr=document.createElement('tr'); tr.innerHTML = `<td><strong>${escapeHtml(h)}</strong></td>` + days.map(d=>{ const key = keyFor(h,d); const checked = !!state.marks[key]; return `<td><input type="checkbox" ${checked?'checked':''} data-k="${key}"></td>` }).join(''); tbody.appendChild(tr); }); tbody.querySelectorAll('input[type="checkbox"]').forEach(c => c.onchange = () => { const k=c.dataset.k; if (c.checked) state.marks[k]=true; else delete state.marks[k]; setState({ marks: state.marks }); }); } render(); prev.onclick = () => { monday = addDays(monday, -7); render() }; next.onclick = () => { monday = addDays(monday, +7); render() }; function btn(t){ const b=document.createElement('button'); b.className='btn'; b.textContent=t; return b } function fmt(d){ return d.toLocaleDateString(undefined, { month:'short', day:'numeric' }) } function wday(d){ return d.toLocaleDateString(undefined, { weekday:'short' }).slice(0,3) } function keyFor(h,d){ return `${h}::${d.toISOString().slice(0,10)}` } function startOfWeek(d, first=1){ const dd=new Date(d); const day=(dd.getDay()+7-first)%7; dd.setDate(dd.getDate()-day); dd.setHours(0,0,0,0); return dd } function addDays(d,n){ const x=new Date(d); x.setDate(x.getDate()+n); return x } }, searchIndex(){ return [] }, }); // --- Bookmarks Plugin ------------------------------------------- Registry.register({ id: 'bookmarks', name: 'Bookmarks', icon: icons.link, size: 'S', description: 'Quick links with tags & search.', defaultSettings: { title: 'Bookmarks' }, settingsSchema: [], createState: () => ({ items: [] }), render({ el, state, setState }) { el.innerHTML=''; const row=document.createElement('div'); row.className='row'; const url=inputEl('text','https://', 'URL'); const label=inputEl('text','', 'Label'); const tag=inputEl('text','', 'tag'); const add=button('Add'); row.append(url,label,tag,add); el.appendChild(row); const list=document.createElement('div'); list.style.display='grid'; list.style.gap='8px'; list.style.marginTop='8px'; el.appendChild(list); function draw(){ list.innerHTML=''; state.items.forEach((it, idx)=>{ const a=document.createElement('a'); a.href=it.url; a.target='_blank'; a.rel='noopener'; a.className='pill'; a.innerHTML=`${svg(icons.link)} ${escapeHtml(it.label||it.url)} <span class="muted">${it.tag?('#'+escapeHtml(it.tag)):''}</span>`; const wrap=document.createElement('div'); wrap.className='row'; wrap.append(a, spacer(), tinyBtn('✎', ()=>edit(idx)), tinyBtn('🗑', ()=>del(idx))); list.appendChild(wrap); })} draw(); add.onclick = () => { const u=url.value.trim(); if (!/^https?:\/\//.test(u)) return toast('Enter a valid http(s) URL'); state.items.push({ url:u, label: label.value.trim(), tag: tag.value.trim() }); setState({ items: state.items }); url.value='https://'; label.value=''; tag.value=''; draw(); }; function edit(i){ const it=state.items[i]; const l=prompt('Label', it.label||''); if (l==null) return; const t=prompt('Tag', it.tag||''); if (t==null) return; it.label=l; it.tag=t; setState({ items: state.items }); draw(); } function del(i){ state.items.splice(i,1); setState({ items: state.items }); draw(); } function inputEl(type, value, placeholder){ const i=document.createElement('input'); i.type=type; i.value=value; i.placeholder=placeholder||''; return i } function button(t){ const b=document.createElement('button'); b.className='btn'; b.textContent=t; return b } function tinyBtn(t, fn){ const b=document.createElement('button'); b.className='icon-btn'; b.textContent=t; b.onclick=fn; return b } function spacer(){ const s=document.createElement('span'); s.className='spacer'; return s } }, searchIndex({ state }) { return state.items.map(it => ({ text: `${it.label} ${it.tag} ${it.url}`, href: it.url })) }, actions: [ { label:'Open bookmark by search…', icon: icons.link, run: () => toast('Use the top search to find bookmarks.')} ] }); // --- World Clock Plugin ------------------------------------------- Registry.register({ id: 'worldclock', name: 'World Clock', icon: icons.sun, size: 'S', description: 'Multiple timezones at a glance with live updates.', defaultSettings: { timezones: 'America/Chicago, Europe/London, Asia/Tokyo', hour12: false }, settingsSchema: [ { key: 'timezones', type: 'text', label: 'IANA time zones (comma-separated)', hint: 'e.g., America/Chicago, Europe/London' }, { key: 'hour12', type: 'checkbox', label: '12-hour clock' } ], createState: () => ({ _int: null }), render({ el, state, setState, settings }) { el.innerHTML = ''; const tzs = settings.timezones.split(',').map(s=>s.trim()).filter(Boolean); if (state._int) { clearInterval(state._int); state._int = null } const table = document.createElement('table'); table.innerHTML = '<thead><tr><th>City</th><th>Time</th></tr></thead><tbody></tbody>'; const tbody = table.querySelector('tbody'); tzs.forEach(tz => { const tr = document.createElement('tr'); const city = tz.split('/').pop().replace(/_/g,' '); tr.innerHTML = `<td><strong>${escapeHtml(city)}</strong><div class="hint">${escapeHtml(tz)}</div></td><td data-tz="${tz}"></td>`; tbody.appendChild(tr); }); el.appendChild(table); function tick(){ const now = new Date(); el.querySelectorAll('[data-tz]').forEach(td => { try { const tz = td.getAttribute('data-tz'); const fmt = new Intl.DateTimeFormat([], { hour:'2-digit', minute:'2-digit', second:'2-digit', hour12: !!settings.hour12, timeZone: tz }); td.textContent = fmt.format(now); } catch(e) { td.textContent = '—' } }); } tick(); state._int = setInterval(tick, 1000); setState({ _int: state._int }); }, searchIndex(){ return [] } }); // --- Countdowns Plugin ------------------------------------------ Registry.register({ id: 'countdown', name: 'Countdowns', icon: icons.timer, size: 'M', description: 'Track time left to important dates.', defaultSettings: { title: 'Countdowns' }, settingsSchema: [], createState: () => ({ events: [], _int: null }), render({ el, state, setState }) { el.innerHTML=''; if (state._int) { clearInterval(state._int); state._int = null } const row = document.createElement('div'); row.className = 'row'; const name = document.createElement('input'); name.placeholder='Event name'; name.style.flex='1'; const when = document.createElement('input'); when.type='datetime-local'; const add = document.createElement('button'); add.className='btn'; add.textContent='Add'; row.append(name, when, add); el.appendChild(row); const table=document.createElement('table'); table.innerHTML = '<thead><tr><th>Event</th><th>Target</th><th>Left</th><th></th></tr></thead><tbody></tbody>'; const tbody=table.querySelector('tbody'); el.appendChild(table); function renderRows(){ tbody.innerHTML=''; state.events.forEach((ev, idx)=>{ const tr=document.createElement('tr'); tr.innerHTML = `<td><strong>${escapeHtml(ev.name)}</strong></td><td>${escapeHtml(ev.when || '')}</td><td data-left></td><td class="row" style="justify-content:end"><button class="icon-btn">🗑</button></td>`; tr.querySelector('.icon-btn').onclick = () => { state.events.splice(idx,1); setState({ events: state.events }); renderRows(); }; tbody.appendChild(tr); }); tick(); } add.onclick = () => { const n=name.value.trim(); const w=when.value; if (!n || !w) return toast('Enter a name and date'); state.events.push({ name:n, when:w }); setState({ events: state.events }); name.value=''; when.value=''; renderRows(); }; function fmt(ms){ if(ms<=0) return 'Done'; const s=Math.floor(ms/1000); const d=Math.floor(s/86400); const h=Math.floor((s%86400)/3600); const m=Math.floor((s%3600)/60); const ss=s%60; return `${d}d ${h}h ${m}m ${ss}s` } function tick(){ const now = Date.now(); tbody.querySelectorAll('tr').forEach((tr, i)=>{ const ev = state.events[i]; const t = new Date(ev.when).getTime(); tr.querySelector('[data-left]').textContent = fmt(t - now); }); } renderRows(); state._int = setInterval(tick, 1000); setState({ _int: state._int }); }, searchIndex({ state }) { return state.events.map(e => ({ text: e.name })) } }); // --- Kanban Plugin ---------------------------------------------- Registry.register({ id: 'kanban', name: 'Kanban', icon: icons.list, size: 'L', description: 'Drag tasks across columns. Touch-friendly with fallback buttons.', defaultSettings: { columns: 'Backlog, Doing, Review, Done' }, settingsSchema: [ { key:'columns', type:'text', label:'Columns (comma-separated)' } ], createState: () => ({ items: [] }), render({ el, state, setState, settings }) { el.innerHTML=''; const cols = settings.columns.split(',').map(s=>s.trim()).filter(Boolean); const row = document.createElement('div'); row.className='row row-wrap'; const input = document.createElement('input'); input.placeholder='New task'; input.style.flex='1'; const add = document.createElement('button'); add.className='btn'; add.textContent='Add'; row.append(input, add); el.appendChild(row); const board = document.createElement('div'); board.className='kanban'; board.style.position='relative'; el.appendChild(board); function renderBoard(){ board.innerHTML=''; cols.forEach(c => { const col = document.createElement('div'); col.className='col'; col.dataset.col = c; const h = document.createElement('div'); h.className='row'; h.innerHTML = `<strong>${escapeHtml(c)}</strong><span class="spacer"></span><span class="muted">${state.items.filter(i=>i.col===c).length}</span>`; const list = document.createElement('div'); list.className='list'; col.append(h, list); board.appendChild(col); }); state.items.forEach(it => makeCard(it)); } function makeCard(it){ const host = board.querySelector(`[data-col="${CSS.escape(it.col)}"] .list`); if (!host) return; const card = document.createElement('div'); card.className='task'; card.innerHTML = `${escapeHtml(it.text)}`; // mouse DnD (desktop) card.setAttribute('draggable','true'); card.addEventListener('dragstart', (e)=>{ e.dataTransfer.setData('text/plain', it.id) }); card.addEventListener('dragend', ()=> board.querySelectorAll('.col').forEach(c=>c.classList.remove('dragover'))); // touch/pointer DnD (mobile) card.addEventListener('pointerdown', (e)=>{ if (e.pointerType === 'mouse') return; // let HTML5 DnD handle desktop e.preventDefault(); let overCol = null; const move = (ev)=>{ const elAt = document.elementFromPoint(ev.clientX, ev.clientY); const col = elAt && elAt.closest('.kanban .col'); board.querySelectorAll('.col').forEach(c=>c.classList.remove('dragover')); if (col){ col.classList.add('dragover'); overCol = col; } }; const up = ()=>{ document.removeEventListener('pointermove', move); document.removeEventListener('pointerup', up); board.querySelectorAll('.col').forEach(c=>c.classList.remove('dragover')); if (overCol){ it.col = overCol.dataset.col; setState({ items: state.items }); renderBoard(); } }; document.addEventListener('pointermove', move); document.addEventListener('pointerup', up); }); // fallback arrows (visible on tiny screens via title) card.title = 'Tip: drag on touch; or long-press then drag. Use ←/→ buttons if drag is tricky.'; const controls = document.createElement('div'); controls.className='row'; controls.style.justifyContent='end'; controls.style.gap='6px'; const left = document.createElement('button'); left.className='icon-btn'; left.textContent='←'; const right = document.createElement('button'); right.className='icon-btn'; right.textContent='→'; left.onclick = ()=> { const i = cols.indexOf(it.col); if (i>0){ it.col = cols[i-1]; setState({ items: state.items }); renderBoard(); } }; right.onclick = ()=> { const i = cols.indexOf(it.col); if (i<cols.length-1){ it.col = cols[i+1]; setState({ items: state.items }); renderBoard(); } }; const wrap = document.createElement('div'); wrap.style.display='grid'; wrap.style.gridTemplateColumns='1fr auto'; wrap.style.alignItems='center'; wrap.style.gap='6px'; const txt = document.createElement('div'); txt.textContent = it.text; wrap.append(txt, controls); controls.append(left, right); host.appendChild(card); card.appendChild(wrap); } board.addEventListener('dragover', (e)=>{ e.preventDefault(); const col = e.target.closest('.col'); if (col) col.classList.add('dragover') }); board.addEventListener('dragleave', (e)=>{ const col = e.target.closest('.col'); if (col) col.classList.remove('dragover') }); board.addEventListener('drop', (e)=>{ e.preventDefault(); const col = e.target.closest('.col'); const id = e.dataTransfer.getData('text/plain'); const item = state.items.find(x=>x.id===id); if (item && col){ item.col = col.dataset.col; setState({ items: state.items }); renderBoard(); }}); add.onclick = () => { const t=input.value.trim(); if(!t) return; state.items.push({ id: uid(), text: t, col: cols[0] || 'Backlog' }); setState({ items: state.items }); input.value=''; renderBoard(); }; renderBoard(); }, searchIndex({ state }) { return state.items.map(i => ({ text: i.text })) }, actions: [ { label:'Add Kanban board', icon: icons.plus, run: ({ app }) => app.addWidget('kanban') } ] }); }); state.items.forEach(it => { const host = board.querySelector(`[data-col="${CSS.escape(it.col)}"] .list`); if (!host) return; const card = document.createElement('div'); card.className='task'; card.setAttribute('draggable','true'); card.innerHTML = `${escapeHtml(it.text)}`; card.addEventListener('dragstart', (e)=>{ e.dataTransfer.setData('text/plain', it.id) }); host.appendChild(card); }); } add.onclick = () => { const t=input.value.trim(); if(!t) return; state.items.push({ id: uid(), text: t, col: cols[0] || 'Backlog' }); setState({ items: state.items }); input.value=''; renderBoard(); }; renderBoard(); }, searchIndex({ state }) { return state.items.map(i => ({ text: i.text })) }, actions: [ { label:'Add Kanban board', icon: icons.plus, run: ({ app }) => app.addWidget('kanban') } ] }); // --- Sketch Pad Plugin ------------------------------------------ Registry.register({ id: 'sketch', name: 'Sketch Pad', icon: icons.note, size: 'M', description: 'Quick freehand notes — draw, erase, save snapshot.', defaultSettings: { title: 'Sketch', lineWidth: 3 }, settingsSchema: [ { key:'lineWidth', type:'number', label:'Line width', min:1, max:40 } ], createState: () => ({ dataUrl: null }), render({ el, state, setState, settings }) { el.innerHTML=''; const tools = document.createElement('div'); tools.className='sketch-toolbar'; const color = document.createElement('input'); color.type='color'; const size = document.createElement('input'); size.type='range'; size.min=1; size.max=40; size.value=String(settings.lineWidth||3); const erase = document.createElement('button'); erase.className='btn'; erase.textContent='Eraser'; let erasing=false; const clear = document.createElement('button'); clear.className='btn'; clear.textContent='Clear'; const save = document.createElement('button'); save.className='btn'; save.textContent='Save'; tools.append(color, size, erase, clear, save); el.appendChild(tools); const canvas = document.createElement('canvas'); canvas.className='sketch-canvas'; el.appendChild(canvas); const ctx = canvas.getContext('2d'); function resize(){ const ratio = window.devicePixelRatio || 1; const w = el.clientWidth - 24; const h = 320; canvas.width = Math.max(240, w) * ratio; canvas.height = h * ratio; canvas.style.width = Math.max(240, w) + 'px'; canvas.style.height = h + 'px'; ctx.scale(ratio, ratio); ctx.lineCap='round'; ctx.lineJoin='round'; if (state.dataUrl) { const img=new Image(); img.onload=()=>{ ctx.drawImage(img,0,0,canvas.width/ratio,canvas.height/ratio) }; img.src=state.dataUrl } } resize(); window.addEventListener('resize', resize, { once: true }); let drawing=false, last=null; function pos(e){ const r=canvas.getBoundingClientRect(); const x=(e.clientX|| (e.touches&&e.touches[0].clientX))-r.left; const y=(e.clientY|| (e.touches&&e.touches[0].clientY))-r.top; return {x, y} } function down(e){ drawing=true; last=pos(e); e.preventDefault() } function move(e){ if(!drawing) return; const p=pos(e); ctx.strokeStyle = erasing ? 'rgba(0,0,0,1)' : color.value; ctx.globalCompositeOperation = erasing ? 'destination-out' : 'source-over'; ctx.lineWidth = Number(size.value)||3; ctx.beginPath(); ctx.moveTo(last.x, last.y); ctx.lineTo(p.x, p.y); ctx.stroke(); last=p; } function up(){ drawing=false } canvas.addEventListener('mousedown', down); canvas.addEventListener('mousemove', move); window.addEventListener('mouseup', up); canvas.addEventListener('touchstart', down, {passive:false}); canvas.addEventListener('touchmove', move, {passive:false}); canvas.addEventListener('touchend', up); erase.onclick = () => { erasing=!erasing; erase.classList.toggle('warn', erasing); erase.textContent = erasing ? 'Eraser (on)' : 'Eraser' }; clear.onclick = () => { ctx.clearRect(0,0,canvas.width,canvas.height) }; save.onclick = () => { state.dataUrl = canvas.toDataURL('image/png'); setState({ dataUrl: state.dataUrl }); toast('Sketch saved in widget data') }; }, searchIndex(){ return [] } }); // ========================================================== // App Boot // ========================================================== function init() { // Theme setTheme(localStorage.getItem(THEME_KEY) || 'dark'); // Load state or create a nice starter layout const saved = load(); if (saved && Array.isArray(saved.widgets)) { App.state = saved; } else { App.addWidget('notes', { title: 'Scratchpad' }); App.addWidget('tasks'); App.addWidget('pomodoro'); App.addWidget('habits'); App.addWidget('bookmarks'); App.addWidget('worldclock'); App.addWidget('countdown'); App.addWidget('kanban'); } renderGrid(); // Search field (content search) el('#searchInput').addEventListener('keydown', (e) => { if (e.key === 'Enter') { const q = e.target.value; const results = searchAll(q); if (!results.length) return toast('No results'); // Open palette with matches as actions const palActions = results.slice(0, 20).map(r => ({ label: `${r.plugin.name}: ${r.text.slice(0,100)}`, hint: 'Open widget', run: () => toast('Open the widget to view/edit this item.') })); Palette.open(); // Inject const ul = el('#paletteList'); ul.innerHTML = ''; palActions.forEach(a => { const li = document.createElement('li'); li.innerHTML = `${svg(icons.bolt)}<div><div>${a.label}</div><div class="hint">${a.hint}</div></div>`; li.onclick = () => { Palette.close(); a.run() }; ul.appendChild(li); }); } }); // Top actions el('#addBtn').onclick = openCatalog; el('#themeBtn').onclick = toggleTheme; // Close modals els('[data-close-catalog]').forEach(b => b.onclick = () => el('#catalog').classList.remove('show')); els('[data-close-settings]').forEach(b => b.onclick = () => el('#settings').classList.remove('show')); // Palette shortcuts document.addEventListener('keydown', (e) => { const isMac = /Mac|iPhone|iPad/.test(navigator.platform); if ((isMac && e.metaKey && e.key.toLowerCase()==='k') || (!isMac && e.ctrlKey && e.key.toLowerCase()==='k')) { e.preventDefault(); Palette.open(); } if (e.key === 'Escape') { el('#palette').classList.remove('show'); el('#catalog').classList.remove('show'); el('#settings').classList.remove('show'); } }); el('#paletteInput').oninput = () => Palette.refresh(); } // Escape HTML helper function escapeHtml(s){ return (s||'').replace(/[&<>"]|\"/g, c=>({"&":"&amp;","<":"&lt;",">":"&gt;","\"":"&quot;"}[c])) } // Init now (after definitions) init(); // Expose a tiny developer API for quick experimentation in console window.PicoDesk = { Registry, App }; })(); </script> <!-- ============================================================= Developer notes & extension mini-guide ------------------------------------------------------------- • Register a new widget by calling Registry.register({...}). • See examples above (Notes, Tasks, Pomodoro, Habits, Bookmarks). • Minimal interface: Registry.register({ id: 'my-widget', name: 'My Widget', icon: '<circle cx="12" cy="12" r="6"/>', description: 'What it does', defaultSettings: { title: 'My Widget' }, settingsSchema: [ { key:'title', type:'text', label:'Title' } ], createState: () => ({ count: 0 }), render({ el, state, setState, settings, setSettings, context }) { el.innerHTML = ''; const b = document.createElement('button'); b.className='btn'; b.textContent='Count +1'; b.onclick=()=>{ setState({ count: state.count+1 }); b.nextSibling.textContent = 'Count: '+state.count }; const p = document.createElement('span'); p.style.marginLeft='8px'; p.textContent='Count: '+state.count; el.append(b, p); }, searchIndex({ state }) { return [{ text: 'Count is '+state.count }] }, actions: [ { label:'Add My Widget', run: ({ app }) => app.addWidget('my-widget') } ] }) • Data is persisted to localStorage. Use Export/Import from the Command Palette to backup or sync. • The command palette (Ctrl/Cmd+K) aggregates built-in actions + plugin actions. • The top search indexes plugin text via plugin.searchIndex(). • This app is framework-free, dependency-free, single file. Drop into any static host. ============================================================= --></body> </html>