Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
195b0e343f | ||
|
|
ce6ca73ea0 |
No files matched your search
+1
-1
@@ -1,7 +1,7 @@
|
||||
<?php
|
||||
|
||||
define('APP_NAME', 'hyprcrd');
|
||||
define('APP_VERSION', '0.9.15');
|
||||
define('APP_VERSION', '0.9.17');
|
||||
|
||||
// Database path — writable directory, outside webroot
|
||||
define('DB_PATH', __DIR__ . '/data/hyprcrd.db');
|
||||
|
||||
@@ -474,6 +474,14 @@ body::after {
|
||||
transform: rotate(45deg); margin-top: -2px;
|
||||
}
|
||||
.card-dragging { opacity: 0.15; transform: scale(0.95); }
|
||||
@keyframes card-remove {
|
||||
0% { opacity:1; transform:scale(1); }
|
||||
100% { opacity:0; transform:scale(0.85) translateY(8px); }
|
||||
}
|
||||
.card-removing {
|
||||
animation: card-remove 0.25s ease-in forwards;
|
||||
pointer-events: none;
|
||||
}
|
||||
.box-select {
|
||||
position: absolute; border: 1px solid var(--t-accent); background: rgba(56, 189, 248, 0.08);
|
||||
pointer-events: none; z-index: 10;
|
||||
@@ -786,12 +794,24 @@ body.detail-image-fs-open { overflow: hidden; }
|
||||
animation: card-pulse-new 0.8s ease-in-out 3;
|
||||
z-index: 5;
|
||||
}
|
||||
/* Single pulse for cards the user just created themselves — subtler
|
||||
than the triple-pulse for relay-synced cards so the user registers
|
||||
"I made this" without the urgency of "someone else sent this". */
|
||||
.board-card.card-pulse-local {
|
||||
animation: card-pulse-new 0.8s ease-in-out 1;
|
||||
z-index: 5;
|
||||
}
|
||||
/* Pin the cadence so anim-fast/anim-slow can't strobe it */
|
||||
body.anim-fast .board-card.card-pulse-new,
|
||||
body.anim-slow .board-card.card-pulse-new {
|
||||
animation: card-pulse-new 0.8s ease-in-out 3 !important;
|
||||
animation-duration: 0.8s !important;
|
||||
}
|
||||
body.anim-fast .board-card.card-pulse-local,
|
||||
body.anim-slow .board-card.card-pulse-local {
|
||||
animation: card-pulse-new 0.8s ease-in-out 1 !important;
|
||||
animation-duration: 0.8s !important;
|
||||
}
|
||||
|
||||
/* Super-bucket dropdown popover. Positioned via inline styles by
|
||||
openSuperDropdown() in buckets.js. Lives on document.body (not
|
||||
|
||||
+5
-4
@@ -308,13 +308,13 @@ registerShortcut({ key: 'Enter', guard: () => gBoard() && !!document.querySelect
|
||||
}});
|
||||
// Delete selected cards
|
||||
registerShortcut({ match: (e) => (e.key === 'Delete' || e.key === 'Backspace') && !e.ctrlKey && gBoard() && getSelectedIds().length > 0, section: 'global', keys: '<kbd>Del</kbd>', label: 'delete selected', hidden: true, handler: async () => {
|
||||
// Delegated to board.js's existing handler via a synthetic event is
|
||||
// impractical; re-implement the small logic here to avoid coupling.
|
||||
const ids = getSelectedIds();
|
||||
if (localStorage.getItem('hyprcrd-confirm-delete') !== '0') {
|
||||
const ok = await showDialog({ title: 'delete cards', message: `Delete ${ids.length} card${ids.length > 1 ? 's' : ''}?`, danger: true, okLabel: 'Delete' });
|
||||
if (!ok) return;
|
||||
}
|
||||
const { animateCardRemoval } = await import('./modules/board.js');
|
||||
await animateCardRemoval(ids);
|
||||
for (const id of ids) await api(`api/cards/${id}`, { method: 'DELETE' });
|
||||
clearSelection();
|
||||
await window._reloadAll();
|
||||
@@ -394,14 +394,15 @@ document.getElementById('quick-capture-input')?.addEventListener('keydown', asyn
|
||||
body = lines.slice(1).join('\n').trim();
|
||||
}
|
||||
if (!cardTitle) return;
|
||||
if (!body.includes('#inbox')) body = (body ? body + '\n\n' : '') + '#inbox';
|
||||
// No auto-tag — the card goes into the active bucket (or unsorted).
|
||||
|
||||
// Place the card in the currently active bucket (if it's a real
|
||||
// user bucket). Special filters (all/current/favorites/journal/none)
|
||||
// don't map to a bucket_id — cards captured there stay unsorted.
|
||||
const VIRTUAL = ['all', 'current', 'favorites', 'journal', 'none'];
|
||||
const bucketId = VIRTUAL.includes(state.activeBucket) ? null : state.activeBucket;
|
||||
await api('api/cards', { method: 'POST', body: { title: cardTitle, body, color: '#38bdf8', position: -1, bucket_id: bucketId } });
|
||||
const captured = await api('api/cards', { method: 'POST', body: { title: cardTitle, body, color: '#38bdf8', position: -1, bucket_id: bucketId } });
|
||||
if (captured?.id) state.recentlyCreatedIds?.add(String(captured.id));
|
||||
|
||||
e.target.value = '';
|
||||
document.getElementById('quick-capture').classList.add('hidden');
|
||||
|
||||
+38
-13
@@ -38,17 +38,31 @@ const draggingCardIds = new Set();
|
||||
// the all view) or wait for the next visit to the all tab to pulse.
|
||||
let knownCardIds = null;
|
||||
|
||||
/** Add an accent pulse class to the matching board cards and strip it
|
||||
* again after the animation finishes. The animation itself is in
|
||||
* public/css/style.css `@keyframes card-pulse-new`. */
|
||||
function pulseCardsNew(ids) {
|
||||
/** Animate card removal: adds .card-removing to each matching card
|
||||
* element and waits for the CSS animation to finish before resolving.
|
||||
* Call this BEFORE the API delete + _reloadAll so the user sees the
|
||||
* card shrink/fade out instead of just vanishing. */
|
||||
export function animateCardRemoval(ids) {
|
||||
if (!ids || !ids.length) return Promise.resolve();
|
||||
const els = ids.map(id => board.querySelector(`[data-id="${id}"]`)).filter(Boolean);
|
||||
if (!els.length) return Promise.resolve();
|
||||
for (const el of els) el.classList.add('card-removing');
|
||||
return new Promise(r => setTimeout(r, 260));
|
||||
}
|
||||
|
||||
/** Add a pulse class to matching board cards and strip it after the
|
||||
* animation. `cls` controls the variant: 'card-pulse-new' (3×, for
|
||||
* relay-synced cards) or 'card-pulse-local' (1×, for cards the user
|
||||
* just created themselves). */
|
||||
function pulseCards(ids, cls = 'card-pulse-new') {
|
||||
if (!ids || !ids.size) return;
|
||||
const durationMs = cls === 'card-pulse-local' ? 900 : 2400;
|
||||
requestAnimationFrame(() => {
|
||||
for (const id of ids) {
|
||||
const el = board.querySelector(`[data-id="${id}"]`);
|
||||
if (!el) continue;
|
||||
el.classList.add('card-pulse-new');
|
||||
setTimeout(() => el.classList.remove('card-pulse-new'), 2400);
|
||||
el.classList.add(cls);
|
||||
setTimeout(() => el.classList.remove(cls), durationMs);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -487,9 +501,14 @@ export function renderBoard() {
|
||||
// the first real load looks like "every card just arrived" and the
|
||||
// entire board pulses on page open.
|
||||
const currentIds = new Set(state.cards.map(c => String(c.id)));
|
||||
const newLocalIds = new Set();
|
||||
if (knownCardIds && knownCardIds.size > 0) {
|
||||
for (const id of currentIds) {
|
||||
if (!knownCardIds.has(id)) state.unseenCardIds.add(id);
|
||||
if (knownCardIds.has(id)) continue;
|
||||
// Locally-created cards go into a separate set — they get a
|
||||
// 1× pulse but must NOT trigger the unread dot on the all-tab.
|
||||
if (state.recentlyCreatedIds.has(id)) newLocalIds.add(id);
|
||||
else state.unseenCardIds.add(id);
|
||||
}
|
||||
}
|
||||
knownCardIds = currentIds;
|
||||
@@ -733,14 +752,20 @@ export function renderBoard() {
|
||||
// Notification banner
|
||||
renderNotifs();
|
||||
|
||||
// Unread tracking: if we're on the all view, drain the unseen set
|
||||
// by pulsing every matching card on the board (the "look at me"
|
||||
// accent flash). If we're on a different view, the unseen ids stay
|
||||
// accumulated and the all-tab gets a dot until the user opens it.
|
||||
// Pulse locally-created cards in ANY bucket (1× subtle pulse).
|
||||
// These never enter unseenCardIds so they can't trigger the all-tab
|
||||
// dot — the user just made them, there's nothing "unseen" about it.
|
||||
if (newLocalIds.size) {
|
||||
for (const id of newLocalIds) state.recentlyCreatedIds.delete(id);
|
||||
pulseCards(newLocalIds, 'card-pulse-local');
|
||||
}
|
||||
// Remote (relay-synced) cards: pulse 3× only when the user is on
|
||||
// the all view. On other views, the unseen ids accumulate and
|
||||
// the all-tab gets a dot until the user opens it.
|
||||
if (state.activeBucket === 'all' && state.unseenCardIds.size) {
|
||||
const toPulse = new Set(state.unseenCardIds);
|
||||
const remote = new Set(state.unseenCardIds);
|
||||
state.unseenCardIds.clear();
|
||||
pulseCardsNew(toPulse);
|
||||
pulseCards(remote, 'card-pulse-new');
|
||||
}
|
||||
decorateAllUnreadDot();
|
||||
}
|
||||
|
||||
@@ -283,9 +283,11 @@ export function showContextMenu(x, y, card) {
|
||||
const newDate = await showDialog({ title: 'duplicate_to_date', input: 'date', defaultValue: curDate, okLabel: 'Duplicate' });
|
||||
if (!newDate) return;
|
||||
const newTitle = card.title.replace(/^\d{4}-\d{2}-\d{2}/, newDate.trim());
|
||||
await api('api/cards', { method: 'POST', body: { title: newTitle, body: card.body, color: card.color, bucket_id: card.bucket_id, position: -1 } });
|
||||
const dup1 = await api('api/cards', { method: 'POST', body: { title: newTitle, body: card.body, color: card.color, bucket_id: card.bucket_id, position: -1 } });
|
||||
if (dup1?.id) state.recentlyCreatedIds?.add(String(dup1.id));
|
||||
} else {
|
||||
await api('api/cards', { method: 'POST', body: { title: card.title + ' (copy)', body: card.body, color: card.color, bucket_id: card.bucket_id, position: -1 } });
|
||||
const dup2 = await api('api/cards', { method: 'POST', body: { title: card.title + ' (copy)', body: card.body, color: card.color, bucket_id: card.bucket_id, position: -1 } });
|
||||
if (dup2?.id) state.recentlyCreatedIds?.add(String(dup2.id));
|
||||
}
|
||||
await window._reloadAll();
|
||||
}
|
||||
@@ -395,7 +397,9 @@ export function showContextMenu(x, y, card) {
|
||||
}
|
||||
}
|
||||
});
|
||||
// Delete children first, then parent (avoids orphan flicker)
|
||||
// Animate out, then delete children first, then parent
|
||||
const { animateCardRemoval } = await import('./board.js');
|
||||
await animateCardRemoval(allCards.map(c => c.id));
|
||||
for (const c of children) await api(`api/cards/${c.id}`, { method: 'DELETE' });
|
||||
await api(`api/cards/${card.id}`, { method: 'DELETE' });
|
||||
await window._reloadAll();
|
||||
|
||||
@@ -415,6 +415,7 @@ async function doSave(close, { animate = true } = {}) {
|
||||
if (created?.id) {
|
||||
if (!close) state.editingId = created.id;
|
||||
state._lastSavedId = created.id;
|
||||
state.recentlyCreatedIds?.add(String(created.id));
|
||||
}
|
||||
}
|
||||
await window._reloadAll();
|
||||
|
||||
@@ -37,6 +37,11 @@ export const state = {
|
||||
// accent pulse on the cards themselves once the user looks at them.
|
||||
// Updated by board.js renderBoard() — see the diff there.
|
||||
unseenCardIds: new Set(),
|
||||
// Ids of cards the user just created locally (editor save, quick
|
||||
// capture, duplicate, undo-recreate). These get a single-pulse (1x)
|
||||
// instead of the triple-pulse (3x) that relay-synced cards get.
|
||||
// Consumed by renderBoard's diff — once used, the id is removed.
|
||||
recentlyCreatedIds: new Set(),
|
||||
|
||||
// Detail / editor
|
||||
editingId: null,
|
||||
|
||||
@@ -258,6 +258,14 @@ function refreshPresetSelect() {
|
||||
sel.appendChild(userGroup);
|
||||
}
|
||||
if (cur && sel.querySelector(`option[value="${CSS.escape(cur)}"]`)) sel.value = cur;
|
||||
// Disable delete button when a built-in preset is selected
|
||||
const delBtn = $('design-preset-delete');
|
||||
if (delBtn) {
|
||||
const isUser = sel.value?.startsWith('user:');
|
||||
delBtn.disabled = !isUser;
|
||||
delBtn.style.opacity = isUser ? '' : '0.35';
|
||||
delBtn.style.cursor = isUser ? '' : 'not-allowed';
|
||||
}
|
||||
}
|
||||
|
||||
function getSelectedPreset() {
|
||||
|
||||
Reference in new issue
Block a user