Relay:
- Inbox/shared/sent split into own view-inbox (nav button + poll).
Compose is now single-column, centered.
- Header logo links to compose. Inbox badge stays fresh on all
views via a quiet loadInbox on every view switch.
- Mobile menu: full-height sheet below header, larger buttons
(15px font, 14px padding), full width.
- Login form wrapped in <form> with name attributes + for/id on
labels so password managers can autofill.
- Admin user list shows "last seen <relative time>" per user.
last_seen_at column on tenants, updated on every authenticated
request (session + client API).
- Cache-busting on style.css + app.js via filemtime.
- View persistence in localStorage (survives reload).
- Shared-bucket cards show title only (preview line removed).
Favicon:
- favicon.svg added to project root + copied to public/, relay/,
website/. Linked via <link rel="icon" type="image/svg+xml"> in
all three <head> sections.
Settings:
- New toggle: "Show shopping list items in task collector"
(default off). collectTasks skips #einkauf/#shopping cards when
disabled.
Fixes:
- card-spinner gets display:inline-block so it rotates outside
flex containers (shopping save spinner).
- Tim tab test opens the plugin before checking active state.
- Compose view centered with margin:auto on desktop.
Bumps to 1.9.29.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
120 lines
5.3 KiB
JavaScript
120 lines
5.3 KiB
JavaScript
// ── Global tasks view ──
|
|
|
|
import { $, esc, api } from './util.js';
|
|
import { state } from './state.js';
|
|
|
|
const overlay = $('tasks');
|
|
const btn = $('tasks-btn');
|
|
const closeBtn = $('tasks-close');
|
|
const statusFilter = $('tasks-status-filter');
|
|
const bucketFilter = $('tasks-bucket-filter');
|
|
const list = $('tasks-list');
|
|
const summary = $('tasks-summary');
|
|
const countBadge = $('tasks-open-count');
|
|
|
|
function collectTasks() {
|
|
const showShopping = localStorage.getItem('hyprcrd-tasks-show-shopping') === '1';
|
|
const all = [];
|
|
for (const card of state.cards) {
|
|
if (!showShopping) {
|
|
const tags = (card.tags || '').split(',').map(s => s.trim());
|
|
if (tags.includes('einkauf') || tags.includes('shopping')) continue;
|
|
}
|
|
const re = /- \[([ xX])\][ \t]*([^\n\r]*)/g;
|
|
let m, idx = 0;
|
|
while ((m = re.exec(card.body || '')) !== null) {
|
|
all.push({ cardId: card.id, cardTitle: card.title || 'untitled', cardColor: card.color || '#94a3b8',
|
|
bucketId: card.bucket_id, bucketName: card.bucket_name || '', done: m[1].toLowerCase() === 'x',
|
|
text: (m[2] || '').trim() || '(empty task)', todoIndex: idx++ });
|
|
}
|
|
}
|
|
return all;
|
|
}
|
|
|
|
function filter(tasks) {
|
|
const s = statusFilter?.value || 'open', b = bucketFilter?.value || '';
|
|
return tasks.filter(t => {
|
|
if (s === 'open' && t.done) return false;
|
|
if (s === 'done' && !t.done) return false;
|
|
if (b === 'none') return !t.bucketId;
|
|
if (b && t.bucketId != b) return false;
|
|
return true;
|
|
});
|
|
}
|
|
|
|
export function refreshTasksView() {
|
|
if (!countBadge) return;
|
|
const tasks = collectTasks();
|
|
const open = tasks.filter(t => !t.done).length;
|
|
countBadge.textContent = String(open);
|
|
|
|
if (!overlay?.classList.contains('open') || !list) return;
|
|
const filtered = filter(tasks);
|
|
if (summary) summary.textContent = `open: ${open} | done: ${tasks.length - open} | showing: ${filtered.length}`;
|
|
|
|
if (!filtered.length) {
|
|
list.innerHTML = `<div class="px-4 py-10 text-center text-t-dim"><i class="ph ph-check-circle text-3xl mb-2 block"></i><p class="font-pixel text-[10px]">no tasks in this filter</p></div>`;
|
|
return;
|
|
}
|
|
|
|
list.innerHTML = filtered.map(t => `
|
|
<div class="px-4 py-3 border-b border-t-border flex items-start gap-3 hover:bg-t-raised/40">
|
|
<button data-task-toggle="${t.cardId}:${t.todoIndex}" class="group mt-0.5 shrink-0 border rounded-sm flex items-center justify-center transition-colors ${t.done ? 'border-t-accent bg-t-accent' : 'border-t-border bg-transparent hover:border-t-accent'}" style="width:30px;height:30px;">
|
|
<i class="ph ph-check text-[18px] transition-opacity ${t.done ? 'opacity-100 text-t-bg' : 'opacity-0 text-t-accent group-hover:opacity-75'}"></i>
|
|
</button>
|
|
<div class="min-w-0 flex-1">
|
|
<div class="text-xs leading-relaxed ${t.done ? 'text-t-dim line-through' : 'text-t-text'}">${esc(t.text)}</div>
|
|
<button data-task-open="${t.cardId}" class="mt-1 inline-flex items-center gap-1.5 text-[10px] font-pixel text-t-muted hover:text-t-accent2 transition-colors">
|
|
<span class="w-1.5 h-1.5 rounded-full" style="background:${t.cardColor}"></span>
|
|
<span>${esc(t.cardTitle)}</span><span class="text-t-dim">/ ${t.bucketName ? esc(t.bucketName) : 'unsorted'}</span>
|
|
</button>
|
|
</div>
|
|
</div>`).join('');
|
|
|
|
list.querySelectorAll('[data-task-toggle]').forEach(b => b.addEventListener('click', async e => {
|
|
e.preventDefault(); e.stopPropagation();
|
|
const [cardId, idx] = b.dataset.taskToggle.split(':');
|
|
await toggleTask(cardId, Number(idx));
|
|
}));
|
|
list.querySelectorAll('[data-task-open]').forEach(b => b.addEventListener('click', e => {
|
|
e.preventDefault(); e.stopPropagation();
|
|
closeTasks();
|
|
window._openDetail(b.dataset.taskOpen);
|
|
}));
|
|
}
|
|
|
|
async function toggleTask(cardId, todoIdx) {
|
|
const card = state.cards.find(c => c.id == cardId);
|
|
if (!card) return;
|
|
let idx = 0, changed = false;
|
|
const next = (card.body || '').replace(/- \[([ xX])\]/g, (m, s) => {
|
|
if (idx++ !== todoIdx) return m;
|
|
changed = true;
|
|
return `- [${s.toLowerCase() === 'x' ? ' ' : 'x'}]`;
|
|
});
|
|
if (!changed) return;
|
|
const updated = await api(`api/cards/${cardId}`, { method: 'PATCH', body: { body: next } });
|
|
if (updated?.id) { const i = state.cards.findIndex(c => c.id == updated.id); if (i !== -1) state.cards[i] = updated; }
|
|
else card.body = next;
|
|
if (state.viewingCard?.id == cardId) {
|
|
const latest = state.cards.find(c => c.id == cardId);
|
|
if (latest) { state.viewingCard = latest; /* detail re-render handled by board */ }
|
|
}
|
|
window._renderBoard();
|
|
}
|
|
|
|
export function openTasks() {
|
|
if (!overlay) return;
|
|
window._closeSearch?.();
|
|
overlay.classList.add('open');
|
|
refreshTasksView();
|
|
}
|
|
|
|
export function closeTasks() { overlay?.classList.remove('open'); }
|
|
|
|
btn?.addEventListener('click', openTasks);
|
|
closeBtn?.addEventListener('click', closeTasks);
|
|
overlay?.addEventListener('click', e => { if (e.target === overlay) closeTasks(); });
|
|
statusFilter?.addEventListener('change', refreshTasksView);
|
|
bucketFilter?.addEventListener('change', refreshTasksView);
|