diff --git a/public/css/style.css b/public/css/style.css index ad4bfab..4cbacdb 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -190,10 +190,8 @@ body::after { border: 2px solid var(--t-border); box-shadow: 0 2px 8px rgba(0,0,0,0.3); z-index: 2; - transition: width 0.25s cubic-bezier(0.34, 1.56, 0.64, 1), height 0.25s cubic-bezier(0.34, 1.56, 0.64, 1), margin 0.25s cubic-bezier(0.34, 1.56, 0.64, 1), border-color 0.2s ease, box-shadow 0.25s ease; -} -.card-thumb { transform-origin: center center; + transition: width 0.25s cubic-bezier(0.34, 1.56, 0.64, 1), height 0.25s cubic-bezier(0.34, 1.56, 0.64, 1), margin 0.25s cubic-bezier(0.34, 1.56, 0.64, 1), border-color 0.2s ease, box-shadow 0.25s ease; } .card-thumb:hover { width: 80px; @@ -203,8 +201,6 @@ body::after { border-color: var(--t-muted); box-shadow: 0 4px 16px rgba(0,0,0,0.4); z-index: 10; - background-size: cover; - background-repeat: no-repeat; } .card-glow:hover { box-shadow: 0 0 16px -3px var(--glow-color, rgba(56, 189, 248, 0.15)); } .card-selected { outline: 2px solid var(--t-accent) !important; outline-offset: -2px; } diff --git a/public/index.php b/public/index.php index 31f3aa0..0d89ba5 100644 --- a/public/index.php +++ b/public/index.php @@ -196,7 +196,6 @@ if (str_starts_with($route, '/api/auth')) { foreach ($reEncrypted as $ck => $val) $db->prepare("UPDATE settings SET value = :v WHERE key = :k")->execute(['v' => $val, 'k' => $ck]); $db->prepare("UPDATE settings SET value = :h WHERE key = 'password_hash'")->execute(['h' => $newHash]); $db->commit(); - $db->exec("UPDATE cards SET caldav_etag = NULL, caldav_synced_at = NULL WHERE caldav_uid LIKE 'note:%'"); echo json_encode(['ok' => true, 'rekey' => true]); } exit; @@ -773,10 +772,6 @@ if (str_starts_with($route, '/api/')) { if ($route === '/api/caldav/sync' && $method === 'POST') { echo json_encode($caldav->sync()); exit; } - if ($route === '/api/caldav/log' && $method === 'GET') { - echo json_encode($db->query('SELECT * FROM caldav_sync_log ORDER BY id DESC LIMIT 50')->fetchAll()); exit; - } - // ── Relay ── if ($route === '/api/relay/settings' && $method === 'GET') { @@ -814,6 +809,13 @@ if (str_starts_with($route, '/api/')) { $pulled = 0; $encKey = substr(hash('sha256', $relayKey), 0, 32); + $decryptField = function($text) use ($encKey) { + if (!str_starts_with($text, 'HYPRCRD-RELAY-V1')) return $text; + $raw = base64_decode(trim(explode("\n", $text, 2)[1] ?? '')); + if (strlen($raw) < 17) return $text; + $plain = openssl_decrypt(substr($raw, 16), 'aes-256-cbc', $encKey, OPENSSL_RAW_DATA, substr($raw, 0, 16)); + return $plain !== false ? $plain : $text; + }; // Load IDs we pushed ourselves $ownIds = array_column($db->query('SELECT relay_msg_id FROM relay_pushed')->fetchAll(), 'relay_msg_id'); @@ -821,30 +823,12 @@ if (str_starts_with($route, '/api/')) { foreach ($messages as $msg) { if ($msg['fetched'] ?? false) continue; if (in_array($msg['id'] ?? 0, $ownIds)) { - // Clean up tracking row since relay will delete it after 24h anyway $db->prepare('DELETE FROM relay_pushed WHERE relay_msg_id = ?')->execute([$msg['id']]); continue; } - // Decrypt - $title = $msg['title'] ?? ''; - $body = $msg['body'] ?? ''; - if (str_starts_with($body, 'HYPRCRD-RELAY-V1')) { - $enc = trim(explode("\n", $body, 2)[1] ?? ''); - $raw = base64_decode($enc); - if (strlen($raw) >= 17) { - $plain = openssl_decrypt(substr($raw, 16), 'aes-256-cbc', $encKey, OPENSSL_RAW_DATA, substr($raw, 0, 16)); - if ($plain !== false) $body = $plain; - } - } - if (str_starts_with($title, 'HYPRCRD-RELAY-V1')) { - $enc = trim(explode("\n", $title, 2)[1] ?? ''); - $raw = base64_decode($enc); - if (strlen($raw) >= 17) { - $plain = openssl_decrypt(substr($raw, 16), 'aes-256-cbc', $encKey, OPENSSL_RAW_DATA, substr($raw, 0, 16)); - if ($plain !== false) $title = $plain; - } - } + $title = $decryptField($msg['title'] ?? ''); + $body = $decryptField($msg['body'] ?? ''); if (!$title) $title = substr(strip_tags($body), 0, 60) ?: 'Relay message'; $body = trim($body); diff --git a/public/js/app.js b/public/js/app.js index 93fd845..81c967e 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -136,7 +136,7 @@ importFile?.addEventListener('change', async e => { const data = JSON.parse(await res.text()); if (data.error) throw new Error(data.error); await loadBuckets(); state.cards = await api('api/cards'); renderBoard(); - } catch (err) { console.error('Import failed:', err); } + } catch {} e.target.value = ''; }); @@ -152,7 +152,7 @@ importLogseq?.addEventListener('change', async e => { if (data.error) throw new Error(data.error); await loadBuckets(); state.cards = await api('api/cards'); renderBoard(); await showDialog({ title: 'logseq import', message: `${data.imported} cards, ${data.assets} images imported`, okLabel: 'ok' }); - } catch (err) { console.error('Logseq import failed:', err); } + } catch {} e.target.value = ''; }); diff --git a/public/js/modules/board.js b/public/js/modules/board.js index ce47872..b0b968c 100644 --- a/public/js/modules/board.js +++ b/public/js/modules/board.js @@ -2,10 +2,9 @@ import { $, esc, api } from './util.js'; import { state, sortedCards, parseTags, tagChipHtml, getChildren, getRecent, getFavorites, isFavorite, SORT_MODES, SORT_ICONS, CARD_COLORS } from './state.js'; -import { stripMd, getTodos, firstImage, leadingImage } from './markdown.js'; +import { stripMd, getTodos, firstImage, leadingImage, renderMd, addCodeCopyButtons, formatTodoItems } from './markdown.js'; import { pushReorder, pushCardEdit, pushBatch, snapshotCard } from './undo.js'; import { getCalConfig } from './caldav.js'; -import { renderMd, addCodeCopyButtons, formatTodoItems } from './markdown.js'; import { addProvider as addNotifProvider, render as renderNotifs } from './notifications.js'; import { bindCheckboxes, bindDetailImageFullscreen } from './detail.js'; @@ -22,13 +21,11 @@ const boardPreviewTitle = $('board-preview-title'); const boardPreviewColor = $('board-preview-color'); const boardPreviewTags = $('board-preview-tags'); const boardPreviewMeta = $('board-preview-meta'); -let boardSplitOpen = localStorage.getItem('hyprcrd-board-split') === '1'; -let boardPreviewCardId = null; let activeDragEl = null; let dragGhost = null; // ── Multi-select ── -const selectedIds = new Set(); +const selectedIds = state.selectedIds; const selectBar = $('select-bar'); const selectCount = $('select-count'); @@ -263,8 +260,8 @@ export function updateSortUI() { function showBoardPreview(cardId) { const card = state.cards.find(c => c.id == cardId); - if (!card || !boardSplitOpen) return; - boardPreviewCardId = cardId; + if (!card || !state.boardSplitOpen) return; + state.boardPreviewCardId = cardId; $('board-preview-empty')?.classList.add('hidden'); $('board-preview-content')?.classList.remove('hidden'); if (boardPreviewColor) boardPreviewColor.style.background = card.color; @@ -296,12 +293,12 @@ function showBoardPreview(cardId) { function closeBoardPreview() { boardPreview?.classList.add('hidden'); - boardSplitOpen = false; + state.boardSplitOpen = false; localStorage.setItem('hyprcrd-board-split', '0'); const btn = $('board-split-toggle'); if (btn) btn.classList.remove('text-t-accent'); board.querySelectorAll('.board-card').forEach(el => el.classList.remove('preview-active')); - boardPreviewCardId = null; + state.boardPreviewCardId = null; resetBoardPreview(); } @@ -313,26 +310,26 @@ function resetBoardPreview() { } function toggleBoardSplit() { - boardSplitOpen = !boardSplitOpen; - localStorage.setItem('hyprcrd-board-split', boardSplitOpen ? '1' : '0'); - boardPreview?.classList.toggle('hidden', !boardSplitOpen); + state.boardSplitOpen = !state.boardSplitOpen; + localStorage.setItem('hyprcrd-board-split', state.boardSplitOpen ? '1' : '0'); + boardPreview?.classList.toggle('hidden', !state.boardSplitOpen); const btn = $('board-split-toggle'); - if (btn) btn.classList.toggle('text-t-accent', boardSplitOpen); - if (!boardSplitOpen) { + if (btn) btn.classList.toggle('text-t-accent', state.boardSplitOpen); + if (!state.boardSplitOpen) { board.querySelectorAll('.board-card').forEach(el => el.classList.remove('preview-active')); - boardPreviewCardId = null; + state.boardPreviewCardId = null; } } $('board-split-toggle')?.addEventListener('click', toggleBoardSplit); $('board-preview-close')?.addEventListener('click', closeBoardPreview); $('board-preview-edit')?.addEventListener('click', () => { - const card = state.cards.find(c => c.id == boardPreviewCardId); + const card = state.cards.find(c => c.id == state.boardPreviewCardId); if (card) window._openModal(card); }); // Init split state -if (boardSplitOpen) { +if (state.boardSplitOpen) { boardPreview?.classList.remove('hidden'); const btn = $('board-split-toggle'); if (btn) btn.classList.add('text-t-accent'); @@ -559,7 +556,7 @@ export function renderBoard() { } board.querySelectorAll('.superview-item[data-id]').forEach(el => { - el.addEventListener('click', () => { clearHover(); if (boardSplitOpen) showBoardPreview(el.dataset.id); else window._openDetail(el.dataset.id); }); + el.addEventListener('click', () => { clearHover(); if (state.boardSplitOpen) showBoardPreview(el.dataset.id); else window._openDetail(el.dataset.id); }); el.addEventListener('dblclick', () => { clearHover(); const card = state.cards.find(c => c.id == el.dataset.id); if (card) window._openModal(card); }); el.addEventListener('contextmenu', e => { e.preventDefault(); clearHover(); const card = state.cards.find(c => c.id == el.dataset.id); if (card) window._showContextMenu(e.clientX, e.clientY, card); }); @@ -645,10 +642,10 @@ export function renderBoard() { if (fc) fc.textContent = `${cards.length} card${cards.length !== 1 ? 's' : ''} · ${state.cards.length} total`; // Refresh split preview if open - if (boardSplitOpen && boardPreviewCardId) { - const stillExists = state.cards.some(c => c.id == boardPreviewCardId); - if (stillExists) showBoardPreview(boardPreviewCardId); - else { boardPreviewCardId = null; resetBoardPreview(); } + if (state.boardSplitOpen && state.boardPreviewCardId) { + const stillExists = state.cards.some(c => c.id == state.boardPreviewCardId); + if (stillExists) showBoardPreview(state.boardPreviewCardId); + else { state.boardPreviewCardId = null; resetBoardPreview(); } } // Notification banner @@ -912,7 +909,7 @@ function bindCardEvents() { if (clickTimer) return; clickTimer = setTimeout(() => { clickTimer = null; - if (boardSplitOpen) { showBoardPreview(id); } + if (state.boardSplitOpen) { showBoardPreview(id); } else if (localStorage.getItem('hyprcrd-click-action') === 'edit') { const card = state.cards.find(c => c.id == id); if (card) window._openModal(card); diff --git a/public/js/modules/caldav.js b/public/js/modules/caldav.js index 0692583..8c2aadf 100644 --- a/public/js/modules/caldav.js +++ b/public/js/modules/caldav.js @@ -1,6 +1,7 @@ // ── CalDAV Sync ── import { $, esc, api, getCsrf } from './util.js'; +import { state } from './state.js'; const CAL_ICONS = [ { id: 'calendar-dots', label: 'calendar' }, @@ -259,10 +260,9 @@ function showSyncNotification(stats) { setTimeout(() => { note.style.transition = 'opacity 0.3s'; note.style.opacity = '0'; setTimeout(() => note.remove(), 350); }, 4000); } -let bgSyncRunning = false; async function backgroundSync() { - if (bgSyncRunning) return; - bgSyncRunning = true; + if (state.bgSyncRunning) return; + state.bgSyncRunning = true; try { const headers = { 'X-CSRF-Token': getCsrf() }; const [res, relayRes] = await Promise.all([ @@ -277,7 +277,7 @@ async function backgroundSync() { showSyncNotification(total); } } catch {} finally { - bgSyncRunning = false; + state.bgSyncRunning = false; } } diff --git a/public/js/modules/calendar.js b/public/js/modules/calendar.js index 645bd1a..5dab3ea 100644 --- a/public/js/modules/calendar.js +++ b/public/js/modules/calendar.js @@ -12,7 +12,7 @@ const title = $('cal-title'); const DAYS = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']; const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']; -let viewYear, viewMonth; // 0-indexed month +// Calendar view year/month stored in state.calYear/state.calMonth function pad(n) { return String(n).padStart(2, '0'); } @@ -146,12 +146,12 @@ function clearCalHover() { } function render() { - title.textContent = `${MONTHS[viewMonth]} ${viewYear}`; + title.textContent = `${MONTHS[state.calMonth]} ${state.calYear}`; - const firstDay = new Date(viewYear, viewMonth, 1).getDay(); // 0=Sun + const firstDay = new Date(state.calYear, state.calMonth, 1).getDay(); // 0=Sun const startOffset = (firstDay + 6) % 7; // Mon=0 - const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate(); - const prevMonthDays = new Date(viewYear, viewMonth, 0).getDate(); + const daysInMonth = new Date(state.calYear, state.calMonth + 1, 0).getDate(); + const prevMonthDays = new Date(state.calYear, state.calMonth, 0).getDate(); const today = new Date(); const todayStr = dateStr(today.getFullYear(), today.getMonth(), today.getDate()); @@ -169,17 +169,17 @@ function render() { if (dayNum < 1) { // Previous month displayNum = prevMonthDays + dayNum; - const pm = viewMonth === 0 ? 11 : viewMonth - 1; - const py = viewMonth === 0 ? viewYear - 1 : viewYear; + const pm = state.calMonth === 0 ? 11 : state.calMonth - 1; + const py = state.calMonth === 0 ? state.calYear - 1 : state.calYear; ds = dateStr(py, pm, displayNum); } else if (dayNum > daysInMonth) { // Next month displayNum = dayNum - daysInMonth; - const nm = viewMonth === 11 ? 0 : viewMonth + 1; - const ny = viewMonth === 11 ? viewYear + 1 : viewYear; + const nm = state.calMonth === 11 ? 0 : state.calMonth + 1; + const ny = state.calMonth === 11 ? state.calYear + 1 : state.calYear; ds = dateStr(ny, nm, displayNum); } else { - ds = dateStr(viewYear, viewMonth, dayNum); + ds = dateStr(state.calYear, state.calMonth, dayNum); } const isToday = ds === todayStr; const cards = getCardsForDate(ds); @@ -317,9 +317,9 @@ function render() { function navigate(delta) { closeDayPopup(); - viewMonth += delta; - if (viewMonth > 11) { viewMonth = 0; viewYear++; } - if (viewMonth < 0) { viewMonth = 11; viewYear--; } + state.calMonth += delta; + if (state.calMonth > 11) { state.calMonth = 0; state.calYear++; } + if (state.calMonth < 0) { state.calMonth = 11; state.calYear--; } render(); } @@ -495,8 +495,8 @@ export function refreshCalendar() { export function openCalendar() { const now = new Date(); - viewYear = now.getFullYear(); - viewMonth = now.getMonth(); + state.calYear = now.getFullYear(); + state.calMonth = now.getMonth(); render(); overlay.classList.add('open'); overlay.style.visibility = ''; @@ -514,8 +514,8 @@ $('cal-prev')?.addEventListener('click', () => navigate(-1)); $('cal-next')?.addEventListener('click', () => navigate(1)); $('cal-today')?.addEventListener('click', () => { const now = new Date(); - viewYear = now.getFullYear(); - viewMonth = now.getMonth(); + state.calYear = now.getFullYear(); + state.calMonth = now.getMonth(); render(); }); overlay?.addEventListener('click', e => { if (e.target === overlay) closeCalendar(); }); diff --git a/public/js/modules/detail.js b/public/js/modules/detail.js index 550797d..e98aa7b 100644 --- a/public/js/modules/detail.js +++ b/public/js/modules/detail.js @@ -29,25 +29,24 @@ const FS_MAX_SCALE = 8; // ── Zoom ── const ZOOM_KEY = 'hyprcrd-detail-zoom'; -let zoom = parseFloat(localStorage.getItem(ZOOM_KEY) || '1') || 1; function applyZoom() { if (!titleEl) return; - titleEl.style.fontSize = `${(1.125 * zoom).toFixed(3)}rem`; - bodyEl.style.fontSize = `${(0.875 * zoom).toFixed(3)}rem`; - metaEl.style.fontSize = `${Math.max(0.625, 0.6875 * zoom).toFixed(3)}rem`; - titleEl.style.lineHeight = `${Math.max(1.35, 1.55 + (zoom - 1) * 0.3).toFixed(3)}`; - bodyEl.style.lineHeight = `${Math.max(1.5, 1.7 + (zoom - 1) * 0.35).toFixed(3)}`; - metaEl.style.lineHeight = `${Math.max(1.2, 1.35 + (zoom - 1) * 0.25).toFixed(3)}`; + titleEl.style.fontSize = `${(1.125 * state.zoom).toFixed(3)}rem`; + bodyEl.style.fontSize = `${(0.875 * state.zoom).toFixed(3)}rem`; + metaEl.style.fontSize = `${Math.max(0.625, 0.6875 * state.zoom).toFixed(3)}rem`; + titleEl.style.lineHeight = `${Math.max(1.35, 1.55 + (state.zoom - 1) * 0.3).toFixed(3)}`; + bodyEl.style.lineHeight = `${Math.max(1.5, 1.7 + (state.zoom - 1) * 0.35).toFixed(3)}`; + metaEl.style.lineHeight = `${Math.max(1.2, 1.35 + (state.zoom - 1) * 0.25).toFixed(3)}`; } applyZoom(); scroll?.addEventListener('wheel', e => { if (!detail.classList.contains('open') || !e.shiftKey || Math.abs(e.deltaY) < Math.abs(e.deltaX)) return; e.preventDefault(); - zoom = Math.min(2.2, Math.max(0.75, zoom + (e.deltaY < 0 ? 0.08 : -0.08))); - zoom = Math.round(zoom * 100) / 100; - localStorage.setItem(ZOOM_KEY, String(zoom)); + state.zoom = Math.min(2.2, Math.max(0.75, state.zoom + (e.deltaY < 0 ? 0.08 : -0.08))); + state.zoom = Math.round(state.zoom * 100) / 100; + localStorage.setItem(ZOOM_KEY, String(state.zoom)); applyZoom(); }, { passive: false }); diff --git a/public/js/modules/editor.js b/public/js/modules/editor.js index ecccfa1..4752d1f 100644 --- a/public/js/modules/editor.js +++ b/public/js/modules/editor.js @@ -1,11 +1,9 @@ // ── Editor (card create/edit) ── -import { $, api, escRe, getCsrf } from './util.js'; +import { $, esc, api, escRe, getCsrf, fuzzyMatch } from './util.js'; import { state, getChildren, CARD_COLORS } from './state.js'; import { renderMd, addCodeCopyButtons, formatTodoItems } from './markdown.js'; import { pushCardEdit, snapshotCard } from './undo.js'; -import { fuzzyMatch } from './util.js'; -import { esc } from './util.js'; const modal = $('modal'); const body = $('editor-body'); @@ -975,7 +973,6 @@ async function uploadFile(file) { try { const res = await fetch('api/upload', { method: 'POST', body: form, headers: { 'X-CSRF-Token': getCsrf() } }); const text = await res.text(); - console.log('Upload response:', res.status, text.substring(0, 300)); const data = JSON.parse(text); if (data.error) throw new Error(data.error); if (data.url) { @@ -988,8 +985,7 @@ async function uploadFile(file) { body.selectionStart = body.selectionEnd = idx + md.length; } } - } catch (err) { - console.error('Upload failed:', err); + } catch { const idx = body.value.indexOf(placeholder); if (idx !== -1) { body.value = body.value.substring(0, idx) + body.value.substring(idx + placeholder.length); diff --git a/public/js/modules/state.js b/public/js/modules/state.js index aebbd37..c6ba71a 100644 --- a/public/js/modules/state.js +++ b/public/js/modules/state.js @@ -3,22 +3,44 @@ import { esc } from './util.js'; export const state = { + // Data cards: [], buckets: [], + + // Filters & view activeBucket: 'all', activeTags: [], + activeGroup: null, + sortMode: localStorage.getItem('hyprcrd-sort') || 'manual', hideTermine: localStorage.getItem('hyprcrd-hide-termine') === '1', hideDone: localStorage.getItem('hyprcrd-hide-done') === '1', superView: localStorage.getItem('hyprcrd-superview') === '1', - activeGroup: null, - sortMode: localStorage.getItem('hyprcrd-sort') || 'manual', + + // Board split preview + boardSplitOpen: localStorage.getItem('hyprcrd-board-split') === '1', + boardPreviewCardId: null, + + // Multi-select + selectedIds: 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']; diff --git a/src/CalDavSync.php b/src/CalDavSync.php index b4e1ec0..09f21ff 100644 --- a/src/CalDavSync.php +++ b/src/CalDavSync.php @@ -406,7 +406,7 @@ class CalDavSync $this->addLog('sync', null, null, 'fetch', 'ok', "total remote: " . count($remoteMap) . " events"); // 3. Fetch local appointment cards — only #termin tagged or already synced - $stmt = $this->db->query("SELECT * FROM cards WHERE tags = 'termin' OR tags LIKE 'termin,%' OR tags LIKE '%,termin,%' OR tags LIKE '%,termin' OR tags = 'geburtstag' OR tags LIKE 'geburtstag,%' OR tags LIKE '%,geburtstag,%' OR tags LIKE '%,geburtstag' OR (caldav_uid IS NOT NULL AND caldav_uid NOT LIKE 'note:%')"); + $stmt = $this->db->query("SELECT * FROM cards WHERE tags = 'termin' OR tags LIKE 'termin,%' OR tags LIKE '%,termin,%' OR tags LIKE '%,termin' OR tags = 'geburtstag' OR tags LIKE 'geburtstag,%' OR tags LIKE '%,geburtstag,%' OR tags LIKE '%,geburtstag' OR caldav_uid IS NOT NULL"); $localCards = $stmt->fetchAll(); $localMap = []; // caldav_uid -> card $localNew = []; // cards without caldav_uid @@ -418,9 +418,9 @@ class CalDavSync } } - // 4. Tombstones (only CalDAV, not notes) + // 4. Tombstones $tombstones = []; - foreach ($this->db->query("SELECT * FROM caldav_tombstones WHERE caldav_uid NOT LIKE 'note:%'")->fetchAll() as $t) { + foreach ($this->db->query("SELECT * FROM caldav_tombstones")->fetchAll() as $t) { $tombstones[$t['caldav_uid']] = $t; }