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

105 lines
9.5 KiB
JavaScript
Raw 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 } = 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 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 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-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);
})();