diff --git a/public/desktop-widgets.js b/public/desktop-widgets.js new file mode 100644 index 0000000..705c85f --- /dev/null +++ b/public/desktop-widgets.js @@ -0,0 +1,77 @@ +/* Desktop widgets use the same 110 × 98 grid as app shortcuts. */ +(() => { + const KEY = 'lifeos_widget_layout_v1'; + const grid = { x: 24, y: 72, width: 110, height: 98 }; + const label = key => LifeI18n.t(`widgets.${key}`); + const desktop = () => innerWidth > 650; + const limits = () => ({ columns: Math.max(2, Math.floor((innerWidth - 48) / grid.width)), rows: Math.max(2, Math.floor((innerHeight - 175) / grid.height)) }); + const read = () => { try { return JSON.parse(localStorage.getItem(KEY) || '{}') || {}; } catch { return {}; } }; + const saveLayout = (id, layout) => localStorage.setItem(KEY, JSON.stringify({ ...read(), [id]: layout })); + const positive = value => Number.isInteger(value) && value >= 0; + const initial = id => { const { columns } = limits(); return { col: Math.max(0, columns - 3), row: 0, w: 3, h: 3 }; }; + const normalized = id => { + const stored = read()[id], base = initial(id), { columns, rows } = limits(); + const w = Math.min(columns, Math.max(2, positive(stored?.w) ? stored.w : base.w)); + const h = Math.min(rows, Math.max(2, positive(stored?.h) ? stored.h : base.h)); + return { col: Math.min(columns - w, positive(stored?.col) ? stored.col : base.col), row: Math.min(rows - h, positive(stored?.row) ? stored.row : base.row), w, h }; + }; + const layer = document.createElement('div'); + layer.id = 'desktop-widget-layer'; + document.querySelector('#desktop').append(layer); + const wrappers = new Map(); + function place(id) { + const node = wrappers.get(id); if (!node) return; + const pos = normalized(id); + node.style.left = `${grid.x + pos.col * grid.width}px`; + node.style.top = `${grid.y + pos.row * grid.height}px`; + node.style.width = `${pos.w * grid.width - 8}px`; + node.style.height = `${pos.h * grid.height - 8}px`; + } + function createWidget(id, heading) { + const node = document.createElement('section'); + node.className = 'desktop-widget'; node.dataset.widget = id; + node.innerHTML = `
`; + layer.append(node); wrappers.set(id, node); place(id); + node.addEventListener('pointerdown', event => { + if (!desktop() || event.button !== 0) return; + const resize = event.target.closest('.widget-resize'), handle = resize || event.target.closest('.widget-drag'); + if (!handle) return; + event.preventDefault(); handle.setPointerCapture?.(event.pointerId); + const start = normalized(id), x = event.clientX, y = event.clientY; + node.classList.add('is-adjusting'); layer.classList.add('is-aligning'); + const move = pointer => { + const dx = Math.round((pointer.clientX - x) / grid.width), dy = Math.round((pointer.clientY - y) / grid.height), { columns, rows } = limits(); + const next = resize + ? { ...start, w: Math.max(2, Math.min(columns - start.col, start.w + dx)), h: Math.max(2, Math.min(rows - start.row, start.h + dy)) } + : { ...start, col: Math.max(0, Math.min(columns - start.w, start.col + dx)), row: Math.max(0, Math.min(rows - start.h, start.row + dy)) }; + node.style.left = `${grid.x + next.col * grid.width}px`; node.style.top = `${grid.y + next.row * grid.height}px`; + node.style.width = `${next.w * grid.width - 8}px`; node.style.height = `${next.h * grid.height - 8}px`; + node.dataset.pending = JSON.stringify(next); + }; + const stop = () => { + handle.removeEventListener('pointermove', move); handle.removeEventListener('pointerup', stop); handle.removeEventListener('pointercancel', stop); + node.classList.remove('is-adjusting'); layer.classList.remove('is-aligning'); + if (node.dataset.pending) { saveLayout(id, JSON.parse(node.dataset.pending)); delete node.dataset.pending; } + place(id); + }; + handle.addEventListener('pointermove', move); handle.addEventListener('pointerup', stop); handle.addEventListener('pointercancel', stop); + }); + return node; + } + const goal = createWidget('goals', label('goals')); + goal.querySelector('.widget-body').append(document.querySelector('#desktop-goal-countdowns')); + const originalGoals = window.renderDesktopGoalCountdowns; + window.renderDesktopGoalCountdowns = () => { + originalGoals(); + const list = document.querySelector('#desktop-goal-countdowns'); + goal.classList.toggle('widget-empty', list.classList.contains('hidden')); + const empty = goal.querySelector('.widget-empty-message'); + if (list.classList.contains('hidden') && !empty) goal.querySelector('.widget-body').insertAdjacentHTML('beforeend', ``); + else if (!list.classList.contains('hidden')) empty?.remove(); + }; + window.renderDesktopGoalCountdowns(); + window.addEventListener('resize', () => wrappers.forEach((_, id) => place(id))); + const style = document.createElement('style'); + style.textContent = `#desktop-widget-layer{position:absolute;inset:0;z-index:1;pointer-events:none}.desktop-widget{position:absolute;display:flex;flex-direction:column;min-width:0;min-height:0;overflow:hidden;background:color-mix(in srgb,var(--panel) 88%,transparent);border:1px solid var(--line);border-radius:14px;box-shadow:var(--shadow);backdrop-filter:blur(12px);pointer-events:auto}.desktop-widget.is-adjusting{border-color:var(--accent)}.widget-drag{display:flex;align-items:center;justify-content:space-between;gap:8px;flex:none;padding:10px 13px;border-bottom:1px solid var(--line);font:500 11px 'DM Mono';letter-spacing:.1em;color:var(--muted);cursor:grab;touch-action:none}.widget-drag:active{cursor:grabbing}.widget-body{flex:1;min-height:0;overflow:auto;padding:10px}.widget-resize{position:absolute;right:0;bottom:0;width:22px;height:22px;cursor:nwse-resize;touch-action:none;background:linear-gradient(135deg,transparent 65%,var(--accent) 65%,var(--accent) 72%,transparent 72%)}.desktop-widget .desktop-goal-countdowns{position:static!important;width:100%!important;height:auto!important;max-height:none!important;margin:0!important;display:grid;grid-template-columns:1fr;gap:8px;overflow:visible}.desktop-widget .desktop-goal-countdowns.hidden{display:none!important}.desktop-widget .desktop-goal-heading{display:none}.desktop-widget .desktop-goal-card{box-shadow:none;padding:10px 12px}.widget-empty-message{border:0;background:transparent;color:var(--muted);padding:10px;text-align:left;font-size:13px}@media(max-width:650px){#desktop-widget-layer{position:relative;inset:auto;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;margin:10px 14px 90px}.desktop-widget{position:relative;left:auto!important;top:auto!important;width:auto!important;height:auto!important;min-height:140px}.widget-drag{cursor:default}.widget-resize{display:none}.widget-body{max-height:35vh}}`; + document.head.append(style); +})(); diff --git a/public/index.html b/public/index.html index 3462d67..ecb59df 100644 --- a/public/index.html +++ b/public/index.html @@ -25,4 +25,4 @@ function profileApp(c){let p=profile(),a=mems(),common=Object.entries(a.reduce(( function today(c){let m=mems().find(x=>x.date===day());if(todayDraft===null){selectedMood=m?.mood||'cloudy';selectedEnergy=m?.energy||3}let f=m||filmFor(selectedMood,selectedEnergy),text=todayDraft===null?(m?.text||''):todayDraft,cWeek=new Date().toLocaleDateString(lang()==='zh'?'zh-CN':'en-US',{weekday:'long'}).toUpperCase();c.innerHTML=`${i18n('How was your day?')}
Local Storage
${mems().length} memories · ${museum().length} museum items · Approx. ${Math.ceil(bytes/1024)} KB
Your data is stored locally in this browser.
Create regular backups to prevent data loss.
${s.lastBackup?'LAST BACKUP · '+fmtDate(s.lastBackup.slice(0,10)):'LAST BACKUP · NEVER'} ${rec?'
BACKUP RECOMMENDED':''}
Life OS
Version ${window.LifeOSVersion||'1.7.0'} · Schema v2
Exported: ${fmtDate(d.exportedAt.slice(0,10))}
Memories: ${d.memories.length}
Museum Items: ${d.museum.length}
This will replace all current Life OS data.
Export a backup first if you want to keep it.