@@ -729,7 +843,7 @@ if (str_starts_with($route, '/api/')) {
-
+
diff --git a/public/js/app.js b/public/js/app.js
index 1d781ce..88988f8 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -6,7 +6,7 @@ import { api, setCsrf, getCsrf } from './modules/util.js';
import { state, getGroupCards } from './modules/state.js';
import { showDialog, isDialogOpen } from './modules/dialog.js';
import './modules/theme.js';
-import { loadBuckets } from './modules/buckets.js';
+import { loadBuckets, renderTagBar } from './modules/buckets.js';
import { renderBoard, updateSortUI, getSelectedIds, clearSelection } from './modules/board.js';
import { openDetail, closeDetail, showDetailCard, ungroupCard } from './modules/detail.js';
import { openModal, closeModal, saveCard, saveOnly } from './modules/editor.js';
@@ -24,6 +24,7 @@ window._openGallery = openGallery;
window._closeSearch = closeSearch;
window._closeTasks = closeTasks;
window._renderBoard = renderBoard;
+window._renderTagBar = renderTagBar;
window._showContextMenu = showContextMenu;
window._ungroupCard = ungroupCard;
window._getSelectedIds = getSelectedIds;
@@ -56,6 +57,7 @@ async function loadCards() {
// ── Export / Import ──
document.getElementById('export-btn')?.addEventListener('click', () => { window.location.href = 'api/export'; });
+document.getElementById('export-md-btn')?.addEventListener('click', () => { window.location.href = 'api/export-md'; });
const importFile = document.getElementById('import-file');
document.getElementById('import-btn')?.addEventListener('click', () => importFile?.click());
diff --git a/public/js/modules/board.js b/public/js/modules/board.js
index 2f84e81..f404225 100644
--- a/public/js/modules/board.js
+++ b/public/js/modules/board.js
@@ -1,7 +1,7 @@
// ── Board rendering & card interactions ──
import { $, esc, api } from './util.js';
-import { state, sortedCards, getChildren, getRecent, getFavorites, isFavorite, SORT_MODES, SORT_ICONS, CARD_COLORS } from './state.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 { pushReorder, pushCardEdit, pushBatch, snapshotCard } from './undo.js';
@@ -131,6 +131,13 @@ export function renderBoard() {
}
}
}
+ // Tag filter (intersects with bucket filter) — hierarchical: #nase matches #nase/popel
+ if (state.activeTags.length) {
+ cards = cards.filter(c => {
+ const ct = parseTags(c);
+ return state.activeTags.every(at => ct.some(t => t === at || t.startsWith(at + '/')));
+ });
+ }
// Current and journal have fixed sort order, skip global sort
if (state.activeBucket !== 'current' && state.activeBucket !== 'journal') {
cards = sortedCards(cards);
@@ -161,6 +168,7 @@ export function renderBoard() {
const todos = getTodos(c.body);
const todoPct = todos ? Math.round(todos.done / todos.total * 100) : 0;
const hasCover = !!coverImg;
+ const tags = parseTags(c);
return `
${todos.done}/${todos.total}
${(c.updated_at || c.created_at || '').substring(0, 10)}
${c.bucket_name ? `
${esc(c.bucket_name)}` : ''}
@@ -291,6 +300,22 @@ function bindCardEvents() {
badge.addEventListener('click', e => { e.stopPropagation(); state.activeGroup = badge.dataset.group; renderBoard(); });
});
+ board.querySelectorAll('.tag-seg[data-tag]').forEach(seg => {
+ seg.addEventListener('click', e => {
+ e.stopPropagation();
+ const tag = seg.dataset.tag;
+ if (e.ctrlKey || e.metaKey) {
+ const idx = state.activeTags.indexOf(tag);
+ if (idx >= 0) state.activeTags.splice(idx, 1);
+ else state.activeTags.push(tag);
+ } else {
+ state.activeTags = state.activeTags.length === 1 && state.activeTags[0] === tag ? [] : [tag];
+ }
+ window._renderTagBar?.();
+ renderBoard();
+ });
+ });
+
board.querySelectorAll('[data-id]').forEach(el => {
const id = el.dataset.id;
const children = getChildren(id);
diff --git a/public/js/modules/buckets.js b/public/js/modules/buckets.js
index 2986279..7ea4968 100644
--- a/public/js/modules/buckets.js
+++ b/public/js/modules/buckets.js
@@ -131,14 +131,15 @@ function autoBucketNameFromCard(card) {
}
function updateHeaderTint() {
- const tint = $('header-tint');
- if (!tint) return;
+ const header = $('main-header');
+ if (!header) return;
const bucket = state.buckets.find(b => b.id == state.activeBucket);
if (bucket && bucket.color) {
- tint.style.background = `linear-gradient(135deg, ${bucket.color}30 0%, ${bucket.color}12 100%)`;
- tint.style.opacity = '1';
+ header.style.borderBottomWidth = '4px';
+ header.style.borderBottomColor = bucket.color;
} else {
- tint.style.opacity = '0';
+ header.style.borderBottomWidth = '';
+ header.style.borderBottomColor = '';
}
}
@@ -155,7 +156,7 @@ function renderBar() {
${state.buckets.map(b => {
const c = b.color || '#64748b';
const active = state.activeBucket == b.id;
- return `
`;
+ return `
`;
}).join('')}
`;
@@ -279,4 +280,44 @@ function renderBar() {
await loadBuckets();
await window._reloadAll();
});
+
+ renderTagBar();
+}
+
+export function renderTagBar() {
+ let tagBar = $('tag-bar');
+ if (!tagBar) {
+ tagBar = document.createElement('div');
+ tagBar.id = 'tag-bar';
+ tagBar.className = 'flex items-center gap-1.5 px-6 py-1';
+ const bucketRow = $('bucket-row');
+ bucketRow?.parentNode.insertBefore(tagBar, bucketRow.nextSibling);
+ }
+ if (!state.activeTags.length) {
+ tagBar.classList.add('hidden');
+ return;
+ }
+ tagBar.classList.remove('hidden');
+ tagBar.innerHTML = `
` +
+ state.activeTags.map(t => `
+
+ #${esc(t)}
+
+ `).join('') +
+ `
`;
+ tagBar.querySelectorAll('[data-remove-tag]').forEach(btn => {
+ btn.addEventListener('click', e => {
+ e.stopPropagation();
+ state.activeTags = state.activeTags.filter(t => t !== btn.dataset.removeTag);
+ renderTagBar();
+ window._renderBoard();
+ });
+ });
+ $('clear-tag-filter')?.addEventListener('click', () => {
+ state.activeTags = [];
+ renderTagBar();
+ window._renderBoard();
+ });
}
diff --git a/public/js/modules/context-menu.js b/public/js/modules/context-menu.js
index 19275be..6958fb9 100644
--- a/public/js/modules/context-menu.js
+++ b/public/js/modules/context-menu.js
@@ -1,7 +1,7 @@
// ── Context menu ──
import { $, esc, api } from './util.js';
-import { state, getChildren, isFavorite, toggleFavorite, CARD_COLORS } from './state.js';
+import { state, parseTags, getChildren, isFavorite, toggleFavorite, CARD_COLORS } from './state.js';
import { showDialog } from './dialog.js';
import { pushCardEdit, pushCardDelete, pushBatch, snapshotCard } from './undo.js';
@@ -10,6 +10,7 @@ const items = $('ctx-items');
const colors = $('ctx-colors');
const buckets = $('ctx-buckets');
const members = $('ctx-members');
+const tags = $('ctx-tags');
export function hideContextMenu() { menu.classList.add('hidden'); }
@@ -25,10 +26,12 @@ export function showContextMenu(x, y, card) {
+
+
${isParent ? `
@@ -60,11 +63,72 @@ export function showContextMenu(x, y, card) {
state.buckets.map(b => `
`).join('');
buckets.classList.add('hidden');
+ // Tag sub-panel
+ const cardTags = parseTags(card);
+ const allTags = [...new Set(state.cards.flatMap(c => parseTags(c)))].sort();
+ const tagInput = $('ctx-tag-input');
+ const tagList = $('ctx-tag-list');
+
+ function renderCtxTags(filter = '') {
+ const f = filter.toLowerCase().replace(/^#/, '');
+ const shown = f ? allTags.filter(t => t.includes(f)) : allTags;
+ const hasCard = tag => cardTags.includes(tag);
+ tagList.innerHTML = shown.map(t => `
+
`).join('') +
+ (f && !allTags.includes(f) ? `
` : '');
+
+ tagList.querySelectorAll('[data-ctx-tag]').forEach(btn => btn.addEventListener('click', async e => {
+ e.stopPropagation();
+ const tag = btn.dataset.ctxTag;
+ const has = cardTags.includes(tag);
+ let newBody = card.body || '';
+ if (has) {
+ newBody = newBody.replace(new RegExp('\\s*#' + tag.replace(/[.*+?^${}()|[\]\\\/]/g, '\\$&') + '(?=\\s|$)', 'm'), '');
+ cardTags.splice(cardTags.indexOf(tag), 1);
+ } else {
+ newBody = newBody.trimEnd() + ' #' + tag;
+ cardTags.push(tag);
+ }
+ card.body = newBody;
+ await api(`api/cards/${card.id}`, { method: 'PATCH', body: { body: newBody } });
+ const c = state.cards.find(x => x.id == card.id);
+ if (c) { c.body = newBody; c.tags = cardTags.join(','); }
+ renderCtxTags(tagInput.value);
+ window._renderBoard();
+ }));
+
+ tagList.querySelectorAll('[data-ctx-tag-new]').forEach(btn => btn.addEventListener('click', async e => {
+ e.stopPropagation();
+ const tag = btn.dataset.ctxTagNew;
+ const newBody = (card.body || '').trimEnd() + ' #' + tag;
+ card.body = newBody;
+ cardTags.push(tag);
+ allTags.push(tag);
+ allTags.sort();
+ await api(`api/cards/${card.id}`, { method: 'PATCH', body: { body: newBody } });
+ const c = state.cards.find(x => x.id == card.id);
+ if (c) { c.body = newBody; c.tags = cardTags.join(','); }
+ tagInput.value = '';
+ renderCtxTags('');
+ window._renderBoard();
+ }));
+ }
+
+ renderCtxTags();
+ tagInput.value = '';
+ tagInput.addEventListener('input', () => renderCtxTags(tagInput.value));
+ tags.classList.add('hidden');
+
menu.style.left = Math.min(x, innerWidth - 188) + 'px';
menu.style.top = Math.min(y, innerHeight - 300) + 'px';
menu.classList.remove('hidden');
- const toggle = (panel) => { [colors, buckets, members].forEach(p => { if (p !== panel) p.classList.add('hidden'); }); panel.classList.toggle('hidden'); };
+ const toggle = (panel) => { [colors, buckets, members, tags].forEach(p => { if (p !== panel) p.classList.add('hidden'); }); panel.classList.toggle('hidden'); if (panel === tags && !tags.classList.contains('hidden')) setTimeout(() => tagInput.focus(), 0); };
// Actions
items.querySelectorAll('[data-action]').forEach(btn => btn.addEventListener('click', async e => {
@@ -73,9 +137,11 @@ export function showContextMenu(x, y, card) {
if (a === 'open') { hideContextMenu(); window._openDetail(card.id); }
else if (a === 'edit') { hideContextMenu(); window._openModal(card); }
else if (a === 'duplicate') { hideContextMenu(); await api('api/cards', { method: 'POST', body: { title: card.title + ' (copy)', body: card.body, color: card.color, bucket_id: card.bucket_id, position: -1 } }); await window._reloadAll(); }
+ else if (a === 'save-md') { hideContextMenu(); window.location.href = `api/export-md?ids=${card.id}`; }
else if (a === 'favorite') { hideContextMenu(); toggleFavorite(card.id); window._renderBoard(); }
else if (a === 'color') toggle(colors);
else if (a === 'bucket') toggle(buckets);
+ else if (a === 'tags') toggle(tags);
else if (a === 'group-members') toggle(members);
else if (a === 'expand-group') { hideContextMenu(); state.activeGroup = card.id; window._renderBoard(); }
else if (a === 'go-to-group') { hideContextMenu(); const p = state.cards.find(c => c.id == card.parent_id); if (p) { state.activeGroup = p.id; window._renderBoard(); } }
diff --git a/public/js/modules/detail.js b/public/js/modules/detail.js
index 8b50e73..afe0f54 100644
--- a/public/js/modules/detail.js
+++ b/public/js/modules/detail.js
@@ -1,7 +1,7 @@
// ── Detail view ──
import { $, esc, api, fuzzyMatch } from './util.js';
-import { state, getChildren, getGroupCards, getRecent, markRecent } from './state.js';
+import { state, parseTags, tagChipHtml, getChildren, getGroupCards, getRecent, markRecent } from './state.js';
import { renderMd, stripMd, firstImage, addCodeCopyButtons } from './markdown.js';
import { pushCardDelete, pushCardEdit, snapshotCard } from './undo.js';
import { showDialog } from './dialog.js';
@@ -87,6 +87,9 @@ function clearMorphGhost() {
}
}
+let galleryImages = [];
+let galleryIndex = 0;
+
function ensureImageFullscreenOverlay() {
if (imageFsOverlay) return imageFsOverlay;
@@ -96,6 +99,9 @@ function ensureImageFullscreenOverlay() {
+
+
+
@@ -104,6 +110,8 @@ function ensureImageFullscreenOverlay() {
if (e.target === overlay) closeImageFullscreen();
});
overlay.querySelector('.detail-image-fs-close')?.addEventListener('click', closeImageFullscreen);
+ overlay.querySelector('.detail-image-fs-prev')?.addEventListener('click', e => { e.stopPropagation(); navigateGallery(-1); });
+ overlay.querySelector('.detail-image-fs-next')?.addEventListener('click', e => { e.stopPropagation(); navigateGallery(1); });
document.body.appendChild(overlay);
imageFsOverlay = overlay;
@@ -118,6 +126,26 @@ function ensureImageFullscreenOverlay() {
return overlay;
}
+function updateGalleryNav() {
+ if (!imageFsOverlay) return;
+ const hasGallery = galleryImages.length > 1;
+ imageFsOverlay.querySelector('.detail-image-fs-prev').style.display = hasGallery ? '' : 'none';
+ imageFsOverlay.querySelector('.detail-image-fs-next').style.display = hasGallery ? '' : 'none';
+ const counter = imageFsOverlay.querySelector('.detail-image-fs-counter');
+ counter.style.display = hasGallery ? '' : 'none';
+ if (hasGallery) counter.textContent = `${galleryIndex + 1} / ${galleryImages.length}`;
+}
+
+function navigateGallery(dir) {
+ if (galleryImages.length < 2) return;
+ galleryIndex = (galleryIndex + dir + galleryImages.length) % galleryImages.length;
+ resetImageFsTransform();
+ if (imageFsImg) {
+ imageFsImg.src = galleryImages[galleryIndex];
+ }
+ updateGalleryNav();
+}
+
function applyImageFsTransform() {
if (!imageFsImg) return;
imageFsImg.style.transform = `translate(${fsTx}px, ${fsTy}px) scale(${fsScale})`;
@@ -178,14 +206,18 @@ function onImageFsMouseUp() {
imageFsOverlay?.classList.remove('dragging');
}
-function openImageFullscreen(src, alt = '') {
+function openImageFullscreen(src, alt = '', gallery = []) {
if (!src) return;
const overlay = ensureImageFullscreenOverlay();
resetImageFsTransform();
+ galleryImages = gallery.length > 1 ? gallery : [];
+ galleryIndex = galleryImages.indexOf(src);
+ if (galleryIndex < 0) galleryIndex = 0;
if (imageFsImg) {
imageFsImg.src = src;
imageFsImg.alt = alt || 'image';
}
+ updateGalleryNav();
overlay.classList.add('open');
document.body.classList.add('detail-image-fs-open');
}
@@ -433,6 +465,32 @@ function showContent(card) {
(card.bucket_name ? ' | bucket: ' + card.bucket_name : '');
applyZoom();
+ // Tags
+ const tags = parseTags(card);
+ const tagEl = $('detail-tags');
+ if (tagEl) {
+ if (tags.length) {
+ tagEl.classList.remove('hidden');
+ tagEl.innerHTML = tags.map(t => tagChipHtml(t)).join('');
+ tagEl.querySelectorAll('.tag-seg[data-tag]').forEach(seg => {
+ seg.addEventListener('click', e => {
+ const tag = seg.dataset.tag;
+ if (e.ctrlKey || e.metaKey) {
+ const idx = state.activeTags.indexOf(tag);
+ if (idx >= 0) state.activeTags.splice(idx, 1);
+ else state.activeTags.push(tag);
+ } else {
+ state.activeTags = [tag];
+ }
+ window._renderTagBar?.();
+ window._renderBoard();
+ });
+ });
+ } else {
+ tagEl.classList.add('hidden');
+ }
+ }
+
// Backlinks
const pattern = `[[${card.title}]]`.toLowerCase();
const backlinks = state.cards.filter(c => c.id != card.id && (c.body || '').toLowerCase().includes(pattern));
@@ -505,7 +563,24 @@ function applyReadingResizeHandles(root) {
function bindDetailImageFullscreen(root) {
if (!root) return;
+
+ // Gallery grids: single click, with prev/next
+ root.querySelectorAll('.md-gallery').forEach(gallery => {
+ const srcs = Array.from(gallery.querySelectorAll('img')).map(i => i.getAttribute('src'));
+ gallery.querySelectorAll('.md-gallery-item').forEach(item => {
+ const img = item.querySelector('img');
+ if (!img) return;
+ item.addEventListener('click', e => {
+ e.preventDefault();
+ e.stopPropagation();
+ openImageFullscreen(img.getAttribute('src'), img.getAttribute('alt') || 'image', srcs);
+ });
+ });
+ });
+
+ // Regular images: double-click, no gallery nav
root.querySelectorAll('img').forEach(img => {
+ if (img.closest('.md-gallery')) return;
img.title = 'double-click to open fullscreen';
img.addEventListener('dblclick', e => {
e.preventDefault();
@@ -526,8 +601,10 @@ detail.addEventListener('click', e => {
$('detail-close')?.addEventListener('click', closeDetail);
document.addEventListener('keydown', e => {
- if (e.key !== 'Escape') return;
if (!imageFsOverlay?.classList.contains('open')) return;
+ if (e.key === 'ArrowLeft') { navigateGallery(-1); e.preventDefault(); return; }
+ if (e.key === 'ArrowRight') { navigateGallery(1); e.preventDefault(); return; }
+ if (e.key !== 'Escape') return;
e.preventDefault();
e.stopImmediatePropagation();
closeImageFullscreen();
diff --git a/public/js/modules/editor.js b/public/js/modules/editor.js
index 6a3b63b..907060e 100644
--- a/public/js/modules/editor.js
+++ b/public/js/modules/editor.js
@@ -16,8 +16,10 @@ const inpColor = $('inp-color');
const inpBucket = $('inp-bucket');
const presets = $('color-presets');
const linkAc = $('link-ac');
+const tagAc = $('tag-ac');
let linkAcVisible = false, linkAcItems = [], linkAcActive = 0;
+let tagAcVisible = false, tagAcItems = [], tagAcActive = 0;
let morphGhost = null, morphTimer = null;
function clearMorphGhost() {
@@ -112,6 +114,8 @@ CARD_COLORS.forEach(c => {
function selectColor(hex) {
inpColor.value = hex;
+ const bar = $('editor-color');
+ if (bar) bar.style.background = hex;
presets.querySelectorAll('.color-swatch').forEach(el => {
const a = el.dataset.color === hex;
el.classList.toggle('border-t-bright', a);
@@ -450,11 +454,69 @@ function completeLinkAc(idx) {
}
body.addEventListener('input', () => {
- const info = getLinkQuery();
- info ? showLinkAc(info.query) : hideLinkAc();
+ const linkInfo = getLinkQuery();
+ linkInfo ? showLinkAc(linkInfo.query) : hideLinkAc();
+ const tagInfo = getTagQuery();
+ tagInfo ? showTagAc(tagInfo.query) : hideTagAc();
updatePreview();
});
+// ── Tag autocomplete (#) ──
+
+function getTagQuery() {
+ const before = body.value.substring(0, body.selectionStart);
+ // Find # preceded by whitespace or start of line, not part of heading (# + space)
+ const m = before.match(/(?:^|[\s])#([a-zA-Z0-9_\/-]*)$/m);
+ if (!m) return null;
+ const start = before.length - m[0].length + (m[0].startsWith('#') ? 0 : 1); // skip leading whitespace
+ return { start, query: m[1] };
+}
+
+function getAllTags() {
+ const set = new Set();
+ for (const c of state.cards) {
+ for (const t of (c.tags || '').split(',')) { if (t) set.add(t); }
+ }
+ return [...set].sort();
+}
+
+function showTagAc(query) {
+ const q = query.toLowerCase();
+ const all = getAllTags();
+ tagAcItems = all.filter(t => !q || t.includes(q));
+ if (!tagAcItems.length) { hideTagAc(); return; }
+ tagAcActive = 0;
+ tagAc.innerHTML = tagAcItems.slice(0, 8).map((t, i) => `
+
+ #${esc(t)}
+
`).join('');
+ tagAcItems = tagAcItems.slice(0, 8);
+ tagAc.querySelectorAll('.ac-item').forEach(el => {
+ el.addEventListener('mousedown', e => { e.preventDefault(); completeTagAc(parseInt(el.dataset.idx)); });
+ });
+ const rect = body.getBoundingClientRect();
+ const lines = body.value.substring(0, body.selectionStart).split('\n');
+ tagAc.style.top = Math.min(rect.top + (lines.length - 1) * 22 - body.scrollTop + 26, innerHeight - 200) + 'px';
+ tagAc.style.left = Math.min(rect.left + 40, innerWidth - 320) + 'px';
+ tagAc.classList.remove('hidden');
+ tagAcVisible = true;
+}
+
+function hideTagAc() { tagAc.classList.add('hidden'); tagAcVisible = false; }
+
+function completeTagAc(idx) {
+ const tag = tagAcItems[idx];
+ if (!tag) return;
+ const info = getTagQuery();
+ if (!info) { hideTagAc(); return; }
+ const before = body.value.substring(0, info.start);
+ const after = body.value.substring(body.selectionStart);
+ const ins = `#${tag} `;
+ body.value = before + ins + after;
+ body.selectionStart = body.selectionEnd = info.start + ins.length;
+ hideTagAc(); updatePreview(); body.focus();
+}
+
// ── Line helpers ──
function getLineInfo() {
@@ -511,6 +573,17 @@ body.addEventListener('keydown', e => {
linkAc.querySelectorAll('.ac-item').forEach((el, i) => el.classList.toggle('active', i === linkAcActive));
return;
}
+ if (tagAcVisible) {
+ if (e.key === 'ArrowDown') { e.preventDefault(); tagAcActive = Math.min(tagAcActive + 1, tagAcItems.length - 1); }
+ else if (e.key === 'ArrowUp') { e.preventDefault(); tagAcActive = Math.max(tagAcActive - 1, 0); }
+ else if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault(); completeTagAc(tagAcActive); return; }
+ else if (e.key === 'Escape') { e.preventDefault(); hideTagAc(); return; }
+ tagAc.querySelectorAll('.ac-item').forEach((el, i) => {
+ el.classList.toggle('active', i === tagAcActive);
+ if (i === tagAcActive) el.scrollIntoView({ block: 'nearest' });
+ });
+ return;
+ }
// Alt+Arrow: move line
if (e.altKey && !e.shiftKey && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) {
e.preventDefault();
@@ -526,7 +599,7 @@ body.addEventListener('keydown', e => {
if (e.key === 'Tab') { e.preventDefault(); insertMd(' '); }
});
-body.addEventListener('blur', () => setTimeout(hideLinkAc, 150));
+body.addEventListener('blur', () => { setTimeout(hideLinkAc, 150); setTimeout(hideTagAc, 150); });
// ── File upload (paste + drag-drop) ──
diff --git a/public/js/modules/gallery.js b/public/js/modules/gallery.js
index cac8673..36eb760 100644
--- a/public/js/modules/gallery.js
+++ b/public/js/modules/gallery.js
@@ -73,16 +73,8 @@ export async function openGallery() {
if (orphaned.length) {
html += `
orphaned (not used by any card)
`;
html += orphaned.map(f => `
-
+
${mediaTile(f.url, 'border-t-danger/30')}
-
-
-
-
${esc(f.name)}
`).join('');
}
@@ -113,27 +105,13 @@ export async function openGallery() {
});
});
- // Orphan: create card from image
- grid.querySelectorAll('[data-create]').forEach(btn => {
- btn.addEventListener('click', async e => {
- e.stopPropagation();
- const url = btn.dataset.create;
- const name = url.split('/').pop().replace(/\.[^.]+$/, '');
- await api('api/cards', { method: 'POST', body: { title: name, body: ``, color: '#38bdf8', position: -1 } });
- await window._reloadAll();
- openGallery(); // refresh
- });
- });
-
- // Orphan: delete
- grid.querySelectorAll('[data-del]').forEach(btn => {
- btn.addEventListener('click', async e => {
- e.stopPropagation();
- const name = btn.dataset.del;
- const ok = await showDialog({ title: 'delete_image', message: `"${name}" loeschen?`, danger: true, okLabel: 'delete' });
- if (!ok) return;
- await fetch(`api/uploads/${name}`, { method: 'DELETE', headers: { 'X-CSRF-Token': getCsrf() } });
- openGallery(); // refresh
+ // Orphan: click to enlarge
+ grid.querySelectorAll('[data-orphan]').forEach(item => {
+ item.addEventListener('click', e => {
+ if (e.target.closest('button')) return;
+ const name = item.dataset.orphan;
+ const f = orphaned.find(o => o.name === name);
+ if (f && !isAudio(f.url)) openOrphanLightbox(f);
});
});
}
@@ -141,6 +119,51 @@ export async function openGallery() {
el.classList.add('open');
}
+function openOrphanLightbox(file) {
+ let overlay = document.querySelector('.orphan-lightbox');
+ if (overlay) overlay.remove();
+
+ overlay = document.createElement('div');
+ overlay.className = 'orphan-lightbox';
+ overlay.style.cssText = 'position:fixed;inset:0;z-index:200;display:flex;align-items:center;justify-content:center;background:rgba(2,6,23,0.92);';
+ overlay.innerHTML = `
+

+
+
+
+
+
+ `;
+
+ const close = () => overlay.remove();
+ overlay.addEventListener('click', e => { if (e.target === overlay) close(); });
+ overlay.querySelector('.orphan-lb-close').addEventListener('click', close);
+
+ overlay.querySelector('.orphan-lb-create').addEventListener('click', async () => {
+ const name = file.url.split('/').pop().replace(/\.[^.]+$/, '');
+ await api('api/cards', { method: 'POST', body: { title: name, body: ``, color: '#38bdf8', position: -1 } });
+ await window._reloadAll();
+ close();
+ openGallery();
+ });
+
+ overlay.querySelector('.orphan-lb-delete').addEventListener('click', async () => {
+ const ok = await showDialog({ title: 'delete_image', message: `"${file.name}" loeschen?`, danger: true, okLabel: 'delete' });
+ if (!ok) return;
+ await fetch(`api/uploads/${file.name}`, { method: 'DELETE', headers: { 'X-CSRF-Token': getCsrf() } });
+ close();
+ openGallery();
+ });
+
+ document.body.appendChild(overlay);
+}
+
export function closeGallery() {
$('gallery')?.classList.remove('open');
}
diff --git a/public/js/modules/markdown.js b/public/js/modules/markdown.js
index 9083be1..ce473dd 100644
--- a/public/js/modules/markdown.js
+++ b/public/js/modules/markdown.js
@@ -3,11 +3,17 @@
import { esc } from './util.js';
import { state } from './state.js';
-marked.use({ breaks: true, gfm: true });
+marked.use({ breaks: true, gfm: true, renderer: {
+ link({ href, text }) {
+ const isExternal = /^https?:\/\//.test(href);
+ if (isExternal) return `
${text}`;
+ return `
${text}`;
+ }
+}});
const purifyConfig = {
ALLOWED_TAGS: ['h1','h2','h3','h4','h5','h6','p','br','hr','ul','ol','li','a','strong','em','del','code','pre','blockquote','table','thead','tbody','tr','th','td','img','input','span','div','audio','source'],
- ALLOWED_ATTR: ['href','src','alt','width','height','type','checked','disabled','class','style','data-card-id','title','controls','preload'],
+ ALLOWED_ATTR: ['href','src','alt','width','height','type','checked','disabled','class','style','data-card-id','title','controls','preload','target','rel'],
ALLOW_DATA_ATTR: true,
};
@@ -33,6 +39,17 @@ function normalizeListBlock(block) {
export function renderMd(src) {
let processed = String(src || '').replace(/\r\n?/g, '\n')
+ // Gallery fenced block: ```gallery
+ .replace(/```gallery\s*\n([\s\S]*?)(?:```|$)/g, (_, content) => {
+ const imgs = [];
+ const imgRe = /!\[([^\]]*)\]\(([^)]+)\)/g;
+ let m;
+ while ((m = imgRe.exec(content)) !== null) imgs.push({ alt: m[1], src: m[2].split(/\s/)[0] });
+ if (!imgs.length) return '';
+ return '
' + imgs.map(i =>
+ `
`
+ ).join('') + '
';
+ })
// Custom fenced list blocks: ```tree / ```bullets with or without closing ```
.replace(/```(tree|bullets)\s*\n([\s\S]*?)(?:```|$)/g, (_, kind, content) => {
const normalized = normalizeListBlock(content);
diff --git a/public/js/modules/search.js b/public/js/modules/search.js
index dddacf8..4aae54b 100644
--- a/public/js/modules/search.js
+++ b/public/js/modules/search.js
@@ -1,7 +1,7 @@
// ── Search ──
import { $, esc, escRe, fuzzyMatch, countSubstring, highlight } from './util.js';
-import { state } from './state.js';
+import { state, parseTags, tagChipHtml } from './state.js';
import { renderMd, stripMd } from './markdown.js';
const overlay = $('search');
@@ -50,6 +50,31 @@ function updatePreview() {
for (const w of words) html = html.replace(new RegExp('(>)([^<]*?)(' + escRe(w) + ')', 'gi'), '$1$2
$3');
}
$('search-preview-body').innerHTML = html;
+
+ const ptags = parseTags(card);
+ const ptagEl = $('search-preview-tags');
+ if (ptags.length) {
+ ptagEl.classList.remove('hidden');
+ ptagEl.innerHTML = ptags.map(t => tagChipHtml(t)).join('');
+ ptagEl.querySelectorAll('.tag-seg[data-tag]').forEach(seg => {
+ seg.addEventListener('click', e => {
+ closeSearch();
+ const tag = seg.dataset.tag;
+ if (e.ctrlKey || e.metaKey) {
+ const idx = state.activeTags.indexOf(tag);
+ if (idx >= 0) state.activeTags.splice(idx, 1);
+ else state.activeTags.push(tag);
+ } else {
+ state.activeTags = [tag];
+ }
+ window._renderTagBar?.();
+ window._renderBoard();
+ });
+ });
+ } else {
+ ptagEl.classList.add('hidden');
+ }
+
$('search-preview-meta').textContent = '> created: ' + card.created_at + ' | modified: ' + card.updated_at + (card.bucket_name ? ' | bucket: ' + card.bucket_name : '');
}
@@ -66,24 +91,51 @@ function renderHome() {
function search(query) {
const q = query.toLowerCase().trim();
if (!q) { renderHome(); return; }
- const words = q.split(/\s+/).filter(Boolean);
- const scored = getPool().map(c => {
+
+ // Split into #tag filters and regular words
+ const parts = q.split(/\s+/).filter(Boolean);
+ const tagFilters = parts.filter(p => p.startsWith('#') && p.length > 1).map(p => p.substring(1));
+ const words = parts.filter(p => !p.startsWith('#'));
+ const textQ = words.join(' ');
+
+ // Pre-filter by tags (must match ALL)
+ let pool = getPool();
+ if (tagFilters.length) {
+ pool = pool.filter(c => {
+ const ct = (c.tags || '').toLowerCase().split(',');
+ return tagFilters.every(tf => ct.some(t => t === tf || t.startsWith(tf + '/') || t.includes(tf)));
+ });
+ }
+
+ // Tag-only search (no words)
+ if (!words.length) {
+ hits = pool; active = pool.length ? 0 : -1;
+ if (!pool.length) { results.innerHTML = `
no cards with ${tagFilters.map(t => '#' + t).join(' + ')}
`; footer.classList.add('hidden'); clearPreview(); return; }
+ results.innerHTML = `
${pool.length} tagged
` +
+ pool.map((c, i) => hitHtml(c, i, null)).join('');
+ footer.classList.remove('hidden'); bindHits(); updateActive(); updatePreview(); return;
+ }
+
+ const scored = pool.map(c => {
const title = (c.title || '').toLowerCase(), body = stripMd(c.body || '').toLowerCase();
- let score = 0, matched = false;
- if (title.includes(q)) { score += 150; matched = true; }
- if (body.includes(q)) { score += 40 + countSubstring(body, q) * 10; matched = true; }
+ const tags = (c.tags || '').toLowerCase();
+ let score = tagFilters.length ? 50 : 0, matched = tagFilters.length > 0;
+ if (title.includes(textQ)) { score += 150; matched = true; }
+ if (body.includes(textQ)) { score += 40 + countSubstring(body, textQ) * 10; matched = true; }
+ if (tags.includes(textQ)) { score += 60; matched = true; }
let allWords = true;
for (const w of words) {
const inT = title.includes(w), inB = body.includes(w);
if (inT) { score += 30; if (new RegExp('(^|[\\s\\-_./])' + escRe(w)).test(title)) score += 20; if (title.startsWith(w)) score += 15; score += countSubstring(title, w) * 5; }
if (inB) score += 8 + countSubstring(body, w) * 3;
+ if (tags.includes(w)) score += 25;
if (!inT && !inB) allWords = false;
}
if (allWords) { score += 50; matched = true; }
- const tf = fuzzyMatch(q, title), bf = fuzzyMatch(q, body);
- if (tf !== null) { score += 20 + tf * 2; matched = true; }
- if (bf !== null) { score += 5 + bf * 0.5; matched = true; }
- if (!matched) { let fh = 0; for (const w of words) { if (fuzzyMatch(w, title) !== null) { score += 10; fh++; } if (fuzzyMatch(w, body) !== null) { score += 3; fh++; } } if (fh > 0) matched = true; }
+ if (!matched) {
+ const tf = fuzzyMatch(textQ, title);
+ if (tf !== null) { score += 15 + tf * 2; matched = true; }
+ }
if (!matched) return null;
return { card: c, score: score - title.length * 0.1 };
}).filter(Boolean).sort((a, b) => b.score - a.score);
@@ -117,10 +169,12 @@ function hitHtml(card, idx, words) {
snippet = highlight(esc(snippet), words);
} else { snippet = esc(plain.substring(0, 120)) + (plain.length > 120 ? '...' : ''); }
+ const htags = parseTags(card);
return `
`;
+
${snippet}
+ ${htags.length ? `
${htags.slice(0, 3).map(t => `#${esc(t)}`).join('')}
` : ''}
`;
}
function bindHits() {
diff --git a/public/js/modules/state.js b/public/js/modules/state.js
index a4f2007..4aaf43f 100644
--- a/public/js/modules/state.js
+++ b/public/js/modules/state.js
@@ -1,9 +1,12 @@
// ── Shared app state ──
+import { esc } from './util.js';
+
export const state = {
cards: [],
buckets: [],
activeBucket: 'all',
+ activeTags: [],
activeGroup: null,
sortMode: localStorage.getItem('hyprcrd-sort') || 'manual',
editingId: null,
@@ -15,8 +18,8 @@ export const state = {
previewVisible: false,
};
-export const SORT_MODES = ['manual', 'newest', 'oldest', 'alpha'];
-export const SORT_ICONS = { manual: 'ph-dots-six', newest: 'ph-sort-descending', oldest: 'ph-sort-ascending', alpha: 'ph-text-aa' };
+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' };
export const CARD_COLORS = [
{ name: 'sky', hex: '#38bdf8' }, { name: 'violet', hex: '#8b5cf6' },
@@ -26,12 +29,30 @@ export const CARD_COLORS = [
{ 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 `
`;
+}
+
export function sortedCards(cards) {
const arr = [...cards];
switch (state.sortMode) {
case 'newest': return arr.sort((a, b) => (b.created_at || '').localeCompare(a.created_at || ''));
case 'oldest': return arr.sort((a, b) => (a.created_at || '').localeCompare(b.created_at || ''));
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));
}
}
diff --git a/src/Database.php b/src/Database.php
index 7c2cf4e..b200c96 100644
--- a/src/Database.php
+++ b/src/Database.php
@@ -69,6 +69,20 @@ class Database
self::$pdo->exec('ALTER TABLE cards ADD COLUMN parent_id INTEGER DEFAULT NULL REFERENCES cards(id) ON DELETE SET NULL');
}
+ if (!in_array('tags', $colNames)) {
+ self::$pdo->exec("ALTER TABLE cards ADD COLUMN tags TEXT NOT NULL DEFAULT ''");
+ // Backfill tags from existing card bodies
+ $all = self::$pdo->query('SELECT id, body FROM cards')->fetchAll();
+ foreach ($all as $row) {
+ preg_match_all('/(?<=\s|^)#([a-zA-Z0-9_\/-][a-zA-Z0-9_\/-]*[a-zA-Z0-9_-]|[a-zA-Z0-9_-]+)/m', $row['body'], $matches);
+ if (!empty($matches[1])) {
+ $tags = implode(',', array_unique(array_map('strtolower', $matches[1])));
+ self::$pdo->prepare('UPDATE cards SET tags = :tags WHERE id = :id')
+ ->execute(['tags' => $tags, 'id' => $row['id']]);
+ }
+ }
+ }
+
// Bucket color migration
$bcols = self::$pdo->query('PRAGMA table_info(buckets)')->fetchAll();
$bcolNames = array_column($bcols, 'name');