This commit is contained in:
th committed 2026-04-07 08:20:48 +02:00
1 parent 69a3a27348
commit 03f57cbaee
8 files changed
+124 -34

No files matched your search

+3
View File
@@ -13,6 +13,9 @@ RUN sed -ri -e 's!/var/www/html!${APACHE_DOCUMENT_ROOT}!g' \
/etc/apache2/sites-available/*.conf \ /etc/apache2/sites-available/*.conf \
/etc/apache2/apache2.conf /etc/apache2/apache2.conf
# PHP upload limits
RUN echo "upload_max_filesize = 20M\npost_max_size = 22M" > /usr/local/etc/php/conf.d/uploads.ini
# Allow .htaccess overrides # Allow .htaccess overrides
RUN sed -ri -e 's/AllowOverride None/AllowOverride All/g' \ RUN sed -ri -e 's/AllowOverride None/AllowOverride All/g' \
/etc/apache2/apache2.conf /etc/apache2/apache2.conf
+21 -1
View File
@@ -111,8 +111,22 @@ body::after {
border: 2px solid var(--t-border); border: 2px solid var(--t-border);
box-shadow: 0 2px 8px rgba(0,0,0,0.3); box-shadow: 0 2px 8px rgba(0,0,0,0.3);
z-index: 2; z-index: 2;
transition: width 0.25s cubic-bezier(0.34, 1.56, 0.64, 1), height 0.25s cubic-bezier(0.34, 1.56, 0.64, 1), margin 0.25s cubic-bezier(0.34, 1.56, 0.64, 1), border-color 0.2s ease, box-shadow 0.25s ease;
}
.card-thumb {
transform-origin: center center;
}
.card-thumb:hover {
width: 80px;
height: 80px;
margin-top: -18px;
margin-right: -18px;
border-color: var(--t-muted);
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
z-index: 10;
background-size: cover;
background-repeat: no-repeat;
} }
.group:hover .card-thumb { border-color: var(--t-muted); }
.card-glow:hover { box-shadow: 0 0 16px -3px var(--glow-color, rgba(56, 189, 248, 0.15)); } .card-glow:hover { box-shadow: 0 0 16px -3px var(--glow-color, rgba(56, 189, 248, 0.15)); }
.card-dragging { opacity: 0.15; transform: scale(0.95); } .card-dragging { opacity: 0.15; transform: scale(0.95); }
@@ -444,6 +458,12 @@ body.detail-image-fs-open { overflow: hidden; }
} }
.prose li:has(input[type="checkbox"]) { list-style: none; margin-left: -1.5em; } .prose li:has(input[type="checkbox"]) { list-style: none; margin-left: -1.5em; }
.prose audio {
width: 100%;
max-width: 400px;
margin: 0.8em 0;
border-radius: 6px;
}
.prose img { .prose img {
max-width: 100%; max-width: 100%;
border-radius: 4px; border-radius: 4px;
+12 -5
View File
@@ -204,7 +204,7 @@ if (str_starts_with($route, '/api/')) {
$dir = __DIR__ . '/uploads'; $dir = __DIR__ . '/uploads';
$files = []; $files = [];
if (is_dir($dir)) { if (is_dir($dir)) {
foreach (glob($dir . '/*.{png,jpg,gif,webp}', GLOB_BRACE) as $f) { foreach (glob($dir . '/*.{png,jpg,gif,webp,mp3,ogg,wav,m4a}', GLOB_BRACE) as $f) {
$name = basename($f); $name = basename($f);
$files[] = ['name' => $name, 'url' => 'uploads/' . $name, 'size' => filesize($f), 'modified' => filemtime($f)]; $files[] = ['name' => $name, 'url' => 'uploads/' . $name, 'size' => filesize($f), 'modified' => filemtime($f)];
} }
@@ -245,14 +245,20 @@ if (str_starts_with($route, '/api/')) {
exit; exit;
} }
// Check real MIME via finfo, not client-supplied type // Check MIME — finfo first, fallback to extension for audio
$allowed = ['image/png' => 'png', 'image/jpeg' => 'jpg', 'image/gif' => 'gif', 'image/webp' => 'webp']; $mimeMap = ['image/png' => 'png', 'image/jpeg' => 'jpg', 'image/gif' => 'gif', 'image/webp' => 'webp', 'audio/mpeg' => 'mp3', 'audio/ogg' => 'ogg', 'audio/wav' => 'wav', 'audio/x-wav' => 'wav', 'audio/mp4' => 'm4a', 'application/ogg' => 'ogg'];
$extMap = ['mp3' => 'mp3', 'ogg' => 'ogg', 'wav' => 'wav', 'm4a' => 'm4a'];
$finfo = new finfo(FILEINFO_MIME_TYPE); $finfo = new finfo(FILEINFO_MIME_TYPE);
$realMime = $finfo->file($file['tmp_name']); $realMime = $finfo->file($file['tmp_name']);
$ext = $allowed[$realMime] ?? null; $ext = $mimeMap[$realMime] ?? null;
// Fallback: trust extension for audio when finfo returns octet-stream
if (!$ext && ($realMime === 'application/octet-stream' || str_starts_with($realMime, 'audio/'))) {
$origExt = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
$ext = $extMap[$origExt] ?? null;
}
if (!$ext) { if (!$ext) {
http_response_code(400); http_response_code(400);
echo json_encode(['error' => 'invalid image type']); echo json_encode(['error' => 'invalid file type', 'detected' => $realMime]);
exit; exit;
} }
@@ -470,6 +476,7 @@ if (str_starts_with($route, '/api/')) {
</div> </div>
</div> </div>
</div> </div>
<div id="footer-count" class="font-pixel text-[10px] text-t-dim whitespace-nowrap absolute left-1/2 -translate-x-1/2"></div>
<div class="font-pixel text-[10px] text-t-dim whitespace-nowrap">© <?= date('Y') ?> hyprcrd</div> <div class="font-pixel text-[10px] text-t-dim whitespace-nowrap">© <?= date('Y') ?> hyprcrd</div>
</footer> </footer>
+16 -1
View File
@@ -51,7 +51,16 @@ export function renderBoard() {
cards = parent ? [parent, ...getChildren(parent.id)] : []; cards = parent ? [parent, ...getChildren(parent.id)] : [];
} else { } else {
cards = state.cards.filter(c => !c.parent_id); cards = state.cards.filter(c => !c.parent_id);
if (state.activeBucket === 'none') cards = cards.filter(c => !c.bucket_id); if (state.activeBucket === 'current') {
const recent = getRecent();
const now = Date.now();
const freshMs = 48 * 60 * 60 * 1000; // 48h
cards = cards.filter(c => {
if (recent.includes(String(c.id))) return true;
const updated = new Date(c.updated_at || c.created_at || 0).getTime();
return (now - updated) < freshMs;
});
} else if (state.activeBucket === 'none') cards = cards.filter(c => !c.bucket_id);
else if (state.activeBucket !== 'all') cards = cards.filter(c => c.bucket_id == state.activeBucket); else if (state.activeBucket !== 'all') cards = cards.filter(c => c.bucket_id == state.activeBucket);
} }
cards = sortedCards(cards); cards = sortedCards(cards);
@@ -64,6 +73,8 @@ export function renderBoard() {
board.innerHTML = `<div class="col-span-full text-center py-20"> board.innerHTML = `<div class="col-span-full text-center py-20">
<i class="ph ph-cards text-4xl text-t-dim mb-4 block"></i> <i class="ph ph-cards text-4xl text-t-dim mb-4 block"></i>
<p class="font-pixel text-sm text-t-dim cursor-blink">no cards found</p></div>`; <p class="font-pixel text-sm text-t-dim cursor-blink">no cards found</p></div>`;
const fc = $('footer-count');
if (fc) fc.textContent = `0 cards · ${state.cards.length} total`;
return; return;
} }
@@ -105,6 +116,10 @@ export function renderBoard() {
}).join(''); }).join('');
bindCardEvents(); bindCardEvents();
// Footer count
const fc = $('footer-count');
if (fc) fc.textContent = `${cards.length} card${cards.length !== 1 ? 's' : ''} · ${state.cards.length} total`;
} }
// ── Group bar ── // ── Group bar ──
+5 -4
View File
@@ -148,6 +148,7 @@ function renderBar() {
bar.innerHTML = ` bar.innerHTML = `
<button class="${tab(state.activeBucket === 'all')}" data-bucket="all"><i class="ph ph-squares-four text-sm"></i><span class="font-pixel text-[10px]">all</span></button> <button class="${tab(state.activeBucket === 'all')}" data-bucket="all"><i class="ph ph-squares-four text-sm"></i><span class="font-pixel text-[10px]">all</span></button>
<button class="${tab(state.activeBucket === 'current')}" data-bucket="current"><i class="ph ph-lightning text-sm"></i><span class="font-pixel text-[10px]">current</span></button>
<button class="${tab(state.activeBucket === 'none')}" data-bucket="none"><i class="ph ph-tray text-sm"></i><span class="font-pixel text-[10px]">unsorted</span></button> <button class="${tab(state.activeBucket === 'none')}" data-bucket="none"><i class="ph ph-tray text-sm"></i><span class="font-pixel text-[10px]">unsorted</span></button>
${state.buckets.map(b => { ${state.buckets.map(b => {
const c = b.color || '#64748b'; const c = b.color || '#64748b';
@@ -164,7 +165,7 @@ function renderBar() {
const bid = t.dataset.bucket; const bid = t.dataset.bucket;
const isUser = bid !== 'all' && bid !== 'none'; const isUser = bid !== 'all' && bid !== 'none';
t.addEventListener('click', () => { state.activeBucket = bid; renderBar(); window._renderBoard(); }); t.addEventListener('click', () => { state.activeBucket = bid; state.activeGroup = null; renderBar(); window._renderBoard(); });
if (isUser) { if (isUser) {
t.addEventListener('contextmenu', e => { t.addEventListener('contextmenu', e => {
@@ -225,9 +226,9 @@ function renderBar() {
// Card drop on bucket // Card drop on bucket
const cardId = e.dataTransfer.getData('text/plain'); const cardId = e.dataTransfer.getData('text/plain');
if (!cardId) return; if (!cardId) return;
const bucketId = bid === 'none' ? null : (bid === 'all' ? undefined : bid); const bucketId = bid === 'none' ? null : ((bid === 'all' || bid === 'current') ? undefined : bid);
if (bucketId === undefined) return; if (bucketId === undefined) return;
await api(`api/cards/${cardId}`, { method: 'PATCH', body: { bucket_id: bucketId } }); await api(`api/cards/${cardId}`, { method: 'PATCH', body: { bucket_id: bucketId, parent_id: null } });
await window._reloadAll(); await window._reloadAll();
}); });
}); });
@@ -258,7 +259,7 @@ function renderBar() {
const card = state.cards.find(c => String(c.id) === String(cardId)); const card = state.cards.find(c => String(c.id) === String(cardId));
const created = await api('api/buckets', { method: 'POST', body: { name: autoBucketNameFromCard(card) } }); const created = await api('api/buckets', { method: 'POST', body: { name: autoBucketNameFromCard(card) } });
if (!created?.id) return; if (!created?.id) return;
await api(`api/cards/${cardId}`, { method: 'PATCH', body: { bucket_id: created.id } }); await api(`api/cards/${cardId}`, { method: 'PATCH', body: { bucket_id: created.id, parent_id: null } });
await loadBuckets(); await loadBuckets();
await window._reloadAll(); await window._reloadAll();
}); });
+48 -15
View File
@@ -207,39 +207,72 @@ body.addEventListener('keydown', e => {
body.addEventListener('blur', () => setTimeout(hideLinkAc, 150)); body.addEventListener('blur', () => setTimeout(hideLinkAc, 150));
// ── Clipboard image paste ── // ── File upload (paste + drag-drop) ──
body.addEventListener('paste', async e => { async function uploadFile(file) {
const items = e.clipboardData?.items; const isAudio = file.type.startsWith('audio/');
if (!items) return; const isImage = file.type.startsWith('image/');
for (const item of items) { if (!isAudio && !isImage) return;
if (!item.type.startsWith('image/')) continue;
e.preventDefault(); const ext = file.name?.split('.').pop() || file.type.split('/')[1] || 'bin';
const file = item.getAsFile(); const placeholder = isAudio ? '[uploading audio...]' : '![uploading...]()';
if (!file) return; const pos = body.selectionStart;
const placeholder = '![uploading...]()'; body.setRangeText(placeholder, pos, body.selectionEnd, 'end');
body.setRangeText(placeholder, body.selectionStart, body.selectionEnd, 'end');
updatePreview(); updatePreview();
const form = new FormData(); const form = new FormData();
form.append('file', file, 'paste.' + (file.type.split('/')[1] || 'png')); form.append('file', file, (file.name || 'file.' + ext));
try { try {
const res = await fetch('api/upload', { method: 'POST', body: form, headers: { 'X-CSRF-Token': getCsrf() } }); const res = await fetch('api/upload', { method: 'POST', body: form, headers: { 'X-CSRF-Token': getCsrf() } });
const text = await res.text(); const text = await res.text();
console.log('Upload response:', res.status, text.substring(0, 300));
const data = JSON.parse(text); const data = JSON.parse(text);
if (data.error) throw new Error(data.error); if (data.error) throw new Error(data.error);
if (data.url) { if (data.url) {
const md = `![${data.name}](${data.url})`; const md = isAudio
? `<audio controls preload="metadata" src="${data.url}"></audio>`
: `![${data.name}](${data.url})`;
const idx = body.value.indexOf(placeholder); const idx = body.value.indexOf(placeholder);
if (idx !== -1) { body.value = body.value.substring(0, idx) + md + body.value.substring(idx + placeholder.length); body.selectionStart = body.selectionEnd = idx + md.length; } if (idx !== -1) {
body.value = body.value.substring(0, idx) + md + body.value.substring(idx + placeholder.length);
body.selectionStart = body.selectionEnd = idx + md.length;
}
} }
} catch (err) { } catch (err) {
console.error('Upload failed:', err); console.error('Upload failed:', err);
const idx = body.value.indexOf(placeholder); const idx = body.value.indexOf(placeholder);
if (idx !== -1) { body.value = body.value.substring(0, idx) + body.value.substring(idx + placeholder.length); body.selectionStart = body.selectionEnd = idx; } if (idx !== -1) {
body.value = body.value.substring(0, idx) + body.value.substring(idx + placeholder.length);
body.selectionStart = body.selectionEnd = idx;
}
} }
updatePreview(); updatePreview();
}
body.addEventListener('paste', e => {
const items = e.clipboardData?.items;
if (!items) return;
for (const item of items) {
if (item.type.startsWith('image/') || item.type.startsWith('audio/')) {
e.preventDefault();
const file = item.getAsFile();
if (file) uploadFile(file);
return; return;
} }
}
});
body.addEventListener('dragover', e => { e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; });
body.addEventListener('drop', e => {
const files = e.dataTransfer?.files;
if (!files?.length) return;
for (const file of files) {
if (file.type.startsWith('image/') || file.type.startsWith('audio/')) {
e.preventDefault();
uploadFile(file);
return;
}
}
}); });
// ── Image resize ── // ── Image resize ──
+17 -6
View File
@@ -1,6 +1,21 @@
// ── Image gallery ── // ── Image gallery ──
import { $, esc, api, getCsrf } from './util.js'; import { $, esc, api, getCsrf } from './util.js';
const AUDIO_EXT = /\.(mp3|ogg|wav|m4a)$/i;
function isAudio(url) { return AUDIO_EXT.test(url); }
function mediaTile(url, extraClass = '') {
if (isAudio(url)) {
return `<div class="aspect-square bg-t-bg border ${extraClass} rounded overflow-hidden flex flex-col items-center justify-center gap-2 p-2">
<i class="ph ph-music-note text-2xl text-t-dim"></i>
<audio controls preload="metadata" src="${url}" class="w-full" style="height:28px"></audio>
</div>`;
}
return `<div class="aspect-square bg-t-bg border ${extraClass} rounded overflow-hidden">
<img src="${url}" class="w-full h-full object-cover${extraClass.includes('danger') ? ' opacity-60' : ''}" loading="lazy">
</div>`;
}
import { state } from './state.js'; import { state } from './state.js';
import { showDialog } from './dialog.js'; import { showDialog } from './dialog.js';
@@ -59,9 +74,7 @@ export async function openGallery() {
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 += `<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 => ` html += orphaned.map(f => `
<div class="gallery-item group relative" data-orphan="${f.name}"> <div class="gallery-item group relative" data-orphan="${f.name}">
<div class="aspect-square bg-t-bg border border-t-danger/30 rounded overflow-hidden"> ${mediaTile(f.url, 'border-t-danger/30')}
<img src="${f.url}" class="w-full h-full object-cover opacity-60" loading="lazy">
</div>
<div class="absolute top-1 right-1 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity"> <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"> <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> <i class="ph ph-plus"></i>
@@ -78,9 +91,7 @@ export async function openGallery() {
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}" data-gallery-origin="${encodeURIComponent(img.url)}"> <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"> ${mediaTile(img.url, 'border-t-border')}
<img src="${img.url}" class="w-full h-full object-cover" loading="lazy">
</div>
<div class="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity rounded flex flex-col justify-end p-2"> <div class="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity rounded flex flex-col justify-end p-2">
<div class="flex items-center gap-1.5"> <div class="flex items-center gap-1.5">
<span class="w-2 h-2 rounded-full shrink-0" style="background:${img.cardColor}"></span> <span class="w-2 h-2 rounded-full shrink-0" style="background:${img.cardColor}"></span>
+2 -2
View File
@@ -6,8 +6,8 @@ import { state } from './state.js';
marked.use({ breaks: true, gfm: true }); marked.use({ breaks: true, gfm: true });
const purifyConfig = { 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'], 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'], ALLOWED_ATTR: ['href','src','alt','width','height','type','checked','disabled','class','style','data-card-id','title','controls','preload'],
ALLOW_DATA_ATTR: true, ALLOW_DATA_ATTR: true,
}; };