Board: new-card unread indicator (public/js/modules/board.js,
public/js/modules/buckets.js, public/js/modules/state.js,
public/css/style.css):
* state.unseenCardIds (Set) tracks card ids that arrived since the
last visit to the "all" view. Diff runs in renderBoard() against a
module-private knownCardIds snapshot.
* Bug fix: the first renderBoard runs with state.cards = [] (before
the API returns). The previous diff treated every card from the
real load as "new" and pulsed the entire board. Now the diff is
skipped when knownCardIds is null or empty — the first non-empty
render is a silent prime.
* When activeBucket === 'all' and unseenCardIds is non-empty, the set
is drained and every matching board card gets a 3-cycle accent glow
(`.card-pulse-new`, 0.8s × 3, pinned for anim-fast/slow). Works
both when navigating TO all and when a poll arrives while already
on all.
* When the user is on a different bucket: unseenCardIds accumulates
across polls and a red dot (`.bar-unread-dot`) appears on the "all"
tab. Position is top:3px right:3px *inside* the tab to survive
the double overflow clip (`#main-header overflow:hidden` +
`#bucket-bar overflow-x:auto`). renderBar() bakes the dot into the
all-tab template on every render so it survives bar re-renders that
aren't followed by a board render.
* decorateAllUnreadDot() in board.js is a fallback called at the end
of every renderBoard for cases where the diff changed unseenCardIds
but renderBar hasn't run yet.
Relay: readable inbox (relay/inc/routes/messages.php,
relay/ui/app.js, relay/ui/layout.php, relay/ui/style.css):
* New session-based GET /api/messages/inbox — returns up to 200
messages for the logged-in user, decrypted server-side with the
inter-tenant key (no extra leakage — the relay already sees
plaintext during the routing hop). Does NOT mark messages as
fetched; the hyprcrd client is still the canonical receiver.
* loadInbox() now fetches from /api/messages/inbox and renders
actual inbox items: sender (with user-circle icon + accent2),
optional title (bold), body (pre-wrap, max-height 120px), relative
timestamp (formatRelTime: "just now", "2 min ago", "today 14:32",
"3 d ago", or YYYY-MM-DD for older). Unfetched messages show a
small "new" accent badge next to the sender name.
* deleteMessage(id, btn) — confirms, calls existing DELETE
/api/messages/{id} (already session-aware), removes the DOM row,
re-loads the list, toasts "message deleted".
* Auto-poll: startInboxPoll / stopInboxPoll refresh the inbox every
15s while the compose view is active. Stops on view switch.
* Header-status badge: mirrors the real message count (not just the
/api/status count) and toggles the has-unread class + dot so the
tap-to-scroll-to-inbox target stays fresh.
Relay UI polish (relay/ui/style.css):
* .header-status is now a clickable button with tray icon, hover
tint, focus-ring, and a red dot (::after pseudo) when has-unread.
jumpToInbox() scrolls smoothly to #inbox-section and flashes it
with a 1.6s accent box-shadow pulse.
* .inbox-item .from is flex-row with icon + badge-new pill.
* .inbox-item .del gets hover-background for better mobile tappability.
* @keyframes flash-highlight for the scroll-to-inbox animation.
chore: bump version to 0.9.9
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
167 lines
6.4 KiB
JavaScript
167 lines
6.4 KiB
JavaScript
// ── Shared app state ──
|
|
|
|
import { esc } from './util.js';
|
|
|
|
export const state = {
|
|
// Data
|
|
cards: [],
|
|
buckets: [],
|
|
|
|
// Filters & view — activeBucket is persisted across reloads via
|
|
// localStorage. Numeric ids are stringified; the special filters
|
|
// ('all', 'current', 'favorites', 'journal', 'none') are stored
|
|
// as-is. Validity is re-checked after buckets load — see
|
|
// ensureActiveBucketValid() below.
|
|
activeBucket: localStorage.getItem('hyprcrd-active-bucket') || 'all',
|
|
activeTags: [],
|
|
activeGroup: null,
|
|
sortMode: localStorage.getItem('hyprcrd-sort') || 'manual',
|
|
hideTermine: localStorage.getItem('hyprcrd-hide-termine') === '1',
|
|
hideDone: localStorage.getItem('hyprcrd-hide-done') === '1',
|
|
hideTagged: localStorage.getItem('hyprcrd-hide-tagged') === '1',
|
|
superView: localStorage.getItem('hyprcrd-superview') === '1',
|
|
|
|
// Board split preview
|
|
boardSplitOpen: localStorage.getItem('hyprcrd-board-split') === '1',
|
|
boardPreviewCardId: null,
|
|
|
|
// Multi-select
|
|
selectedIds: new Set(),
|
|
// True when at least one bulk gesture (rubber-band) participated in
|
|
// building the current selection. Used by the join/merge action to
|
|
// pick the title strategy: false → first selected wins, true →
|
|
// longest title wins (since "first" isn't meaningful for bulk).
|
|
selectionWasBulk: false,
|
|
// Card ids that have arrived since the user last entered the "all"
|
|
// view. Drives the unread dot on the all bucket-tab and the brief
|
|
// accent pulse on the cards themselves once the user looks at them.
|
|
// Updated by board.js renderBoard() — see the diff there.
|
|
unseenCardIds: new Set(),
|
|
|
|
// Detail / editor
|
|
editingId: null,
|
|
viewingCard: null,
|
|
zoom: parseFloat(localStorage.getItem('hyprcrd-detail-zoom') || '1') || 1,
|
|
|
|
// Navigation context
|
|
openedFromSearch: false,
|
|
openedFromGallery: false,
|
|
galleryOrigin: null,
|
|
returnToGalleryAfterEdit: false,
|
|
previewVisible: false,
|
|
|
|
// Calendar
|
|
calYear: new Date().getFullYear(),
|
|
calMonth: new Date().getMonth(),
|
|
|
|
// Sync
|
|
bgSyncRunning: false,
|
|
};
|
|
|
|
export const SORT_MODES = ['manual', 'newest', 'oldest', 'alpha', 'tag'];
|
|
export const SORT_ICONS = { manual: 'ph-dots-six', newest: 'ph-sort-descending', oldest: 'ph-sort-ascending', alpha: 'ph-text-aa', tag: 'ph-hash' };
|
|
|
|
const SPECIAL_BUCKETS = new Set(['all', 'current', 'favorites', 'journal', 'none']);
|
|
|
|
/** Set state.activeBucket and persist it to localStorage so it survives
|
|
* page reloads. Use this instead of `state.activeBucket = …` everywhere
|
|
* except tests (tests reset directly to avoid touching localStorage). */
|
|
export function setActiveBucket(value) {
|
|
state.activeBucket = value;
|
|
if (value == null) localStorage.removeItem('hyprcrd-active-bucket');
|
|
else localStorage.setItem('hyprcrd-active-bucket', String(value));
|
|
}
|
|
|
|
/** Called from loadBuckets after the fresh bucket list arrives. If the
|
|
* persisted activeBucket points to a bucket id that no longer exists
|
|
* (deleted from another tab, removed in import, etc.), fall back to
|
|
* 'all'. Special filters are always valid. */
|
|
export function ensureActiveBucketValid() {
|
|
const cur = state.activeBucket;
|
|
if (!cur || SPECIAL_BUCKETS.has(cur)) return;
|
|
const exists = state.buckets.some(b => String(b.id) === String(cur));
|
|
if (!exists) setActiveBucket('all');
|
|
}
|
|
|
|
export const CARD_COLORS = [
|
|
{ name: 'sky', hex: '#38bdf8' }, { name: 'violet', hex: '#8b5cf6' },
|
|
{ name: 'rose', hex: '#fb7185' }, { name: 'amber', hex: '#fbbf24' },
|
|
{ name: 'green', hex: '#4ade80' }, { name: 'orange', hex: '#fb923c' },
|
|
{ name: 'cyan', hex: '#22d3ee' }, { name: 'pink', hex: '#f472b6' },
|
|
{ name: 'lime', hex: '#a3e635' }, { name: 'slate', hex: '#94a3b8' },
|
|
];
|
|
|
|
export function parseTags(card) {
|
|
return (card.tags || '').split(',').filter(Boolean);
|
|
}
|
|
|
|
export function tagChipHtml(tag) {
|
|
const parts = tag.split('/');
|
|
const segs = parts.map((p, i) => {
|
|
const path = parts.slice(0, i + 1).join('/');
|
|
return `<span class="tag-seg" data-tag="${path}">${i === 0 ? '#' : ''}${esc(p)}</span>`;
|
|
});
|
|
return `<span class="tag-chip">${segs.join('<span class="tag-sep">/</span>')}</span>`;
|
|
}
|
|
|
|
function effectiveDate(c) {
|
|
if (c.due_date) return c.due_date;
|
|
const m = (c.title || '').match(/^(\d{4}-\d{2}-\d{2})/);
|
|
if (m) return m[1];
|
|
return (c.created_at || '').substring(0, 10);
|
|
}
|
|
|
|
export function sortedCards(cards) {
|
|
const arr = [...cards];
|
|
switch (state.sortMode) {
|
|
case 'newest': return arr.sort((a, b) => effectiveDate(b).localeCompare(effectiveDate(a)));
|
|
case 'oldest': return arr.sort((a, b) => effectiveDate(a).localeCompare(effectiveDate(b)));
|
|
case 'alpha': return arr.sort((a, b) => (a.title || '').localeCompare(b.title || ''));
|
|
case 'tag': return arr.sort((a, b) => {
|
|
const at = (a.tags || '').split(',')[0] || '\uffff';
|
|
const bt = (b.tags || '').split(',')[0] || '\uffff';
|
|
return at.localeCompare(bt);
|
|
});
|
|
default: return arr.sort((a, b) => (a.position || 0) - (b.position || 0));
|
|
}
|
|
}
|
|
|
|
export function getChildren(parentId) {
|
|
return state.cards.filter(c => c.parent_id == parentId);
|
|
}
|
|
|
|
export function getGroupCards(card) {
|
|
if (card.parent_id) {
|
|
const parent = state.cards.find(c => c.id == card.parent_id);
|
|
if (parent) return [parent, ...getChildren(parent.id)];
|
|
}
|
|
const children = getChildren(card.id);
|
|
if (children.length) return [card, ...children];
|
|
return null;
|
|
}
|
|
|
|
// Recently opened
|
|
export function getRecent() {
|
|
try { return JSON.parse(localStorage.getItem('hyprcrd-recent') || '[]'); } catch { return []; }
|
|
}
|
|
export function markRecent(id) {
|
|
const r = getRecent().filter(i => i != id);
|
|
r.unshift(String(id));
|
|
localStorage.setItem('hyprcrd-recent', JSON.stringify(r.slice(0, 10)));
|
|
}
|
|
|
|
// Favorites
|
|
export function getFavorites() {
|
|
try { return JSON.parse(localStorage.getItem('hyprcrd-favorites') || '[]'); } catch { return []; }
|
|
}
|
|
export function toggleFavorite(id) {
|
|
const favs = getFavorites();
|
|
const sid = String(id);
|
|
const idx = favs.indexOf(sid);
|
|
if (idx !== -1) favs.splice(idx, 1);
|
|
else favs.push(sid);
|
|
localStorage.setItem('hyprcrd-favorites', JSON.stringify(favs));
|
|
return favs.includes(sid);
|
|
}
|
|
export function isFavorite(id) { return getFavorites().includes(String(id)); }
|