add user banners :)
This commit is contained in:
@@ -118,6 +118,7 @@
|
|||||||
"enable_swiping": true,
|
"enable_swiping": true,
|
||||||
"enable_profile_description": true,
|
"enable_profile_description": true,
|
||||||
"user_alternative_infobox": false,
|
"user_alternative_infobox": false,
|
||||||
|
"user_banner_enabled": true,
|
||||||
"user_alternative_steuerung": false,
|
"user_alternative_steuerung": false,
|
||||||
"enable_swf": false,
|
"enable_swf": false,
|
||||||
"swf_thumb": "/s/img/swf.png",
|
"swf_thumb": "/s/img/swf.png",
|
||||||
|
|||||||
7
migrations/add_user_banner.sql
Normal file
7
migrations/add_user_banner.sql
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
-- Add banner_file column to user_options for custom profile banners
|
||||||
|
-- Banners are displayed in the alternative infobox (user-infobox-block)
|
||||||
|
|
||||||
|
ALTER TABLE user_options
|
||||||
|
ADD COLUMN IF NOT EXISTS banner_file character varying(255) DEFAULT NULL;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN public.user_options.banner_file IS 'Custom uploaded banner image filename, stored in public/a/ (same dir as avatars)';
|
||||||
@@ -13725,11 +13725,34 @@ textarea#profile_description {
|
|||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Banner inside alternative infobox ──────────────────────────────── */
|
||||||
|
|
||||||
|
.user-infobox-block {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-infobox-block::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background-image: var(--author-banner, none);
|
||||||
|
background-size: var(--author-banner-size, cover);
|
||||||
|
background-position: var(--author-banner-position, center);
|
||||||
|
background-repeat: var(--author-banner-repeat, no-repeat);
|
||||||
|
opacity: var(--author-banner-opacity, 0.4);
|
||||||
|
z-index: -1;
|
||||||
|
border-radius: inherit;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── End banner styles ────────────────────────────────────────────────── */
|
||||||
|
|
||||||
.user-infobox-avatar {
|
.user-infobox-avatar {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.user-infobox-avatar img {
|
.user-infobox-avatar img {
|
||||||
width: 64px;
|
width: 64px;
|
||||||
height: 64px;
|
height: 64px;
|
||||||
@@ -13758,7 +13781,7 @@ textarea#profile_description {
|
|||||||
align-self: baseline;
|
align-self: baseline;
|
||||||
margin: 1px 10px 0px 0px;
|
margin: 1px 10px 0px 0px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
top: 0;
|
top: 2px;
|
||||||
right: 0;
|
right: 0;
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -344,6 +344,414 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ==== Banner File Upload Logic (with crop modal) ====
|
||||||
|
const bannerFileInput = document.getElementById('banner-file-input');
|
||||||
|
const bannerChooseBtn = document.getElementById('banner-choose-btn');
|
||||||
|
const bannerFilenameSpan = document.getElementById('banner-filename');
|
||||||
|
const bannerUploadBtn = document.getElementById('banner-upload-btn');
|
||||||
|
const bannerProgressWrapper = document.getElementById('banner-progress-wrapper');
|
||||||
|
const bannerProgressFill = document.getElementById('banner-progress-fill');
|
||||||
|
const bannerProgressText = document.getElementById('banner-progress-text');
|
||||||
|
const bannerStatusDiv = document.getElementById('banner-upload-status');
|
||||||
|
|
||||||
|
const showBannerStatus = (msg, type) => {
|
||||||
|
if (bannerStatusDiv) {
|
||||||
|
bannerStatusDiv.textContent = msg;
|
||||||
|
bannerStatusDiv.className = 'avatar-status ' + type;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Crop Modal ──────────────────────────────────────────────────────────
|
||||||
|
const showBannerCropModal = (file) => new Promise((resolve) => {
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.style.cssText = `
|
||||||
|
position:fixed;inset:0;z-index:99999;
|
||||||
|
background:rgba(0,0,0,0.92);
|
||||||
|
display:flex;flex-direction:column;align-items:center;justify-content:center;
|
||||||
|
gap:16px;padding:20px;box-sizing:border-box;
|
||||||
|
font-family: inherit;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
`;
|
||||||
|
|
||||||
|
|
||||||
|
const title = document.createElement('div');
|
||||||
|
title.textContent = 'Position Banner';
|
||||||
|
title.style.cssText = 'color:#fff;font-size:1.2em;font-weight:600;letter-spacing:0.5px;';
|
||||||
|
|
||||||
|
const hint = document.createElement('div');
|
||||||
|
hint.textContent = 'Drag to position. Use the slider below to zoom.';
|
||||||
|
hint.style.cssText = 'color:#aaa;font-size:0.85em;text-align:center;margin-bottom:4px;';
|
||||||
|
|
||||||
|
// Responsive cropper container
|
||||||
|
const cropperContainer = document.createElement('div');
|
||||||
|
cropperContainer.style.cssText = `
|
||||||
|
width: 100%;
|
||||||
|
max-width: 600px;
|
||||||
|
aspect-ratio: 3 / 1;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--nav-border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: 0 4px 20px rgba(0,0,0,0.8);
|
||||||
|
background: #111;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = 1200;
|
||||||
|
canvas.height = 400;
|
||||||
|
canvas.style.cssText = 'width:100%;height:100%;display:block;cursor:move;touch-action:none;pointer-events:auto !important;';
|
||||||
|
cropperContainer.appendChild(canvas);
|
||||||
|
|
||||||
|
// Zoom slider container
|
||||||
|
const zoomContainer = document.createElement('div');
|
||||||
|
zoomContainer.style.cssText = 'display:flex;align-items:center;gap:12px;width:100%;max-width:320px;color:#fff;margin-top:4px;';
|
||||||
|
|
||||||
|
const zoomOutLabel = document.createElement('span');
|
||||||
|
zoomOutLabel.textContent = 'A';
|
||||||
|
zoomOutLabel.style.cssText = 'font-size:0.8em;opacity:0.6;user-select:none;font-weight:bold;';
|
||||||
|
|
||||||
|
const zoomInput = document.createElement('input');
|
||||||
|
zoomInput.type = 'range';
|
||||||
|
zoomInput.style.cssText = 'flex:1;height:4px;border-radius:2px;outline:none;cursor:pointer;';
|
||||||
|
|
||||||
|
const zoomInLabel = document.createElement('span');
|
||||||
|
zoomInLabel.textContent = 'A';
|
||||||
|
zoomInLabel.style.cssText = 'font-size:1.2em;opacity:0.9;user-select:none;font-weight:bold;';
|
||||||
|
|
||||||
|
zoomContainer.append(zoomOutLabel, zoomInput, zoomInLabel);
|
||||||
|
|
||||||
|
const btnRow = document.createElement('div');
|
||||||
|
btnRow.style.cssText = 'display:flex;gap:12px;margin-top:8px;';
|
||||||
|
|
||||||
|
const confirmBtn = document.createElement('button');
|
||||||
|
confirmBtn.textContent = 'Save & Upload';
|
||||||
|
confirmBtn.className = 'button';
|
||||||
|
|
||||||
|
const cancelBtn = document.createElement('button');
|
||||||
|
cancelBtn.textContent = 'Cancel';
|
||||||
|
cancelBtn.className = 'button button-danger';
|
||||||
|
|
||||||
|
btnRow.append(confirmBtn, cancelBtn);
|
||||||
|
overlay.append(title, hint, cropperContainer, zoomContainer, btnRow);
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
overlay.tabIndex = 0;
|
||||||
|
overlay.focus();
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
const img = new Image();
|
||||||
|
const objectUrl = URL.createObjectURL(file);
|
||||||
|
|
||||||
|
// State variables
|
||||||
|
let zoom = 1.0;
|
||||||
|
let minZoom = 1.0;
|
||||||
|
let maxZoom = 4.0;
|
||||||
|
let panX = 0;
|
||||||
|
let panY = 0;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const draw = () => {
|
||||||
|
ctx.clearRect(0, 0, 1200, 400);
|
||||||
|
ctx.drawImage(img, panX, panY, img.naturalWidth * zoom, img.naturalHeight * zoom);
|
||||||
|
|
||||||
|
// Update hint with live coords/zoom
|
||||||
|
hint.textContent = `Drag to position | Zoom: ${Math.round((zoom/minZoom)*100)}% | Pan: ${Math.round(panX)}, ${Math.round(panY)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getBounds = (z) => {
|
||||||
|
return {
|
||||||
|
minX: 1200 - img.naturalWidth * z,
|
||||||
|
minY: 400 - img.naturalHeight * z
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
img.onload = () => {
|
||||||
|
if (!img.naturalWidth || !img.naturalHeight) {
|
||||||
|
hint.textContent = 'Error: Image has 0 dimensions';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine minimum zoom so the image fully covers 1200x400 canvas without black edges
|
||||||
|
minZoom = Math.max(1200 / img.naturalWidth, 400 / img.naturalHeight);
|
||||||
|
maxZoom = minZoom * 4;
|
||||||
|
zoom = minZoom;
|
||||||
|
|
||||||
|
// Center image initially
|
||||||
|
panX = (1200 - img.naturalWidth * zoom) / 2;
|
||||||
|
panY = (400 - img.naturalHeight * zoom) / 2;
|
||||||
|
|
||||||
|
// Configure zoom slider
|
||||||
|
zoomInput.min = 0;
|
||||||
|
zoomInput.max = 100;
|
||||||
|
zoomInput.value = 0;
|
||||||
|
|
||||||
|
draw();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleZoomChange = (pct) => {
|
||||||
|
const oldZoom = zoom;
|
||||||
|
const t = pct / 100;
|
||||||
|
const newZoom = minZoom + (maxZoom - minZoom) * t;
|
||||||
|
|
||||||
|
const imgCenterX = (600 - panX) / oldZoom;
|
||||||
|
const imgCenterY = (200 - panY) / oldZoom;
|
||||||
|
|
||||||
|
zoom = newZoom;
|
||||||
|
const bounds = getBounds(zoom);
|
||||||
|
panX = Math.max(bounds.minX, Math.min(0, 600 - imgCenterX * zoom));
|
||||||
|
panY = Math.max(bounds.minY, Math.min(0, 200 - imgCenterY * zoom));
|
||||||
|
|
||||||
|
draw();
|
||||||
|
};
|
||||||
|
|
||||||
|
zoomInput.addEventListener('input', (e) => {
|
||||||
|
handleZoomChange(parseFloat(e.target.value));
|
||||||
|
});
|
||||||
|
|
||||||
|
let dragging = false;
|
||||||
|
let dragOffX = 0;
|
||||||
|
let dragOffY = 0;
|
||||||
|
|
||||||
|
const getPos = (e) => {
|
||||||
|
const r = canvas.getBoundingClientRect();
|
||||||
|
const sx = canvas.width / r.width, sy = canvas.height / r.height;
|
||||||
|
const src = e.touches ? e.touches[0] : e;
|
||||||
|
return { x: (src.clientX - r.left) * sx, y: (src.clientY - r.top) * sy };
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDown = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
dragging = true;
|
||||||
|
const { x, y } = getPos(e);
|
||||||
|
dragOffX = x - panX;
|
||||||
|
dragOffY = y - panY;
|
||||||
|
canvas.style.cursor = 'grabbing';
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMove = (e) => {
|
||||||
|
if (!dragging) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const { x, y } = getPos(e);
|
||||||
|
|
||||||
|
const dx = x - dragOffX;
|
||||||
|
const dy = y - dragOffY;
|
||||||
|
|
||||||
|
const bounds = getBounds(zoom);
|
||||||
|
panX = Math.max(bounds.minX, Math.min(0, dx));
|
||||||
|
panY = Math.max(bounds.minY, Math.min(0, dy));
|
||||||
|
|
||||||
|
draw();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onUp = () => {
|
||||||
|
if (dragging) {
|
||||||
|
dragging = false;
|
||||||
|
canvas.style.cursor = 'move';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Strict fallback to original robust event binding
|
||||||
|
canvas.addEventListener('mousedown', onDown);
|
||||||
|
window.addEventListener('mousemove', onMove, { passive: false });
|
||||||
|
window.addEventListener('mouseup', onUp);
|
||||||
|
|
||||||
|
canvas.addEventListener('touchstart', onDown, { passive: false });
|
||||||
|
window.addEventListener('touchmove', onMove, { passive: false });
|
||||||
|
window.addEventListener('touchend', onUp);
|
||||||
|
|
||||||
|
// Prevent native HTML5 drag and drop behavior from hijacking our drag
|
||||||
|
canvas.addEventListener('dragstart', (e) => e.preventDefault());
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
URL.revokeObjectURL(objectUrl);
|
||||||
|
window.removeEventListener('mousemove', onMove);
|
||||||
|
window.removeEventListener('mouseup', onUp);
|
||||||
|
window.removeEventListener('touchmove', onMove);
|
||||||
|
window.removeEventListener('touchend', onUp);
|
||||||
|
overlay.remove();
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
confirmBtn.addEventListener('click', () => {
|
||||||
|
canvas.toBlob(blob => {
|
||||||
|
cleanup();
|
||||||
|
resolve(blob);
|
||||||
|
}, 'image/webp', 0.85);
|
||||||
|
});
|
||||||
|
|
||||||
|
cancelBtn.addEventListener('click', () => {
|
||||||
|
cleanup();
|
||||||
|
resolve(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
overlay.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
cleanup();
|
||||||
|
resolve(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Trigger image load AFTER attaching onload listener
|
||||||
|
img.src = objectUrl;
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// ── Upload blob to server ───────────────────────────────────────────────
|
||||||
|
const uploadBannerBlob = (blob) => {
|
||||||
|
if (!blob) return;
|
||||||
|
|
||||||
|
if (bannerUploadBtn) bannerUploadBtn.disabled = true;
|
||||||
|
if (bannerChooseBtn) bannerChooseBtn.disabled = true;
|
||||||
|
if (bannerProgressWrapper) { bannerProgressWrapper.style.display = 'flex'; }
|
||||||
|
if (bannerProgressFill) bannerProgressFill.style.width = '0%';
|
||||||
|
if (bannerProgressText) bannerProgressText.textContent = '0%';
|
||||||
|
showBannerStatus('Uploading...', '');
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', blob, 'banner.webp');
|
||||||
|
|
||||||
|
const xhr = new XMLHttpRequest();
|
||||||
|
|
||||||
|
xhr.upload.addEventListener('progress', (e) => {
|
||||||
|
if (e.lengthComputable) {
|
||||||
|
const pct = Math.round((e.loaded / e.total) * 100);
|
||||||
|
if (bannerProgressFill) bannerProgressFill.style.width = pct + '%';
|
||||||
|
if (bannerProgressText) bannerProgressText.textContent = pct + '%';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
xhr.addEventListener('load', () => {
|
||||||
|
try {
|
||||||
|
const res = JSON.parse(xhr.responseText);
|
||||||
|
if (xhr.status === 200 && res.success) {
|
||||||
|
showBannerStatus(res.msg || 'Banner uploaded!', 'success');
|
||||||
|
|
||||||
|
const bannerPreview = document.getElementById('banner-preview');
|
||||||
|
if (bannerPreview) {
|
||||||
|
if (bannerPreview.tagName === 'IMG') {
|
||||||
|
bannerPreview.src = '/a/' + res.banner_file + '?t=' + Date.now();
|
||||||
|
} else {
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.id = 'banner-preview';
|
||||||
|
img.className = 'banner-preview-img';
|
||||||
|
img.style.cssText = 'width:100%;max-width:360px;height:auto;border-radius:4px;border:1px solid var(--nav-border-color);object-fit:cover;';
|
||||||
|
img.src = '/a/' + res.banner_file + '?t=' + Date.now();
|
||||||
|
bannerPreview.replaceWith(img);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingRemoveBtn = document.getElementById('banner-remove-btn');
|
||||||
|
if (!existingRemoveBtn && bannerUploadBtn) {
|
||||||
|
const actionsDiv = bannerUploadBtn.closest('.avatar-upload-actions');
|
||||||
|
if (actionsDiv) {
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.type = 'button'; btn.id = 'banner-remove-btn';
|
||||||
|
btn.className = 'button button-danger';
|
||||||
|
btn.textContent = 'Remove Banner';
|
||||||
|
actionsDiv.appendChild(btn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bannerFileInput) bannerFileInput.value = '';
|
||||||
|
if (bannerFilenameSpan) bannerFilenameSpan.textContent = 'No file selected';
|
||||||
|
} else {
|
||||||
|
showBannerStatus(res.msg || 'Upload failed', 'error');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showBannerStatus('Upload failed: Invalid response', 'error');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bannerProgressWrapper) bannerProgressWrapper.style.display = 'none';
|
||||||
|
if (bannerChooseBtn) bannerChooseBtn.disabled = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
xhr.addEventListener('error', () => {
|
||||||
|
showBannerStatus('Upload failed: Network error', 'error');
|
||||||
|
if (bannerProgressWrapper) bannerProgressWrapper.style.display = 'none';
|
||||||
|
if (bannerChooseBtn) bannerChooseBtn.disabled = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
xhr.open('POST', '/api/v2/settings/uploadBanner');
|
||||||
|
xhr.setRequestHeader('X-CSRF-Token', window.f0ckSession?.csrf_token);
|
||||||
|
xhr.send(formData);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Wire up ─────────────────────────────────────────────────────────────
|
||||||
|
if (bannerChooseBtn && bannerFileInput) {
|
||||||
|
bannerChooseBtn.addEventListener('click', () => bannerFileInput.click());
|
||||||
|
|
||||||
|
bannerFileInput.addEventListener('change', async () => {
|
||||||
|
const file = bannerFileInput.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
if (!allowedTypes.includes(file.type)) {
|
||||||
|
showBannerStatus('Invalid file type. Allowed: gif, jpg, png, webp', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (file.size > maxSize) {
|
||||||
|
showBannerStatus(`File too large. Max 5MB.`, 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
showBannerStatus('', '');
|
||||||
|
if (bannerFilenameSpan) bannerFilenameSpan.textContent = file.name;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const blob = await showBannerCropModal(file);
|
||||||
|
if (blob) {
|
||||||
|
uploadBannerBlob(blob);
|
||||||
|
} else {
|
||||||
|
// Modal was cancelled
|
||||||
|
if (bannerFilenameSpan) bannerFilenameSpan.textContent = 'No file selected';
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[CROPPER] Error:', err);
|
||||||
|
showBannerStatus('Cropper error', 'error');
|
||||||
|
} finally {
|
||||||
|
// ALWAYS clear the file input value so selecting the same file again works
|
||||||
|
bannerFileInput.value = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Remove banner handler ────────────────────────────────────────────────
|
||||||
|
document.addEventListener('click', async (e) => {
|
||||||
|
if (e.target.id === 'banner-remove-btn') {
|
||||||
|
e.target.disabled = true;
|
||||||
|
e.target.textContent = 'Removing...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v2/settings/uploadBanner', {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'X-CSRF-Token': window.f0ckSession?.csrf_token }
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
showBannerStatus('Banner removed', 'success');
|
||||||
|
e.target.remove();
|
||||||
|
const bannerPreview = document.getElementById('banner-preview');
|
||||||
|
if (bannerPreview) {
|
||||||
|
const ph = document.createElement('div');
|
||||||
|
ph.id = 'banner-preview'; ph.className = 'banner-preview-img banner-placeholder';
|
||||||
|
ph.style.cssText = 'width:100%;max-width:360px;height:80px;display:flex;align-items:center;justify-content:center;border:1px dashed var(--nav-border-color);border-radius:4px;color:var(--text-muted);font-size:0.85em;';
|
||||||
|
ph.textContent = 'No banner set';
|
||||||
|
bannerPreview.replaceWith(ph);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
showBannerStatus(data.msg || 'Failed to remove', 'error');
|
||||||
|
e.target.disabled = false; e.target.textContent = 'Remove Banner';
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showBannerStatus('Failed to remove banner', 'error');
|
||||||
|
e.target.disabled = false; e.target.textContent = 'Remove Banner';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
// Generic Linking Logic
|
// Generic Linking Logic
|
||||||
const genTokenBtn = document.getElementById('btn-gen-link-token');
|
const genTokenBtn = document.getElementById('btn-gen-link-token');
|
||||||
const linkedAccountsList = document.getElementById('linked-accounts-list');
|
const linkedAccountsList = document.getElementById('linked-accounts-list');
|
||||||
|
|||||||
255
src/banner_handler.mjs
Normal file
255
src/banner_handler.mjs
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
import cfg from "./inc/config.mjs";
|
||||||
|
import path from "path";
|
||||||
|
import { promises as fs } from "fs";
|
||||||
|
import db from "./inc/sql.mjs";
|
||||||
|
import lib from "./inc/lib.mjs";
|
||||||
|
import { parseMultipart, collectBody } from "./inc/multipart.mjs";
|
||||||
|
import { execFile as _execFile } from "child_process";
|
||||||
|
import { promisify } from "util";
|
||||||
|
|
||||||
|
const execFile = promisify(_execFile);
|
||||||
|
|
||||||
|
// Helper for JSON response
|
||||||
|
const sendJson = (res, data, code = 200) => {
|
||||||
|
res.writeHead(code, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify(data));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Generate UUID using the same method as video uploads
|
||||||
|
const genuuid = async () => {
|
||||||
|
const raw = (await db`select replace(gen_random_uuid()::text, '-', '') || replace(gen_random_uuid()::text, '-', '') as uuid`)[0].uuid;
|
||||||
|
return raw.substring(0, 48);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const handleBannerUpload = async (req, res) => {
|
||||||
|
console.log('[BANNER HANDLER] Upload started');
|
||||||
|
|
||||||
|
// Manual Session Lookup
|
||||||
|
let user = [];
|
||||||
|
if (req.cookies && req.cookies.session) {
|
||||||
|
user = await db`
|
||||||
|
select "user".id, "user".login, "user".user, "user".admin, "user_sessions".id as sess_id, "user_sessions".csrf_token, "user_options".*
|
||||||
|
from "user_sessions"
|
||||||
|
left join "user" on "user".id = "user_sessions".user_id
|
||||||
|
left join "user_options" on "user_options".user_id = "user_sessions".user_id
|
||||||
|
where "user_sessions".session = ${lib.sha256(req.cookies.session)}
|
||||||
|
limit 1
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.length === 0) {
|
||||||
|
console.log('[BANNER HANDLER] Unauthorized - No valid session found');
|
||||||
|
return sendJson(res, { success: false, msg: 'Unauthorized' }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
req.session = user[0];
|
||||||
|
console.log('[BANNER HANDLER] Authorized:', req.session.user);
|
||||||
|
|
||||||
|
// CSRF validation
|
||||||
|
if (req.session.csrf_token) {
|
||||||
|
const csrfToken = req.headers['x-csrf-token'];
|
||||||
|
if (!csrfToken || csrfToken !== req.session.csrf_token) {
|
||||||
|
console.warn(`[CSRF] Blocked banner upload for user ${req.session.user}. Invalid token.`);
|
||||||
|
return sendJson(res, { success: false, msg: 'Invalid CSRF token' }, 403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const contentType = req.headers['content-type'] || '';
|
||||||
|
const boundaryMatch = contentType.match(/boundary=(.+)$/);
|
||||||
|
|
||||||
|
if (!boundaryMatch) {
|
||||||
|
console.log('[BANNER HANDLER] No boundary');
|
||||||
|
return sendJson(res, { success: false, msg: 'Invalid content type' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[BANNER HANDLER] Collecting body...');
|
||||||
|
const body = await collectBody(req);
|
||||||
|
console.log('[BANNER HANDLER] Body collected, size:', body.length);
|
||||||
|
|
||||||
|
console.log('[BANNER HANDLER] Parsing multipart...');
|
||||||
|
const parts = parseMultipart(body, boundaryMatch[1]);
|
||||||
|
const file = parts.file;
|
||||||
|
console.log('[BANNER HANDLER] Parsed, file present:', !!file, 'keys:', Object.keys(parts));
|
||||||
|
|
||||||
|
if (!file || !file.data) {
|
||||||
|
return sendJson(res, { success: false, msg: 'No file provided' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate file size (5MB max)
|
||||||
|
const maxSize = 5 * 1024 * 1024;
|
||||||
|
if (file.data.length > maxSize) {
|
||||||
|
return sendJson(res, {
|
||||||
|
success: false,
|
||||||
|
msg: `File too large. Maximum size is 5MB, got ${(file.data.length / 1024 / 1024).toFixed(2)}MB`
|
||||||
|
}, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allowed MIME types
|
||||||
|
const allowedMimes = [
|
||||||
|
'image/gif',
|
||||||
|
'image/jpeg',
|
||||||
|
'image/jpg',
|
||||||
|
'image/png',
|
||||||
|
'image/webp'
|
||||||
|
];
|
||||||
|
|
||||||
|
// Validate MIME type from content-type header
|
||||||
|
let mime = (file.contentType || '').toLowerCase().split(';')[0].trim();
|
||||||
|
console.log('[BANNER HANDLER] File MIME from header:', mime);
|
||||||
|
if (!allowedMimes.includes(mime)) {
|
||||||
|
return sendJson(res, {
|
||||||
|
success: false,
|
||||||
|
msg: `Invalid file type. Allowed: gif, jpg, jpeg, png, webp. Got: ${mime}`
|
||||||
|
}, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save to tmp and verify with file magic
|
||||||
|
console.log('[BANNER HANDLER] Generating UUID...');
|
||||||
|
const uuid = await genuuid();
|
||||||
|
const tmpPath = path.join(cfg.paths.tmp, `banner_${uuid}_tmp`);
|
||||||
|
const finalFilename = `banner_${uuid}.webp`;
|
||||||
|
const finalPath = path.join(cfg.paths.a, finalFilename);
|
||||||
|
|
||||||
|
await fs.mkdir(cfg.paths.tmp, { recursive: true });
|
||||||
|
await fs.mkdir(cfg.paths.a, { recursive: true });
|
||||||
|
|
||||||
|
console.log('[BANNER HANDLER] Writing tmp file:', tmpPath);
|
||||||
|
await fs.writeFile(tmpPath, file.data);
|
||||||
|
|
||||||
|
// Verify MIME with file magic
|
||||||
|
console.log('[BANNER HANDLER] Checking MIME with file magic...');
|
||||||
|
const { stdout: actualMime } = await execFile('file', ['--mime-type', '-b', tmpPath]);
|
||||||
|
console.log('[BANNER HANDLER] Actual MIME:', actualMime.trim());
|
||||||
|
const allowedActualMimes = [
|
||||||
|
'image/gif',
|
||||||
|
'image/jpeg',
|
||||||
|
'image/png',
|
||||||
|
'image/webp'
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!allowedActualMimes.includes(actualMime.trim())) {
|
||||||
|
await fs.unlink(tmpPath).catch(() => { });
|
||||||
|
return sendJson(res, {
|
||||||
|
success: false,
|
||||||
|
msg: `Invalid file type detected: ${actualMime.trim()}`
|
||||||
|
}, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to webp using ImageMagick — landscape banner crop (1200x400)
|
||||||
|
// NOTE: [0] frame selector must be appended to the input path, not a separate arg
|
||||||
|
console.log('[BANNER HANDLER] Running magick...');
|
||||||
|
try {
|
||||||
|
await execFile('magick', [`${tmpPath}[0]`, '-resize', '1200x400^', '-gravity', 'center', '-background', 'none', '-extent', '1200x400', '-quality', '75', finalPath]);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[BANNER HANDLER] Magick error:', err.message, err.stderr);
|
||||||
|
await fs.unlink(tmpPath).catch(() => { });
|
||||||
|
return sendJson(res, { success: false, msg: 'Failed to process image: ' + err.message }, 500);
|
||||||
|
}
|
||||||
|
console.log('[BANNER HANDLER] Magick done, output:', finalPath);
|
||||||
|
|
||||||
|
// Get current banner_file to delete old one (after magick succeeds)
|
||||||
|
let currentBanner = null;
|
||||||
|
try {
|
||||||
|
currentBanner = (await db`
|
||||||
|
select banner_file from user_options where user_id = ${+req.session.id}
|
||||||
|
`)[0]?.banner_file;
|
||||||
|
} catch (dbErr) {
|
||||||
|
console.error('[BANNER HANDLER] Could not fetch current banner (column may not exist yet):', dbErr.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up tmp file
|
||||||
|
await fs.unlink(tmpPath).catch(() => { });
|
||||||
|
|
||||||
|
// Delete old banner file if exists
|
||||||
|
if (currentBanner) {
|
||||||
|
const oldPath = path.join(cfg.paths.a, currentBanner);
|
||||||
|
await fs.unlink(oldPath).catch(() => { });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update database
|
||||||
|
console.log('[BANNER HANDLER] Updating database...');
|
||||||
|
try {
|
||||||
|
await db`
|
||||||
|
update user_options
|
||||||
|
set banner_file = ${finalFilename}
|
||||||
|
where user_id = ${+req.session.id}
|
||||||
|
`;
|
||||||
|
} catch (dbErr) {
|
||||||
|
console.error('[BANNER HANDLER] DB update failed:', dbErr.message);
|
||||||
|
await fs.unlink(finalPath).catch(() => {});
|
||||||
|
return sendJson(res, { success: false, msg: 'DB error — did you run the migration? ' + dbErr.message }, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[BANNER HANDLER] Upload complete:', finalFilename);
|
||||||
|
return sendJson(res, {
|
||||||
|
success: true,
|
||||||
|
banner_file: finalFilename,
|
||||||
|
msg: 'Banner uploaded successfully'
|
||||||
|
}, 200);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code === 'BODY_TOO_LARGE') {
|
||||||
|
return sendJson(res, { success: false, msg: 'File too large (5 MB max for banners)' }, 413);
|
||||||
|
}
|
||||||
|
console.error('[BANNER HANDLER ERROR]', err.message, err.stack);
|
||||||
|
try {
|
||||||
|
return sendJson(res, { success: false, msg: err.message || 'Banner upload failed' }, 500);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const handleBannerDelete = async (req, res) => {
|
||||||
|
console.log('[BANNER HANDLER] Delete started');
|
||||||
|
|
||||||
|
// Manual Session Lookup
|
||||||
|
let user = [];
|
||||||
|
if (req.cookies && req.cookies.session) {
|
||||||
|
user = await db`
|
||||||
|
select "user".id, "user".login, "user".user, "user".admin, "user_sessions".id as sess_id, "user_sessions".csrf_token, "user_options".*
|
||||||
|
from "user_sessions"
|
||||||
|
left join "user" on "user".id = "user_sessions".user_id
|
||||||
|
left join "user_options" on "user_options".user_id = "user_sessions".user_id
|
||||||
|
where "user_sessions".session = ${lib.sha256(req.cookies.session)}
|
||||||
|
limit 1
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.length === 0) {
|
||||||
|
return sendJson(res, { success: false, msg: 'Unauthorized' }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
req.session = user[0];
|
||||||
|
|
||||||
|
// CSRF validation
|
||||||
|
if (req.session.csrf_token) {
|
||||||
|
const csrfToken = req.headers['x-csrf-token'];
|
||||||
|
if (!csrfToken || csrfToken !== req.session.csrf_token) {
|
||||||
|
console.warn(`[CSRF] Blocked banner delete for user ${req.session.user}. Invalid token.`);
|
||||||
|
return sendJson(res, { success: false, msg: 'Invalid CSRF token' }, 403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const currentBanner = (await db`
|
||||||
|
select banner_file from user_options where user_id = ${+req.session.id}
|
||||||
|
`)[0]?.banner_file;
|
||||||
|
|
||||||
|
if (currentBanner) {
|
||||||
|
const oldPath = path.join(cfg.paths.a, currentBanner);
|
||||||
|
await fs.unlink(oldPath).catch(() => { });
|
||||||
|
}
|
||||||
|
|
||||||
|
await db`
|
||||||
|
update user_options
|
||||||
|
set banner_file = null
|
||||||
|
where user_id = ${+req.session.id}
|
||||||
|
`;
|
||||||
|
|
||||||
|
console.log('[BANNER HANDLER] Delete complete');
|
||||||
|
return sendJson(res, { success: true, msg: 'Custom banner removed' }, 200);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[BANNER DELETE ERROR]', err);
|
||||||
|
return sendJson(res, { success: false, msg: 'Failed to remove banner' }, 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -534,6 +534,7 @@ export default {
|
|||||||
uo.display_name as author_display_name,
|
uo.display_name as author_display_name,
|
||||||
uo.avatar as author_avatar,
|
uo.avatar as author_avatar,
|
||||||
uo.avatar_file as author_avatar_file,
|
uo.avatar_file as author_avatar_file,
|
||||||
|
uo.banner_file as author_banner_file,
|
||||||
uo.description as author_description,
|
uo.description as author_description,
|
||||||
author_u.id as author_id,
|
author_u.id as author_id,
|
||||||
items.is_pinned,
|
items.is_pinned,
|
||||||
@@ -794,6 +795,7 @@ export default {
|
|||||||
author_display_name: actitem.author_display_name || null,
|
author_display_name: actitem.author_display_name || null,
|
||||||
author_avatar: actitem.author_avatar,
|
author_avatar: actitem.author_avatar,
|
||||||
author_avatar_file: actitem.author_avatar_file,
|
author_avatar_file: actitem.author_avatar_file,
|
||||||
|
author_banner_file: actitem.author_banner_file,
|
||||||
author_description: actitem.author_description,
|
author_description: actitem.author_description,
|
||||||
title: actitem.title || null,
|
title: actitem.title || null,
|
||||||
|
|
||||||
|
|||||||
@@ -23,9 +23,9 @@ export default (router, tpl) => {
|
|||||||
join tags t on t.id = et.id
|
join tags t on t.id = et.id
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Get custom avatar file if exists
|
// Get custom avatar file and banner file if exists
|
||||||
const userOptions = (await db`
|
const userOptions = (await db`
|
||||||
select avatar_file from user_options where user_id = ${+req.session.id}
|
select avatar_file, banner_file from user_options where user_id = ${+req.session.id}
|
||||||
`)[0];
|
`)[0];
|
||||||
|
|
||||||
// Get full user info
|
// Get full user info
|
||||||
@@ -46,8 +46,10 @@ export default (router, tpl) => {
|
|||||||
sessions,
|
sessions,
|
||||||
excluded_tags: excluded_tags || [],
|
excluded_tags: excluded_tags || [],
|
||||||
avatar_file: userOptions?.avatar_file || null,
|
avatar_file: userOptions?.avatar_file || null,
|
||||||
|
banner_file: userOptions?.banner_file || null,
|
||||||
email: user?.email || '',
|
email: user?.email || '',
|
||||||
joined: user?.created_at || null,
|
joined: user?.created_at || null,
|
||||||
|
user_banner_enabled: cfg.websrv.user_banner_enabled !== false,
|
||||||
enable_swf: cfg.enable_swf,
|
enable_swf: cfg.enable_swf,
|
||||||
enable_data_export: cfg.websrv.enable_data_export,
|
enable_data_export: cfg.websrv.enable_data_export,
|
||||||
enable_user_api_keys: cfg.websrv.enable_user_api_keys !== false,
|
enable_user_api_keys: cfg.websrv.enable_user_api_keys !== false,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { getAboutText, setAboutText, getRulesText, setRulesText, getTermsText, s
|
|||||||
import flummpress from "flummpress";
|
import flummpress from "flummpress";
|
||||||
import { handleUpload } from "./upload_handler.mjs";
|
import { handleUpload } from "./upload_handler.mjs";
|
||||||
import { handleAvatarUpload, handleAvatarDelete } from "./avatar_handler.mjs";
|
import { handleAvatarUpload, handleAvatarDelete } from "./avatar_handler.mjs";
|
||||||
|
import { handleBannerUpload, handleBannerDelete } from "./banner_handler.mjs";
|
||||||
import { handleRethumbUpload } from "./rethumb_handler.mjs";
|
import { handleRethumbUpload } from "./rethumb_handler.mjs";
|
||||||
import { handleMemeUpload, handleMemeEdit } from "./meme_upload_handler.mjs";
|
import { handleMemeUpload, handleMemeEdit } from "./meme_upload_handler.mjs";
|
||||||
import { handleEmojiUpload, handleEmojiEdit } from "./emoji_upload_handler.mjs";
|
import { handleEmojiUpload, handleEmojiEdit } from "./emoji_upload_handler.mjs";
|
||||||
@@ -785,7 +786,7 @@ process.on('uncaughtException', err => {
|
|||||||
// because the session middleware will have completed by the time router callbacks execute.
|
// because the session middleware will have completed by the time router callbacks execute.
|
||||||
app.use(async (req, res) => {
|
app.use(async (req, res) => {
|
||||||
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return;
|
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return;
|
||||||
if (['/login', '/register', '/api/v2/upload', '/api/v2/settings/uploadAvatar', '/api/v2/admin/memes', '/api/v2/admin/emojis', '/api/v2/meta/extract-file', '/api/v2/meta/strip-gps', '/api/v2/scroller/external/rehost-meta', '/api/v2/comments/upload', '/api/v2/admin/sticker-packs/import'].includes(req.url.pathname)) return;
|
if (['/login', '/register', '/api/v2/upload', '/api/v2/settings/uploadAvatar', '/api/v2/settings/uploadBanner', '/api/v2/admin/memes', '/api/v2/admin/emojis', '/api/v2/meta/extract-file', '/api/v2/meta/strip-gps', '/api/v2/scroller/external/rehost-meta', '/api/v2/comments/upload', '/api/v2/admin/sticker-packs/import'].includes(req.url.pathname)) return;
|
||||||
// DM attachment upload validates CSRF internally
|
// DM attachment upload validates CSRF internally
|
||||||
if (req.url.pathname.match(/^\/api\/dm\/attachment\/upload\//)) return;
|
if (req.url.pathname.match(/^\/api\/dm\/attachment\/upload\//)) return;
|
||||||
// Hall manager routes are handled by bypass middleware with their own session auth
|
// Hall manager routes are handled by bypass middleware with their own session auth
|
||||||
@@ -830,6 +831,23 @@ process.on('uncaughtException', err => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Bypass middleware for banner upload (needs raw body before router consumes it)
|
||||||
|
// CSRF is validated inside handleBannerUpload/handleBannerDelete after their own session lookups
|
||||||
|
app.use(async (req, res) => {
|
||||||
|
if (req.url.pathname === '/api/v2/settings/uploadBanner') {
|
||||||
|
if (cfg.websrv.user_banner_enabled === false) {
|
||||||
|
return res.reply({ success: false, msg: 'Banner feature is currently disabled' }, 403);
|
||||||
|
}
|
||||||
|
if (req.method === 'POST') {
|
||||||
|
await handleBannerUpload(req, res);
|
||||||
|
req.url.pathname = '/handled_banner_upload_bypass';
|
||||||
|
} else if (req.method === 'DELETE') {
|
||||||
|
await handleBannerDelete(req, res);
|
||||||
|
req.url.pathname = '/handled_banner_delete_bypass';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Bypass middleware for custom thumbnail uploads
|
// Bypass middleware for custom thumbnail uploads
|
||||||
app.use(async (req, res) => {
|
app.use(async (req, res) => {
|
||||||
const thumbMatch = req.url.pathname.match(/^\/api\/v2\/items\/([^/]+)\/thumbnail$/);
|
const thumbMatch = req.url.pathname.match(/^\/api\/v2\/items\/([^/]+)\/thumbnail$/);
|
||||||
@@ -1343,6 +1361,7 @@ process.on('uncaughtException', err => {
|
|||||||
lang: perRequestLang,
|
lang: perRequestLang,
|
||||||
user_alternative_infobox: useAltInfobox,
|
user_alternative_infobox: useAltInfobox,
|
||||||
user_alternative_steuerung: useAltSteuerung,
|
user_alternative_steuerung: useAltSteuerung,
|
||||||
|
user_banner_enabled: cfg.websrv.user_banner_enabled !== false,
|
||||||
comment_display_mode: (req && req.session && typeof req.session.comment_display_mode === 'number')
|
comment_display_mode: (req && req.session && typeof req.session.comment_display_mode === 'number')
|
||||||
? req.session.comment_display_mode
|
? req.session.comment_display_mode
|
||||||
: (data && typeof data.comment_display_mode === 'number'
|
: (data && typeof data.comment_display_mode === 'number'
|
||||||
|
|||||||
@@ -84,7 +84,7 @@
|
|||||||
|
|
||||||
<div class="blahlol">
|
<div class="blahlol">
|
||||||
@if(user_alternative_infobox)
|
@if(user_alternative_infobox)
|
||||||
<div class="user-infobox-block" style="--author-accent: @if(item.author_color){{ item.author_color }}@else var(--accent) @endif; --author-border: @if(item.author_color){{ item.author_color }}@else var(--accent) @endif;">
|
<div class="user-infobox-block" style="--author-accent: @if(item.author_color){{ item.author_color }}@else var(--accent) @endif; --author-border: @if(item.author_color){{ item.author_color }}@else var(--accent) @endif;@if(item.author_banner_file && user_banner_enabled) --author-banner: url('/a/{{ item.author_banner_file }}');@endif">
|
||||||
|
|
||||||
<div class="user-infobox-avatar">
|
<div class="user-infobox-avatar">
|
||||||
<a href="/user/{{ (item.username || '').toLowerCase() }}">
|
<a href="/user/{{ (item.username || '').toLowerCase() }}">
|
||||||
|
|||||||
@@ -136,6 +136,49 @@
|
|||||||
</fieldset>
|
</fieldset>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
|
@if(user_banner_enabled)
|
||||||
|
<fieldset style="border: 1px solid var(--nav-border-color); padding: 10px; border-radius: 4px; margin-bottom: 15px;">
|
||||||
|
<legend style="width: auto; padding: 0 5px; font-size: 1.1em; font-weight: bold;">Profile Banner</legend>
|
||||||
|
<div class="avatar-settings-wrapper">
|
||||||
|
<div class="avatar-preview-container" style="min-width: 0; flex: 1 1 auto;">
|
||||||
|
<div class="avatar-preview-label">Current Banner</div>
|
||||||
|
@if(banner_file)
|
||||||
|
<a href="/user/{!! session.user !!}" target="_blank" style="display:block;">
|
||||||
|
<img id="banner-preview" class="banner-preview-img" src="/a/{{ banner_file }}" style="width:100%;max-width:360px;height:auto;border-radius:4px;border:1px solid var(--nav-border-color);object-fit:cover;">
|
||||||
|
</a>
|
||||||
|
@else
|
||||||
|
<div id="banner-preview" class="banner-preview-img banner-placeholder" style="width:100%;max-width:360px;height:80px;display:flex;align-items:center;justify-content:center;border:1px dashed var(--nav-border-color);border-radius:4px;color:var(--text-muted);font-size:0.85em;">No banner set</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="avatar-upload-section">
|
||||||
|
<p class="avatar-hint">Upload a custom banner image for your profile card (max 5 MB — gif, jpg, png, webp). Will be cropped to a landscape ratio.</p>
|
||||||
|
|
||||||
|
<div class="avatar-upload-wrapper">
|
||||||
|
<input type="file" id="banner-file-input" accept="image/gif,image/jpeg,image/png,image/webp" hidden>
|
||||||
|
<button type="button" id="banner-choose-btn" class="button">Choose File</button>
|
||||||
|
<span id="banner-filename" class="avatar-filename">No file selected</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="avatar-progress-wrapper" id="banner-progress-wrapper" style="display: none;">
|
||||||
|
<div class="avatar-progress-bar">
|
||||||
|
<div class="avatar-progress-fill" id="banner-progress-fill"></div>
|
||||||
|
</div>
|
||||||
|
<span class="avatar-progress-text" id="banner-progress-text">0%</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="avatar-upload-actions">
|
||||||
|
<button type="button" id="banner-upload-btn" class="button" disabled>Upload Banner</button>
|
||||||
|
@if(banner_file)
|
||||||
|
<button type="button" id="banner-remove-btn" class="button button-danger">Remove Banner</button>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
<div id="banner-upload-status" class="avatar-status"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
@endif
|
||||||
|
|
||||||
<fieldset style="border: 1px solid var(--nav-border-color); padding: 10px; border-radius: 4px; margin-bottom: 15px;">
|
<fieldset style="border: 1px solid var(--nav-border-color); padding: 10px; border-radius: 4px; margin-bottom: 15px;">
|
||||||
<legend style="width: auto; padding: 0 5px; font-size: 1.1em; font-weight: bold;">{{ t('settings.username_color') }}</legend>
|
<legend style="width: auto; padding: 0 5px; font-size: 1.1em; font-weight: bold;">{{ t('settings.username_color') }}</legend>
|
||||||
<div class="setting-item">
|
<div class="setting-item">
|
||||||
|
|||||||
Reference in New Issue
Block a user