🌐
TestChunkB_copy.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 } </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, context }) { el.innerHTML = ''; const addRow = document.createElement('div'); addRow.className = 'row'; 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); const table = document.createElement('table'); table.innerHTML = `<thead><tr><th>Done</th><th>Task</th><th>Due</th><th>Pri</th><th></th></tr></thead><tbody></tbody>`; const tbody = table.querySelector('tbody'); el.appendChild(table); function renderRows() { tbody.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 tr = document.createElement('tr'); tr.innerHTML = ` <td><input type="checkbox" ${t.done?'checked':''}></td> <td>${escapeHtml(t.text)}</td> <td>${t.due || ''}</td> <td>${['','L','M','H'][t.priority||0]||''}</td> <td class="row" style="justify-content:end; gap:6px"> <button class="icon-btn" title="Edit">✎</button> <button class="icon-btn" title="Delete">🗑</button> </td>`; // Handlers tr.querySelector('input').onchange = () => { t.done = !t.done; setState({ items: state.items }); renderRows(); }; tr.querySelectorAll('button')[0].onclick = () => { const txt = prompt('Edit task', t.text); if (txt != null) { t.text = txt; setState({ items: state.items }); renderRows(); } }; tr.querySelectorAll('button')[1].onclick = () => { state.items.splice(idx,1); setState({ items: state.items }); renderRows(); }; tbody.appendChild(tr); }); } 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.')} ] }); // ========================================================== // 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'); } 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>