banner v2
This commit is contained in:
@@ -4,4 +4,12 @@
|
|||||||
ALTER TABLE user_options
|
ALTER TABLE user_options
|
||||||
ADD COLUMN IF NOT EXISTS banner_file character varying(255) DEFAULT NULL;
|
ADD COLUMN IF NOT EXISTS banner_file character varying(255) DEFAULT NULL;
|
||||||
|
|
||||||
|
ALTER TABLE user_options
|
||||||
|
ADD COLUMN IF NOT EXISTS banner_position character varying(50) DEFAULT 'center';
|
||||||
|
|
||||||
|
ALTER TABLE user_options
|
||||||
|
ADD COLUMN IF NOT EXISTS banner_size character varying(50) DEFAULT 'cover';
|
||||||
|
|
||||||
COMMENT ON COLUMN public.user_options.banner_file IS 'Custom uploaded banner image filename, stored in public/a/ (same dir as avatars)';
|
COMMENT ON COLUMN public.user_options.banner_file IS 'Custom uploaded banner image filename, stored in public/a/ (same dir as avatars)';
|
||||||
|
COMMENT ON COLUMN public.user_options.banner_position IS 'CSS background-position for the banner';
|
||||||
|
COMMENT ON COLUMN public.user_options.banner_size IS 'CSS background-size for the banner (e.g. cover, contain, or a percentage)';
|
||||||
|
|||||||
@@ -13631,6 +13631,22 @@ i.iconset#a_oc {
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: auto 1fr;
|
grid-template-columns: auto 1fr;
|
||||||
background: var(--nav-bg);
|
background: var(--nav-bg);
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile_head::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;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile_head_avatar {
|
.profile_head_avatar {
|
||||||
|
|||||||
@@ -224,8 +224,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update navbar avatar
|
// Update navbar avatar and live preview avatar
|
||||||
updateNavAvatar('/a/' + res.avatar_file + '?t=' + Date.now());
|
const avatarUrl = '/a/' + res.avatar_file + '?t=' + Date.now();
|
||||||
|
updateNavAvatar(avatarUrl);
|
||||||
|
const livePreviewAvatar = document.getElementById('live-preview-avatar');
|
||||||
|
if (livePreviewAvatar) livePreviewAvatar.src = avatarUrl;
|
||||||
|
|
||||||
// Show remove button if not present
|
// Show remove button if not present
|
||||||
const existingRemoveBtn = document.getElementById('avatar-remove-btn');
|
const existingRemoveBtn = document.getElementById('avatar-remove-btn');
|
||||||
@@ -362,7 +365,7 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
// ── Crop Modal ──────────────────────────────────────────────────────────
|
// ── Crop Modal ──────────────────────────────────────────────────────────
|
||||||
const showBannerCropModal = (file) => new Promise((resolve) => {
|
const showBannerCropModal = (file, existingConfig = null) => new Promise((resolve) => {
|
||||||
const overlay = document.createElement('div');
|
const overlay = document.createElement('div');
|
||||||
overlay.style.cssText = `
|
overlay.style.cssText = `
|
||||||
position:fixed;inset:0;z-index:99999;
|
position:fixed;inset:0;z-index:99999;
|
||||||
@@ -375,84 +378,101 @@
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
|
|
||||||
const title = document.createElement('div');
|
const title = document.createElement('h3');
|
||||||
title.textContent = 'Position Banner';
|
title.textContent = 'Position Banner';
|
||||||
title.style.cssText = 'color:#fff;font-size:1.2em;font-weight:600;letter-spacing:0.5px;';
|
title.style.margin = '0';
|
||||||
|
title.style.color = '#fff';
|
||||||
|
|
||||||
const hint = document.createElement('div');
|
const hint = document.createElement('div');
|
||||||
hint.textContent = 'Drag to position. Use the slider below to zoom.';
|
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;';
|
hint.style.cssText = 'color:#aaa;font-size:0.85em;text-align:center;margin-bottom:4px;';
|
||||||
|
|
||||||
// Responsive cropper container
|
const canvasWrapper = document.createElement('div');
|
||||||
const cropperContainer = document.createElement('div');
|
canvasWrapper.style.cssText = `
|
||||||
cropperContainer.style.cssText = `
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 600px;
|
max-width: 600px;
|
||||||
aspect-ratio: 3 / 1;
|
aspect-ratio: 4 / 1;
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid var(--nav-border-color);
|
border: 1px solid var(--nav-border-color);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
box-shadow: 0 4px 20px rgba(0,0,0,0.8);
|
box-shadow: 0 4px 20px rgba(0,0,0,0.8);
|
||||||
background: #111;
|
background: #111;
|
||||||
|
margin: 0 auto;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement('canvas');
|
||||||
canvas.width = 1200;
|
canvas.width = 1200;
|
||||||
canvas.height = 400;
|
canvas.height = 300;
|
||||||
canvas.style.cssText = 'width:100%;height:100%;display:block;cursor:move;touch-action:none;pointer-events:auto !important;';
|
canvas.style.cssText = 'width:100%;height:100%;display:block;cursor:move;touch-action:none;pointer-events:auto !important;';
|
||||||
cropperContainer.appendChild(canvas);
|
canvasWrapper.appendChild(canvas);
|
||||||
|
|
||||||
// Zoom slider container
|
const controls = document.createElement('div');
|
||||||
const zoomContainer = document.createElement('div');
|
controls.style.cssText = 'display: flex; gap: 10px; align-items: center; justify-content: space-between; flex-wrap: wrap; margin-top: 15px;';
|
||||||
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');
|
const zoomLabel = document.createElement('label');
|
||||||
zoomOutLabel.textContent = 'A';
|
zoomLabel.textContent = 'Zoom:';
|
||||||
zoomOutLabel.style.cssText = 'font-size:0.8em;opacity:0.6;user-select:none;font-weight:bold;';
|
zoomLabel.style.display = 'flex';
|
||||||
|
zoomLabel.style.gap = '10px';
|
||||||
|
zoomLabel.style.alignItems = 'center';
|
||||||
|
zoomLabel.style.flex = '1';
|
||||||
|
zoomLabel.style.minWidth = '200px';
|
||||||
|
zoomLabel.style.color = '#fff';
|
||||||
|
|
||||||
const zoomInput = document.createElement('input');
|
const zoomSlider = document.createElement('input');
|
||||||
zoomInput.type = 'range';
|
zoomSlider.type = 'range';
|
||||||
zoomInput.style.cssText = 'flex:1;height:4px;border-radius:2px;outline:none;cursor:pointer;';
|
zoomSlider.min = '0';
|
||||||
|
zoomSlider.max = '100';
|
||||||
|
zoomSlider.step = '0.01';
|
||||||
|
zoomSlider.value = '0';
|
||||||
|
zoomSlider.style.flex = '1';
|
||||||
|
zoomLabel.appendChild(zoomSlider);
|
||||||
|
|
||||||
const zoomInLabel = document.createElement('span');
|
const actions = document.createElement('div');
|
||||||
zoomInLabel.textContent = 'A';
|
actions.style.cssText = 'display: flex; gap: 10px;';
|
||||||
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');
|
const cancelBtn = document.createElement('button');
|
||||||
cancelBtn.textContent = 'Cancel';
|
|
||||||
cancelBtn.className = 'button button-danger';
|
cancelBtn.className = 'button button-danger';
|
||||||
|
cancelBtn.textContent = 'Cancel';
|
||||||
|
|
||||||
btnRow.append(confirmBtn, cancelBtn);
|
const confirmBtn = document.createElement('button');
|
||||||
overlay.append(title, hint, cropperContainer, zoomContainer, btnRow);
|
confirmBtn.className = 'button';
|
||||||
|
confirmBtn.textContent = 'Confirm Crop';
|
||||||
|
|
||||||
|
actions.appendChild(cancelBtn);
|
||||||
|
actions.appendChild(confirmBtn);
|
||||||
|
|
||||||
|
controls.appendChild(zoomLabel);
|
||||||
|
controls.appendChild(actions);
|
||||||
|
|
||||||
|
const wrapper = document.createElement('div');
|
||||||
|
wrapper.style.cssText = 'width: 90%; max-width: 1200px; background: var(--bg); padding: 20px; border-radius: 8px; border: 1px solid var(--nav-border-color); display: flex; flex-direction: column; gap: 15px;';
|
||||||
|
|
||||||
|
wrapper.appendChild(title);
|
||||||
|
wrapper.appendChild(hint);
|
||||||
|
wrapper.appendChild(canvasWrapper);
|
||||||
|
wrapper.appendChild(controls);
|
||||||
|
|
||||||
|
overlay.appendChild(wrapper);
|
||||||
document.body.appendChild(overlay);
|
document.body.appendChild(overlay);
|
||||||
overlay.tabIndex = 0;
|
|
||||||
overlay.focus();
|
|
||||||
|
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
const objectUrl = URL.createObjectURL(file);
|
const objectUrl = URL.createObjectURL(file);
|
||||||
|
|
||||||
// State variables
|
let zoom = 1;
|
||||||
let zoom = 1.0;
|
let minZoom = 1;
|
||||||
let minZoom = 1.0;
|
let maxZoom = 4;
|
||||||
let maxZoom = 4.0;
|
|
||||||
let panX = 0;
|
let panX = 0;
|
||||||
let panY = 0;
|
let panY = 0;
|
||||||
|
let isDragging = false;
|
||||||
|
let startX = 0;
|
||||||
|
let startY = 0;
|
||||||
|
|
||||||
|
const clamp = (val, min, max) => Math.min(Math.max(val, min), max);
|
||||||
|
|
||||||
const draw = () => {
|
const draw = () => {
|
||||||
ctx.clearRect(0, 0, 1200, 400);
|
ctx.clearRect(0, 0, 1200, 300);
|
||||||
ctx.drawImage(img, panX, panY, img.naturalWidth * zoom, img.naturalHeight * zoom);
|
ctx.drawImage(img, panX, panY, img.naturalWidth * zoom, img.naturalHeight * zoom);
|
||||||
|
|
||||||
// Update hint with live coords/zoom
|
// Update hint with live coords/zoom
|
||||||
@@ -462,7 +482,7 @@
|
|||||||
const getBounds = (z) => {
|
const getBounds = (z) => {
|
||||||
return {
|
return {
|
||||||
minX: 1200 - img.naturalWidth * z,
|
minX: 1200 - img.naturalWidth * z,
|
||||||
minY: 400 - img.naturalHeight * z
|
minY: 300 - img.naturalHeight * z
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -473,18 +493,47 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Determine minimum zoom so the image fully covers 1200x400 canvas without black edges
|
// Determine minimum zoom so the image fully covers 1200x400 canvas without black edges
|
||||||
minZoom = Math.max(1200 / img.naturalWidth, 400 / img.naturalHeight);
|
minZoom = Math.max(1200 / img.naturalWidth, 300 / img.naturalHeight);
|
||||||
maxZoom = minZoom * 4;
|
maxZoom = minZoom * 4;
|
||||||
zoom = minZoom;
|
|
||||||
|
|
||||||
// Center image initially
|
if (existingConfig && existingConfig.bgSize && existingConfig.bgSize !== 'cover') {
|
||||||
panX = (1200 - img.naturalWidth * zoom) / 2;
|
const match = existingConfig.bgSize.match(/([\d.]+)%/);
|
||||||
panY = (400 - img.naturalHeight * zoom) / 2;
|
if (match) {
|
||||||
|
const pct = parseFloat(match[1]);
|
||||||
|
const iw = (pct / 100) * 1200;
|
||||||
|
zoom = Math.max(minZoom, Math.min(maxZoom, iw / img.naturalWidth));
|
||||||
|
} else {
|
||||||
|
zoom = minZoom;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
zoom = minZoom;
|
||||||
|
}
|
||||||
|
|
||||||
// Configure zoom slider
|
const iw = img.naturalWidth * zoom;
|
||||||
zoomInput.min = 0;
|
const ih = img.naturalHeight * zoom;
|
||||||
zoomInput.max = 100;
|
const maxPanX = 1200 - iw;
|
||||||
zoomInput.value = 0;
|
const maxPanY = 300 - ih;
|
||||||
|
|
||||||
|
if (existingConfig && existingConfig.bgPos && existingConfig.bgPos !== 'center') {
|
||||||
|
const parts = existingConfig.bgPos.split(' ');
|
||||||
|
const posXPercent = parseFloat(parts[0]) || 50;
|
||||||
|
const posYPercent = parseFloat(parts[1] !== undefined ? parts[1] : parts[0]) || 50;
|
||||||
|
panX = (posXPercent / 100) * maxPanX;
|
||||||
|
panY = (posYPercent / 100) * maxPanY;
|
||||||
|
|
||||||
|
// Clamp bounds immediately in case image size changed
|
||||||
|
const bounds = getBounds(zoom);
|
||||||
|
panX = clamp(panX, bounds.minX, 0);
|
||||||
|
panY = clamp(panY, bounds.minY, 0);
|
||||||
|
} else {
|
||||||
|
// Center image initially
|
||||||
|
panX = maxPanX / 2;
|
||||||
|
panY = maxPanY / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update zoom slider visually
|
||||||
|
const sliderPct = ((zoom - minZoom) / (maxZoom - minZoom)) * 100;
|
||||||
|
zoomSlider.value = sliderPct || 0;
|
||||||
|
|
||||||
draw();
|
draw();
|
||||||
};
|
};
|
||||||
@@ -495,17 +544,17 @@
|
|||||||
const newZoom = minZoom + (maxZoom - minZoom) * t;
|
const newZoom = minZoom + (maxZoom - minZoom) * t;
|
||||||
|
|
||||||
const imgCenterX = (600 - panX) / oldZoom;
|
const imgCenterX = (600 - panX) / oldZoom;
|
||||||
const imgCenterY = (200 - panY) / oldZoom;
|
const imgCenterY = (150 - panY) / oldZoom;
|
||||||
|
|
||||||
zoom = newZoom;
|
zoom = newZoom;
|
||||||
const bounds = getBounds(zoom);
|
const bounds = getBounds(zoom);
|
||||||
panX = Math.max(bounds.minX, Math.min(0, 600 - imgCenterX * zoom));
|
panX = Math.max(bounds.minX, Math.min(0, 600 - imgCenterX * zoom));
|
||||||
panY = Math.max(bounds.minY, Math.min(0, 200 - imgCenterY * zoom));
|
panY = Math.max(bounds.minY, Math.min(0, 150 - imgCenterY * zoom));
|
||||||
|
|
||||||
draw();
|
draw();
|
||||||
};
|
};
|
||||||
|
|
||||||
zoomInput.addEventListener('input', (e) => {
|
zoomSlider.addEventListener('input', (e) => {
|
||||||
handleZoomChange(parseFloat(e.target.value));
|
handleZoomChange(parseFloat(e.target.value));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -574,10 +623,20 @@
|
|||||||
|
|
||||||
|
|
||||||
confirmBtn.addEventListener('click', () => {
|
confirmBtn.addEventListener('click', () => {
|
||||||
canvas.toBlob(blob => {
|
const iw = img.naturalWidth * zoom;
|
||||||
cleanup();
|
const ih = img.naturalHeight * zoom;
|
||||||
resolve(blob);
|
|
||||||
}, 'image/webp', 0.85);
|
const maxPanX = 1200 - iw;
|
||||||
|
const posXPercent = maxPanX === 0 ? 50 : (panX / maxPanX) * 100;
|
||||||
|
|
||||||
|
const maxPanY = 300 - ih;
|
||||||
|
const posYPercent = maxPanY === 0 ? 50 : (panY / maxPanY) * 100;
|
||||||
|
|
||||||
|
const bgSize = `${(iw / 1200) * 100}% auto`;
|
||||||
|
const bgPos = `${posXPercent.toFixed(2)}% ${posYPercent.toFixed(2)}%`;
|
||||||
|
|
||||||
|
cleanup();
|
||||||
|
resolve({ file, banner_position: bgPos, banner_size: bgSize });
|
||||||
});
|
});
|
||||||
|
|
||||||
cancelBtn.addEventListener('click', () => {
|
cancelBtn.addEventListener('click', () => {
|
||||||
@@ -598,9 +657,9 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
// ── Upload blob to server ───────────────────────────────────────────────
|
// ── Upload raw file to server ───────────────────────────────────────────────
|
||||||
const uploadBannerBlob = (blob) => {
|
const uploadBannerPayload = (payload) => {
|
||||||
if (!blob) return;
|
if (!payload || !payload.file) return;
|
||||||
|
|
||||||
if (bannerUploadBtn) bannerUploadBtn.disabled = true;
|
if (bannerUploadBtn) bannerUploadBtn.disabled = true;
|
||||||
if (bannerChooseBtn) bannerChooseBtn.disabled = true;
|
if (bannerChooseBtn) bannerChooseBtn.disabled = true;
|
||||||
@@ -610,7 +669,9 @@
|
|||||||
showBannerStatus('Uploading...', '');
|
showBannerStatus('Uploading...', '');
|
||||||
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', blob, 'banner.webp');
|
formData.append('file', payload.file);
|
||||||
|
formData.append('banner_position', payload.banner_position);
|
||||||
|
formData.append('banner_size', payload.banner_size);
|
||||||
|
|
||||||
const xhr = new XMLHttpRequest();
|
const xhr = new XMLHttpRequest();
|
||||||
|
|
||||||
@@ -630,22 +691,41 @@
|
|||||||
|
|
||||||
const bannerPreview = document.getElementById('banner-preview');
|
const bannerPreview = document.getElementById('banner-preview');
|
||||||
if (bannerPreview) {
|
if (bannerPreview) {
|
||||||
if (bannerPreview.tagName === 'IMG') {
|
if (bannerPreview.tagName === 'DIV' && bannerPreview.classList.contains('banner-placeholder')) {
|
||||||
bannerPreview.src = '/a/' + res.banner_file + '?t=' + Date.now();
|
const a = document.createElement('a');
|
||||||
|
a.href = `/user/${window.f0ckSession?.user}`;
|
||||||
|
a.target = '_blank';
|
||||||
|
a.style.display = 'block';
|
||||||
|
const newDiv = document.createElement('div');
|
||||||
|
newDiv.id = 'banner-preview';
|
||||||
|
newDiv.className = 'banner-preview-img';
|
||||||
|
newDiv.style.cssText = `width:100%;aspect-ratio:4/1;border-radius:4px;border:1px solid var(--nav-border-color);background-image:url('/a/${res.banner_file}?t=${Date.now()}');background-size:${payload.banner_size};background-position:${payload.banner_position};background-repeat:no-repeat;`;
|
||||||
|
a.appendChild(newDiv);
|
||||||
|
bannerPreview.replaceWith(a);
|
||||||
} else {
|
} else {
|
||||||
const img = document.createElement('img');
|
bannerPreview.style.backgroundImage = `url('/a/${res.banner_file}?t=${Date.now()}')`;
|
||||||
img.id = 'banner-preview';
|
bannerPreview.style.backgroundSize = payload.banner_size;
|
||||||
img.className = 'banner-preview-img';
|
bannerPreview.style.backgroundPosition = payload.banner_position;
|
||||||
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 livePreviewBox = document.getElementById('live-profile-preview-box');
|
||||||
|
if (livePreviewBox) {
|
||||||
|
livePreviewBox.style.setProperty('--author-banner', `url('/a/${res.banner_file}?t=${Date.now()}')`);
|
||||||
|
livePreviewBox.style.setProperty('--author-banner-size', payload.banner_size);
|
||||||
|
livePreviewBox.style.setProperty('--author-banner-position', payload.banner_position);
|
||||||
|
}
|
||||||
|
|
||||||
const existingRemoveBtn = document.getElementById('banner-remove-btn');
|
const existingRemoveBtn = document.getElementById('banner-remove-btn');
|
||||||
if (!existingRemoveBtn && bannerUploadBtn) {
|
if (!existingRemoveBtn && bannerUploadBtn) {
|
||||||
const actionsDiv = bannerUploadBtn.closest('.avatar-upload-actions');
|
const actionsDiv = bannerUploadBtn.closest('.avatar-upload-actions');
|
||||||
if (actionsDiv) {
|
if (actionsDiv) {
|
||||||
|
const editBtn = document.createElement('button');
|
||||||
|
editBtn.type = 'button'; editBtn.id = 'banner-edit-btn';
|
||||||
|
editBtn.className = 'button';
|
||||||
|
editBtn.textContent = 'Edit Position';
|
||||||
|
actionsDiv.appendChild(editBtn);
|
||||||
|
|
||||||
const btn = document.createElement('button');
|
const btn = document.createElement('button');
|
||||||
btn.type = 'button'; btn.id = 'banner-remove-btn';
|
btn.type = 'button'; btn.id = 'banner-remove-btn';
|
||||||
btn.className = 'button button-danger';
|
btn.className = 'button button-danger';
|
||||||
@@ -699,9 +779,9 @@
|
|||||||
if (bannerFilenameSpan) bannerFilenameSpan.textContent = file.name;
|
if (bannerFilenameSpan) bannerFilenameSpan.textContent = file.name;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const blob = await showBannerCropModal(file);
|
const payload = await showBannerCropModal(file);
|
||||||
if (blob) {
|
if (payload) {
|
||||||
uploadBannerBlob(blob);
|
uploadBannerPayload(payload);
|
||||||
} else {
|
} else {
|
||||||
// Modal was cancelled
|
// Modal was cancelled
|
||||||
if (bannerFilenameSpan) bannerFilenameSpan.textContent = 'No file selected';
|
if (bannerFilenameSpan) bannerFilenameSpan.textContent = 'No file selected';
|
||||||
@@ -716,6 +796,43 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Edit banner position handler ─────────────────────────────────────────
|
||||||
|
document.addEventListener('click', async (e) => {
|
||||||
|
if (e.target.id === 'banner-edit-btn') {
|
||||||
|
const preview = document.getElementById('banner-preview');
|
||||||
|
if (!preview) return;
|
||||||
|
|
||||||
|
const bgImg = preview.style.backgroundImage;
|
||||||
|
const bgPos = preview.style.backgroundPosition;
|
||||||
|
const bgSize = preview.style.backgroundSize;
|
||||||
|
|
||||||
|
const urlMatch = bgImg.match(/url\(['"]?(.*?)['"]?\)/);
|
||||||
|
if (!urlMatch) return;
|
||||||
|
const url = urlMatch[1].split('?')[0]; // Strip cache buster if present
|
||||||
|
|
||||||
|
try {
|
||||||
|
e.target.disabled = true;
|
||||||
|
e.target.textContent = 'Loading...';
|
||||||
|
|
||||||
|
const res = await fetch(url);
|
||||||
|
const blob = await res.blob();
|
||||||
|
const file = new File([blob], 'banner.webp', { type: blob.type });
|
||||||
|
|
||||||
|
showBannerStatus('', '');
|
||||||
|
const payload = await showBannerCropModal(file, { bgPos, bgSize });
|
||||||
|
if (payload) {
|
||||||
|
uploadBannerPayload(payload);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[EDIT BANNER] Error:', err);
|
||||||
|
showBannerStatus('Failed to load current banner for editing', 'error');
|
||||||
|
} finally {
|
||||||
|
e.target.disabled = false;
|
||||||
|
e.target.textContent = 'Edit Position';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ── Remove banner handler ────────────────────────────────────────────────
|
// ── Remove banner handler ────────────────────────────────────────────────
|
||||||
document.addEventListener('click', async (e) => {
|
document.addEventListener('click', async (e) => {
|
||||||
if (e.target.id === 'banner-remove-btn') {
|
if (e.target.id === 'banner-remove-btn') {
|
||||||
@@ -1410,6 +1527,11 @@
|
|||||||
if (colorPicker && colorHex) {
|
if (colorPicker && colorHex) {
|
||||||
colorPicker.addEventListener('input', () => {
|
colorPicker.addEventListener('input', () => {
|
||||||
colorHex.value = colorPicker.value;
|
colorHex.value = colorPicker.value;
|
||||||
|
const liveBox = document.getElementById('live-profile-preview-box');
|
||||||
|
if (liveBox) {
|
||||||
|
liveBox.style.setProperty('--author-accent', colorPicker.value);
|
||||||
|
liveBox.style.setProperty('--author-border', colorPicker.value);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Text input → swatch (validate on each keystroke)
|
// Text input → swatch (validate on each keystroke)
|
||||||
@@ -1418,6 +1540,11 @@
|
|||||||
if (/^#[0-9a-fA-F]{6}$/.test(val)) {
|
if (/^#[0-9a-fA-F]{6}$/.test(val)) {
|
||||||
colorPicker.value = val;
|
colorPicker.value = val;
|
||||||
colorHex.style.borderColor = '';
|
colorHex.style.borderColor = '';
|
||||||
|
const liveBox = document.getElementById('live-profile-preview-box');
|
||||||
|
if (liveBox) {
|
||||||
|
liveBox.style.setProperty('--author-accent', val);
|
||||||
|
liveBox.style.setProperty('--author-border', val);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
colorHex.style.borderColor = '#e74c3c';
|
colorHex.style.borderColor = '#e74c3c';
|
||||||
}
|
}
|
||||||
@@ -1474,6 +1601,11 @@
|
|||||||
const defaultColor = '#ffffff';
|
const defaultColor = '#ffffff';
|
||||||
colorPicker.value = defaultColor;
|
colorPicker.value = defaultColor;
|
||||||
if (colorHex) { colorHex.value = defaultColor; colorHex.style.borderColor = ''; }
|
if (colorHex) { colorHex.value = defaultColor; colorHex.style.borderColor = ''; }
|
||||||
|
const liveBox = document.getElementById('live-profile-preview-box');
|
||||||
|
if (liveBox) {
|
||||||
|
liveBox.style.setProperty('--author-accent', defaultColor);
|
||||||
|
liveBox.style.setProperty('--author-border', defaultColor);
|
||||||
|
}
|
||||||
|
|
||||||
resetColorBtn.disabled = true;
|
resetColorBtn.disabled = true;
|
||||||
resetColorBtn.textContent = 'Resetting...';
|
resetColorBtn.textContent = 'Resetting...';
|
||||||
@@ -1749,6 +1881,13 @@
|
|||||||
const displayNameInput = document.getElementById('display_name_input');
|
const displayNameInput = document.getElementById('display_name_input');
|
||||||
const displayNameStatus = document.getElementById('display-name-status');
|
const displayNameStatus = document.getElementById('display-name-status');
|
||||||
if (displayNameBtn && displayNameInput) {
|
if (displayNameBtn && displayNameInput) {
|
||||||
|
displayNameInput.addEventListener('input', () => {
|
||||||
|
const liveName = document.getElementById('live-preview-username');
|
||||||
|
if (liveName) {
|
||||||
|
liveName.textContent = displayNameInput.value.trim() || window.f0ckSession?.user || 'Username';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
displayNameBtn.addEventListener('click', async () => {
|
displayNameBtn.addEventListener('click', async () => {
|
||||||
const display_name = displayNameInput.value;
|
const display_name = displayNameInput.value;
|
||||||
displayNameBtn.disabled = true;
|
displayNameBtn.disabled = true;
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ export const handleBannerUpload = async (req, res) => {
|
|||||||
// NOTE: [0] frame selector must be appended to the input path, not a separate arg
|
// NOTE: [0] frame selector must be appended to the input path, not a separate arg
|
||||||
console.log('[BANNER HANDLER] Running magick...');
|
console.log('[BANNER HANDLER] Running magick...');
|
||||||
try {
|
try {
|
||||||
await execFile('magick', [`${tmpPath}[0]`, '-resize', '1200x400^', '-gravity', 'center', '-background', 'none', '-extent', '1200x400', '-quality', '75', finalPath]);
|
await execFile('magick', [`${tmpPath}[0]`, '-resize', '3840x3840>', '-quality', '85', finalPath]);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[BANNER HANDLER] Magick error:', err.message, err.stderr);
|
console.error('[BANNER HANDLER] Magick error:', err.message, err.stderr);
|
||||||
await fs.unlink(tmpPath).catch(() => { });
|
await fs.unlink(tmpPath).catch(() => { });
|
||||||
@@ -172,7 +172,9 @@ export const handleBannerUpload = async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
await db`
|
await db`
|
||||||
update user_options
|
update user_options
|
||||||
set banner_file = ${finalFilename}
|
set banner_file = ${finalFilename},
|
||||||
|
banner_position = ${parts.banner_position || 'center'},
|
||||||
|
banner_size = ${parts.banner_size || 'cover'}
|
||||||
where user_id = ${+req.session.id}
|
where user_id = ${+req.session.id}
|
||||||
`;
|
`;
|
||||||
} catch (dbErr) {
|
} catch (dbErr) {
|
||||||
|
|||||||
@@ -535,6 +535,8 @@ export default {
|
|||||||
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.banner_file as author_banner_file,
|
||||||
|
uo.banner_position as author_banner_position,
|
||||||
|
uo.banner_size as author_banner_size,
|
||||||
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,
|
||||||
@@ -796,6 +798,8 @@ export default {
|
|||||||
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_banner_file: actitem.author_banner_file,
|
||||||
|
author_banner_position: actitem.author_banner_position,
|
||||||
|
author_banner_size: actitem.author_banner_size,
|
||||||
author_description: actitem.author_description,
|
author_description: actitem.author_description,
|
||||||
title: actitem.title || null,
|
title: actitem.title || null,
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ export default (router, tpl) => {
|
|||||||
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,
|
banner_file: userOptions?.banner_file || null,
|
||||||
|
banner_position: userOptions?.banner_position || 'center',
|
||||||
|
banner_size: userOptions?.banner_size || 'cover',
|
||||||
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,
|
user_banner_enabled: cfg.websrv.user_banner_enabled !== false,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<div class="profile_head">
|
<div class="profile_head" style="@if(user.banner_file && user_banner_enabled) --author-banner: url('/a/{{ user.banner_file }}'); --author-banner-position: {{ user.banner_position || 'center' }}; --author-banner-size: {{ user.banner_size || 'cover' }}; @endif">
|
||||||
@if(user.avatar_file)
|
@if(user.avatar_file)
|
||||||
<div class="profile_head_avatar">
|
<div class="profile_head_avatar">
|
||||||
<img src="/a/{{ user.avatar_file }}" style="display: grid;width: 55px" />
|
<img src="/a/{{ user.avatar_file }}" style="display: grid;width: 55px" />
|
||||||
|
|||||||
@@ -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;@if(item.author_banner_file && user_banner_enabled) --author-banner: url('/a/{{ item.author_banner_file }}');@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 }}'); --author-banner-position: {{ item.author_banner_position || 'center' }}; --author-banner-size: {{ item.author_banner_size || 'cover' }};@endif">
|
||||||
|
|
||||||
<div class="user-infobox-avatar">
|
<div class="user-infobox-avatar">
|
||||||
<a href="/user/{{ (item.username || '').toLowerCase() }}">
|
<a href="/user/{{ (item.username || '').toLowerCase() }}">
|
||||||
|
|||||||
@@ -140,14 +140,14 @@
|
|||||||
<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;">Profile Banner</legend>
|
<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-settings-wrapper">
|
||||||
<div class="avatar-preview-container" style="min-width: 0; flex: 1 1 auto;">
|
<div class="avatar-preview-container" style="width: 360px; max-width: 100%;">
|
||||||
<div class="avatar-preview-label">Current Banner</div>
|
<div class="avatar-preview-label">Current Banner</div>
|
||||||
@if(banner_file)
|
@if(banner_file)
|
||||||
<a href="/user/{!! session.user !!}" target="_blank" style="display:block;">
|
<a href="/user/{!! session.user !!}" target="_blank" style="display:block; width: 100%;">
|
||||||
<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;">
|
<div id="banner-preview" class="banner-preview-img" style="width:100%;aspect-ratio:4/1;border-radius:4px;border:1px solid var(--nav-border-color);background-image:url('/a/{{ banner_file }}');background-size:{{ banner_size || 'cover' }};background-position:{{ banner_position || 'center' }};background-repeat:no-repeat;"></div>
|
||||||
</a>
|
</a>
|
||||||
@else
|
@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>
|
<div id="banner-preview" class="banner-preview-img banner-placeholder" style="width:100%;aspect-ratio:4/1;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
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -170,6 +170,7 @@
|
|||||||
<div class="avatar-upload-actions">
|
<div class="avatar-upload-actions">
|
||||||
<button type="button" id="banner-upload-btn" class="button" disabled>Upload Banner</button>
|
<button type="button" id="banner-upload-btn" class="button" disabled>Upload Banner</button>
|
||||||
@if(banner_file)
|
@if(banner_file)
|
||||||
|
<button type="button" id="banner-edit-btn" class="button">Edit Position</button>
|
||||||
<button type="button" id="banner-remove-btn" class="button button-danger">Remove Banner</button>
|
<button type="button" id="banner-remove-btn" class="button button-danger">Remove Banner</button>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
@@ -211,6 +212,39 @@
|
|||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
|
<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;">Live Profile Preview</legend>
|
||||||
|
<div class="setting-item" style="max-width: 750px;">
|
||||||
|
<div class="user-infobox-block" id="live-profile-preview-box" style="--author-accent: {{ session.username_color || 'var(--accent)' }}; --author-border: {{ session.username_color || 'var(--accent)' }}; @if(banner_file && user_banner_enabled) --author-banner: url('/a/{{ banner_file }}?t={{ Date.now() }}'); --author-banner-position: {{ banner_position || 'center' }}; --author-banner-size: {{ banner_size || 'cover' }}; @endif">
|
||||||
|
<div class="user-infobox-avatar">
|
||||||
|
<a href="#">
|
||||||
|
@if(avatar_file)
|
||||||
|
<img id="live-preview-avatar" src="/a/{{ avatar_file }}" />
|
||||||
|
@else
|
||||||
|
<img id="live-preview-avatar" src="/a/default.png" style="border-color: #444;" />
|
||||||
|
@endif
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="user-infobox-info">
|
||||||
|
<div class="user-infobox-header">
|
||||||
|
<div class="user-infobox-username-container">
|
||||||
|
<a id="live-preview-username" href="#" class="user-infobox-username">{!! session.display_name || session.user !!}</a>
|
||||||
|
</div>
|
||||||
|
<span class="user-infobox-timestamp"><a href="#" class="timestamp-link"><time>just now</time></a></span>
|
||||||
|
</div>
|
||||||
|
<div class="user-infobox-body">
|
||||||
|
<div class="user-infobox-description">
|
||||||
|
{!! session.description || 'Welcome to my profile! This is a live preview.' !!}
|
||||||
|
</div>
|
||||||
|
<div class="user-infobox-actions">
|
||||||
|
<i class="iconset fa-solid fa-heart" title="Favorite"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
<!-- ═══════════════════════════════ PREFERENCES ═══════════════════════════════ -->
|
<!-- ═══════════════════════════════ PREFERENCES ═══════════════════════════════ -->
|
||||||
<h2 id="preferences">{{ t('settings.preferences') }}</h2>
|
<h2 id="preferences">{{ t('settings.preferences') }}</h2>
|
||||||
<div class="preferences-settings-wrapper">
|
<div class="preferences-settings-wrapper">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<div class="profile_head">
|
<div class="profile_head" style="@if(user.banner_file && user_banner_enabled) --author-banner: url('/a/{{ user.banner_file }}'); --author-banner-position: {{ user.banner_position || 'center' }}; --author-banner-size: {{ user.banner_size || 'cover' }}; @endif">
|
||||||
@if(user.is_ghost)
|
@if(user.is_ghost)
|
||||||
<div class="profile_head_avatar">
|
<div class="profile_head_avatar">
|
||||||
<div class="avatar-placeholder" style="background: #444; color: #888; width: 55px; height: 55px; display: flex; align-items: center; justify-content: center; font-size: 24px; font-weight: bold; border-radius: 4px;">?</div>
|
<div class="avatar-placeholder" style="background: #444; color: #888; width: 55px; height: 55px; display: flex; align-items: center; justify-content: center; font-size: 24px; font-weight: bold; border-radius: 4px;">?</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user