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/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
RUN sed -ri -e 's/AllowOverride None/AllowOverride All/g' \
/etc/apache2/apache2.conf
+21 -1
View File
@@ -111,8 +111,22 @@ body::after {
border: 2px solid var(--t-border);
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
z-index: 2;
transition: width 0.25s cubic-bezier(0.34, 1.56, 0.64, 1), height 0.25s cubic-bezier(0.34, 1.56, 0.64, 1), margin 0.25s cubic-bezier(0.34, 1.56, 0.64, 1), border-color 0.2s ease, box-shadow 0.25s ease;
}
.card-thumb {
transform-origin: center center;
}
.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-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 audio {
width: 100%;
max-width: 400px;
margin: 0.8em 0;
border-radius: 6px;
}
.prose img {
max-width: 100%;
border-radius: 4px;
+12 -5
View File
@@ -204,7 +204,7 @@ if (str_starts_with($route, '/api/')) {
$dir = __DIR__ . '/uploads';
$files = [];
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);
$files[] = ['name' => $name, 'url' => 'uploads/' . $name, 'size' => filesize($f), 'modified' => filemtime($f)];
}
@@ -245,14 +245,20 @@ if (str_starts_with($route, '/api/')) {
exit;
}
// Check real MIME via finfo, not client-supplied type
$allowed = ['image/png' => 'png', 'image/jpeg' => 'jpg', 'image/gif' => 'gif', 'image/webp' => 'webp'];
// Check MIME — finfo first, fallback to extension for audio
$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);
$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) {
http_response_code(400);
echo json_encode(['error' => 'invalid image type']);
echo json_encode(['error' => 'invalid file type', 'detected' => $realMime]);
exit;
}
@@ -470,6 +476,7 @@ if (str_starts_with($route, '/api/')) {
</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>
</footer>
+16 -1
View File
@@ -51,7 +51,16 @@ export function renderBoard() {
cards = parent ? [parent, ...getChildren(parent.id)] : [];
} else {
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);
}
cards = sortedCards(cards);
@@ -64,6 +73,8 @@ export function renderBoard() {
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>
<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;
}
@@ -105,6 +116,10 @@ export function renderBoard() {
}).join('');
bindCardEvents();
// Footer count
const fc = $('footer-count');
if (fc) fc.textContent = `${cards.length} card${cards.length !== 1 ? 's' : ''} · ${state.cards.length} total`;
}
// ── Group bar ──
+5 -4
View File
@@ -148,6 +148,7 @@ function renderBar() {
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 === '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>
${state.buckets.map(b => {
const c = b.color || '#64748b';
@@ -164,7 +165,7 @@ function renderBar() {
const bid = t.dataset.bucket;
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) {
t.addEventListener('contextmenu', e => {
@@ -225,9 +226,9 @@ function renderBar() {
// Card drop on bucket
const cardId = e.dataTransfer.getData('text/plain');
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;
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();
});
});
@@ -258,7 +259,7 @@ function renderBar() {
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 api(`api/cards/${cardId}`, { method: 'PATCH', body: { bucket_id: created.id, parent_id: null } });
await loadBuckets();
await window._reloadAll();
});
+48 -15
View File
@@ -207,39 +207,72 @@ body.addEventListener('keydown', e => {
body.addEventListener('blur', () => setTimeout(hideLinkAc, 150));
// ── Clipboard image paste ──
// ── File upload (paste + drag-drop) ──
body.addEventListener('paste', async e => {
const items = e.clipboardData?.items;
if (!items) return;
for (const item of items) {
if (!item.type.startsWith('image/')) continue;
e.preventDefault();
const file = item.getAsFile();
if (!file) return;
const placeholder = '![uploading...]()';
body.setRangeText(placeholder, body.selectionStart, body.selectionEnd, 'end');
async function uploadFile(file) {
const isAudio = file.type.startsWith('audio/');
const isImage = file.type.startsWith('image/');
if (!isAudio && !isImage) return;
const ext = file.name?.split('.').pop() || file.type.split('/')[1] || 'bin';
const placeholder = isAudio ? '[uploading audio...]' : '![uploading...]()';
const pos = body.selectionStart;
body.setRangeText(placeholder, pos, body.selectionEnd, 'end');
updatePreview();
const form = new FormData();
form.append('file', file, 'paste.' + (file.type.split('/')[1] || 'png'));
form.append('file', file, (file.name || 'file.' + ext));
try {
const res = await fetch('api/upload', { method: 'POST', body: form, headers: { 'X-CSRF-Token': getCsrf() } });
const text = await res.text();
console.log('Upload response:', res.status, text.substring(0, 300));
const data = JSON.parse(text);
if (data.error) throw new Error(data.error);
if (data.url) {
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);
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) {
console.error('Upload failed:', err);
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();
}
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;
}
}
});
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 ──
+17 -6
View File
@@ -1,6 +1,21 @@
// ── Image gallery ──
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 { 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 += orphaned.map(f => `
<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">
<img src="${f.url}" class="w-full h-full object-cover opacity-60" loading="lazy">
</div>
${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>
@@ -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>`;
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="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">
</div>
${mediaTile(img.url, 'border-t-border')}
<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">
<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 });
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_ATTR: ['href','src','alt','width','height','type','checked','disabled','class','style','data-card-id','title'],
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'],
ALLOW_DATA_ATTR: true,
};