bak
This commit is contained in:
1 parent
0d4d369870
commit
9c71e65041
9 files changed
+422
-15
No files matched your search
+113
-1
@@ -36,6 +36,7 @@ body::after {
|
|||||||
|
|
||||||
/* Cards */
|
/* Cards */
|
||||||
.card-bar { image-rendering: pixelated; height: 3px; }
|
.card-bar { image-rendering: pixelated; height: 3px; }
|
||||||
|
.board-card { min-height: 180px; }
|
||||||
.card-layer { position: relative; z-index: 1; }
|
.card-layer { position: relative; z-index: 1; }
|
||||||
.card-has-cover {
|
.card-has-cover {
|
||||||
background-size: cover;
|
background-size: cover;
|
||||||
@@ -43,6 +44,9 @@ body::after {
|
|||||||
background-repeat: no-repeat;
|
background-repeat: no-repeat;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
.card-group.card-has-cover {
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
.card-cover-overlay {
|
.card-cover-overlay {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
@@ -74,7 +78,10 @@ body::after {
|
|||||||
right: -6px;
|
right: -6px;
|
||||||
min-width: 20px;
|
min-width: 20px;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
border-radius: 10px;
|
padding: 0 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
white-space: nowrap;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -185,6 +192,61 @@ body.detail-zen-open { overflow: hidden; }
|
|||||||
.overlay:not(.open) { opacity: 0; pointer-events: none; }
|
.overlay:not(.open) { opacity: 0; pointer-events: none; }
|
||||||
.overlay.open { opacity: 1; }
|
.overlay.open { opacity: 1; }
|
||||||
|
|
||||||
|
/* Detail image fullscreen */
|
||||||
|
body.detail-image-fs-open { overflow: hidden; }
|
||||||
|
#detail-body img { cursor: zoom-in; }
|
||||||
|
.detail-image-fs-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 170;
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px;
|
||||||
|
background: rgba(2, 6, 23, 0.94);
|
||||||
|
}
|
||||||
|
.detail-image-fs-overlay.open { display: flex; }
|
||||||
|
.detail-image-fs-stage {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.detail-image-fs-img {
|
||||||
|
max-width: min(96vw, 1900px);
|
||||||
|
max-height: 94vh;
|
||||||
|
object-fit: contain;
|
||||||
|
border: 1px solid var(--t-border);
|
||||||
|
box-shadow: 0 16px 60px rgba(0, 0, 0, 0.55);
|
||||||
|
cursor: grab;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-drag: none;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
.detail-image-fs-overlay.dragging .detail-image-fs-img { cursor: grabbing; }
|
||||||
|
.detail-image-fs-close {
|
||||||
|
position: fixed;
|
||||||
|
top: 12px;
|
||||||
|
right: 12px;
|
||||||
|
z-index: 171;
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: #fff;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.7);
|
||||||
|
transition: color 0.12s, transform 0.12s;
|
||||||
|
}
|
||||||
|
.detail-image-fs-close:hover {
|
||||||
|
color: var(--t-accent);
|
||||||
|
transform: scale(1.06);
|
||||||
|
}
|
||||||
|
|
||||||
/* Theme picker */
|
/* Theme picker */
|
||||||
.theme-picker { position: relative; }
|
.theme-picker { position: relative; }
|
||||||
.theme-menu { display: none; position: absolute; bottom: calc(100% + 8px); right: 0; z-index: 200; }
|
.theme-menu { display: none; position: absolute; bottom: calc(100% + 8px); right: 0; z-index: 200; }
|
||||||
@@ -201,6 +263,14 @@ body.detail-zen-open { overflow: hidden; }
|
|||||||
}
|
}
|
||||||
.bucket-tab.drag-over i,
|
.bucket-tab.drag-over i,
|
||||||
.bucket-tab.drag-over span { color: inherit !important; }
|
.bucket-tab.drag-over span { color: inherit !important; }
|
||||||
|
.bucket-add.drag-over {
|
||||||
|
background: var(--t-accent) !important;
|
||||||
|
color: var(--t-bg) !important;
|
||||||
|
transform: scale(1.05);
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
.bucket-add.drag-over i,
|
||||||
|
.bucket-add.drag-over span { color: inherit !important; }
|
||||||
.bucket-drop-left { border-left: 3px solid var(--t-accent) !important; }
|
.bucket-drop-left { border-left: 3px solid var(--t-accent) !important; }
|
||||||
.bucket-drop-right { border-right: 3px solid var(--t-accent) !important; }
|
.bucket-drop-right { border-right: 3px solid var(--t-accent) !important; }
|
||||||
|
|
||||||
@@ -321,3 +391,45 @@ body.detail-zen-open { overflow: hidden; }
|
|||||||
.prose table { border-collapse: collapse; width: 100%; margin: 0.8em 0; }
|
.prose table { border-collapse: collapse; width: 100%; margin: 0.8em 0; }
|
||||||
.prose th, .prose td { border: 1px solid var(--t-border); padding: 0.4em 0.8em; text-align: left; }
|
.prose th, .prose td { border: 1px solid var(--t-border); padding: 0.4em 0.8em; text-align: left; }
|
||||||
.prose th { background: var(--t-raised); color: var(--t-bright); font-weight: 600; }
|
.prose th { background: var(--t-raised); color: var(--t-bright); font-weight: 600; }
|
||||||
|
|
||||||
|
/* Temporary reading aid in open detail view (not persisted) */
|
||||||
|
#detail-body .detail-reading-resize {
|
||||||
|
position: relative;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
min-height: 1.8em;
|
||||||
|
}
|
||||||
|
#detail-body .detail-reading-resize:hover,
|
||||||
|
#detail-body .detail-reading-resize.resizing {
|
||||||
|
outline: 1px dashed var(--t-border);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
#detail-body .detail-font-dragline {
|
||||||
|
position: absolute;
|
||||||
|
left: 0.4rem;
|
||||||
|
right: 0.4rem;
|
||||||
|
bottom: 0.2rem;
|
||||||
|
height: 10px;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
cursor: ns-resize;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.12s ease;
|
||||||
|
}
|
||||||
|
#detail-body .detail-font-dragline::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
top: 4px;
|
||||||
|
border-top: 1px dashed var(--t-border);
|
||||||
|
transition: border-color 0.12s ease;
|
||||||
|
}
|
||||||
|
#detail-body .detail-reading-resize:hover .detail-font-dragline,
|
||||||
|
#detail-body .detail-reading-resize.resizing .detail-font-dragline {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
#detail-body .detail-font-dragline:hover::before,
|
||||||
|
#detail-body .detail-reading-resize.resizing .detail-font-dragline::before {
|
||||||
|
border-color: var(--t-accent);
|
||||||
|
}
|
||||||
+5
-2
@@ -141,8 +141,11 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
$title = trim($input['title'] ?? '');
|
$title = trim($input['title'] ?? '');
|
||||||
if ($title === '') { http_response_code(400); echo json_encode(['error' => 'title is required']); exit; }
|
if ($title === '') { http_response_code(400); echo json_encode(['error' => 'title is required']); exit; }
|
||||||
$pos = $input['position'] ?? 0;
|
$pos = $input['position'] ?? 0;
|
||||||
$db->prepare('INSERT INTO cards (title, body, color, bucket_id, position) VALUES (:title, :body, :color, :bucket_id, :position)')->execute([
|
$db->prepare('INSERT INTO cards (title, body, color, bucket_id, parent_id, position) VALUES (:title, :body, :color, :bucket_id, :parent_id, :position)')->execute([
|
||||||
'title' => $title, 'body' => $input['body'] ?? '', 'color' => validColor($input['color'] ?? null), 'bucket_id' => validInt($input['bucket_id'] ?? null, null), 'position' => validInt($pos),
|
'title' => $title, 'body' => $input['body'] ?? '', 'color' => validColor($input['color'] ?? null),
|
||||||
|
'bucket_id' => validInt($input['bucket_id'] ?? null, null),
|
||||||
|
'parent_id' => validInt($input['parent_id'] ?? null, null),
|
||||||
|
'position' => validInt($pos),
|
||||||
]);
|
]);
|
||||||
echo json_encode($db->query('SELECT c.*, b.name as bucket_name FROM cards c LEFT JOIN buckets b ON c.bucket_id = b.id WHERE c.id = ' . $db->lastInsertId())->fetch());
|
echo json_encode($db->query('SELECT c.*, b.name as bucket_name FROM cards c LEFT JOIN buckets b ON c.bucket_id = b.id WHERE c.id = ' . $db->lastInsertId())->fetch());
|
||||||
exit;
|
exit;
|
||||||
|
|||||||
+2
-1
@@ -13,13 +13,14 @@ import { openModal, closeModal, saveCard } from './modules/editor.js';
|
|||||||
import { openSearch, closeSearch } from './modules/search.js';
|
import { openSearch, closeSearch } from './modules/search.js';
|
||||||
import { showContextMenu } from './modules/context-menu.js';
|
import { showContextMenu } from './modules/context-menu.js';
|
||||||
import { undo, canUndo } from './modules/undo.js';
|
import { undo, canUndo } from './modules/undo.js';
|
||||||
import { closeGallery } from './modules/gallery.js';
|
import { openGallery, closeGallery } from './modules/gallery.js';
|
||||||
import { refreshTasksView, openTasks, closeTasks } from './modules/tasks.js';
|
import { refreshTasksView, openTasks, closeTasks } from './modules/tasks.js';
|
||||||
|
|
||||||
// ── Global API (for inline onclick handlers and cross-module calls) ──
|
// ── Global API (for inline onclick handlers and cross-module calls) ──
|
||||||
|
|
||||||
window._openDetail = openDetail;
|
window._openDetail = openDetail;
|
||||||
window._openModal = openModal;
|
window._openModal = openModal;
|
||||||
|
window._openGallery = openGallery;
|
||||||
window._closeSearch = closeSearch;
|
window._closeSearch = closeSearch;
|
||||||
window._closeTasks = closeTasks;
|
window._closeTasks = closeTasks;
|
||||||
window._renderBoard = renderBoard;
|
window._renderBoard = renderBoard;
|
||||||
|
|||||||
@@ -79,11 +79,11 @@ export function renderBoard() {
|
|||||||
const hasCover = !!coverImg;
|
const hasCover = !!coverImg;
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="${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"
|
<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"
|
||||||
style="--glow-color:${c.color}22;${hasCover ? `background-image:url('${coverImg}')` : ''};${recent.includes(String(c.id)) ? 'border-bottom:3px solid ' + c.color : ''}" draggable="true" data-id="${c.id}">
|
style="--glow-color:${c.color}22;${hasCover ? `background-image:url('${coverImg}')` : ''};${recent.includes(String(c.id)) ? 'border-bottom:3px solid ' + c.color : ''}" draggable="true" data-id="${c.id}">
|
||||||
${hasCover ? '<div class="card-cover-overlay"></div>' : ''}
|
${hasCover ? '<div class="card-cover-overlay"></div>' : ''}
|
||||||
<div class="card-bar w-full card-layer" style="background:${c.color}"></div>
|
<div class="card-bar w-full card-layer" style="background:${c.color}"></div>
|
||||||
${isGroup ? `<div class="card-group-badge font-pixel cursor-pointer hover:scale-110 transition-transform" style="background:${c.color};color:#000" data-group="${c.id}">${children.length + 1}</div>` : ''}
|
${isGroup ? `<div class="card-group-badge cursor-pointer hover:scale-110 transition-transform" style="background:${c.color};color:#000" data-group="${c.id}">${children.length + 1}</div>` : ''}
|
||||||
${thumbImg ? `<div class="card-thumb" style="background-image:url('${thumbImg}')"></div>` : ''}
|
${thumbImg ? `<div class="card-thumb" style="background-image:url('${thumbImg}')"></div>` : ''}
|
||||||
<div class="p-4 card-layer ${thumbImg ? 'pr-16' : ''}">
|
<div class="p-4 card-layer ${thumbImg ? 'pr-16' : ''}">
|
||||||
<div class="flex items-start gap-2 mb-2">
|
<div class="flex items-start gap-2 mb-2">
|
||||||
|
|||||||
@@ -121,6 +121,15 @@ export function populateSelects() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function autoBucketNameFromCard(card) {
|
||||||
|
const existing = new Set(state.buckets.map(b => String(b.name || '').trim().toLowerCase()).filter(Boolean));
|
||||||
|
const base = String(card?.title || '').trim().replace(/\s+/g, ' ').slice(0, 40) || 'bucket';
|
||||||
|
if (!existing.has(base.toLowerCase())) return base;
|
||||||
|
let i = 2;
|
||||||
|
while (existing.has(`${base} ${i}`.toLowerCase())) i++;
|
||||||
|
return `${base} ${i}`;
|
||||||
|
}
|
||||||
|
|
||||||
function updateHeaderTint() {
|
function updateHeaderTint() {
|
||||||
const tint = $('header-tint');
|
const tint = $('header-tint');
|
||||||
if (!tint) return;
|
if (!tint) return;
|
||||||
@@ -145,7 +154,7 @@ function renderBar() {
|
|||||||
const active = state.activeBucket == b.id;
|
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 ? `border-color:${c};color:${c}` : ''}"><i class="ph ph-folder text-sm"></i><span class="font-pixel text-[10px]">${esc(b.name)}</span></button>`;
|
||||||
}).join('')}
|
}).join('')}
|
||||||
<button id="add-bucket-btn" class="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>`;
|
<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>`;
|
||||||
|
|
||||||
updateHeaderTint();
|
updateHeaderTint();
|
||||||
|
|
||||||
@@ -223,11 +232,34 @@ function renderBar() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
$('add-bucket-btn')?.addEventListener('click', async () => {
|
const addBtn = $('add-bucket-btn');
|
||||||
|
addBtn?.addEventListener('click', async () => {
|
||||||
const name = await showDialog({ title: 'new_bucket', input: true, placeholder: '> bucket name...' });
|
const name = await showDialog({ title: 'new_bucket', input: true, placeholder: '> bucket name...' });
|
||||||
if (!name || !name.trim()) return;
|
if (!name || !name.trim()) return;
|
||||||
await api('api/buckets', { method: 'POST', body: { name: name.trim() } });
|
await api('api/buckets', { method: 'POST', body: { name: name.trim() } });
|
||||||
await loadBuckets();
|
await loadBuckets();
|
||||||
window._renderBoard();
|
window._renderBoard();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isCardDrag = e => Array.from(e.dataTransfer?.types || []).includes('text/plain');
|
||||||
|
|
||||||
|
addBtn?.addEventListener('dragover', e => {
|
||||||
|
if (!isCardDrag(e)) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = 'move';
|
||||||
|
addBtn.classList.add('drag-over');
|
||||||
|
});
|
||||||
|
addBtn?.addEventListener('dragleave', () => addBtn.classList.remove('drag-over'));
|
||||||
|
addBtn?.addEventListener('drop', async e => {
|
||||||
|
addBtn.classList.remove('drag-over');
|
||||||
|
const cardId = e.dataTransfer.getData('text/plain');
|
||||||
|
if (!cardId) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const card = state.cards.find(c => String(c.id) === String(cardId));
|
||||||
|
const created = await api('api/buckets', { method: 'POST', body: { name: autoBucketNameFromCard(card) } });
|
||||||
|
if (!created?.id) return;
|
||||||
|
await api(`api/cards/${cardId}`, { method: 'PATCH', body: { bucket_id: created.id } });
|
||||||
|
await loadBuckets();
|
||||||
|
await window._reloadAll();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
+241
-4
@@ -13,6 +13,18 @@ const bodyEl = $('detail-body');
|
|||||||
const metaEl = $('detail-meta');
|
const metaEl = $('detail-meta');
|
||||||
let morphTimer = null;
|
let morphTimer = null;
|
||||||
let morphGhost = null;
|
let morphGhost = null;
|
||||||
|
let imageFsOverlay = null;
|
||||||
|
let imageFsImg = null;
|
||||||
|
let fsScale = 1;
|
||||||
|
let fsTx = 0;
|
||||||
|
let fsTy = 0;
|
||||||
|
let fsPanning = false;
|
||||||
|
let fsStartX = 0;
|
||||||
|
let fsStartY = 0;
|
||||||
|
let fsStartTx = 0;
|
||||||
|
let fsStartTy = 0;
|
||||||
|
const FS_MIN_SCALE = 0.35;
|
||||||
|
const FS_MAX_SCALE = 8;
|
||||||
|
|
||||||
// ── Zoom ──
|
// ── Zoom ──
|
||||||
|
|
||||||
@@ -41,7 +53,7 @@ scroll?.addEventListener('wheel', e => {
|
|||||||
|
|
||||||
// ── Open / close ──
|
// ── Open / close ──
|
||||||
|
|
||||||
export function openDetail(id) {
|
export function openDetail(id, options = {}) {
|
||||||
const card = state.cards.find(c => c.id == id);
|
const card = state.cards.find(c => c.id == id);
|
||||||
if (!card) return;
|
if (!card) return;
|
||||||
markRecent(id);
|
markRecent(id);
|
||||||
@@ -51,6 +63,11 @@ export function openDetail(id) {
|
|||||||
|
|
||||||
if (!detail.classList.contains('open')) {
|
if (!detail.classList.contains('open')) {
|
||||||
state.openedFromSearch = $('search')?.classList.contains('open') || false;
|
state.openedFromSearch = $('search')?.classList.contains('open') || false;
|
||||||
|
state.openedFromGallery = !!options.fromGallery;
|
||||||
|
state.galleryOrigin = options.fromGallery ? (options.galleryOrigin || null) : null;
|
||||||
|
} else if (options.fromGallery) {
|
||||||
|
state.openedFromGallery = true;
|
||||||
|
state.galleryOrigin = options.galleryOrigin || state.galleryOrigin || null;
|
||||||
}
|
}
|
||||||
state.viewingCard = card;
|
state.viewingCard = card;
|
||||||
|
|
||||||
@@ -70,7 +87,139 @@ function clearMorphGhost() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ensureImageFullscreenOverlay() {
|
||||||
|
if (imageFsOverlay) return imageFsOverlay;
|
||||||
|
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.className = 'detail-image-fs-overlay';
|
||||||
|
overlay.innerHTML = `
|
||||||
|
<button type="button" class="detail-image-fs-close" aria-label="close image">
|
||||||
|
<i class="ph ph-x text-xl"></i>
|
||||||
|
</button>
|
||||||
|
<div class="detail-image-fs-stage">
|
||||||
|
<img class="detail-image-fs-img" alt="">
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
overlay.addEventListener('click', e => {
|
||||||
|
if (e.target === overlay) closeImageFullscreen();
|
||||||
|
});
|
||||||
|
overlay.querySelector('.detail-image-fs-close')?.addEventListener('click', closeImageFullscreen);
|
||||||
|
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
imageFsOverlay = overlay;
|
||||||
|
imageFsImg = overlay.querySelector('.detail-image-fs-img');
|
||||||
|
if (imageFsImg) {
|
||||||
|
imageFsImg.draggable = false;
|
||||||
|
imageFsImg.addEventListener('mousedown', onImageFsMouseDown);
|
||||||
|
}
|
||||||
|
overlay.addEventListener('wheel', onImageFsWheel, { passive: false });
|
||||||
|
document.addEventListener('mousemove', onImageFsMouseMove);
|
||||||
|
document.addEventListener('mouseup', onImageFsMouseUp);
|
||||||
|
return overlay;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyImageFsTransform() {
|
||||||
|
if (!imageFsImg) return;
|
||||||
|
imageFsImg.style.transform = `translate(${fsTx}px, ${fsTy}px) scale(${fsScale})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetImageFsTransform() {
|
||||||
|
fsScale = 1;
|
||||||
|
fsTx = 0;
|
||||||
|
fsTy = 0;
|
||||||
|
fsPanning = false;
|
||||||
|
imageFsOverlay?.classList.remove('dragging');
|
||||||
|
applyImageFsTransform();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onImageFsWheel(e) {
|
||||||
|
if (!imageFsOverlay?.classList.contains('open') || !imageFsImg) return;
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const old = fsScale;
|
||||||
|
const factor = Math.exp(-e.deltaY * 0.0015);
|
||||||
|
const next = Math.min(FS_MAX_SCALE, Math.max(FS_MIN_SCALE, old * factor));
|
||||||
|
if (Math.abs(next - old) < 0.0001) return;
|
||||||
|
|
||||||
|
const r = imageFsOverlay.getBoundingClientRect();
|
||||||
|
const cx = r.left + r.width / 2;
|
||||||
|
const cy = r.top + r.height / 2;
|
||||||
|
const px = e.clientX - cx;
|
||||||
|
const py = e.clientY - cy;
|
||||||
|
|
||||||
|
fsTx = px - ((px - fsTx) / old) * next;
|
||||||
|
fsTy = py - ((py - fsTy) / old) * next;
|
||||||
|
fsScale = next;
|
||||||
|
applyImageFsTransform();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onImageFsMouseDown(e) {
|
||||||
|
if (!imageFsOverlay?.classList.contains('open')) return;
|
||||||
|
if (e.button !== 0) return;
|
||||||
|
e.preventDefault();
|
||||||
|
fsPanning = true;
|
||||||
|
fsStartX = e.clientX;
|
||||||
|
fsStartY = e.clientY;
|
||||||
|
fsStartTx = fsTx;
|
||||||
|
fsStartTy = fsTy;
|
||||||
|
imageFsOverlay.classList.add('dragging');
|
||||||
|
}
|
||||||
|
|
||||||
|
function onImageFsMouseMove(e) {
|
||||||
|
if (!fsPanning || !imageFsOverlay?.classList.contains('open')) return;
|
||||||
|
fsTx = fsStartTx + (e.clientX - fsStartX);
|
||||||
|
fsTy = fsStartTy + (e.clientY - fsStartY);
|
||||||
|
applyImageFsTransform();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onImageFsMouseUp() {
|
||||||
|
if (!fsPanning) return;
|
||||||
|
fsPanning = false;
|
||||||
|
imageFsOverlay?.classList.remove('dragging');
|
||||||
|
}
|
||||||
|
|
||||||
|
function openImageFullscreen(src, alt = '') {
|
||||||
|
if (!src) return;
|
||||||
|
const overlay = ensureImageFullscreenOverlay();
|
||||||
|
resetImageFsTransform();
|
||||||
|
if (imageFsImg) {
|
||||||
|
imageFsImg.src = src;
|
||||||
|
imageFsImg.alt = alt || 'image';
|
||||||
|
}
|
||||||
|
overlay.classList.add('open');
|
||||||
|
document.body.classList.add('detail-image-fs-open');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeImageFullscreen() {
|
||||||
|
if (!imageFsOverlay) return;
|
||||||
|
fsPanning = false;
|
||||||
|
imageFsOverlay.classList.remove('dragging');
|
||||||
|
imageFsOverlay.classList.remove('open');
|
||||||
|
document.body.classList.remove('detail-image-fs-open');
|
||||||
|
if (imageFsImg) {
|
||||||
|
imageFsImg.removeAttribute('src');
|
||||||
|
imageFsImg.alt = '';
|
||||||
|
imageFsImg.style.transform = '';
|
||||||
|
}
|
||||||
|
fsScale = 1;
|
||||||
|
fsTx = 0;
|
||||||
|
fsTy = 0;
|
||||||
|
}
|
||||||
|
|
||||||
function resolveMorphTarget(cardId) {
|
function resolveMorphTarget(cardId) {
|
||||||
|
if (state.openedFromGallery) {
|
||||||
|
const grid = $('gallery-grid');
|
||||||
|
if (grid) {
|
||||||
|
if (state.galleryOrigin) {
|
||||||
|
const exact = Array.from(grid.querySelectorAll('[data-gallery-origin]'))
|
||||||
|
.find(el => el.dataset.galleryOrigin === state.galleryOrigin);
|
||||||
|
if (exact) return exact;
|
||||||
|
}
|
||||||
|
const byCard = grid.querySelector(`[data-card-id="${cardId}"]`);
|
||||||
|
if (byCard) return byCard;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const board = $('board');
|
const board = $('board');
|
||||||
if (!board || !cardId) return null;
|
if (!board || !cardId) return null;
|
||||||
|
|
||||||
@@ -133,7 +282,9 @@ function startCloseMorph(cardId) {
|
|||||||
|
|
||||||
const frame = document.createElement('div');
|
const frame = document.createElement('div');
|
||||||
frame.className = 'detail-close-frame';
|
frame.className = 'detail-close-frame';
|
||||||
const targetCard = state.cards.find(c => c.id == target.dataset.id);
|
const targetCard = target.dataset.id
|
||||||
|
? state.cards.find(c => c.id == target.dataset.id)
|
||||||
|
: card;
|
||||||
Object.assign(frame.style, {
|
Object.assign(frame.style, {
|
||||||
left: `${from.left}px`,
|
left: `${from.left}px`,
|
||||||
top: `${from.top}px`,
|
top: `${from.top}px`,
|
||||||
@@ -170,10 +321,12 @@ function startCloseMorph(cardId) {
|
|||||||
|
|
||||||
export function closeDetail(options = {}) {
|
export function closeDetail(options = {}) {
|
||||||
const animate = options?.animate !== false;
|
const animate = options?.animate !== false;
|
||||||
|
const restoreGallery = options?.restoreGallery !== false;
|
||||||
const cardId = state.viewingCard?.id;
|
const cardId = state.viewingCard?.id;
|
||||||
|
|
||||||
if (animate) startCloseMorph(cardId);
|
if (animate) startCloseMorph(cardId);
|
||||||
else clearMorphGhost();
|
else clearMorphGhost();
|
||||||
|
closeImageFullscreen();
|
||||||
|
|
||||||
detail.classList.remove('open', 'zen');
|
detail.classList.remove('open', 'zen');
|
||||||
document.documentElement.classList.remove('detail-zen-open');
|
document.documentElement.classList.remove('detail-zen-open');
|
||||||
@@ -185,6 +338,12 @@ export function closeDetail(options = {}) {
|
|||||||
state.openedFromSearch = false;
|
state.openedFromSearch = false;
|
||||||
$('search-input')?.focus();
|
$('search-input')?.focus();
|
||||||
}
|
}
|
||||||
|
if (state.openedFromGallery) {
|
||||||
|
const galleryOpen = $('gallery')?.classList.contains('open');
|
||||||
|
state.openedFromGallery = false;
|
||||||
|
state.galleryOrigin = null;
|
||||||
|
if (restoreGallery && !galleryOpen) window._openGallery?.();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function showDetailCard(id) {
|
export function showDetailCard(id) {
|
||||||
@@ -241,6 +400,8 @@ function showContent(card) {
|
|||||||
$('detail-color').style.background = card.color;
|
$('detail-color').style.background = card.color;
|
||||||
titleEl.textContent = card.title;
|
titleEl.textContent = card.title;
|
||||||
bodyEl.innerHTML = renderMd(card.body);
|
bodyEl.innerHTML = renderMd(card.body);
|
||||||
|
applyReadingResizeHandles(bodyEl);
|
||||||
|
bindDetailImageFullscreen(bodyEl);
|
||||||
|
|
||||||
// Interactive checkboxes
|
// Interactive checkboxes
|
||||||
bodyEl.querySelectorAll('input[type="checkbox"]').forEach((cb, idx) => {
|
bodyEl.querySelectorAll('input[type="checkbox"]').forEach((cb, idx) => {
|
||||||
@@ -280,6 +441,68 @@ function showContent(card) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyReadingResizeHandles(root) {
|
||||||
|
if (!root) return;
|
||||||
|
// Reading aid only: temporary per-block font-size scaling (not persisted).
|
||||||
|
root.querySelectorAll('p, blockquote, pre').forEach(el => {
|
||||||
|
el.classList.add('detail-reading-resize');
|
||||||
|
|
||||||
|
const line = document.createElement('button');
|
||||||
|
line.type = 'button';
|
||||||
|
line.className = 'detail-font-dragline';
|
||||||
|
line.title = 'drag to resize text';
|
||||||
|
line.setAttribute('aria-label', 'resize text');
|
||||||
|
el.appendChild(line);
|
||||||
|
|
||||||
|
line.addEventListener('mousedown', e => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
const startY = e.clientY;
|
||||||
|
const startFont = parseFloat(getComputedStyle(el).fontSize) || 14;
|
||||||
|
const startLineHeightPx = parseFloat(getComputedStyle(el).lineHeight);
|
||||||
|
const lineRatio = Number.isFinite(startLineHeightPx) && startLineHeightPx > 0
|
||||||
|
? startLineHeightPx / startFont
|
||||||
|
: 1.65;
|
||||||
|
const prevSelect = document.body.style.userSelect;
|
||||||
|
const min = 12;
|
||||||
|
const max = 44;
|
||||||
|
|
||||||
|
el.classList.add('resizing');
|
||||||
|
document.body.style.userSelect = 'none';
|
||||||
|
|
||||||
|
const onMove = ev => {
|
||||||
|
const delta = ev.clientY - startY;
|
||||||
|
const next = Math.min(max, Math.max(min, startFont + delta * 0.08));
|
||||||
|
el.style.fontSize = `${next.toFixed(2)}px`;
|
||||||
|
el.style.lineHeight = `${(next * lineRatio).toFixed(2)}px`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onUp = () => {
|
||||||
|
document.removeEventListener('mousemove', onMove);
|
||||||
|
document.removeEventListener('mouseup', onUp);
|
||||||
|
document.body.style.userSelect = prevSelect;
|
||||||
|
el.classList.remove('resizing');
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', onMove);
|
||||||
|
document.addEventListener('mouseup', onUp);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindDetailImageFullscreen(root) {
|
||||||
|
if (!root) return;
|
||||||
|
root.querySelectorAll('img').forEach(img => {
|
||||||
|
img.title = 'double-click to open fullscreen';
|
||||||
|
img.addEventListener('dblclick', e => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
openImageFullscreen(img.getAttribute('src'), img.getAttribute('alt') || 'image');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ── Event listeners ──
|
// ── Event listeners ──
|
||||||
|
|
||||||
// Delegated card-link clicks (replaces inline onclick)
|
// Delegated card-link clicks (replaces inline onclick)
|
||||||
@@ -290,6 +513,14 @@ detail.addEventListener('click', e => {
|
|||||||
});
|
});
|
||||||
$('detail-close')?.addEventListener('click', closeDetail);
|
$('detail-close')?.addEventListener('click', closeDetail);
|
||||||
|
|
||||||
|
document.addEventListener('keydown', e => {
|
||||||
|
if (e.key !== 'Escape') return;
|
||||||
|
if (!imageFsOverlay?.classList.contains('open')) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopImmediatePropagation();
|
||||||
|
closeImageFullscreen();
|
||||||
|
}, true);
|
||||||
|
|
||||||
// Zen mode
|
// Zen mode
|
||||||
$('detail-zen')?.addEventListener('click', () => {
|
$('detail-zen')?.addEventListener('click', () => {
|
||||||
detail.classList.toggle('zen');
|
detail.classList.toggle('zen');
|
||||||
@@ -303,8 +534,13 @@ $('detail-zen')?.addEventListener('click', () => {
|
|||||||
$('detail-edit')?.addEventListener('click', () => {
|
$('detail-edit')?.addEventListener('click', () => {
|
||||||
if (!state.viewingCard) return;
|
if (!state.viewingCard) return;
|
||||||
const card = state.viewingCard;
|
const card = state.viewingCard;
|
||||||
|
const fromGallery = state.openedFromGallery;
|
||||||
state.openedFromSearch = false;
|
state.openedFromSearch = false;
|
||||||
closeDetail({ animate: false });
|
state.openedFromGallery = false;
|
||||||
|
state.galleryOrigin = null;
|
||||||
|
state.returnToGalleryAfterEdit = fromGallery;
|
||||||
|
closeDetail({ animate: false, restoreGallery: false });
|
||||||
|
$('gallery')?.classList.remove('open');
|
||||||
window._closeSearch();
|
window._closeSearch();
|
||||||
window._openModal(card);
|
window._openModal(card);
|
||||||
});
|
});
|
||||||
@@ -315,7 +551,8 @@ $('detail-delete')?.addEventListener('click', async () => {
|
|||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
pushCardDelete('delete', snapshotCard(state.viewingCard));
|
pushCardDelete('delete', snapshotCard(state.viewingCard));
|
||||||
await api(`api/cards/${state.viewingCard.id}`, { method: 'DELETE' });
|
await api(`api/cards/${state.viewingCard.id}`, { method: 'DELETE' });
|
||||||
closeDetail({ animate: false });
|
state.galleryOrigin = null;
|
||||||
|
closeDetail({ animate: false, restoreGallery: false });
|
||||||
await window._reloadAll();
|
await window._reloadAll();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -59,7 +59,11 @@ export function openModal(card = null) {
|
|||||||
inpTitle.focus();
|
inpTitle.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function closeModal() { modal.classList.remove('open'); state.editingId = null; }
|
export function closeModal() {
|
||||||
|
modal.classList.remove('open');
|
||||||
|
state.editingId = null;
|
||||||
|
state.returnToGalleryAfterEdit = false;
|
||||||
|
}
|
||||||
|
|
||||||
function updatePreview() {
|
function updatePreview() {
|
||||||
if (state.previewVisible) previewContent.innerHTML = renderMd(body.value);
|
if (state.previewVisible) previewContent.innerHTML = renderMd(body.value);
|
||||||
@@ -68,16 +72,26 @@ function updatePreview() {
|
|||||||
export async function saveCard() {
|
export async function saveCard() {
|
||||||
const data = { title: inpTitle.value, body: body.value, color: inpColor.value, bucket_id: inpBucket.value || null };
|
const data = { title: inpTitle.value, body: body.value, color: inpColor.value, bucket_id: inpBucket.value || null };
|
||||||
if (!data.title.trim()) return;
|
if (!data.title.trim()) return;
|
||||||
|
const returnToGallery = state.returnToGalleryAfterEdit;
|
||||||
if (state.editingId) {
|
if (state.editingId) {
|
||||||
const prev = snapshotCard(state.cards.find(c => c.id == state.editingId));
|
const prev = snapshotCard(state.cards.find(c => c.id == state.editingId));
|
||||||
if (prev) pushCardEdit('edit', state.editingId, { title: prev.title, body: prev.body, color: prev.color, bucket_id: prev.bucket_id });
|
if (prev) pushCardEdit('edit', state.editingId, { title: prev.title, body: prev.body, color: prev.color, bucket_id: prev.bucket_id });
|
||||||
await api(`api/cards/${state.editingId}`, { method: 'PATCH', body: data });
|
await api(`api/cards/${state.editingId}`, { method: 'PATCH', body: data });
|
||||||
|
} else {
|
||||||
|
if (state.activeGroup) {
|
||||||
|
const parent = state.cards.find(c => c.id == state.activeGroup);
|
||||||
|
const groupCards = parent ? [parent, ...state.cards.filter(c => c.parent_id == parent.id)] : [];
|
||||||
|
const maxPos = groupCards.reduce((max, c) => Math.max(max, Number(c.position) || 0), -1);
|
||||||
|
data.parent_id = parent ? parent.id : state.activeGroup;
|
||||||
|
data.position = maxPos + 1;
|
||||||
} else {
|
} else {
|
||||||
data.position = -1;
|
data.position = -1;
|
||||||
|
}
|
||||||
await api('api/cards', { method: 'POST', body: data });
|
await api('api/cards', { method: 'POST', body: data });
|
||||||
}
|
}
|
||||||
closeModal();
|
closeModal();
|
||||||
await window._reloadAll();
|
await window._reloadAll();
|
||||||
|
if (returnToGallery) window._openGallery?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Markdown toolbar ──
|
// ── Markdown toolbar ──
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ export async function openGallery() {
|
|||||||
if (linked.length) {
|
if (linked.length) {
|
||||||
if (orphaned.length) html += `<div class="col-span-full font-pixel text-[10px] text-t-muted flex items-center gap-1.5 pt-3"><i class="ph ph-link text-xs"></i>linked to cards</div>`;
|
if (orphaned.length) html += `<div class="col-span-full font-pixel text-[10px] text-t-muted flex items-center gap-1.5 pt-3"><i class="ph ph-link text-xs"></i>linked to cards</div>`;
|
||||||
html += linked.map(img => `
|
html += linked.map(img => `
|
||||||
<div class="gallery-item group relative cursor-pointer" data-card-id="${img.cardId}">
|
<div class="gallery-item group relative cursor-pointer" data-card-id="${img.cardId}" data-gallery-origin="${encodeURIComponent(img.url)}">
|
||||||
<div class="aspect-square bg-t-bg border border-t-border rounded overflow-hidden">
|
<div class="aspect-square bg-t-bg border border-t-border rounded overflow-hidden">
|
||||||
<img src="${img.url}" class="w-full h-full object-cover" loading="lazy">
|
<img src="${img.url}" class="w-full h-full object-cover" loading="lazy">
|
||||||
</div>
|
</div>
|
||||||
@@ -94,7 +94,12 @@ export async function openGallery() {
|
|||||||
|
|
||||||
// Linked image clicks → open card
|
// Linked image clicks → open card
|
||||||
grid.querySelectorAll('[data-card-id]').forEach(item => {
|
grid.querySelectorAll('[data-card-id]').forEach(item => {
|
||||||
item.addEventListener('click', () => { closeGallery(); window._openDetail(item.dataset.cardId); });
|
item.addEventListener('click', () => {
|
||||||
|
window._openDetail(item.dataset.cardId, {
|
||||||
|
fromGallery: true,
|
||||||
|
galleryOrigin: item.dataset.galleryOrigin || null,
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Orphan: create card from image
|
// Orphan: create card from image
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ export const state = {
|
|||||||
editingId: null,
|
editingId: null,
|
||||||
viewingCard: null,
|
viewingCard: null,
|
||||||
openedFromSearch: false,
|
openedFromSearch: false,
|
||||||
|
openedFromGallery: false,
|
||||||
|
galleryOrigin: null,
|
||||||
|
returnToGalleryAfterEdit: false,
|
||||||
previewVisible: false,
|
previewVisible: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user