feat(widgets): make goal countdown movable and resizable

This commit is contained in:
Codex committed 2026-09-28 11:38:12 +09:00
1 parent 248e1639a3
commit 6089059dbc
6 files changed
+96 -7

No files matched your search

+77
View File
@@ -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 = `<div class="widget-drag" role="button" tabindex="0" aria-label="${label('move')} ${heading}"><span>${heading}</span><span aria-hidden="true">⠿</span></div><div class="widget-body"></div><div class="widget-resize" role="button" tabindex="0" aria-label="${label('resize')} ${heading}"></div>`;
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', `<button class="widget-empty-message" onclick="openApp('tasks')">${label('noGoals')}</button>`);
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);
})();
+1 -1
View File
@@ -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=`<div class="eyebrow">${cWeek}</div><h1>${fmtDate(day()).replace(/^[^,]+, /,'')}</h1><p class="sub">${i18n('How was your day?')}</p><div class="today-grid"><div><textarea id="entry" class="entry" placeholder="${i18n('Write something about today...')}" oninput="todayDraft=this.value">${esc(text)}</textarea><div class="actions"><button class="primary" onclick="archiveToday()">${i18n(m?'UPDATE MEMORY':'ARCHIVE TODAY')}</button><span id="saveStatus" class="status"></span></div></div><aside><div class="eyebrow">${i18n("TODAY'S WEATHER")}</div><div class="choices">${['clear','light','cloudy','rain','storm'].map(x=>`<button class="chip ${x===selectedMood?'selected':''}" onclick="setMood('${x}')">${moodName(x)}</button>`).join('')}</div><div class="eyebrow" style="margin-top:22px">${i18n('ENERGY LEVEL')}</div><div class="energy">${[1,2,3,4,5].map(x=>`<button class="${x===selectedEnergy?'selected':''}" onclick="setEnergy(${x})">${x}</button>`).join('')}</div><div class="film"><div class="eyebrow">${i18n("TODAY'S FILM")}</div><div class="film-title">${f.filmTitle}</div><div class="sub">${f.filmSubtitle}</div><p class="film-copy">“${f.filmLine}”</p></div></aside></div>`}function rerenderToday(){let old=document.querySelector('#entry');todayDraft=old?old.value:todayDraft||'';window.today(document.querySelector('#content-today'));let entry=document.querySelector('#entry');entry?.focus();entry?.setSelectionRange(entry.value.length,entry.value.length)}
function systemApp(c){let s=settings(),bytes=new Blob([JSON.stringify({mems:mems(),museum:museum(),profile:profile()})]).size;let rec=s.lastBackup&&(Date.now()-new Date(s.lastBackup)>12096e5);c.innerHTML=`<div class="eyebrow">SYSTEM SETTINGS</div><h1>System, local and yours.</h1><div class="settings"><section><div class="eyebrow">APPEARANCE</div><div class="choices">${['dark','light','auto'].map(x=>`<button class="chip ${s.theme===x?'selected':''}" onclick="theme('${x}')">${x.toUpperCase()}</button>`).join('')}</div></section><section><div class="eyebrow">LANGUAGE</div><div class="choices"><button class="chip ${s.language!=='zh'?'selected':''}" onclick="setLanguage('en')">ENGLISH</button><button class="chip ${s.language==='zh'?'selected':''}" onclick="setLanguage('zh')">CHINESE</button></div></section><section><div class="eyebrow">DATA</div><div class="actions"><button class="primary" onclick="exportData()">EXPORT DATA</button><button class="secondary" onclick="document.querySelector('#importer').click()">IMPORT DATA</button><button class="danger" onclick="resetConfirm()">RESET LIFE OS</button><input id="importer" class="hidden" type="file" accept=".json" onchange="readImport(this)"></div></section><section><div class="eyebrow">STORAGE</div><p>Local Storage<br>${mems().length} memories · ${museum().length} museum items · Approx. ${Math.ceil(bytes/1024)} KB</p></section><section><div class="eyebrow">DATA STORAGE</div><p>Your data is stored locally in this browser.<br>Create regular backups to prevent data loss.</p><p>${s.lastBackup?'LAST BACKUP · '+fmtDate(s.lastBackup.slice(0,10)):'LAST BACKUP · NEVER'} ${rec?'<br><b style="color:var(--accent)">BACKUP RECOMMENDED</b>':''}</p></section><section><div class="eyebrow">VERSION</div><p>Life OS<br>Version ${window.LifeOSVersion||'1.7.0'} · Schema v2</p></section></div>`}function theme(x){let s=settings();s.theme=x;save('lifeos_settings',s);init();renderAll()}function exportData(){let data={app:'Life OS',version:window.LifeOSVersion||'1.7.0',schemaVersion:1,exportedAt:new Date().toISOString(),profile:profile(),memories:mems(),museum:museum(),settings:settings()};let a=document.createElement('a');a.href=URL.createObjectURL(new Blob([JSON.stringify(data,null,2)],{type:'application/json'}));a.download='life-os-backup-'+day()+'.json';a.click();let s=settings();s.lastBackup=new Date().toISOString();save('lifeos_settings',s);renderAll();toast('BACKUP CREATED')}function readImport(el){let f=el.files[0];if(!f)return;let r=new FileReader;r.onload=()=>{try{let d=JSON.parse(r.result);if(d.app!=='Life OS'||![1,2].includes(d.schemaVersion)||!Array.isArray(d.memories)||!Array.isArray(d.museum))throw 0;importData=d;importPreview()}catch{toast('INVALID BACKUP FILE')}};r.readAsText(f)}function importPreview(){let d=importData;modal.innerHTML=`<div class="modal"><div class="modalbox"><div class="eyebrow">LIFE OS BACKUP</div><h2>Ready to restore</h2><p class="sub">Exported: ${fmtDate(d.exportedAt.slice(0,10))}<br>Memories: ${d.memories.length}<br>Museum Items: ${d.museum.length}</p><div class="actions"><button class="primary" onclick="applyImport('merge')">MERGE</button><button class="danger" onclick="replaceConfirm()">REPLACE ALL DATA</button><button class="secondary" onclick="modal.innerHTML=''">CANCEL</button></div></div></div>`}function replaceConfirm(){modal.innerHTML=`<div class="modal"><div class="modalbox"><div class="eyebrow">FINAL CONFIRMATION</div><h2>Replace all Life OS data?</h2><p class="sub">This will replace all current Life OS data.</p><div class="actions"><button class="danger" onclick="applyImport('replace')">REPLACE DATA</button><button class="secondary" onclick="importPreview()">CANCEL</button></div></div></div>`}function applyImport(mode){let d=importData;if(mode==='replace'){save('lifeos_memories',d.memories);save('lifeos_museum',d.museum);save('lifeos_profile',d.profile||{});save('lifeos_settings',d.settings||{})}else{let a=mems(), ids=new Set(a.map(x=>x.id)),dates=new Set(a.map(x=>x.date));save('lifeos_memories',[...a,...d.memories.filter(x=>!ids.has(x.id)&&!dates.has(x.date))]);let mu=museum(),mi=new Set(mu.map(x=>x.id));save('lifeos_museum',[...mu,...d.museum.filter(x=>!mi.has(x.id))])}modal.innerHTML='';toast('RESTORING SYSTEM...');setTimeout(()=>{renderAll();toast('SYSTEM RESTORED')},500)}function resetConfirm(){modal.innerHTML=`<div class="modal"><div class="modalbox"><div class="eyebrow">RESET LIFE OS</div><h2>Reset all local data?</h2><p class="sub">Export a backup first if you want to keep it.</p><div class="actions"><button class="danger" onclick="['lifeos_memories','lifeos_museum','lifeos_profile','lifeos_settings','lifeos_system'].forEach(k=>localStorage.removeItem(k));location.reload()">RESET DATA</button><button class="secondary" onclick="modal.innerHTML=''">CANCEL</button></div></div></div>`}function toast(t){let x=document.createElement('div');x.className='toast';x.textContent=t;document.body.append(x);setTimeout(()=>x.remove(),2000)}
window.renderIcons=renderIcons;window.renderAll=renderAll;window.toast=toast;
</script><script src="boot-guard.js"></script><script src="locales/en.js?v=1.7.0"></script><script src="locales/zh-CN.js?v=1.7.0"></script><script src="i18n.js?v=1.7.0"></script><script src="tasks.js"></script><script src="calendar.js"></script><script src="addons.js"></script><script src="i18n-renderers.js?v=1.7.0"></script><script src="desktop-personalization.js?v=1.7.0"></script><script>window.LifeOSVersion=window.LifeOSVersion||'1.7.0';window.init();if('serviceWorker' in navigator){window.addEventListener('load',()=>navigator.serviceWorker.register('/service-worker.js').catch(()=>{}))}</script></body></html>
</script><script src="boot-guard.js"></script><script src="locales/en.js?v=1.7.0"></script><script src="locales/zh-CN.js?v=1.7.0"></script><script src="i18n.js?v=1.7.0"></script><script src="tasks.js"></script><script src="calendar.js"></script><script src="addons.js"></script><script src="i18n-renderers.js?v=1.7.0"></script><script src="desktop-personalization.js?v=1.7.0"></script><script src="desktop-widgets.js?v=1.7.0"></script><script>window.LifeOSVersion=window.LifeOSVersion||'1.7.0';window.init();if('serviceWorker' in navigator){window.addEventListener('load',()=>navigator.serviceWorker.register('/service-worker.js').catch(()=>{}))}</script></body></html>
+6
View File
@@ -349,6 +349,12 @@
"startup": "Open at startup",
"startupHint": "These apps open whenever Life OS starts, alongside any remembered windows."
},
"widgets": {
"goals": "GOAL COUNTDOWNS",
"move": "Move",
"resize": "Resize",
"noGoals": "No pinned goals. Open Tasks to pin a goal."
},
"prompt": {
"films": [
{
Loaded 3 of 6 files, more files were not shown because too many files have changed in this diff. Show more