bak
This commit is contained in:
1 parent
69a3a27348
commit
03f57cbaee
8 files changed
+124
-34
No files matched your search
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
|
||||
@@ -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 ──
|
||||
|
||||
@@ -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
@@ -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 = ``;
|
||||
const md = isAudio
|
||||
? `<audio controls preload="metadata" src="${data.url}"></audio>`
|
||||
: ``;
|
||||
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 ──
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
Reference in new issue
Block a user