feat(desktop): remember layout and personalize shortcuts and wallpaper
This commit is contained in:
1 parent
477e86f694
commit
2c5e3b4019
10 files changed
+291
-22
No files matched your search
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "life-os",
|
"name": "life-os",
|
||||||
"version": "1.6.0",
|
"version": "1.7.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
+5
-3
@@ -3,7 +3,7 @@
|
|||||||
const DRAFT_KEY = 'lifeos_today_draft_v1';
|
const DRAFT_KEY = 'lifeos_today_draft_v1';
|
||||||
const localDay = () => { const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`; };
|
const localDay = () => { const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`; };
|
||||||
const escapeHtml = value => String(value || '').replace(/[&<>]/g, char => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[char]));
|
const escapeHtml = value => String(value || '').replace(/[&<>]/g, char => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[char]));
|
||||||
const APP_VERSION = '1.6.0';
|
const APP_VERSION = '1.7.0';
|
||||||
window.LifeOSVersion = APP_VERSION;
|
window.LifeOSVersion = APP_VERSION;
|
||||||
const appIds = ['today', 'timeline', 'museum', 'insights', 'profile', 'tasks', 'calendar', 'system'];
|
const appIds = ['today', 'timeline', 'museum', 'insights', 'profile', 'tasks', 'calendar', 'system'];
|
||||||
const t = (key, values) => LifeI18n.t(key, values);
|
const t = (key, values) => LifeI18n.t(key, values);
|
||||||
@@ -68,7 +68,9 @@
|
|||||||
document.head.append(style);
|
document.head.append(style);
|
||||||
|
|
||||||
function exportData() {
|
function exportData() {
|
||||||
const data = { app: 'Life OS', version: APP_VERSION, schemaVersion: 2, exportedAt: new Date().toISOString(), profile: profile(), memories: mems(), museum: museum(), settings: settings(), system: sys() };
|
let desktopLayout = 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 };
|
||||||
const link = document.createElement('a');
|
const link = document.createElement('a');
|
||||||
link.href = URL.createObjectURL(new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }));
|
link.href = URL.createObjectURL(new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }));
|
||||||
link.download = `life-os-backup-${day()}.json`; link.click();
|
link.download = `life-os-backup-${day()}.json`; link.click();
|
||||||
@@ -104,7 +106,7 @@
|
|||||||
applyingCloud = true;
|
applyingCloud = true;
|
||||||
Object.entries(CLOUD_KEYS).forEach(([field, key]) => localStorage.setItem(key, JSON.stringify(state[field] ?? cloudFallback[field])));
|
Object.entries(CLOUD_KEYS).forEach(([field, key]) => localStorage.setItem(key, JSON.stringify(state[field] ?? cloudFallback[field])));
|
||||||
applyingCloud = false;
|
applyingCloud = false;
|
||||||
todayDraft = null; window.renderIcons?.(); window.renderAll?.(); window.applyLanguage?.();
|
todayDraft = null; window.renderIcons?.(); window.renderAll?.(); window.applyLanguage?.(); window.applyDesktopPreferences?.();
|
||||||
return mergedLocalBoard || systemNeedsMigration;
|
return mergedLocalBoard || systemNeedsMigration;
|
||||||
}
|
}
|
||||||
async function uploadCloudState() {
|
async function uploadCloudState() {
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
/* Device-aware desktop preferences. The app list and actual records stay in the existing stores. */
|
||||||
|
(() => {
|
||||||
|
const APP_IDS = Object.keys(APPS);
|
||||||
|
const LAYOUT_KEY = 'lifeos_window_layout_v1';
|
||||||
|
const cell = { width: 110, height: 98, left: 24, top: 72 };
|
||||||
|
const desktopText = (key, values) => LifeI18n.t(`desktop.${key}`, values);
|
||||||
|
const validApps = value => Array.isArray(value) ? [...new Set(value.filter(id => APP_IDS.includes(id)))] : [];
|
||||||
|
const preference = () => settings();
|
||||||
|
const desktopMode = () => innerWidth > 650;
|
||||||
|
const bounds = () => ({ rows: Math.max(1, Math.floor((innerHeight - 175) / cell.height)), columns: Math.max(1, Math.floor((innerWidth - 48) / cell.width)) });
|
||||||
|
const chosenShortcuts = () => Array.isArray(preference().desktopShortcuts) ? validApps(preference().desktopShortcuts) : APP_IDS;
|
||||||
|
const savedPositions = () => preference().iconPositions || {};
|
||||||
|
const updateSettings = patch => { const state = preference(); Object.assign(state, patch); save('lifeos_settings', state); };
|
||||||
|
const isSlot = (value, limit) => Number.isInteger(value) && value >= 0 && value < limit;
|
||||||
|
let placed = {}, restoring = false, suppressClick = false;
|
||||||
|
|
||||||
|
function arrangeIcons() {
|
||||||
|
const visible = chosenShortcuts(), { rows, columns } = bounds(), occupied = new Set(), result = {};
|
||||||
|
document.querySelectorAll('#icons .app-icon').forEach(node => { if (!visible.includes(node.dataset.app)) node.remove(); });
|
||||||
|
const nodes = [...document.querySelectorAll('#icons .app-icon')];
|
||||||
|
if (!desktopMode()) { nodes.forEach(node => { node.style.left = ''; node.style.top = ''; node.style.position = ''; }); placed = {}; return; }
|
||||||
|
nodes.forEach((node, index) => {
|
||||||
|
const id = node.dataset.app, stored = savedPositions()[id];
|
||||||
|
let col = stored?.col, row = stored?.row;
|
||||||
|
if (!isSlot(col, columns) || !isSlot(row, rows) || occupied.has(`${col}:${row}`)) {
|
||||||
|
const next = Array.from({ length: rows * columns }, (_, slot) => ({ col: Math.floor(slot / rows), row: slot % rows })).find(slot => !occupied.has(`${slot.col}:${slot.row}`));
|
||||||
|
col = next?.col ?? Math.floor(index / rows); row = next?.row ?? index % rows;
|
||||||
|
}
|
||||||
|
occupied.add(`${col}:${row}`); result[id] = { col, row };
|
||||||
|
node.style.position = 'absolute'; node.style.left = `${cell.left + col * cell.width}px`; node.style.top = `${cell.top + row * cell.height}px`;
|
||||||
|
});
|
||||||
|
placed = result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalRenderIcons = window.renderIcons;
|
||||||
|
window.renderIcons = () => { originalRenderIcons(); arrangeIcons(); };
|
||||||
|
const icons = document.querySelector('#icons');
|
||||||
|
let dragState = null;
|
||||||
|
icons.addEventListener('pointerdown', event => {
|
||||||
|
const icon = event.target.closest('.app-icon');
|
||||||
|
if (!desktopMode() || !icon || event.button !== 0) return;
|
||||||
|
dragState = { icon, id: icon.dataset.app, x: event.clientX, y: event.clientY, moved: false };
|
||||||
|
icon.setPointerCapture?.(event.pointerId);
|
||||||
|
});
|
||||||
|
icons.addEventListener('pointermove', event => {
|
||||||
|
if (!dragState) return;
|
||||||
|
const dx = event.clientX - dragState.x, dy = event.clientY - dragState.y;
|
||||||
|
if (Math.hypot(dx, dy) > 6) dragState.moved = true;
|
||||||
|
if (dragState.moved) { dragState.icon.classList.add('dragging'); dragState.icon.style.transform = `translate(${dx}px,${dy}px)`; }
|
||||||
|
});
|
||||||
|
icons.addEventListener('pointerup', event => {
|
||||||
|
if (!dragState) return;
|
||||||
|
const { icon, id, moved, x, y } = dragState; dragState = null;
|
||||||
|
icon.classList.remove('dragging'); icon.style.transform = '';
|
||||||
|
if (!moved) return;
|
||||||
|
suppressClick = true;
|
||||||
|
setTimeout(() => { suppressClick = false; }, 0);
|
||||||
|
const { rows, columns } = bounds(), origin = placed[id];
|
||||||
|
const col = Math.max(0, Math.min(columns - 1, Math.round((cell.left + origin.col * cell.width + event.clientX - x - cell.left) / cell.width)));
|
||||||
|
const row = Math.max(0, Math.min(rows - 1, Math.round((cell.top + origin.row * cell.height + event.clientY - y - cell.top) / cell.height)));
|
||||||
|
const other = Object.keys(placed).find(key => key !== id && placed[key].col === col && placed[key].row === row);
|
||||||
|
const positions = { ...savedPositions(), [id]: { col, row } };
|
||||||
|
if (other) positions[other] = origin;
|
||||||
|
updateSettings({ iconPositions: positions }); arrangeIcons();
|
||||||
|
});
|
||||||
|
icons.addEventListener('pointercancel', () => { if (dragState) { dragState.icon.style.transform = ''; dragState.icon.classList.remove('dragging'); dragState = null; } });
|
||||||
|
icons.addEventListener('click', event => { if (suppressClick) { event.preventDefault(); event.stopImmediatePropagation(); } }, true);
|
||||||
|
window.addEventListener('resize', () => { arrangeIcons(); if (desktopMode()) Object.values(windows).forEach(clampWindow); });
|
||||||
|
|
||||||
|
const readLayout = () => { try { return JSON.parse(localStorage.getItem(LAYOUT_KEY) || '{}'); } catch { return {}; } };
|
||||||
|
function clampWindow(node) {
|
||||||
|
if (!desktopMode() || node.classList.contains('max')) return;
|
||||||
|
const width = Math.min(node.offsetWidth || 700, Math.max(360, innerWidth - 20));
|
||||||
|
const height = Math.min(node.offsetHeight || 590, Math.max(280, innerHeight - 85));
|
||||||
|
node.style.width = `${width}px`; node.style.height = `${height}px`;
|
||||||
|
node.style.left = `${Math.max(0, Math.min(parseFloat(node.style.left) || 0, innerWidth - 80))}px`;
|
||||||
|
node.style.top = `${Math.max(0, Math.min(parseFloat(node.style.top) || 0, innerHeight - 95))}px`;
|
||||||
|
}
|
||||||
|
function rememberWindows() {
|
||||||
|
if (restoring || preference().rememberWindowLayout === false || !desktopMode()) return;
|
||||||
|
const entries = Object.values(windows).sort((a, b) => Number(a.style.zIndex || 0) - Number(b.style.zIndex || 0)).map(node => ({
|
||||||
|
id: node.dataset.id, left: parseFloat(node.style.left), top: parseFloat(node.style.top),
|
||||||
|
width: parseFloat(node.style.width) || node.offsetWidth, height: parseFloat(node.style.height) || node.offsetHeight,
|
||||||
|
max: node.classList.contains('max'), min: node.classList.contains('min')
|
||||||
|
}));
|
||||||
|
localStorage.setItem(LAYOUT_KEY, JSON.stringify({ entries }));
|
||||||
|
}
|
||||||
|
function observeWindow(node) {
|
||||||
|
if (node.dataset.layoutObserved) return;
|
||||||
|
node.dataset.layoutObserved = 'true';
|
||||||
|
if (typeof ResizeObserver !== 'undefined') new ResizeObserver(() => rememberWindows()).observe(node);
|
||||||
|
}
|
||||||
|
const originalOpenApp = window.openApp;
|
||||||
|
window.openApp = id => { if (!APP_IDS.includes(id)) return; originalOpenApp(id); observeWindow(windows[id]); rememberWindows(); };
|
||||||
|
const originalFocus = window.focus;
|
||||||
|
window.focus = node => { originalFocus(node); rememberWindows(); };
|
||||||
|
for (const method of ['closeWin', 'minWin', 'maxWin']) {
|
||||||
|
const original = window[method];
|
||||||
|
window[method] = (...args) => { original(...args); rememberWindows(); };
|
||||||
|
}
|
||||||
|
document.querySelector('#windows').addEventListener('pointerup', () => setTimeout(rememberWindows, 0));
|
||||||
|
function restoreWindows() {
|
||||||
|
restoring = true;
|
||||||
|
try {
|
||||||
|
const remembered = desktopMode() && preference().rememberWindowLayout !== false ? readLayout().entries : [];
|
||||||
|
const entries = Array.isArray(remembered) ? remembered.filter(item => APP_IDS.includes(item.id)) : [];
|
||||||
|
const startup = validApps(preference().startupApps);
|
||||||
|
for (const id of [...new Set([...entries.map(item => item.id), ...startup])]) {
|
||||||
|
window.openApp(id);
|
||||||
|
const node = windows[id], position = entries.find(item => item.id === id);
|
||||||
|
if (!position || !desktopMode()) continue;
|
||||||
|
for (const field of ['left', 'top', 'width', 'height']) if (Number.isFinite(position[field]) && position[field] >= 0 && position[field] < 10000) node.style[field] = `${position[field]}px`;
|
||||||
|
node.classList.toggle('max', position.max === true);
|
||||||
|
node.classList.toggle('min', position.min === true);
|
||||||
|
clampWindow(node);
|
||||||
|
}
|
||||||
|
const visible = Object.values(windows).filter(node => !node.classList.contains('min'));
|
||||||
|
if (visible.length) focus(visible.at(-1)); else renderTaskbar();
|
||||||
|
} finally { restoring = false; }
|
||||||
|
rememberWindows();
|
||||||
|
}
|
||||||
|
const originalInit = window.init;
|
||||||
|
window.init = () => { originalInit(); restoreWindows(); };
|
||||||
|
window.applyDesktopPreferences = () => {
|
||||||
|
window.renderIcons(); window.loadBingWallpaper(); window.applyWallpaper();
|
||||||
|
for (const id of validApps(preference().startupApps)) if (!windows[id]) window.openApp(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const originalWallpaper = window.applyWallpaper;
|
||||||
|
window.applyWallpaper = () => {
|
||||||
|
const state = preference(), mode = state.wallpaperSource || 'bing', desktop = document.querySelector('#desktop');
|
||||||
|
if (mode === 'calm') { desktop.style.backgroundImage = 'radial-gradient(ellipse at 20% 0%,#3f4a503d,transparent 40%),radial-gradient(ellipse at 93% 65%,#59604224,transparent 42%)'; desktop.title = desktopText('calm'); return; }
|
||||||
|
if (mode === 'custom') {
|
||||||
|
try {
|
||||||
|
const url = new URL(state.customWallpaperUrl);
|
||||||
|
if (url.protocol !== 'https:') throw new Error('invalid wallpaper URL');
|
||||||
|
desktop.style.backgroundImage = `linear-gradient(120deg,color-mix(in srgb,var(--bg) 68%,transparent),color-mix(in srgb,var(--bg) 36%,transparent)),url("${url.href.replaceAll('"', '%22')}")`;
|
||||||
|
desktop.style.backgroundSize = 'cover'; desktop.style.backgroundPosition = 'center'; desktop.title = desktopText('custom'); return;
|
||||||
|
} catch { /* Use the built-in background until a valid URL is saved. */ }
|
||||||
|
}
|
||||||
|
if (state.wallpaper?.url) originalWallpaper(state.wallpaper);
|
||||||
|
};
|
||||||
|
const originalLoadBing = window.loadBingWallpaper;
|
||||||
|
window.loadBingWallpaper = () => { if ((preference().wallpaperSource || 'bing') === 'bing') return originalLoadBing(); window.applyWallpaper(); };
|
||||||
|
window.setWallpaperSource = mode => {
|
||||||
|
if (!['bing', 'calm', 'custom'].includes(mode)) return;
|
||||||
|
if (mode === 'custom') {
|
||||||
|
const value = document.querySelector('#desktop-wallpaper-url')?.value.trim() || '';
|
||||||
|
try { const url = new URL(value); if (url.protocol !== 'https:' || value.length > 2048) throw new Error('invalid URL'); }
|
||||||
|
catch { toast(desktopText('invalidUrl')); return; }
|
||||||
|
updateSettings({ wallpaperSource: mode, customWallpaperUrl: value });
|
||||||
|
} else updateSettings({ wallpaperSource: mode });
|
||||||
|
window.loadBingWallpaper(); window.applyWallpaper(); window.renderAll();
|
||||||
|
};
|
||||||
|
window.toggleDesktopApp = (kind, id) => {
|
||||||
|
if (!APP_IDS.includes(id) || !['desktopShortcuts', 'startupApps'].includes(kind)) return;
|
||||||
|
const current = kind === 'desktopShortcuts' ? chosenShortcuts() : validApps(preference().startupApps);
|
||||||
|
const next = current.includes(id) ? current.filter(app => app !== id) : APP_IDS.filter(app => current.includes(app) || app === id);
|
||||||
|
updateSettings({ [kind]: next });
|
||||||
|
if (kind === 'desktopShortcuts') window.renderIcons();
|
||||||
|
window.renderAll();
|
||||||
|
};
|
||||||
|
window.toggleWindowMemory = () => { const enabled = preference().rememberWindowLayout === false; updateSettings({ rememberWindowLayout: enabled }); if (enabled) rememberWindows(); window.renderAll(); };
|
||||||
|
window.resetIconPositions = () => { updateSettings({ iconPositions: {} }); window.renderIcons(); window.renderAll(); };
|
||||||
|
const originalSystemApp = window.systemApp;
|
||||||
|
window.systemApp = container => {
|
||||||
|
originalSystemApp(container);
|
||||||
|
const state = preference(), mode = state.wallpaperSource || 'bing';
|
||||||
|
const appChoices = kind => `<div class="desktop-app-choices">${APP_IDS.map(id => `<button type="button" class="chip ${(kind === 'desktopShortcuts' ? chosenShortcuts() : validApps(state.startupApps)).includes(id) ? 'selected' : ''}" aria-pressed="${(kind === 'desktopShortcuts' ? chosenShortcuts() : validApps(state.startupApps)).includes(id)}" onclick="toggleDesktopApp('${kind}','${id}')">${appName(id)}</button>`).join('')}</div>`;
|
||||||
|
const customUrl = String(state.customWallpaperUrl || '').replace(/[&<>"']/g, char => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[char]));
|
||||||
|
const personalization = `<section class="desktop-preferences"><div class="eyebrow">${desktopText('heading')}</div><h2>${desktopText('wallpaper')}</h2><div class="choices">${['bing','calm'].map(value => `<button class="chip ${mode === value ? 'selected' : ''}" aria-pressed="${mode === value}" onclick="setWallpaperSource('${value}')">${desktopText(value)}</button>`).join('')}</div><div class="desktop-wallpaper-custom"><label for="desktop-wallpaper-url">${desktopText('custom')}</label><div><input id="desktop-wallpaper-url" type="url" maxlength="2048" placeholder="https://example.com/image.jpg" value="${customUrl}"><button class="secondary" onclick="setWallpaperSource('custom')">${desktopText('useUrl')}</button></div><p>${desktopText('urlHint')}</p></div><h2>${desktopText('shortcuts')}</h2><p>${desktopText('shortcutsHint')}</p>${appChoices('desktopShortcuts')}<button class="desktop-text-button" onclick="resetIconPositions()">${desktopText('resetPositions')}</button><h2>${desktopText('windowLayout')}</h2><button class="chip ${state.rememberWindowLayout !== false ? 'selected' : ''}" aria-pressed="${state.rememberWindowLayout !== false}" onclick="toggleWindowMemory()">${state.rememberWindowLayout !== false ? desktopText('rememberOn') : desktopText('rememberOff')}</button><p>${desktopText('windowHint')}</p><h2>${desktopText('startup')}</h2><p>${desktopText('startupHint')}</p>${appChoices('startupApps')}</section>`;
|
||||||
|
const panel = container.querySelector('.settings');
|
||||||
|
if (panel) panel.insertAdjacentHTML('beforeend', personalization);
|
||||||
|
};
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.textContent = `.desktop-preferences h2{font-size:14px;margin:18px 0 9px}.desktop-preferences .desktop-app-choices{display:flex;flex-wrap:wrap;gap:8px}.desktop-preferences .chip{font-size:12px;min-height:36px}.desktop-wallpaper-custom{margin-top:13px}.desktop-wallpaper-custom label{display:block;font-size:13px;color:var(--muted);margin-bottom:7px}.desktop-wallpaper-custom>div{display:flex;gap:8px}.desktop-wallpaper-custom input{flex:1;min-width:0;background:var(--panel2);border:1px solid var(--line);border-radius:8px;padding:10px;color:var(--ink)}.desktop-wallpaper-custom .secondary{font-size:12px;white-space:nowrap}.desktop-preferences p{font-size:13px;line-height:1.5}.desktop-text-button{margin:10px 0 2px;border:0;background:transparent;padding:5px 0;color:var(--accent);font-size:13px;text-decoration:underline}.app-icon.dragging{z-index:9;cursor:grabbing;touch-action:none}.desktop-icons .app-icon{touch-action:none}@media(max-width:650px){.desktop-icons .app-icon{position:static!important;left:auto!important;top:auto!important;touch-action:auto}.desktop-wallpaper-custom>div{flex-wrap:wrap}.desktop-wallpaper-custom input{flex-basis:100%}}`;
|
||||||
|
document.head.append(style);
|
||||||
|
window.renderIcons();
|
||||||
|
})();
|
||||||
Loaded 3 of 10 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user