diff --git a/public/addons.js b/public/addons.js index 46eadc6..a1c8a22 100644 --- a/public/addons.js +++ b/public/addons.js @@ -68,9 +68,10 @@ document.head.append(style); function exportData() { - let desktopLayout = null; + let desktopLayout = null, widgetLayout = null; try { desktopLayout = JSON.parse(localStorage.getItem('lifeos_window_layout_v1') || 'null'); } catch {} - const data = { app: 'Life OS', version: APP_VERSION, schemaVersion: 2, exportedAt: new Date().toISOString(), profile: profile(), memories: mems(), museum: museum(), settings: settings(), system: sys(), desktopLayout }; + try { widgetLayout = JSON.parse(localStorage.getItem('lifeos_widget_layout_v1') || 'null'); } catch {} + const data = { app: 'Life OS', version: APP_VERSION, schemaVersion: 2, exportedAt: new Date().toISOString(), profile: profile(), memories: mems(), museum: museum(), settings: settings(), system: sys(), desktopLayout, widgetLayout }; const link = document.createElement('a'); link.href = URL.createObjectURL(new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })); link.download = `life-os-backup-${day()}.json`; link.click(); diff --git a/public/desktop-widgets.js b/public/desktop-widgets.js index b3865d4..4e2060e 100644 --- a/public/desktop-widgets.js +++ b/public/desktop-widgets.js @@ -8,7 +8,13 @@ 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 initial = id => { + const { columns, rows } = limits(); + if (id === 'clock') return { col: Math.max(0, columns - 5), row: 0, w: 2, h: 2 }; + if (id === 'today') return { col: Math.max(0, columns - 5), row: Math.min(2, rows - 2), w: 2, h: 3 }; + if (id === 'actions') return { col: Math.max(0, columns - 3), row: Math.min(3, rows - 2), w: 3, h: 2 }; + 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)); @@ -87,6 +93,24 @@ } const goal = createWidget('goals', label('goals')); goal.querySelector('.widget-body').append(document.querySelector('#desktop-goal-countdowns')); + const clock = createWidget('clock', label('clock')); + const today = createWidget('today', label('today')); + const actions = createWidget('actions', label('actions')); + const widgetIds = ['goals', 'clock', 'today', 'actions']; + const defaultVisible = ['goals', 'clock', 'today']; + const selected = () => Array.isArray(settings().desktopWidgets) ? widgetIds.filter(id => settings().desktopWidgets.includes(id)) : defaultVisible; + function renderWidgets() { + const configured = settings().desktopWidgets, visible = selected(); + const hasGoals = !document.querySelector('#desktop-goal-countdowns').classList.contains('hidden'); + wrappers.forEach((node, id) => node.classList.toggle('hidden', !visible.includes(id) || (id === 'goals' && !hasGoals && !Array.isArray(configured)))); + layer.classList.toggle('hidden', [...wrappers.values()].every(node => node.classList.contains('hidden'))); + const now = new Date(); + clock.querySelector('.widget-body').innerHTML = `
${new Intl.DateTimeFormat(LifeI18n.locale(), { hour: '2-digit', minute: '2-digit' }).format(now)}
${LifeI18n.date(now, { weekday: 'long', month: 'long', day: 'numeric' })}
`; + const current = day(), memories = mems().filter(item => item.date === current).length; + const tasks = sys().tasksBoard?.tasks || [], due = tasks.filter(item => !item.done && item.dueDate && item.dueDate <= current).length; + today.querySelector('.widget-body').innerHTML = `
${LifeI18n.number(memories)}${label('memoriesToday')}
${LifeI18n.number(due)}${label('tasksDue')}
`; + actions.querySelector('.widget-body').innerHTML = `
${[['today','newMemory'],['tasks','openTasks'],['calendar','openCalendar']].map(([id, text]) => ``).join('')}
`; + } const originalGoals = window.renderDesktopGoalCountdowns; window.renderDesktopGoalCountdowns = () => { originalGoals(); @@ -95,10 +119,32 @@ 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(); + renderWidgets(); }; window.renderDesktopGoalCountdowns(); + const originalRenderAll = window.renderAll; + window.renderAll = () => { originalRenderAll(); renderWidgets(); }; + const originalApplyDesktopPreferences = window.applyDesktopPreferences; + window.applyDesktopPreferences = () => { originalApplyDesktopPreferences?.(); renderWidgets(); }; + window.toggleDesktopWidget = id => { + if (!widgetIds.includes(id)) return; + const visible = selected(), next = visible.includes(id) ? visible.filter(item => item !== id) : widgetIds.filter(item => visible.includes(item) || item === id); + const state = settings(); state.desktopWidgets = next; save('lifeos_settings', state); window.renderAll(); + }; + window.resetWidgetLayout = () => { localStorage.removeItem(KEY); wrappers.forEach((_, id) => place(id)); }; + const originalSystemApp = window.systemApp; + window.systemApp = container => { + originalSystemApp(container); + const panel = container.querySelector('.settings'); + if (!panel) return; + panel.insertAdjacentHTML('beforeend', `
${label('heading')}

${label('hint')}

${widgetIds.map(id => ``).join('')}
`); + }; + setInterval(renderWidgets, 60000); 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-snap-grid{position:absolute;z-index:20;left:24px;top:72px;width:calc(100% - 48px);height:calc(100% - 175px);pointer-events:none;opacity:0;transition:opacity .12s;background-image:linear-gradient(to right,color-mix(in srgb,var(--accent) 25%,transparent) 1px,transparent 1px),linear-gradient(to bottom,color-mix(in srgb,var(--accent) 25%,transparent) 1px,transparent 1px);background-size:110px 98px}#desktop-snap-grid.visible{opacity:1}.snap-target{position:absolute;border:2px solid var(--accent);border-radius:13px;background:color-mix(in srgb,var(--accent) 14%,transparent);box-shadow:0 0 0 2px var(--bg)}.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-drag:focus-visible,.widget-resize:focus-visible{outline:2px solid var(--accent);outline-offset:-3px}.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%)}.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-snap-grid{display:none}#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); + const contentStyle = document.createElement('style'); + contentStyle.textContent = `.widget-clock-time{font:600 clamp(26px,3vw,42px) Manrope,system-ui;letter-spacing:-.06em;line-height:1.2}.widget-clock-date{font-size:13px;color:var(--muted);line-height:1.5}.widget-today-row{display:flex;align-items:baseline;gap:10px;margin:6px 0}.widget-today-row strong{font-size:25px;color:var(--accent)}.widget-today-row span{font-size:13px;color:var(--muted)}.widget-link{border:0;background:none;color:var(--accent);padding:5px 0;text-decoration:underline;font-size:12px}.widget-actions{display:grid;gap:8px}.widget-actions button{border:1px solid var(--line);border-radius:8px;background:var(--panel2);color:var(--ink);padding:9px;text-align:left;font-size:13px}.widget-actions button:hover,.widget-actions button:focus-visible{border-color:var(--accent)}.widget-preferences .desktop-app-choices{display:flex;gap:8px;flex-wrap:wrap}.widget-preferences .chip{font-size:12px;min-height:36px}`; + document.head.append(contentStyle); })(); diff --git a/public/i18n-renderers.js b/public/i18n-renderers.js index 4c86be1..200a4d6 100644 --- a/public/i18n-renderers.js +++ b/public/i18n-renderers.js @@ -119,6 +119,7 @@ if (mode === 'replace') { save('lifeos_memories', data.memories); save('lifeos_museum', data.museum); save('lifeos_profile', data.profile || {}); save('lifeos_settings', data.settings || {}); save('lifeos_system', data.system || { schemaVersion: 2 }); if (Array.isArray(data.desktopLayout?.entries)) localStorage.setItem('lifeos_window_layout_v1', JSON.stringify({ entries: data.desktopLayout.entries })); + if (data.widgetLayout && typeof data.widgetLayout === 'object' && !Array.isArray(data.widgetLayout)) localStorage.setItem('lifeos_widget_layout_v1', JSON.stringify(data.widgetLayout)); } else { const memories = mems(), ids = new Set(memories.map(item => item.id)), dates = new Set(memories.map(item => item.date)); save('lifeos_memories', [...memories, ...data.memories.filter(item => !ids.has(item.id) && !dates.has(item.date))]); @@ -133,7 +134,7 @@ } rootModal().innerHTML = ''; toast('feedback.restoring'); setTimeout(() => { renderAll(); toast('feedback.restored'); }, 500); }; - window.resetConfirm = () => { rootModal().innerHTML = ``; }; + window.resetConfirm = () => { rootModal().innerHTML = ``; }; window.toast = key => { const message = key.includes('.') ? t(key) : key; const node = document.createElement('div'); node.className = 'toast'; node.textContent = message; document.body.append(node); setTimeout(() => node.remove(), 2000); }; window.minWin = id => { const windowNode = windows[id]; if (!windowNode) return; windowNode.classList.add('min'); if (activeWindow === id) { activeWindow = Object.keys(windows).find(key => key !== id && !windows[key].classList.contains('min')) || null; if (activeWindow) focus(windows[activeWindow]); } renderTaskbar(); toast('feedback.minimized'); }; const memoryStyle = document.createElement('style'); diff --git a/public/locales/en.json b/public/locales/en.json index 050d85a..f74f2b8 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -351,6 +351,17 @@ }, "widgets": { "goals": "GOAL COUNTDOWNS", + "clock": "CLOCK", + "today": "TODAY AT A GLANCE", + "actions": "QUICK ACTIONS", + "heading": "DESKTOP WIDGETS", + "hint": "Choose which widgets appear on the desktop. Drag a header to move; drag its corner to resize. Arrow keys work when either handle has focus.", + "reset": "Reset widget layout", + "memoriesToday": "memories today", + "tasksDue": "tasks due or overdue", + "newMemory": "Open Today", + "openTasks": "Open Tasks", + "openCalendar": "Open Calendar", "move": "Move", "resize": "Resize", "noGoals": "No pinned goals. Open Tasks to pin a goal." diff --git a/public/locales/zh-CN.json b/public/locales/zh-CN.json index 7435437..b8203fb 100644 --- a/public/locales/zh-CN.json +++ b/public/locales/zh-CN.json @@ -351,6 +351,17 @@ }, "widgets": { "goals": "目标倒计时", + "clock": "时钟", + "today": "今日概览", + "actions": "快捷操作", + "heading": "桌面小部件", + "hint": "选择桌面显示的小部件。拖动标题移动、拖动角落缩放;聚焦操作柄后也可用方向键调整。", + "reset": "重置小部件布局", + "memoriesToday": "今日记忆", + "tasksDue": "到期或逾期的待办", + "newMemory": "打开今日", + "openTasks": "打开目标与待办", + "openCalendar": "打开日历", "move": "移动", "resize": "调整大小", "noGoals": "暂无置顶目标。打开目标与待办即可添加。" diff --git a/scripts/validate.mjs b/scripts/validate.mjs index 7098952..2be3cc7 100644 --- a/scripts/validate.mjs +++ b/scripts/validate.mjs @@ -227,6 +227,47 @@ assert.equal(layoutWindows.tasks.style.top, "137px"); assert.equal(layoutWindows.tasks.classList.contains("min"), true); layoutContext.closeWin("tasks"); assert.deepEqual(JSON.parse(layoutStorage.getItem("lifeos_window_layout_v1")).entries.map(entry => entry.id), ["calendar"], "closed windows stay closed on the next launch"); +const widgetStorage = makeStorage({ lifeos_settings: "{}" }); +widgetStorage.removeItem = key => widgetStorage.values.delete(key); +class WidgetNode { + constructor() { this.style = {}; this.dataset = {}; this.children = []; this.listeners = {}; this.flags = new Set(); this.classList = { add: key => this.flags.add(key), remove: key => this.flags.delete(key), contains: key => this.flags.has(key), toggle: (key, enabled) => enabled ? this.flags.add(key) : this.flags.delete(key) }; } + addEventListener(type, callback) { (this.listeners[type] ||= []).push(callback); } + removeEventListener(type, callback) { this.listeners[type] = (this.listeners[type] || []).filter(fn => fn !== callback); } + emit(type, event) { (this.listeners[type] || []).forEach(fn => fn(event)); } + append(node) { this.children.push(node); } + setAttribute() {} + setPointerCapture() {} + querySelector(selector) { if (selector === '.widget-empty-message') return null; return this[selector] ||= new WidgetNode(); } + insertAdjacentHTML() {} +} +const widgetDesktop = new WidgetNode(), widgetGoals = new WidgetNode(); +const widgetContext = vm.createContext({ + document: { querySelector: selector => selector === '#desktop' ? widgetDesktop : widgetGoals, createElement: () => new WidgetNode(), head: { append() {} } }, + localStorage: widgetStorage, settings: () => JSON.parse(widgetStorage.getItem('lifeos_settings')), + save: (key, value) => widgetStorage.setItem(key, JSON.stringify(value)), + innerWidth: 1200, innerHeight: 800, addEventListener() {}, setInterval() {}, setTimeout() {}, clearTimeout() {}, + renderDesktopGoalCountdowns() {}, renderAll() {}, systemApp() {}, openApp() {}, + mems: () => [], sys: () => ({ tasksBoard: { tasks: [] } }), day: () => '2026-09-28', + LifeI18n: { t: key => key, locale: () => 'en-US', date: () => 'Sep 28', number: value => String(value) } +}); +widgetContext.window = widgetContext; +vm.runInContext(assets.desktopWidgetsJs, widgetContext); +const widgetLayer = widgetDesktop.children[0], goalWidget = widgetLayer.children[0]; +const goalHandle = goalWidget.querySelector('.widget-drag'); +goalWidget.emit('pointerdown', { button: 0, target: { closest: selector => selector === '.widget-drag' ? goalHandle : null }, clientX: 0, clientY: 0, preventDefault() {} }); +goalHandle.emit('pointermove', { clientX: -110, clientY: 98 }); +goalHandle.emit('pointerup', {}); +let savedWidget = JSON.parse(widgetStorage.getItem('lifeos_widget_layout_v1')).goals; +assert.equal(savedWidget.row, 1, "goal countdown moves by one visible grid cell"); +const resizeHandle = goalWidget.querySelector('.widget-resize'); +goalWidget.emit('pointerdown', { button: 0, target: { closest: selector => selector === '.widget-resize' ? resizeHandle : null }, clientX: 0, clientY: 0, preventDefault() {} }); +resizeHandle.emit('pointermove', { clientX: -110, clientY: -98 }); +resizeHandle.emit('pointerup', {}); +savedWidget = JSON.parse(widgetStorage.getItem('lifeos_widget_layout_v1')).goals; +assert.equal(savedWidget.w, 2, "goal countdown width can be reduced"); +assert.equal(savedWidget.h, 2, "goal countdown height can be reduced"); +widgetContext.toggleDesktopWidget('actions'); +assert.equal(JSON.parse(widgetStorage.getItem('lifeos_settings')).desktopWidgets.includes('actions'), true); const zhLogin = await worker.default.fetch(new Request("https://life-os.test/", { headers: { "accept-language": "zh-CN,zh;q=0.9" } }), {}); assert.match(await zhLogin.text(), /登录 ChatGPT/); const enLogin = await worker.default.fetch(new Request("https://life-os.test/", { headers: { "accept-language": "en-US,en;q=0.9" } }), {});