From 6089059dbcd41887384e32cf7f12e3e74d85c18c Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 28 Sep 2026 11:38:12 +0900 Subject: [PATCH] feat(widgets): make goal countdown movable and resizable --- public/desktop-widgets.js | 77 +++++++++++++++++++++++++++++++++++++++ public/index.html | 2 +- public/locales/en.json | 6 +++ public/locales/zh-CN.json | 6 +++ scripts/build.mjs | 6 +-- scripts/validate.mjs | 6 +-- 6 files changed, 96 insertions(+), 7 deletions(-) create mode 100644 public/desktop-widgets.js 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 = `
${heading}
`; + 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=`
${cWeek}

${fmtDate(day()).replace(/^[^,]+, /,'')}

${i18n('How was your day?')}

`}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=`
SYSTEM SETTINGS

System, local and yours.

APPEARANCE
${['dark','light','auto'].map(x=>``).join('')}
LANGUAGE
DATA
STORAGE

Local Storage
${mems().length} memories · ${museum().length} museum items · Approx. ${Math.ceil(bytes/1024)} KB

DATA STORAGE

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':''}

VERSION

Life OS
Version ${window.LifeOSVersion||'1.7.0'} · Schema v2

`}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=``}function replaceConfirm(){modal.innerHTML=``}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=``}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; - + diff --git a/public/locales/en.json b/public/locales/en.json index a33af5c..050d85a 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -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": [ { diff --git a/public/locales/zh-CN.json b/public/locales/zh-CN.json index 3a6ee54..7435437 100644 --- a/public/locales/zh-CN.json +++ b/public/locales/zh-CN.json @@ -349,6 +349,12 @@ "startup": "开机默认打开", "startupHint": "每次启动 Life OS 时打开这些 App,并恢复已记住的窗口。" }, + "widgets": { + "goals": "目标倒计时", + "move": "移动", + "resize": "调整大小", + "noGoals": "暂无置顶目标。打开目标与待办即可添加。" + }, "login": { "title": "你的生活,同步在线。", "description": "登录 ChatGPT,打开你的个人 Life OS,并在不同设备间安全同步记忆。", diff --git a/scripts/build.mjs b/scripts/build.mjs index 25e0f3e..c0c9ec6 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -7,7 +7,7 @@ const meta = new URL("../dist/.openai/", import.meta.url); await rm(dist, { recursive: true, force: true }); await Promise.all([mkdir(server, { recursive: true }), mkdir(meta, { recursive: true })]); -const [rawIndexHtml, addonsJs, bootGuardJs, i18nJs, tasksJs, calendarJs, i18nRenderersJs, desktopPersonalizationJs, manifest, serviceWorker, icon, localeEn, localeZh] = await Promise.all([text("index.html"), text("addons.js"), text("boot-guard.js"), text("i18n.js"), text("tasks.js"), text("calendar.js"), text("i18n-renderers.js"), text("desktop-personalization.js"), text("manifest.webmanifest"), text("service-worker.js"), text("life-os-icon.svg"), readFile(new URL("../public/locales/en.json", import.meta.url), "utf8"), readFile(new URL("../public/locales/zh-CN.json", import.meta.url), "utf8")]); +const [rawIndexHtml, addonsJs, bootGuardJs, i18nJs, tasksJs, calendarJs, i18nRenderersJs, desktopPersonalizationJs, desktopWidgetsJs, manifest, serviceWorker, icon, localeEn, localeZh] = await Promise.all([text("index.html"), text("addons.js"), text("boot-guard.js"), text("i18n.js"), text("tasks.js"), text("calendar.js"), text("i18n-renderers.js"), text("desktop-personalization.js"), text("desktop-widgets.js"), text("manifest.webmanifest"), text("service-worker.js"), text("life-os-icon.svg"), readFile(new URL("../public/locales/en.json", import.meta.url), "utf8"), readFile(new URL("../public/locales/zh-CN.json", import.meta.url), "utf8")]); const release = "1.7.0"; const localeEnJs = `window.LifeOSLocales=window.LifeOSLocales||{};window.LifeOSLocales.en=${localeEn};`; const localeZhJs = `window.LifeOSLocales=window.LifeOSLocales||{};window.LifeOSLocales['zh-CN']=${localeZh};`; @@ -15,13 +15,13 @@ let indexHtml = rawIndexHtml.replace('src="addons.js"', `src="addons.js?v=${rele for (const [src, code] of [ ['boot-guard.js', bootGuardJs], ['locales/en.js', localeEnJs], ['locales/zh-CN.js', localeZhJs], ['i18n.js', i18nJs], ['tasks.js', tasksJs], ['calendar.js', calendarJs], - ['addons.js', addonsJs], ['i18n-renderers.js', i18nRenderersJs], ['desktop-personalization.js', desktopPersonalizationJs] + ['addons.js', addonsJs], ['i18n-renderers.js', i18nRenderersJs], ['desktop-personalization.js', desktopPersonalizationJs], ['desktop-widgets.js', desktopWidgetsJs] ]) { const tag = ``; if (!indexHtml.includes(tag)) throw new Error(`Missing Life OS script: ${src}`); indexHtml = indexHtml.replace(tag, ``); } -await writeFile(new URL("assets.js", server), `export const indexHtml=${JSON.stringify(indexHtml)};\nexport const addonsJs=${JSON.stringify(addonsJs)};\nexport const bootGuardJs=${JSON.stringify(bootGuardJs)};\nexport const i18nJs=${JSON.stringify(i18nJs)};\nexport const localeEn=JSON.parse(${JSON.stringify(localeEn)});\nexport const localeZh=JSON.parse(${JSON.stringify(localeZh)});\nexport const localeEnJs=${JSON.stringify(localeEnJs)};\nexport const localeZhJs=${JSON.stringify(localeZhJs)};\nexport const tasksJs=${JSON.stringify(tasksJs)};\nexport const calendarJs=${JSON.stringify(calendarJs)};\nexport const i18nRenderersJs=${JSON.stringify(i18nRenderersJs)};\nexport const desktopPersonalizationJs=${JSON.stringify(desktopPersonalizationJs)};\nexport const manifest=${JSON.stringify(manifest)};\nexport const serviceWorker=${JSON.stringify(serviceWorker)};\nexport const icon=${JSON.stringify(icon)};\n`); +await writeFile(new URL("assets.js", server), `export const indexHtml=${JSON.stringify(indexHtml)};\nexport const addonsJs=${JSON.stringify(addonsJs)};\nexport const bootGuardJs=${JSON.stringify(bootGuardJs)};\nexport const i18nJs=${JSON.stringify(i18nJs)};\nexport const localeEn=JSON.parse(${JSON.stringify(localeEn)});\nexport const localeZh=JSON.parse(${JSON.stringify(localeZh)});\nexport const localeEnJs=${JSON.stringify(localeEnJs)};\nexport const localeZhJs=${JSON.stringify(localeZhJs)};\nexport const tasksJs=${JSON.stringify(tasksJs)};\nexport const calendarJs=${JSON.stringify(calendarJs)};\nexport const i18nRenderersJs=${JSON.stringify(i18nRenderersJs)};\nexport const desktopPersonalizationJs=${JSON.stringify(desktopPersonalizationJs)};\nexport const desktopWidgetsJs=${JSON.stringify(desktopWidgetsJs)};\nexport const manifest=${JSON.stringify(manifest)};\nexport const serviceWorker=${JSON.stringify(serviceWorker)};\nexport const icon=${JSON.stringify(icon)};\n`); await copyFile(new URL("../worker/index.js", import.meta.url), new URL("index.js", server)); await copyFile(new URL("../.openai/hosting.json", import.meta.url), new URL("hosting.json", meta)); console.log("Built Life OS Worker artifact"); diff --git a/scripts/validate.mjs b/scripts/validate.mjs index abcd824..7098952 100644 --- a/scripts/validate.mjs +++ b/scripts/validate.mjs @@ -20,7 +20,7 @@ const flatten = (value, prefix = "") => Object.entries(value).flatMap(([key, chi const enKeys = flatten(assets.localeEn), zhKeys = flatten(assets.localeZh); assert.deepEqual(enKeys, zhKeys, "English and Chinese locale keys must match"); const resolveKey = (locale, key) => key.split(".").reduce((value, part) => value?.[part], locale); -for (const script of [assets.i18nRenderersJs, assets.desktopPersonalizationJs, assets.addonsJs, source]) { +for (const script of [assets.i18nRenderersJs, assets.desktopPersonalizationJs, assets.desktopWidgetsJs, assets.addonsJs, source]) { for (const match of script.matchAll(/(?:\bt|LifeI18n\.t)\(['"`]([A-Za-z][\w.]+)['"`]/g)) { assert.notEqual(resolveKey(assets.localeEn, match[1]), undefined, `Missing locale key: ${match[1]}`); assert.notEqual(resolveKey(assets.localeZh, match[1]), undefined, `Missing Chinese locale key: ${match[1]}`); @@ -45,11 +45,11 @@ assert.doesNotMatch(assets.indexHtml, /setTimeout\(applyLanguage\s*,/); assert.doesNotMatch(assets.addonsJs, /(?([\s\S]*?)<\/script>/g)].map(match => match[1]); -assert.equal(shippedScripts.length, 11, "the complete desktop script chain is present in the served HTML"); +assert.equal(shippedScripts.length, 12, "the complete desktop script chain is present in the served HTML"); assert.doesNotMatch(assets.indexHtml, /