Files
Life-OS/public/desktop-widgets.js
T

151 lines
14 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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, 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));
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 snapGrid = document.createElement('div');
snapGrid.id = 'desktop-snap-grid'; snapGrid.setAttribute('aria-hidden', 'true');
snapGrid.innerHTML = '<div class="snap-target"></div>';
document.querySelector('#desktop').append(snapGrid);
const target = snapGrid.querySelector('.snap-target');
let gridHideTimer;
window.LifeOSSnapGrid = {
show(col, row, w = 1, h = 1) {
if (!desktop()) return;
clearTimeout(gridHideTimer); snapGrid.classList.add('visible');
target.style.left = `${col * grid.width}px`; target.style.top = `${row * grid.height}px`;
target.style.width = `${w * grid.width - 8}px`; target.style.height = `${h * grid.height - 8}px`;
},
hide(delay = 0) { clearTimeout(gridHideTimer); gridHideTimer = setTimeout(() => snapGrid.classList.remove('visible'), delay); }
};
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');
window.LifeOSSnapGrid.show(start.col, start.row, start.w, start.h);
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);
window.LifeOSSnapGrid.show(next.col, next.row, next.w, next.h);
};
const stop = () => {
handle.removeEventListener('pointermove', move); handle.removeEventListener('pointerup', stop); handle.removeEventListener('pointercancel', stop);
node.classList.remove('is-adjusting'); window.LifeOSSnapGrid.hide();
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);
});
node.addEventListener('keydown', event => {
if (!event.target.closest('.widget-drag,.widget-resize') || !['ArrowLeft','ArrowRight','ArrowUp','ArrowDown'].includes(event.key) || !desktop()) return;
event.preventDefault();
const resize = Boolean(event.target.closest('.widget-resize')), { columns, rows } = limits(), pos = normalized(id);
const dx = event.key === 'ArrowRight' ? 1 : event.key === 'ArrowLeft' ? -1 : 0;
const dy = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0;
if (resize) { pos.w = Math.max(2, Math.min(columns - pos.col, pos.w + dx)); pos.h = Math.max(2, Math.min(rows - pos.row, pos.h + dy)); }
else { pos.col = Math.max(0, Math.min(columns - pos.w, pos.col + dx)); pos.row = Math.max(0, Math.min(rows - pos.h, pos.row + dy)); }
saveLayout(id, pos); place(id); window.LifeOSSnapGrid.show(pos.col, pos.row, pos.w, pos.h); window.LifeOSSnapGrid.hide(900);
});
return node;
}
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 = `<div class="widget-clock-time">${new Intl.DateTimeFormat(LifeI18n.locale(), { hour: '2-digit', minute: '2-digit' }).format(now)}</div><div class="widget-clock-date">${LifeI18n.date(now, { weekday: 'long', month: 'long', day: 'numeric' })}</div>`;
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 = `<div class="widget-today-row"><strong>${LifeI18n.number(memories)}</strong><span>${label('memoriesToday')}</span></div><div class="widget-today-row"><strong>${LifeI18n.number(due)}</strong><span>${label('tasksDue')}</span></div><button class="widget-link" onclick="openApp('calendar')">${label('openCalendar')}</button>`;
actions.querySelector('.widget-body').innerHTML = `<div class="widget-actions">${[['today','newMemory'],['tasks','openTasks'],['calendar','openCalendar']].map(([id, text]) => `<button onclick="openApp('${id}')">${label(text)}</button>`).join('')}</div>`;
}
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();
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', `<section class="widget-preferences" data-settings-group="appearance"><div class="eyebrow">${label('heading')}</div><p>${label('hint')}</p><div class="desktop-app-choices">${widgetIds.map(id => `<button type="button" class="chip ${selected().includes(id) ? 'selected' : ''}" aria-pressed="${selected().includes(id)}" onclick="toggleDesktopWidget('${id}')">${label(id)}</button>`).join('')}</div><button class="desktop-text-button" onclick="resetWidgetLayout()">${label('reset')}</button></section>`);
};
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}@media(max-width:650px){.taskbar{position:fixed}}`;
document.head.append(contentStyle);
})();