-
Notifications
You must be signed in to change notification settings - Fork 0
Add frontend-only V3 Quick Hub across 360 core pages #40
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,78 @@ | ||
| (function(){ | ||
| const KEYS={ | ||
| notes:'360_quick_notes_v1', | ||
| pomodoro:'360_pomodoro_minutes', | ||
| focus:'360_focus_mode' | ||
| }; | ||
| const ROUTES=[ | ||
| ['Home','/'],['AI','/ai'],['Weather','/weather'],['Translator','/translator'],['Stocks','/stocks'],['Chat','/chat'],['News','/news'],['Apps','/apps'],['Games','/games'],['Settings','/settings.html'] | ||
| ]; | ||
|
|
||
| function injectUI(){ | ||
| if(document.getElementById('v3Fab')) return; | ||
| const style=document.createElement('style'); | ||
| style.textContent=` | ||
| #v3Fab{position:fixed;right:14px;bottom:14px;z-index:1200;border:none;border-radius:999px;padding:10px 14px;background:linear-gradient(120deg,var(--a),var(--a2));font-weight:700;cursor:pointer} | ||
| #v3Panel{position:fixed;right:14px;bottom:58px;z-index:1200;width:min(360px,92vw);background:rgba(15,23,42,.9);color:#fff;border:1px solid rgba(255,255,255,.15);border-radius:12px;padding:10px;display:none;backdrop-filter:blur(8px)} | ||
| #v3Panel.open{display:block}.v3row{display:flex;gap:6px;margin-bottom:8px}.v3row>*{flex:1} | ||
| .v3input{width:100%;padding:8px;border-radius:8px;border:1px solid rgba(255,255,255,.2);background:rgba(255,255,255,.08);color:#fff} | ||
| .v3btn{padding:8px;border-radius:8px;border:1px solid rgba(255,255,255,.2);background:rgba(255,255,255,.12);color:#fff;cursor:pointer} | ||
| #v3Cmd{position:fixed;inset:0;background:rgba(2,6,23,.7);z-index:1300;display:none;align-items:flex-start;justify-content:center;padding-top:12vh} | ||
| #v3Cmd.open{display:flex}#v3CmdBox{width:min(680px,92vw);background:#0f172a;border:1px solid rgba(255,255,255,.15);border-radius:12px;padding:10px} | ||
| #v3CmdList button{display:block;width:100%;text-align:left;margin-top:6px} | ||
| body.v3-focus .sidebar, body.v3-focus .auth-top-right, body.v3-focus .settings-panel{display:none!important} | ||
| `; | ||
| document.head.appendChild(style); | ||
|
|
||
| const fab=document.createElement('button'); fab.id='v3Fab'; fab.textContent='V3 ⚡'; | ||
| const panel=document.createElement('section'); panel.id='v3Panel'; panel.innerHTML=` | ||
| <div style="font-size:12px;opacity:.8;margin-bottom:8px;">Quick Hub (Frontend-only)</div> | ||
| <div class="v3row"><button class="v3btn" id="v3FocusBtn">Toggle Focus</button><button class="v3btn" id="v3CmdBtn">Command Palette</button></div> | ||
| <div class="v3row"><input class="v3input" id="v3Pomodoro" type="number" min="5" max="120" placeholder="Pomodoro minutes"/><button class="v3btn" id="v3PomodoroStart">Start</button></div> | ||
| <textarea class="v3input" id="v3Notes" rows="4" placeholder="Quick notes (local)"></textarea> | ||
| <div class="v3row"><button class="v3btn" id="v3SaveNotes">Save Notes</button><button class="v3btn" id="v3ExportNotes">Export .txt</button></div> | ||
| `; | ||
|
|
||
| const cmd=document.createElement('div'); cmd.id='v3Cmd'; cmd.innerHTML=`<div id="v3CmdBox"><input class="v3input" id="v3CmdSearch" placeholder="Type to jump pages... (esc to close)"/><div id="v3CmdList"></div></div>`; | ||
|
|
||
| document.body.append(fab,panel,cmd); | ||
|
|
||
| const notesEl=panel.querySelector('#v3Notes'); | ||
| const pomoEl=panel.querySelector('#v3Pomodoro'); | ||
| notesEl.value=localStorage.getItem(KEYS.notes)||''; | ||
| pomoEl.value=localStorage.getItem(KEYS.pomodoro)||'25'; | ||
| if(localStorage.getItem(KEYS.focus)==='true') document.body.classList.add('v3-focus'); | ||
|
|
||
| fab.onclick=()=>panel.classList.toggle('open'); | ||
| panel.querySelector('#v3FocusBtn').onclick=()=>{document.body.classList.toggle('v3-focus');localStorage.setItem(KEYS.focus,String(document.body.classList.contains('v3-focus')))}; | ||
| panel.querySelector('#v3SaveNotes').onclick=()=>localStorage.setItem(KEYS.notes,notesEl.value); | ||
| panel.querySelector('#v3ExportNotes').onclick=()=>{const b=new Blob([notesEl.value],{type:'text/plain'});const a=document.createElement('a');a.href=URL.createObjectURL(b);a.download='360-notes.txt';a.click();URL.revokeObjectURL(a.href)}; | ||
| panel.querySelector('#v3PomodoroStart').onclick=()=>{ | ||
| const mins=Math.max(5,Math.min(120,Number(pomoEl.value)||25)); | ||
| localStorage.setItem(KEYS.pomodoro,String(mins)); | ||
| const end=Date.now()+mins*60000; | ||
| const tick=()=>{ | ||
| const left=Math.max(0,Math.ceil((end-Date.now())/1000)); | ||
| fab.textContent=left>0?`⏱ ${Math.floor(left/60)}:${String(left%60).padStart(2,'0')}`:'Done ✅'; | ||
| if(left>0) requestAnimationFrame(tick); else setTimeout(()=>fab.textContent='V3 ⚡',4000); | ||
| };tick(); | ||
| }; | ||
|
|
||
| const cmdList=cmd.querySelector('#v3CmdList'); | ||
| function renderCmd(q=''){ | ||
| const qq=q.toLowerCase(); | ||
| cmdList.innerHTML=''; | ||
| ROUTES.filter(r=>r[0].toLowerCase().includes(qq)).forEach(([n,u])=>{const b=document.createElement('button');b.className='v3btn';b.textContent=`Go to ${n}`;b.onclick=()=>location.href=u;cmdList.appendChild(b);}); | ||
| } | ||
| renderCmd(); | ||
| panel.querySelector('#v3CmdBtn').onclick=()=>{cmd.classList.add('open');cmd.querySelector('#v3CmdSearch').focus();}; | ||
| cmd.querySelector('#v3CmdSearch').oninput=(e)=>renderCmd(e.target.value); | ||
| cmd.addEventListener('click',e=>{if(e.target===cmd) cmd.classList.remove('open');}); | ||
| document.addEventListener('keydown',e=>{ | ||
| if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='k'){e.preventDefault();cmd.classList.add('open');cmd.querySelector('#v3CmdSearch').focus();} | ||
| if(e.key==='Escape') cmd.classList.remove('open'); | ||
| }); | ||
| } | ||
|
|
||
| document.addEventListener('DOMContentLoaded', injectUI); | ||
| })(); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,198 @@ | ||
| (function () { | ||
| const STORAGE_KEY = '360_widgets_v1'; | ||
| const TYPES = { | ||
| weather: { label: 'Weather' }, | ||
| time: { label: 'Time' }, | ||
| note: { label: 'Note' } | ||
| }; | ||
|
|
||
| function loadWidgets() { | ||
| try { return JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]'); } catch { return []; } | ||
| } | ||
| const byId = id => document.getElementById(id); | ||
| function saveWidgets(w) { localStorage.setItem(STORAGE_KEY, JSON.stringify(w)); } | ||
| function uid() { return `w_${Date.now()}_${Math.random().toString(36).slice(2,8)}`; } | ||
| const clamp = (n, min, max) => Math.max(min, Math.min(max, n)); | ||
|
|
||
| async function getWeather(lat, lon, unit) { | ||
| const tempUnit = unit === 'F' ? 'fahrenheit' : 'celsius'; | ||
| const url = `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}¤t=temperature_2m&temperature_unit=${tempUnit}`; | ||
| const res = await fetch(url); | ||
| const data = await res.json(); | ||
| return data?.current?.temperature_2m; | ||
| } | ||
|
|
||
| function applyWidgetStyles(card, header, body, w) { | ||
| const opacity = clamp(Number(w.opacity ?? 0.85), 0.2, 1); | ||
| card.style.background = w.bgColor || `rgba(15,23,42,${opacity})`; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Useful? React with 👍 / 👎. |
||
| card.style.color = w.textColor || '#ffffff'; | ||
| card.style.borderRadius = `${clamp(Number(w.radius ?? 12), 0, 48)}px`; | ||
| card.style.borderColor = w.borderColor || 'var(--br)'; | ||
| body.style.fontSize = `${clamp(Number(w.fontSize ?? 18), 10, 48)}px`; | ||
| if ((w.shape || 'rounded') === 'pill') card.style.borderRadius = '999px'; | ||
| if ((w.shape || 'rounded') === 'square') card.style.borderRadius = '0px'; | ||
| header.style.background = w.headerColor || 'rgba(59,130,246,.35)'; | ||
| } | ||
|
|
||
| function startIndexMode() { | ||
| const host = document.getElementById('widgetBoard'); | ||
| if (!host) return; | ||
| let widgets = loadWidgets(); | ||
|
|
||
| function render() { | ||
| host.innerHTML = ''; | ||
| widgets.forEach(w => { | ||
| const card = document.createElement('section'); | ||
| card.className = 'home-widget'; | ||
| card.dataset.id = w.id; | ||
| card.style.left = (w.x || 20) + 'px'; | ||
| card.style.top = (w.y || 20) + 'px'; | ||
| card.style.width = `${clamp(Number(w.width || 220), 140, 600)}px`; | ||
| card.style.height = `${clamp(Number(w.height || 130), 80, 500)}px`; | ||
|
|
||
| const header = document.createElement('div'); | ||
| header.className = 'home-widget-header'; | ||
| header.textContent = w.title || TYPES[w.type]?.label || 'Widget'; | ||
|
|
||
| const body = document.createElement('div'); | ||
| body.className = 'home-widget-body'; | ||
| card.append(header, body); | ||
| applyWidgetStyles(card, header, body, w); | ||
| host.appendChild(card); | ||
|
|
||
| if (w.type === 'time') { | ||
| const locale = w.locale || undefined; | ||
| const tz = w.timezone || undefined; | ||
| const f = () => { body.textContent = new Date().toLocaleTimeString(locale, { timeZone: tz, hour: '2-digit', minute: '2-digit', second: '2-digit' }); }; | ||
| f(); | ||
| setInterval(f, 1000); | ||
| } else if (w.type === 'note') { | ||
| body.textContent = w.text || 'Empty note'; | ||
| } else if (w.type === 'weather') { | ||
| body.textContent = 'Loading weather…'; | ||
| const unit = w.unit || 'C'; | ||
| const setFallback = () => body.textContent = 'Weather unavailable'; | ||
| if (!navigator.geolocation) setFallback(); | ||
| else navigator.geolocation.getCurrentPosition(async pos => { | ||
| try { | ||
| const t = await getWeather(pos.coords.latitude, pos.coords.longitude, unit); | ||
| body.textContent = t == null ? 'Weather unavailable' : `${Math.round(t)}°${unit}`; | ||
| } catch { setFallback(); } | ||
| }, setFallback); | ||
| } | ||
|
|
||
| makeDraggable(card, w); | ||
| }); | ||
| } | ||
|
|
||
| function makeDraggable(el, widget) { | ||
| const header = el.querySelector('.home-widget-header'); | ||
| let sx=0, sy=0, ox=0, oy=0, dragging=false; | ||
| header.addEventListener('pointerdown', e => { | ||
| dragging = true; | ||
| sx = e.clientX; sy = e.clientY; | ||
| ox = widget.x || 20; oy = widget.y || 20; | ||
| el.setPointerCapture(e.pointerId); | ||
| }); | ||
| header.addEventListener('pointermove', e => { | ||
| if (!dragging) return; | ||
| widget.x = Math.max(0, ox + e.clientX - sx); | ||
| widget.y = Math.max(0, oy + e.clientY - sy); | ||
| el.style.left = widget.x + 'px'; | ||
| el.style.top = widget.y + 'px'; | ||
| }); | ||
| header.addEventListener('pointerup', () => { | ||
| dragging = false; | ||
| saveWidgets(widgets); | ||
| }); | ||
| } | ||
|
|
||
| render(); | ||
| } | ||
|
|
||
| function startSettingsMode() { | ||
| const list = document.getElementById('widgetList'); | ||
| if (!list) return; | ||
| const form = document.getElementById('widgetForm'); | ||
| const fields = { | ||
| type: byId('widgetType'), | ||
| title: byId('widgetTitle'), | ||
| width: byId('widgetWidth'), | ||
| height: byId('widgetHeight'), | ||
| text: byId('widgetText'), | ||
| unit: byId('widgetUnit'), | ||
| timezone: byId('widgetTimezone'), | ||
| locale: byId('widgetLocale'), | ||
| shape: byId('widgetShape'), | ||
| bgColor: byId('widgetBgColor'), | ||
| headerColor: byId('widgetHeaderColor'), | ||
| textColor: byId('widgetTextColor'), | ||
| borderColor: byId('widgetBorderColor'), | ||
| radius: byId('widgetRadius'), | ||
| fontSize: byId('widgetFontSize'), | ||
| opacity: byId('widgetOpacity') | ||
| }; | ||
| if (!fields.type || !fields.title || !fields.width || !fields.height) return; | ||
| let widgets = loadWidgets(); | ||
|
|
||
| function refresh() { | ||
| list.innerHTML = widgets.map(w => ` | ||
| <div class="st-row"> | ||
| <div> | ||
| <div class="st-row-label">${w.title || w.type}</div> | ||
| <div class="st-row-sub">${w.type} • ${w.width}x${w.height} • ${(w.shape||'rounded')}</div> | ||
| </div> | ||
| <div class="st-row-right"> | ||
| <button class="st-btn" data-act="dup" data-id="${w.id}">Duplicate</button> | ||
| <button class="st-btn" data-act="del" data-id="${w.id}">Delete</button> | ||
| </div> | ||
| </div>`).join('') || '<div class="st-row-sub">No widgets yet.</div>'; | ||
| saveWidgets(widgets); | ||
| } | ||
|
|
||
| list.addEventListener('click', e => { | ||
| const btn = e.target.closest('button[data-act]'); | ||
| if (!btn) return; | ||
| const { act, id } = btn.dataset; | ||
| if (act === 'del') widgets = widgets.filter(w => w.id !== id); | ||
| if (act === 'dup') { | ||
| const src = widgets.find(w => w.id === id); | ||
| if (src) widgets.push({ ...src, id: uid(), title: `${src.title || src.type} Copy`, x: (src.x || 20) + 20, y: (src.y || 20) + 20 }); | ||
| } | ||
| refresh(); | ||
| }); | ||
|
|
||
| form.addEventListener('submit', e => { | ||
| e.preventDefault(); | ||
| widgets.push({ | ||
| id: uid(), | ||
| type: fields.type.value, | ||
| title: fields.title.value.trim() || TYPES[fields.type.value].label, | ||
| width: clamp(Number(fields.width.value) || 220, 140, 600), | ||
| height: clamp(Number(fields.height.value) || 130, 80, 500), | ||
| text: fields.text.value.trim(), | ||
| unit: fields.unit?.value || 'C', | ||
| timezone: fields.timezone?.value?.trim?.() || '', | ||
| locale: fields.locale?.value?.trim?.() || '', | ||
| shape: fields.shape?.value || 'rounded', | ||
| bgColor: fields.bgColor?.value || '#0f172a', | ||
| headerColor: fields.headerColor?.value || '#3b82f6', | ||
| textColor: fields.textColor?.value || '#ffffff', | ||
| borderColor: fields.borderColor?.value || '#94a3b8', | ||
| radius: clamp(Number(fields.radius?.value) || 12, 0, 48), | ||
| fontSize: clamp(Number(fields.fontSize?.value) || 18, 10, 48), | ||
| opacity: clamp(Number(fields.opacity?.value) || 0.85, 0.2, 1), | ||
| x: 20, | ||
| y: 20 | ||
| }); | ||
| form.reset(); | ||
| refresh(); | ||
| }); | ||
| refresh(); | ||
| } | ||
|
|
||
| document.addEventListener('DOMContentLoaded', () => { | ||
| startIndexMode(); | ||
| startSettingsMode(); | ||
| }); | ||
| })(); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
v3PomodoroStartdrives the countdown withrequestAnimationFrame, which runs ~60 times/second for the full 5–120 minute session. In active tabs this causes unnecessary continuous repaint/work for a once-per-second UI update, so starting a long timer can waste CPU and battery for users. Using a 1s interval/timeout cadence would preserve behavior while avoiding the high-frequency loop.Useful? React with 👍 / 👎.