tags
This commit is contained in:
1 parent
a9603aa0e8
commit
719f9d51ff
13 files changed
+625
-70
No files matched your search
+3
-1
@@ -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());
|
||||
|
||||
@@ -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 `
|
||||
<div class="board-card ${isGroup ? 'card-group ' : ''}card-glow ${hasCover ? 'card-has-cover' : 'bg-t-surface'} border border-t-border cursor-pointer hover:border-t-muted transition-colors relative group"
|
||||
@@ -180,6 +188,7 @@ export function renderBoard() {
|
||||
<span class="font-pixel text-[9px] ${todos.done === todos.total ? 'text-t-accent' : 'text-t-dim'}">${todos.done}/${todos.total}</span>
|
||||
</div></div>` : ''}
|
||||
<p class="text-xs ${hasCover ? 'text-t-text' : 'text-t-muted'} leading-relaxed line-clamp-5 flex-1">${esc(stripMd(c.body))}</p>
|
||||
${tags.length ? `<div class="flex flex-wrap gap-1 mt-1.5 card-layer">${tags.slice(0, 5).map(t => tagChipHtml(t)).join('')}${tags.length > 5 ? `<span class="tag-chip tag-chip-more">+${tags.length - 5}</span>` : ''}</div>` : ''}
|
||||
<div class="mt-auto pt-2 flex items-center gap-2 text-[10px] ${hasCover ? 'text-t-text' : 'text-t-dim'} font-pixel">
|
||||
<span class="flex items-center gap-1"><i class="ph ph-clock text-xs"></i>${(c.updated_at || c.created_at || '').substring(0, 10)}</span>
|
||||
${c.bucket_name ? `<span class="flex items-center gap-1"><i class="ph ph-folder text-xs"></i>${esc(c.bucket_name)}</span>` : ''}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 `<button class="${tab(active)}" data-bucket="${b.id}" draggable="true" data-bucket-drag="${b.id}" style="border-top:3px solid ${c};${active ? `border-color:${c};color:${c}` : ''}"><i class="ph ph-folder text-sm"></i><span class="font-pixel text-[10px]">${esc(b.name)}</span></button>`;
|
||||
return `<button class="${tab(active)}" data-bucket="${b.id}" draggable="true" data-bucket-drag="${b.id}" style="border-top:3px solid ${c};${active ? `background:${c};color:var(--t-bg);border-color:${c}` : ''}"><i class="ph ph-folder text-sm"></i><span class="font-pixel text-[10px]">${esc(b.name)}</span></button>`;
|
||||
}).join('')}
|
||||
<button id="add-bucket-btn" class="bucket-add flex items-center gap-1 px-2 py-1 text-xs text-t-dim hover:text-t-accent transition-colors shrink-0"><i class="ph ph-plus text-sm"></i><span class="font-pixel text-[10px]">bucket</span></button>`;
|
||||
|
||||
@@ -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 = `<i class="ph ph-funnel text-xs text-t-accent"></i>` +
|
||||
state.activeTags.map(t => `
|
||||
<span class="tag-chip" style="font-size:10px;">
|
||||
<span class="font-pixel">#${esc(t)}</span>
|
||||
<i class="ph ph-x text-[9px] ml-0.5 cursor-pointer hover:text-t-danger" data-remove-tag="${t}"></i>
|
||||
</span>`).join('') +
|
||||
`<button id="clear-tag-filter" class="flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-pixel text-t-dim hover:text-t-danger transition-colors">
|
||||
<i class="ph ph-x text-xs"></i>clear all
|
||||
</button>`;
|
||||
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();
|
||||
});
|
||||
}
|
||||
@@ -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) {
|
||||
<button class="ctx-item" data-action="open"><i class="ph ph-eye text-sm"></i><span class="font-pixel text-[10px]">open</span></button>
|
||||
<button class="ctx-item" data-action="edit"><i class="ph ph-pencil-simple text-sm"></i><span class="font-pixel text-[10px]">edit</span></button>
|
||||
<button class="ctx-item" data-action="duplicate"><i class="ph ph-copy text-sm"></i><span class="font-pixel text-[10px]">duplicate</span></button>
|
||||
<button class="ctx-item" data-action="save-md"><i class="ph ph-file-md text-sm"></i><span class="font-pixel text-[10px]">save .md</span></button>
|
||||
<button class="ctx-item" data-action="favorite"><i class="ph${isFavorite(card.id) ? '-fill' : ''} ph-star text-sm ${isFavorite(card.id) ? 'text-amber-400' : ''}"></i><span class="font-pixel text-[10px]">${isFavorite(card.id) ? 'unfavorite' : 'favorite'}</span></button>
|
||||
<div class="ctx-sep"></div>
|
||||
<button class="ctx-item" data-action="color"><i class="ph ph-palette text-sm"></i><span class="font-pixel text-[10px]">color</span><i class="ph ph-caret-right text-xs ml-auto"></i></button>
|
||||
<button class="ctx-item" data-action="bucket"><i class="ph ph-folder text-sm"></i><span class="font-pixel text-[10px]">bucket</span><i class="ph ph-caret-right text-xs ml-auto"></i></button>
|
||||
<button class="ctx-item" data-action="tags"><i class="ph ph-hash text-sm"></i><span class="font-pixel text-[10px]">tags</span><i class="ph ph-caret-right text-xs ml-auto"></i></button>
|
||||
${isParent ? `<div class="ctx-sep"></div>
|
||||
<button class="ctx-item" data-action="expand-group"><i class="ph ph-stack text-sm"></i><span class="font-pixel text-[10px]">open group</span></button>
|
||||
<button class="ctx-item" data-action="group-members"><i class="ph ph-list text-sm"></i><span class="font-pixel text-[10px]">members (${children.length + 1})</span><i class="ph ph-caret-right text-xs ml-auto"></i></button>
|
||||
@@ -60,11 +63,72 @@ export function showContextMenu(x, y, card) {
|
||||
state.buckets.map(b => `<button class="ctx-item ${card.bucket_id == b.id ? 'text-t-accent' : ''}" data-ctx-bucket="${b.id}"><i class="ph ph-folder text-sm"></i><span class="font-pixel text-[10px]">${esc(b.name)}</span></button>`).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 => `
|
||||
<button class="ctx-item ${hasCard(t) ? 'text-t-accent' : ''}" data-ctx-tag="${t}">
|
||||
<i class="ph ${hasCard(t) ? 'ph-check-square' : 'ph-square'} text-sm"></i>
|
||||
<span class="font-pixel text-[10px]">#${esc(t)}</span>
|
||||
</button>`).join('') +
|
||||
(f && !allTags.includes(f) ? `<button class="ctx-item text-t-dim" data-ctx-tag-new="${f}">
|
||||
<i class="ph ph-plus text-sm"></i><span class="font-pixel text-[10px]">add #${esc(f)}</span>
|
||||
</button>` : '');
|
||||
|
||||
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(); } }
|
||||
|
||||
@@ -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() {
|
||||
<button type="button" class="detail-image-fs-close" aria-label="close image">
|
||||
<i class="ph ph-x text-xl"></i>
|
||||
</button>
|
||||
<button type="button" class="detail-image-fs-nav detail-image-fs-prev" aria-label="previous"><i class="ph ph-caret-left"></i></button>
|
||||
<button type="button" class="detail-image-fs-nav detail-image-fs-next" aria-label="next"><i class="ph ph-caret-right"></i></button>
|
||||
<div class="detail-image-fs-counter"></div>
|
||||
<div class="detail-image-fs-stage">
|
||||
<img class="detail-image-fs-img" alt="">
|
||||
</div>
|
||||
@@ -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();
|
||||
|
||||
@@ -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) => `
|
||||
<div class="ac-item ${i === 0 ? 'active' : ''}" data-idx="${i}">
|
||||
<span class="ac-title font-pixel">#${esc(t)}</span>
|
||||
</div>`).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) ──
|
||||
|
||||
|
||||
@@ -73,16 +73,8 @@ export async function openGallery() {
|
||||
if (orphaned.length) {
|
||||
html += `<div class="col-span-full font-pixel text-[10px] text-t-danger flex items-center gap-1.5 pt-2"><i class="ph ph-warning text-xs"></i>orphaned (not used by any card)</div>`;
|
||||
html += orphaned.map(f => `
|
||||
<div class="gallery-item group relative" data-orphan="${f.name}">
|
||||
<div class="gallery-item group relative cursor-pointer" data-orphan="${f.name}">
|
||||
${mediaTile(f.url, 'border-t-danger/30')}
|
||||
<div class="absolute top-1 right-1 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button class="w-6 h-6 rounded bg-t-accent text-t-bg flex items-center justify-center text-xs" data-create="${f.url}" title="create card">
|
||||
<i class="ph ph-plus"></i>
|
||||
</button>
|
||||
<button class="w-6 h-6 rounded bg-t-danger text-t-bg flex items-center justify-center text-xs" data-del="${f.name}" title="delete">
|
||||
<i class="ph ph-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-1 font-pixel text-[8px] text-t-dim truncate">${esc(f.name)}</div>
|
||||
</div>`).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 = `
|
||||
<img src="${file.url}" style="max-width:min(92vw,1400px);max-height:85vh;object-fit:contain;border:1px solid var(--t-border);box-shadow:0 16px 60px rgba(0,0,0,0.55);">
|
||||
<div style="position:fixed;bottom:24px;left:50%;transform:translateX(-50%);display:flex;gap:8px;">
|
||||
<button class="orphan-lb-create flex items-center gap-1.5 px-3 py-1.5 text-xs border border-t-accent text-t-accent hover:bg-t-accent hover:text-t-bg transition-colors" style="border-radius:4px;">
|
||||
<i class="ph ph-plus text-sm"></i><span class="font-pixel text-[10px]">new card</span>
|
||||
</button>
|
||||
<button class="orphan-lb-delete flex items-center gap-1.5 px-3 py-1.5 text-xs border border-t-danger text-t-danger hover:bg-t-danger hover:text-t-bg transition-colors" style="border-radius:4px;">
|
||||
<i class="ph ph-trash text-sm"></i><span class="font-pixel text-[10px]">delete</span>
|
||||
</button>
|
||||
</div>
|
||||
<button style="position:fixed;top:12px;right:12px;color:rgba(255,255,255,0.6);font-size:20px;background:none;border:none;cursor:pointer;" class="orphan-lb-close">
|
||||
<i class="ph ph-x"></i>
|
||||
</button>
|
||||
`;
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
@@ -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 `<a href="${href}" target="_blank" rel="noopener">${text}</a>`;
|
||||
return `<a href="${href}">${text}</a>`;
|
||||
}
|
||||
}});
|
||||
|
||||
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 '<div class="md-gallery">' + imgs.map(i =>
|
||||
`<div class="md-gallery-item"><img src="${i.src}" alt="${esc(i.alt)}" loading="lazy"></div>`
|
||||
).join('') + '</div>';
|
||||
})
|
||||
// Custom fenced list blocks: ```tree / ```bullets with or without closing ```
|
||||
.replace(/```(tree|bullets)\s*\n([\s\S]*?)(?:```|$)/g, (_, kind, content) => {
|
||||
const normalized = normalizeListBlock(content);
|
||||
|
||||
+65
-11
@@ -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<mark>$3</mark>');
|
||||
}
|
||||
$('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 = `<div class="px-4 py-8 text-center"><i class="ph ph-hash text-2xl text-t-dim mb-2 block"></i><p class="text-xs text-t-dim font-pixel">no cards with ${tagFilters.map(t => '#' + t).join(' + ')}</p></div>`; footer.classList.add('hidden'); clearPreview(); return; }
|
||||
results.innerHTML = `<div class="px-4 pt-3 pb-1"><span class="font-pixel text-[9px] text-t-dim uppercase tracking-wider">${pool.length} tagged</span></div>` +
|
||||
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 `<div class="search-hit flex items-start gap-3 px-4 py-2.5 cursor-pointer transition-colors hover:bg-t-raised" data-idx="${idx}">
|
||||
<span class="w-2 h-2 rounded-full shrink-0 mt-1.5" style="background:${card.color}"></span>
|
||||
<div class="min-w-0 flex-1"><div class="text-xs text-t-bright font-pixel leading-relaxed truncate">${title}</div>
|
||||
<div class="text-[11px] text-t-muted leading-relaxed mt-0.5 line-clamp-2">${snippet}</div></div></div>`;
|
||||
<div class="text-[11px] text-t-muted leading-relaxed mt-0.5 line-clamp-2">${snippet}</div>
|
||||
${htags.length ? `<div class="flex flex-wrap gap-1 mt-1">${htags.slice(0, 3).map(t => `<span class="tag-chip" style="font-size:8px;padding:0 4px;">#${esc(t)}</span>`).join('')}</div>` : ''}</div></div>`;
|
||||
}
|
||||
|
||||
function bindHits() {
|
||||
|
||||
@@ -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 `<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>`;
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user