esifawafwu
This commit is contained in:
@@ -50,6 +50,7 @@
|
|||||||
"rate_item": false,
|
"rate_item": false,
|
||||||
"filter": true,
|
"filter": true,
|
||||||
"exclude_tags": true,
|
"exclude_tags": true,
|
||||||
|
"chan": false,
|
||||||
"anonymize_users": false,
|
"anonymize_users": false,
|
||||||
"allowed_modes": [
|
"allowed_modes": [
|
||||||
"sfw",
|
"sfw",
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ anonymous_permissions:
|
|||||||
rate_item: false
|
rate_item: false
|
||||||
filter: true
|
filter: true
|
||||||
exclude_tags: true
|
exclude_tags: true
|
||||||
|
chan: false
|
||||||
anonymize_users: false
|
anonymize_users: false
|
||||||
allowed_modes:
|
allowed_modes:
|
||||||
- sfw
|
- sfw
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ services:
|
|||||||
- ./f0ckm-data/koepfe/:/opt/f0ckm/public/s/koepfe/:Z
|
- ./f0ckm-data/koepfe/:/opt/f0ckm/public/s/koepfe/:Z
|
||||||
- ./f0ckm-data/import/:/opt/f0ckm/f0ckm-data/import/:Z
|
- ./f0ckm-data/import/:/opt/f0ckm/f0ckm-data/import/:Z
|
||||||
- ./f0ckm-data/manifest.json:/opt/f0ckm/public/manifest.json:Z
|
- ./f0ckm-data/manifest.json:/opt/f0ckm/public/manifest.json:Z
|
||||||
|
- ./f0ckm-data/s/img/navbar/:/opt/f0ckm/public/s/img/navbar/:Z
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
- GIT_HASH=${f0ckm_TAG:-unknown}
|
- GIT_HASH=${f0ckm_TAG:-unknown}
|
||||||
|
|||||||
+9
-2
@@ -5,8 +5,14 @@
|
|||||||
"main": "index.mjs",
|
"main": "index.mjs",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"prestart": "node scripts/generate-config.mjs",
|
||||||
"start": "node --trace-uncaught src/index.mjs",
|
"start": "node --trace-uncaught src/index.mjs",
|
||||||
"dev": "STORAGE_DIR=f0ckm-data DB_HOST=localhost DB_PORT=5454 node --trace-uncaught --watch src/index.mjs",
|
"predev": "node scripts/generate-config.mjs",
|
||||||
|
"dev": "STORAGE_DIR=f0ckm-data DB_HOST=localhost DB_PORT=5454 node --trace-uncaught --watch --watch-path src src/index.mjs",
|
||||||
|
"devv2": "STORAGE_DIR=/home/kibi/Projects/f0ckmv2/f0ckm/f0ckm-data DB_HOST=localhost DB_PORT=5455 node --trace-uncaught --watch --watch-path src src/index.mjs",
|
||||||
|
"config:gen": "node scripts/generate-config.mjs",
|
||||||
|
"config:watch": "node scripts/generate-config.mjs --watch",
|
||||||
|
"config:to-yaml": "node scripts/generate-config.mjs --to-yaml",
|
||||||
"trigger": "node debug/trigger.mjs",
|
"trigger": "node debug/trigger.mjs",
|
||||||
"autotagger": "node debug/autotagger.mjs",
|
"autotagger": "node debug/autotagger.mjs",
|
||||||
"thumbnailer": "node debug/thumbnailer.mjs",
|
"thumbnailer": "node debug/thumbnailer.mjs",
|
||||||
@@ -30,6 +36,7 @@
|
|||||||
"jszip": "3.10.1",
|
"jszip": "3.10.1",
|
||||||
"marked": "18.0.2",
|
"marked": "18.0.2",
|
||||||
"matrix-js-sdk": "^40.3.0-rc.0",
|
"matrix-js-sdk": "^40.3.0-rc.0",
|
||||||
"postgres": "^3.3.4"
|
"postgres": "^3.3.4",
|
||||||
|
"yaml": "^2.9.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3829,6 +3829,25 @@ body.sidebar-right-hidden #sidebar-drag-zone {
|
|||||||
margin-right: 8px;
|
margin-right: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Sub-tab nav + background mode bar: scroll horizontally inside themselves instead of overflowing the sidebar */
|
||||||
|
.f0ck-tuner-subtabs-nav,
|
||||||
|
.f0ck-tuner-mode-bar {
|
||||||
|
min-width: 0;
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
overscroll-behavior-x: contain;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: rgba(255, 255, 255, 0.2) transparent;
|
||||||
|
}
|
||||||
|
.f0ck-tuner-subtabs-nav::-webkit-scrollbar,
|
||||||
|
.f0ck-tuner-mode-bar::-webkit-scrollbar { height: 4px; }
|
||||||
|
.f0ck-tuner-subtabs-nav::-webkit-scrollbar-thumb,
|
||||||
|
.f0ck-tuner-mode-bar::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.2); }
|
||||||
|
/* Buttons keep their natural width (grow to fill when there is room); mode-bar buttons set flex:1 inline */
|
||||||
|
.f0ck-tuner-subtabs-nav > .f0ck-tuner-subtab-btn { flex: 1 0 auto; }
|
||||||
|
.f0ck-tuner-mode-bar > .f0ck-tuner-mode-btn { flex: 1 0 auto !important; }
|
||||||
|
.f0ck-tuner-header > .f0ck-tuner-close { flex-shrink: 0; }
|
||||||
|
|
||||||
.f0ck-tuner-subtab-btn,
|
.f0ck-tuner-subtab-btn,
|
||||||
.f0ck-tuner-mode-btn {
|
.f0ck-tuner-mode-btn {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -3958,6 +3977,112 @@ body.sidebar-right-hidden #sidebar-drag-zone {
|
|||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Tuner: style guide (square, flat, theme colours, no glow) + touch-friendly sliders ── */
|
||||||
|
.f0ck-audio-tuner-panel,
|
||||||
|
.f0ck-audio-tuner-panel *,
|
||||||
|
.f0ck-audio-tuner-panel *::before,
|
||||||
|
.f0ck-audio-tuner-panel *::after { border-radius: 0 !important; }
|
||||||
|
|
||||||
|
.f0ck-audio-tuner-panel:not(.in-sidebar) {
|
||||||
|
background: var(--bg, #111);
|
||||||
|
border: 1px solid var(--accent, #99ff00);
|
||||||
|
box-shadow: 0 0 20px rgba(0, 0, 0, 0.5);
|
||||||
|
backdrop-filter: none;
|
||||||
|
-webkit-backdrop-filter: none;
|
||||||
|
}
|
||||||
|
.f0ck-audio-tuner-panel { font-family: inherit; color: var(--text-color, var(--white, #e6e6e6)); }
|
||||||
|
|
||||||
|
.f0ck-audio-tuner-panel .f0ck-tuner-header { border-bottom-color: rgba(255, 255, 255, 0.1); }
|
||||||
|
.f0ck-audio-tuner-panel .f0ck-tuner-section-title {
|
||||||
|
color: var(--text-muted, #8a8f98);
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
display: flex; align-items: center; gap: 6px;
|
||||||
|
}
|
||||||
|
.f0ck-audio-tuner-panel .f0ck-tuner-section-title::before {
|
||||||
|
content: ''; width: 3px; height: 10px; background: var(--accent, #99ff00); flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sub-tabs + mode switch: segmented control, active = solid accent, no glow */
|
||||||
|
.f0ck-audio-tuner-panel .f0ck-tuner-subtabs-nav,
|
||||||
|
.f0ck-audio-tuner-panel .f0ck-tuner-mode-bar {
|
||||||
|
background: rgba(0, 0, 0, 0.35) !important;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
.f0ck-audio-tuner-panel .f0ck-tuner-subtab-btn,
|
||||||
|
.f0ck-audio-tuner-panel .f0ck-tuner-mode-btn { color: var(--text-muted, #8e8e93); }
|
||||||
|
.f0ck-audio-tuner-panel .f0ck-tuner-subtab-btn:hover,
|
||||||
|
.f0ck-audio-tuner-panel .f0ck-tuner-mode-btn:hover { color: var(--text-color, #fff); background: rgba(255, 255, 255, 0.06); }
|
||||||
|
.f0ck-audio-tuner-panel .f0ck-tuner-subtab-btn.active,
|
||||||
|
.f0ck-audio-tuner-panel .f0ck-tuner-mode-btn.active { background: var(--accent, #99ff00); color: #000; box-shadow: none; }
|
||||||
|
|
||||||
|
/* Panels / cards */
|
||||||
|
.f0ck-audio-tuner-panel :is(.f0ck-tuner-presets-bar, .f0ck-danmaku-debug-card) {
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inputs & selects */
|
||||||
|
.f0ck-audio-tuner-panel :is(.f0ck-tuner-preset-select, .f0ck-tuner-preset-input, input[type="text"], input[type="number"], select) {
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
color: var(--text-color, #fff);
|
||||||
|
min-height: 30px;
|
||||||
|
}
|
||||||
|
.f0ck-audio-tuner-panel :is(.f0ck-tuner-preset-select, .f0ck-tuner-preset-input, input[type="text"], input[type="number"], select):focus {
|
||||||
|
border-color: var(--accent, #99ff00); outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons: primary = solid accent (outline on hover), the rest outlined; no glows */
|
||||||
|
.f0ck-audio-tuner-panel :is(.f0ck-tuner-btn-copy, .f0ck-danmaku-debug-btn.btn-primary) {
|
||||||
|
background: var(--accent, #99ff00); border: 1px solid var(--accent, #99ff00); color: #000; filter: none;
|
||||||
|
}
|
||||||
|
.f0ck-audio-tuner-panel :is(.f0ck-tuner-btn-copy, .f0ck-danmaku-debug-btn.btn-primary):hover {
|
||||||
|
background: transparent; color: var(--accent, #99ff00); filter: none;
|
||||||
|
}
|
||||||
|
.f0ck-audio-tuner-panel .f0ck-tuner-btn-save-default { background: transparent; border-color: var(--accent, #99ff00); }
|
||||||
|
.f0ck-audio-tuner-panel .f0ck-tuner-btn-save-default:hover { box-shadow: none; }
|
||||||
|
.f0ck-audio-tuner-panel :is(button, select, input):focus-visible { outline: 2px solid var(--accent, #99ff00); outline-offset: 1px; }
|
||||||
|
|
||||||
|
/* Value readout next to each label */
|
||||||
|
.f0ck-audio-tuner-panel .f0ck-tuner-row-label { color: var(--text-muted, #bbb); }
|
||||||
|
.f0ck-audio-tuner-panel .f0ck-tuner-row-label span:last-child {
|
||||||
|
color: var(--accent, #99ff00); font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sliders: flat track, square accent thumb, generous hit area.
|
||||||
|
touch-action pan-y: vertical drags still scroll the panel, horizontal drags move the slider. */
|
||||||
|
.f0ck-audio-tuner-panel input[type="range"] {
|
||||||
|
-webkit-appearance: none; appearance: none;
|
||||||
|
width: 100%; height: 22px; margin: 0; padding: 0;
|
||||||
|
background: transparent; cursor: pointer; touch-action: pan-y; display: block;
|
||||||
|
}
|
||||||
|
.f0ck-audio-tuner-panel input[type="range"]::-webkit-slider-runnable-track {
|
||||||
|
height: 4px; background: rgba(255, 255, 255, 0.15); border: 0;
|
||||||
|
}
|
||||||
|
.f0ck-audio-tuner-panel input[type="range"]::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none; appearance: none;
|
||||||
|
width: 14px; height: 14px; margin-top: -5px;
|
||||||
|
background: var(--accent, #99ff00); border: 0; box-shadow: 0 0 0 3px rgba(0, 0, 0, 0.45);
|
||||||
|
transition: transform 0.1s;
|
||||||
|
}
|
||||||
|
.f0ck-audio-tuner-panel input[type="range"]:active::-webkit-slider-thumb { transform: scale(1.15); }
|
||||||
|
.f0ck-audio-tuner-panel input[type="range"]::-moz-range-track { height: 4px; background: rgba(255, 255, 255, 0.15); border: 0; }
|
||||||
|
.f0ck-audio-tuner-panel input[type="range"]::-moz-range-progress { height: 4px; background: var(--accent, #99ff00); }
|
||||||
|
.f0ck-audio-tuner-panel input[type="range"]::-moz-range-thumb {
|
||||||
|
width: 14px; height: 14px; background: var(--accent, #99ff00); border: 0; box-shadow: 0 0 0 3px rgba(0, 0, 0, 0.45);
|
||||||
|
}
|
||||||
|
.f0ck-audio-tuner-panel input[type="range"]:focus { outline: none; }
|
||||||
|
.f0ck-audio-tuner-panel input[type="range"]:focus-visible::-webkit-slider-thumb { outline: 2px solid var(--text-color, #fff); outline-offset: 1px; }
|
||||||
|
|
||||||
|
/* Touch screens: bigger thumb and row so the slider is easy to grab */
|
||||||
|
@media (pointer: coarse) {
|
||||||
|
.f0ck-audio-tuner-panel input[type="range"] { height: 34px; }
|
||||||
|
.f0ck-audio-tuner-panel input[type="range"]::-webkit-slider-thumb { width: 22px; height: 22px; margin-top: -9px; }
|
||||||
|
.f0ck-audio-tuner-panel input[type="range"]::-moz-range-thumb { width: 22px; height: 22px; }
|
||||||
|
.f0ck-audio-tuner-panel .f0ck-tuner-row { margin-bottom: 6px; }
|
||||||
|
}
|
||||||
|
|
||||||
.sidebar-video-details {
|
.sidebar-video-details {
|
||||||
display: flex;
|
display: flex;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
@@ -5281,6 +5406,7 @@ body.layout-legacy .blahlol {
|
|||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
border-left: 1px solid var(--nav-border-color);
|
border-left: 1px solid var(--nav-border-color);
|
||||||
border-right: 1px solid var(--nav-border-color);
|
border-right: 1px solid var(--nav-border-color);
|
||||||
|
border-top: 1px solid var(--nav-border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
body.layout-legacy .comments-list:not(:has(.comment)) {
|
body.layout-legacy .comments-list:not(:has(.comment)) {
|
||||||
|
|||||||
+77
-11
@@ -369,6 +369,55 @@
|
|||||||
return finishData;
|
return finishData;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Add a passkey to the current anonymous identity ───────────────────
|
||||||
|
|
||||||
|
async addPasskey() {
|
||||||
|
if (!this.supported) throw new Error('WebAuthn / Passkeys are not supported in this browser.');
|
||||||
|
|
||||||
|
const csrfToken = (window.f0ckSession && window.f0ckSession.csrf_token)
|
||||||
|
|| document.querySelector('meta[name="csrf-token"]')?.content || '';
|
||||||
|
const beginRes = await fetch('/api/v2/anon/passkey/add/begin', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'x-csrf-token': csrfToken },
|
||||||
|
body: JSON.stringify({})
|
||||||
|
});
|
||||||
|
const beginData = await beginRes.json();
|
||||||
|
if (!beginData.success) throw new Error(beginData.msg || 'Server error');
|
||||||
|
|
||||||
|
const rawChallenge = beginData.options.challenge;
|
||||||
|
const opts = beginData.options;
|
||||||
|
|
||||||
|
const cred = await navigator.credentials.create({ publicKey: {
|
||||||
|
rp: opts.rp,
|
||||||
|
user: {
|
||||||
|
id: b64urlToArr(opts.user.id),
|
||||||
|
name: opts.user.name,
|
||||||
|
displayName: opts.user.displayName
|
||||||
|
},
|
||||||
|
challenge: b64urlToArr(rawChallenge),
|
||||||
|
pubKeyCredParams: opts.pubKeyCredParams,
|
||||||
|
timeout: opts.timeout || 60000,
|
||||||
|
excludeCredentials: (opts.excludeCredentials || []).map(c => ({ type: c.type, id: b64urlToArr(c.id) })),
|
||||||
|
authenticatorSelection: opts.authenticatorSelection,
|
||||||
|
attestation: opts.attestation || 'none'
|
||||||
|
}});
|
||||||
|
|
||||||
|
const finishRes = await fetch('/api/v2/anon/passkey/add/finish', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'x-csrf-token': csrfToken },
|
||||||
|
body: JSON.stringify({
|
||||||
|
challenge: rawChallenge,
|
||||||
|
credentialId: arrToB64url(cred.rawId),
|
||||||
|
clientDataJSON: arrToB64url(cred.response.clientDataJSON),
|
||||||
|
attestationObject: arrToB64url(cred.response.attestationObject),
|
||||||
|
hw_fingerprint: await getHardwareFingerprint()
|
||||||
|
})
|
||||||
|
});
|
||||||
|
const finishData = await finishRes.json();
|
||||||
|
if (!finishData.success) throw new Error(finishData.msg || 'Registration failed');
|
||||||
|
return finishData;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
_hasLocalPasskey() {
|
_hasLocalPasskey() {
|
||||||
@@ -490,12 +539,26 @@
|
|||||||
const res = await fetch('/api/v2/anon/identity');
|
const res = await fetch('/api/v2/anon/identity');
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const fpEl = document.getElementById('anon-pk-fp-display');
|
const fpEl = document.getElementById('anon-pk-fp-display');
|
||||||
const credEl = document.getElementById('anon-pk-cred-display');
|
|
||||||
if (fpEl) fpEl.textContent = data.fingerprint ? data.fingerprint.slice(7, 15) : 'none';
|
if (fpEl) fpEl.textContent = data.fingerprint ? data.fingerprint.slice(7, 15) : 'none';
|
||||||
if (credEl) credEl.textContent = data.credential_id || '—';
|
this._renderPasskeyCount(data.passkey_count, data.passkey_max);
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_renderPasskeyCount(count, max) {
|
||||||
|
const countEl = document.getElementById('anon-pk-count');
|
||||||
|
const maxEl = document.getElementById('anon-pk-max');
|
||||||
|
const addBtn = document.getElementById('anon-pk-add-btn');
|
||||||
|
if (countEl && typeof count === 'number') countEl.textContent = count;
|
||||||
|
if (maxEl && typeof max === 'number') maxEl.textContent = max;
|
||||||
|
if (addBtn && typeof count === 'number' && typeof max === 'number') {
|
||||||
|
const full = count >= max;
|
||||||
|
addBtn.disabled = full;
|
||||||
|
addBtn.innerHTML = full
|
||||||
|
? '<i class="fa-solid fa-lock"></i> Limit reached'
|
||||||
|
: '<i class="fa-solid fa-plus"></i> Add passkey';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Init ──────────────────────────────────────────────────────────────────
|
// ── Init ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
@@ -568,24 +631,27 @@
|
|||||||
const modalOverlay = document.getElementById('anon-passkey-modal');
|
const modalOverlay = document.getElementById('anon-passkey-modal');
|
||||||
if (modalOverlay) modalOverlay.addEventListener('click', e => { if (e.target === modalOverlay) this.closeModal(); });
|
if (modalOverlay) modalOverlay.addEventListener('click', e => { if (e.target === modalOverlay) this.closeModal(); });
|
||||||
|
|
||||||
// Add-passkey button inside modal (for anonymous users to add a second passkey)
|
// Add-passkey button inside modal — attaches a new passkey to this identity (max enforced server-side)
|
||||||
const addBtn = document.getElementById('anon-pk-add-btn');
|
const addBtn = document.getElementById('anon-pk-add-btn');
|
||||||
if (addBtn) {
|
if (addBtn) {
|
||||||
addBtn.addEventListener('click', async () => {
|
addBtn.addEventListener('click', async () => {
|
||||||
|
const errEl = document.getElementById('anon-pk-error');
|
||||||
|
if (errEl) errEl.style.display = 'none';
|
||||||
addBtn.disabled = true;
|
addBtn.disabled = true;
|
||||||
try {
|
try {
|
||||||
await this.register();
|
const data = await this.addPasskey();
|
||||||
if (typeof window.showToastNotification === 'function') window.showToastNotification('New passkey registered!');
|
if (typeof window.showToastNotification === 'function') window.showToastNotification('Passkey added');
|
||||||
this._refreshModalContent();
|
this._renderPasskeyCount(data.passkey_count, data.passkey_max);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('[ANON_PASSKEY] Modal add passkey error:', err);
|
console.warn('[ANON_PASSKEY] Modal add passkey error:', err);
|
||||||
if (err && err.name === 'NotAllowedError') {
|
if (errEl) {
|
||||||
alert('Passkey creation was cancelled or blocked. If Bitwarden or another password manager is locked, please unlock it.');
|
errEl.style.display = '';
|
||||||
} else {
|
errEl.textContent = (err && err.name === 'NotAllowedError')
|
||||||
alert('Failed to add passkey: ' + ((err && err.message) || 'Unknown error'));
|
? 'Cancelled or blocked. Unlock your password manager and try again.'
|
||||||
|
: ((err && err.message) || 'Failed to add passkey.');
|
||||||
}
|
}
|
||||||
} finally {
|
|
||||||
addBtn.disabled = false;
|
addBtn.disabled = false;
|
||||||
|
this._refreshModalContent();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+39
-14
@@ -1,3 +1,12 @@
|
|||||||
|
// Pages whose content depends on the rating/mime filter and must reload when it changes.
|
||||||
|
// Profile pages count even when both previews are empty (then no .posts grid is rendered).
|
||||||
|
// Elements whose horizontal drags belong to them (sliders, sideways-scrolling rows): swipe gestures ignore touches starting here.
|
||||||
|
window.F0CK_NO_SWIPE_SELECTOR = 'input[type="range"], .f0ck-tuner-subtabs-nav, .f0ck-tuner-mode-bar, [data-no-swipe]';
|
||||||
|
|
||||||
|
window.isFilterReloadableView = function () {
|
||||||
|
return !!document.querySelector('.posts, .tags-grid') || /^\/user\/[^/]+\/?$/.test(window.location.pathname);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
// Normalize percent-encoded characters in the URL bar that are safe to show decoded.
|
// Normalize percent-encoded characters in the URL bar that are safe to show decoded.
|
||||||
// Runs immediately so the address bar is clean before any other JS runs.
|
// Runs immediately so the address bar is clean before any other JS runs.
|
||||||
@@ -1437,7 +1446,7 @@ window.cancelAnimFrame = (function () {
|
|||||||
window.flashMessage('ALL MODE ACTIVATED');
|
window.flashMessage('ALL MODE ACTIVATED');
|
||||||
gridCacheMap.clear();
|
gridCacheMap.clear();
|
||||||
const isOnaraOpen = document.body.classList.contains('onara-modal-open');
|
const isOnaraOpen = document.body.classList.contains('onara-modal-open');
|
||||||
const isGridView = document.querySelector('.posts, .tags-grid');
|
const isGridView = window.isFilterReloadableView();
|
||||||
const isItemView = document.getElementById('prev') || document.getElementById('next') || (typeof isItemPath === 'function' ? isItemPath(window.location.pathname) : (!window.location.pathname.startsWith('/4/') && /^\/\d+(?:[?#]|$)/.test(window.location.pathname)));
|
const isItemView = document.getElementById('prev') || document.getElementById('next') || (typeof isItemPath === 'function' ? isItemPath(window.location.pathname) : (!window.location.pathname.startsWith('/4/') && /^\/\d+(?:[?#]|$)/.test(window.location.pathname)));
|
||||||
let reloadPromise = null;
|
let reloadPromise = null;
|
||||||
if (isOnaraOpen) {
|
if (isOnaraOpen) {
|
||||||
@@ -1501,7 +1510,7 @@ window.cancelAnimFrame = (function () {
|
|||||||
window.flashMessage(label);
|
window.flashMessage(label);
|
||||||
gridCacheMap.clear();
|
gridCacheMap.clear();
|
||||||
const isOnaraOpen = document.body.classList.contains('onara-modal-open');
|
const isOnaraOpen = document.body.classList.contains('onara-modal-open');
|
||||||
const isGridView = document.querySelector('.posts, .tags-grid');
|
const isGridView = window.isFilterReloadableView();
|
||||||
const isItemView = document.getElementById('prev') || document.getElementById('next') || (typeof isItemPath === 'function' ? isItemPath(window.location.pathname) : (!window.location.pathname.startsWith('/4/') && /^\/\d+(?:[?#]|$)/.test(window.location.pathname)));
|
const isItemView = document.getElementById('prev') || document.getElementById('next') || (typeof isItemPath === 'function' ? isItemPath(window.location.pathname) : (!window.location.pathname.startsWith('/4/') && /^\/\d+(?:[?#]|$)/.test(window.location.pathname)));
|
||||||
let reloadPromise = null;
|
let reloadPromise = null;
|
||||||
if (isOnaraOpen) {
|
if (isOnaraOpen) {
|
||||||
@@ -13173,7 +13182,7 @@ window.cancelAnimFrame = (function () {
|
|||||||
|
|
||||||
// Refresh content
|
// Refresh content
|
||||||
const isOnaraOpen = document.body.classList.contains('onara-modal-open');
|
const isOnaraOpen = document.body.classList.contains('onara-modal-open');
|
||||||
const isGridView = document.querySelector('.posts, .tags-grid');
|
const isGridView = window.isFilterReloadableView();
|
||||||
const isItemView = document.getElementById('prev') || document.getElementById('next');
|
const isItemView = document.getElementById('prev') || document.getElementById('next');
|
||||||
|
|
||||||
let reloadPromise = null;
|
let reloadPromise = null;
|
||||||
@@ -13789,6 +13798,7 @@ window.cancelAnimFrame = (function () {
|
|||||||
}, 50);
|
}, 50);
|
||||||
|
|
||||||
const updateDomAfterSave = (resData) => {
|
const updateDomAfterSave = (resData) => {
|
||||||
|
const itemPath = (resData && resData.item_path) || itemId;
|
||||||
try {
|
try {
|
||||||
const finalRating = resData.rating || selectedRating || 'untagged';
|
const finalRating = resData.rating || selectedRating || 'untagged';
|
||||||
|
|
||||||
@@ -13838,11 +13848,11 @@ window.cancelAnimFrame = (function () {
|
|||||||
sep.textContent = '→';
|
sep.textContent = '→';
|
||||||
|
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = `/${itemId}`;
|
a.href = `/${itemPath}`;
|
||||||
a.className = 'location-link is-rehosted';
|
a.className = 'location-link is-rehosted';
|
||||||
a.title = `View rehosted post #${itemId} on f0ckm`;
|
a.title = `View rehosted post /${itemPath} on f0ckm`;
|
||||||
a.style.cssText = 'color: #4ade80; font-weight: 700;';
|
a.style.cssText = 'color: #4ade80; font-weight: 700;';
|
||||||
a.innerHTML = `<i class="fa-solid fa-check"></i> #${itemId}`;
|
a.innerHTML = `<i class="fa-solid fa-check"></i> /${itemPath}`;
|
||||||
|
|
||||||
locEl.appendChild(sep);
|
locEl.appendChild(sep);
|
||||||
locEl.appendChild(a);
|
locEl.appendChild(a);
|
||||||
@@ -13860,8 +13870,8 @@ window.cancelAnimFrame = (function () {
|
|||||||
if (rehostBtn) {
|
if (rehostBtn) {
|
||||||
rehostBtn.outerHTML = `
|
rehostBtn.outerHTML = `
|
||||||
<span class="chan-rehost-btn-group" style="display:inline-flex;gap:4px;align-items:center;">
|
<span class="chan-rehost-btn-group" style="display:inline-flex;gap:4px;align-items:center;">
|
||||||
<a href="/${itemId}" class="chan-rehost-action-btn rehosted" title="View Post #${itemId}" style="display:inline-flex;align-items:center;justify-content:center;gap:6px;padding:4px 12px;border-radius:6px;font-size:0.8rem;background:rgba(74,222,128,0.2);color:#4ade80;border:1px solid rgba(74,222,128,0.4);text-decoration:none;font-weight:600;white-space:nowrap;flex-shrink:0;">
|
<a href="/${itemPath}" class="chan-rehost-action-btn rehosted" title="View /${itemPath}" style="display:inline-flex;align-items:center;justify-content:center;gap:6px;padding:4px 12px;border-radius:6px;font-size:0.8rem;background:rgba(74,222,128,0.2);color:#4ade80;border:1px solid rgba(74,222,128,0.4);text-decoration:none;font-weight:600;white-space:nowrap;flex-shrink:0;">
|
||||||
<i class="fa-solid fa-check"></i> #${itemId}
|
<i class="fa-solid fa-check"></i> /${itemPath}
|
||||||
</a>
|
</a>
|
||||||
<button type="button" class="chan-rehost-edit-btn" data-item-id="${itemId}" title="Edit rating" style="display:inline-flex;align-items:center;padding:3px 7px;border-radius:6px;font-size:0.8rem;background:rgba(255,255,255,0.1);color:#fff;border:1px solid rgba(255,255,255,0.2);cursor:pointer;">
|
<button type="button" class="chan-rehost-edit-btn" data-item-id="${itemId}" title="Edit rating" style="display:inline-flex;align-items:center;padding:3px 7px;border-radius:6px;font-size:0.8rem;background:rgba(255,255,255,0.1);color:#fff;border:1px solid rgba(255,255,255,0.2);cursor:pointer;">
|
||||||
<i class="fa-solid fa-pen"></i>
|
<i class="fa-solid fa-pen"></i>
|
||||||
@@ -13985,8 +13995,8 @@ window.cancelAnimFrame = (function () {
|
|||||||
|
|
||||||
btn.outerHTML = `
|
btn.outerHTML = `
|
||||||
<span class="chan-rehost-btn-group" style="display:inline-flex;gap:4px;align-items:center;">
|
<span class="chan-rehost-btn-group" style="display:inline-flex;gap:4px;align-items:center;">
|
||||||
<a href="/${data.item_id}" class="chan-rehost-action-btn rehosted" title="View Post #${data.item_id}" style="display:inline-flex;align-items:center;justify-content:center;gap:6px;padding:4px 12px;border-radius:6px;font-size:0.8rem;background:rgba(74,222,128,0.2);color:#4ade80;border:1px solid rgba(74,222,128,0.4);text-decoration:none;font-weight:600;white-space:nowrap;flex-shrink:0;">
|
<a href="/${data.item_path || data.item_id}" class="chan-rehost-action-btn rehosted" title="View /${data.item_path || data.item_id}" style="display:inline-flex;align-items:center;justify-content:center;gap:6px;padding:4px 12px;border-radius:6px;font-size:0.8rem;background:rgba(74,222,128,0.2);color:#4ade80;border:1px solid rgba(74,222,128,0.4);text-decoration:none;font-weight:600;white-space:nowrap;flex-shrink:0;">
|
||||||
<i class="fa-solid fa-check"></i> #${data.item_id}
|
<i class="fa-solid fa-check"></i> /${data.item_path || data.item_id}
|
||||||
</a>
|
</a>
|
||||||
<button type="button" class="chan-rehost-edit-btn" data-item-id="${data.item_id}" title="Edit rating" style="display:inline-flex;align-items:center;padding:3px 7px;border-radius:6px;font-size:0.8rem;background:rgba(255,255,255,0.1);color:#fff;border:1px solid rgba(255,255,255,0.2);cursor:pointer;">
|
<button type="button" class="chan-rehost-edit-btn" data-item-id="${data.item_id}" title="Edit rating" style="display:inline-flex;align-items:center;padding:3px 7px;border-radius:6px;font-size:0.8rem;background:rgba(255,255,255,0.1);color:#fff;border:1px solid rgba(255,255,255,0.2);cursor:pointer;">
|
||||||
<i class="fa-solid fa-pen"></i>
|
<i class="fa-solid fa-pen"></i>
|
||||||
@@ -13997,7 +14007,7 @@ window.cancelAnimFrame = (function () {
|
|||||||
window.showToast(data.repost ? 'Already on site — linked to existing post' : 'Rehosted successfully!');
|
window.showToast(data.repost ? 'Already on site — linked to existing post' : 'Rehosted successfully!');
|
||||||
}
|
}
|
||||||
if (typeof window.openChanRehostModal === 'function') {
|
if (typeof window.openChanRehostModal === 'function') {
|
||||||
const newAnchor = document.querySelector(`.chan-rehost-edit-btn[data-item-id="${data.item_id}"]`) || document.querySelector(`.chan-rehost-action-btn[href="/${data.item_id}"]`);
|
const newAnchor = document.querySelector(`.chan-rehost-edit-btn[data-item-id="${data.item_id}"]`) || document.querySelector(`.chan-rehost-action-btn[href="/${data.item_path || data.item_id}"]`);
|
||||||
window.openChanRehostModal({ itemId: data.item_id, anchorEl: newAnchor });
|
window.openChanRehostModal({ itemId: data.item_id, anchorEl: newAnchor });
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -16562,6 +16572,19 @@ window.cancelAnimFrame = (function () {
|
|||||||
if (window.f0ckSession?.disable_swiping) return;
|
if (window.f0ckSession?.disable_swiping) return;
|
||||||
if (document.body.classList.contains('modal-open')) return;
|
if (document.body.classList.contains('modal-open')) return;
|
||||||
|
|
||||||
|
// Sliders and horizontally scrolling rows own their horizontal drags: never start a
|
||||||
|
// sidebar-drag or item-swipe from them (the swipe's preventDefault would freeze the slider)
|
||||||
|
if (e.target?.closest?.(window.F0CK_NO_SWIPE_SELECTOR)) {
|
||||||
|
swipeRT.xDown = null;
|
||||||
|
swipeRT.yDown = null;
|
||||||
|
swipeRT.xDiff = 0;
|
||||||
|
swipeRT.yDiff = 0;
|
||||||
|
swipeRT.isSidebarCandidate = false;
|
||||||
|
swipeRT.isDraggingSidebar = false;
|
||||||
|
swipeRT.startedInSidebarZone = true; // also blocks prev/next navigation on touchend
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const touch = e.touches[0];
|
const touch = e.touches[0];
|
||||||
|
|
||||||
// Check if touch is over a video player, controls, or video element
|
// Check if touch is over a video player, controls, or video element
|
||||||
@@ -17474,13 +17497,15 @@ window.cancelAnimFrame = (function () {
|
|||||||
if (document.body.classList.contains('modal-open')) return;
|
if (document.body.classList.contains('modal-open')) return;
|
||||||
if (!isLSActive()) return;
|
if (!isLSActive()) return;
|
||||||
|
|
||||||
|
swipeRT.isDraggingLeftSidebar = false;
|
||||||
|
swipeRT.isLeftSidebarCandidate = false;
|
||||||
|
if (e.target?.closest?.(window.F0CK_NO_SWIPE_SELECTOR)) return;
|
||||||
|
|
||||||
const { sb, layout } = getLS();
|
const { sb, layout } = getLS();
|
||||||
if (!sb || !layout) return;
|
if (!sb || !layout) return;
|
||||||
|
|
||||||
const touch = e.touches[0];
|
const touch = e.touches[0];
|
||||||
const isHidden = layout.classList.contains('sidebar-hidden');
|
const isHidden = layout.classList.contains('sidebar-hidden');
|
||||||
swipeRT.isDraggingLeftSidebar = false;
|
|
||||||
swipeRT.isLeftSidebarCandidate = false;
|
|
||||||
|
|
||||||
if (isHidden) {
|
if (isHidden) {
|
||||||
if (touch.clientX < 60) {
|
if (touch.clientX < 60) {
|
||||||
@@ -20145,7 +20170,7 @@ document.addEventListener("DOMContentLoaded", function () {
|
|||||||
|
|
||||||
// Trigger reload context-aware
|
// Trigger reload context-aware
|
||||||
const fromModal = !!menu.closest('#excluded-tags-overlay');
|
const fromModal = !!menu.closest('#excluded-tags-overlay');
|
||||||
const postsEl = document.querySelector('.posts, .tags-grid');
|
const postsEl = window.isFilterReloadableView();
|
||||||
let reloadPromise = null;
|
let reloadPromise = null;
|
||||||
|
|
||||||
// Set flag so hideAllModals (called inside loadPageAjax) skips the filter overlay
|
// Set flag so hideAllModals (called inside loadPageAjax) skips the filter overlay
|
||||||
|
|||||||
@@ -1455,7 +1455,7 @@
|
|||||||
${item.is_external && item.external_board && item.external_tid
|
${item.is_external && item.external_board && item.external_tid
|
||||||
? `<a class="scroll-id-link" href="https://boards.4chan.org/${item.external_board}/thread/${item.external_tid}#p${item.external_id}" target="_blank">/${item.external_board}/thread/${item.external_tid}</a>`
|
? `<a class="scroll-id-link" href="https://boards.4chan.org/${item.external_board}/thread/${item.external_tid}#p${item.external_id}" target="_blank">/${item.external_board}/thread/${item.external_tid}</a>`
|
||||||
: (item.local_id
|
: (item.local_id
|
||||||
? `<a class="scroll-id-link" href="/${item.local_id}" target="_blank">#${item.local_id}</a>`
|
? `<a class="scroll-id-link" href="/${item.local_path || item.local_id}" target="_blank">/${item.local_path || item.local_id}</a>`
|
||||||
: `<a class="scroll-id-link" href="/abyss/${item.id}">#${item.id}</a>`)}
|
: `<a class="scroll-id-link" href="/abyss/${item.id}">#${item.id}</a>`)}
|
||||||
</div>`;
|
</div>`;
|
||||||
slide.appendChild(meta);
|
slide.appendChild(meta);
|
||||||
@@ -1486,7 +1486,7 @@
|
|||||||
</button>
|
</button>
|
||||||
${item.is_external ? (
|
${item.is_external ? (
|
||||||
item.local_id
|
item.local_id
|
||||||
? `<a class="scroll-btn rehost-btn success" href="/${item.local_id}" target="_blank" title="${_i.already_added || 'Already added'}">
|
? `<a class="scroll-btn rehost-btn success" href="/${item.local_path || item.local_id}" target="_blank" title="${_i.already_added || 'Already added'}">
|
||||||
<div class="scroll-btn-icon"><i class="fa-solid fa-check"></i></div>
|
<div class="scroll-btn-icon"><i class="fa-solid fa-check"></i></div>
|
||||||
<span class="scroll-btn-label">${_i.view_label || 'View'}</span>
|
<span class="scroll-btn-label">${_i.view_label || 'View'}</span>
|
||||||
</a>`
|
</a>`
|
||||||
@@ -1761,6 +1761,7 @@
|
|||||||
|
|
||||||
const external_media_url = `https://i.4cdn.org/${data.board}/${p.tim}${ext}`;
|
const external_media_url = `https://i.4cdn.org/${data.board}/${p.tim}${ext}`;
|
||||||
const local_id = allRehosts[external_media_url] || null;
|
const local_id = allRehosts[external_media_url] || null;
|
||||||
|
const local_path = (local_id && data.rehost_paths && data.rehost_paths[local_id]) || local_id;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: `${data.board}/${p.no}`,
|
id: `${data.board}/${p.no}`,
|
||||||
@@ -1770,6 +1771,7 @@
|
|||||||
external_source: '4chan',
|
external_source: '4chan',
|
||||||
is_external: true,
|
is_external: true,
|
||||||
local_id,
|
local_id,
|
||||||
|
local_path,
|
||||||
external_media_url,
|
external_media_url,
|
||||||
mime: isVideo ? 'video/unknown' : (isImage ? 'image/unknown' : 'application/octet-stream'),
|
mime: isVideo ? 'video/unknown' : (isImage ? 'image/unknown' : 'application/octet-stream'),
|
||||||
dest: `/api/v2/scroller/external/4chan/${data.board}/media/${p.tim}${ext}`,
|
dest: `/api/v2/scroller/external/4chan/${data.board}/media/${p.tim}${ext}`,
|
||||||
@@ -1972,7 +1974,7 @@
|
|||||||
// Update button to link to the new site-internal post
|
// Update button to link to the new site-internal post
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
btn.outerHTML = `
|
btn.outerHTML = `
|
||||||
<a href="/${data.item_id}" target="_blank" class="scroll-btn rehost-btn success" style="text-decoration:none;">
|
<a href="/${data.item_path || data.item_id}" target="_blank" class="scroll-btn rehost-btn success" style="text-decoration:none;">
|
||||||
<div class="scroll-btn-icon"><i class="fa-solid fa-arrow-up-right-from-square"></i></div>
|
<div class="scroll-btn-icon"><i class="fa-solid fa-arrow-up-right-from-square"></i></div>
|
||||||
<span class="scroll-btn-label">View</span>
|
<span class="scroll-btn-label">View</span>
|
||||||
</a>
|
</a>
|
||||||
@@ -1983,8 +1985,8 @@
|
|||||||
if (slide) {
|
if (slide) {
|
||||||
const idLink = slide.querySelector('.scroll-id-link');
|
const idLink = slide.querySelector('.scroll-id-link');
|
||||||
if (idLink) {
|
if (idLink) {
|
||||||
idLink.href = `/${data.item_id}`;
|
idLink.href = `/${data.item_path || data.item_id}`;
|
||||||
idLink.textContent = `#${data.item_id}`;
|
idLink.textContent = `/${data.item_path || data.item_id}`;
|
||||||
}
|
}
|
||||||
// Reflect rehoster's username and local timestamp
|
// Reflect rehoster's username and local timestamp
|
||||||
if (window.scrollerUsername) {
|
if (window.scrollerUsername) {
|
||||||
|
|||||||
@@ -3401,7 +3401,7 @@ window.initUploadForm = (selector) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (shouldRedirectToItem) {
|
if (shouldRedirectToItem && !lastData?.manual_approval) {
|
||||||
const isMultiNonAlbumShitpost = isShitpost && !isAlbum && (uploadedResults.length > 1 || successCount > 1 || totalFilesToUpload > 1);
|
const isMultiNonAlbumShitpost = isShitpost && !isAlbum && (uploadedResults.length > 1 || successCount > 1 || totalFilesToUpload > 1);
|
||||||
let targetUrl;
|
let targetUrl;
|
||||||
if (isMultiNonAlbumShitpost) {
|
if (isMultiNonAlbumShitpost) {
|
||||||
@@ -3416,7 +3416,7 @@ window.initUploadForm = (selector) => {
|
|||||||
window.location.href = targetUrl;
|
window.location.href = targetUrl;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// else: stay on current page
|
// else: stay on current page (including manual_approval pending)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
restoreBtn();
|
restoreBtn();
|
||||||
|
|||||||
+5
-2
@@ -60,6 +60,9 @@
|
|||||||
(window.f0ckSession && window.f0ckSession.user && document.querySelector('#a_username[data-username]')?.dataset?.username?.toLowerCase() === window.f0ckSession.user.toLowerCase())
|
(window.f0ckSession && window.f0ckSession.user && document.querySelector('#a_username[data-username]')?.dataset?.username?.toLowerCase() === window.f0ckSession.user.toLowerCase())
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Anonymous uploaders may change the rating of their own item without full manage rights
|
||||||
|
const canRate = canManage || !!document.querySelector('#tags[data-can-rate="true"]');
|
||||||
|
|
||||||
// Only remove existing dynamically generated tags
|
// Only remove existing dynamically generated tags
|
||||||
[...inner.querySelectorAll(".badge")].forEach(tag => {
|
[...inner.querySelectorAll(".badge")].forEach(tag => {
|
||||||
// Don't remove the one containing the add/toggle buttons, and don't remove the autocomplete input itself
|
// Don't remove the one containing the add/toggle buttons, and don't remove the autocomplete input itself
|
||||||
@@ -100,7 +103,7 @@
|
|||||||
span.classList.add('rating-tag', `is-${tag.normalized}`);
|
span.classList.add('rating-tag', `is-${tag.normalized}`);
|
||||||
span.dataset.rating = tag.normalized;
|
span.dataset.rating = tag.normalized;
|
||||||
if (activePostId) span.dataset.itemId = activePostId;
|
if (activePostId) span.dataset.itemId = activePostId;
|
||||||
if (canManage) {
|
if (canRate) {
|
||||||
span.classList.add('can-cycle');
|
span.classList.add('can-cycle');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -144,7 +147,7 @@
|
|||||||
const hasRating = !!lastRatingTag;
|
const hasRating = !!lastRatingTag;
|
||||||
if (!hasRating) {
|
if (!hasRating) {
|
||||||
const untaggedSpan = document.createElement("span");
|
const untaggedSpan = document.createElement("span");
|
||||||
untaggedSpan.className = `badge badge-untagged rating-tag is-untagged${canManage ? ' can-cycle' : ''}`;
|
untaggedSpan.className = `badge badge-untagged rating-tag is-untagged${canRate ? ' can-cycle' : ''}`;
|
||||||
untaggedSpan.dataset.rating = 'untagged';
|
untaggedSpan.dataset.rating = 'untagged';
|
||||||
if (activePostId) untaggedSpan.dataset.itemId = activePostId;
|
if (activePostId) untaggedSpan.dataset.itemId = activePostId;
|
||||||
const lbl = document.createElement("span");
|
const lbl = document.createElement("span");
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
/**
|
||||||
|
* chan_http.mjs — curl invocation for all outgoing 4chan requests (API JSON, media, rehost downloads).
|
||||||
|
*
|
||||||
|
* Every call gets a randomized browser identity:
|
||||||
|
* - If curl-impersonate is installed (wrapper binaries like curl_chrome116, curl_ff117, ...), a random
|
||||||
|
* profile is used. These reproduce a real browser's TLS + HTTP/2 handshake and send matching headers,
|
||||||
|
* so we must NOT override the User-Agent (a mismatch would defeat the point).
|
||||||
|
* - Otherwise plain curl is used with a random, current User-Agent and a matching Accept-Language.
|
||||||
|
*
|
||||||
|
* config: main.curl_impersonate
|
||||||
|
* (unset) / true → auto-detect wrappers on PATH
|
||||||
|
* false → never use curl-impersonate
|
||||||
|
* "<dir>" → look for wrappers in that directory (in addition to PATH)
|
||||||
|
*/
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import cfg from './config.mjs';
|
||||||
|
|
||||||
|
const pick = (arr) => arr[Math.floor(Math.random() * arr.length)];
|
||||||
|
|
||||||
|
// Browser majors derived from the date so the pool never goes stale.
|
||||||
|
// Chrome 100 shipped 2022-03-29, Firefox 100 on 2022-05-03; both release every 4 weeks.
|
||||||
|
const majorSince = (base, isoDate) => base + Math.floor((Date.now() - Date.parse(isoDate)) / (28 * 86400000));
|
||||||
|
|
||||||
|
const randomUserAgent = () => {
|
||||||
|
const chrome = majorSince(100, '2022-03-29') - Math.floor(Math.random() * 3); // current or up to 2 behind
|
||||||
|
const firefox = majorSince(100, '2022-05-03') - Math.floor(Math.random() * 3);
|
||||||
|
const templates = [
|
||||||
|
`Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${chrome}.0.0.0 Safari/537.36`,
|
||||||
|
`Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${chrome}.0.0.0 Safari/537.36`,
|
||||||
|
`Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${chrome}.0.0.0 Safari/537.36`,
|
||||||
|
`Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${chrome}.0.0.0 Safari/537.36`,
|
||||||
|
`Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${chrome}.0.0.0 Safari/537.36 Edg/${chrome}.0.0.0`,
|
||||||
|
`Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:${firefox}.0) Gecko/20100101 Firefox/${firefox}.0`,
|
||||||
|
`Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:${firefox}.0) Gecko/20100101 Firefox/${firefox}.0`,
|
||||||
|
`Mozilla/5.0 (X11; Linux x86_64; rv:${firefox}.0) Gecko/20100101 Firefox/${firefox}.0`
|
||||||
|
];
|
||||||
|
return pick(templates);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ACCEPT_LANGUAGES = [
|
||||||
|
'en-US,en;q=0.9',
|
||||||
|
'en-US,en;q=0.8',
|
||||||
|
'en-GB,en;q=0.9,en-US;q=0.8',
|
||||||
|
'en-US,en;q=0.9,de;q=0.8',
|
||||||
|
'de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7'
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── curl-impersonate detection (cached after first lookup) ──────────────────
|
||||||
|
// Wrapper names look like curl_chrome146, curl_firefox147, curl_edge101, curl_safari260, curl_chrome131_android.
|
||||||
|
const IMPERSONATE_RE = /^curl_(chrome|edge|firefox|ff|safari)(\d+)([a-z0-9_]*)$/i;
|
||||||
|
// Only the newest desktop profiles per browser: an old fingerprint (chrome99) stands out as much as plain curl.
|
||||||
|
const PROFILES_PER_BROWSER = 3;
|
||||||
|
let _impersonateBins = null;
|
||||||
|
|
||||||
|
const findImpersonateBins = () => {
|
||||||
|
if (_impersonateBins) return _impersonateBins;
|
||||||
|
const setting = cfg.main?.curl_impersonate;
|
||||||
|
if (setting === false) return (_impersonateBins = []);
|
||||||
|
|
||||||
|
const dirs = (process.env.PATH || '').split(path.delimiter).filter(Boolean);
|
||||||
|
if (typeof setting === 'string' && setting.trim()) dirs.unshift(setting.trim());
|
||||||
|
|
||||||
|
const byBrowser = new Map(); // family → [{ version, full }]
|
||||||
|
const seen = new Set();
|
||||||
|
for (const dir of dirs) {
|
||||||
|
let entries;
|
||||||
|
try { entries = fs.readdirSync(dir); } catch { continue; }
|
||||||
|
for (const name of entries) {
|
||||||
|
const m = name.match(IMPERSONATE_RE);
|
||||||
|
if (!m || m[3] || seen.has(name)) continue; // m[3] = suffix like _android/_ios/a → skip non-desktop variants
|
||||||
|
const full = path.join(dir, name);
|
||||||
|
try { fs.accessSync(full, fs.constants.X_OK); } catch { continue; }
|
||||||
|
seen.add(name);
|
||||||
|
const family = m[1].toLowerCase() === 'ff' ? 'firefox' : m[1].toLowerCase();
|
||||||
|
if (!byBrowser.has(family)) byBrowser.set(family, []);
|
||||||
|
byBrowser.get(family).push({ version: parseInt(m[2], 10), full });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Edge shares Chrome's version numbers; drop Edge profiles that lag far behind the newest Chrome
|
||||||
|
// (releases have shipped edge99/edge101 next to chrome146, which would stand out as ancient).
|
||||||
|
const newestChrome = Math.max(0, ...(byBrowser.get('chrome') || []).map(p => p.version));
|
||||||
|
if (newestChrome && byBrowser.has('edge')) {
|
||||||
|
byBrowser.set('edge', byBrowser.get('edge').filter(p => p.version >= newestChrome - 10));
|
||||||
|
}
|
||||||
|
|
||||||
|
_impersonateBins = [];
|
||||||
|
for (const list of byBrowser.values()) {
|
||||||
|
list.sort((a, b) => b.version - a.version);
|
||||||
|
_impersonateBins.push(...list.slice(0, PROFILES_PER_BROWSER).map(p => p.full));
|
||||||
|
}
|
||||||
|
console.log(_impersonateBins.length
|
||||||
|
? `[BOOT] 4chan requests: curl-impersonate enabled (${_impersonateBins.map(b => path.basename(b)).join(', ')})`
|
||||||
|
: '[BOOT] 4chan requests: curl-impersonate not found, using curl with randomized User-Agent');
|
||||||
|
return _impersonateBins;
|
||||||
|
};
|
||||||
|
|
||||||
|
// The static curl-impersonate build looks for CAs at the Debian/Alpine path only. On other distros
|
||||||
|
// (openSUSE, Fedora, macOS) point it at the local bundle explicitly.
|
||||||
|
const CA_DEFAULT = '/etc/ssl/certs/ca-certificates.crt';
|
||||||
|
const CA_FALLBACKS = ['/etc/ssl/ca-bundle.pem', '/etc/pki/tls/certs/ca-bundle.crt', '/etc/ssl/cert.pem'];
|
||||||
|
let _caArgs = null;
|
||||||
|
const caArgs = () => {
|
||||||
|
if (_caArgs) return _caArgs;
|
||||||
|
if (fs.existsSync(CA_DEFAULT)) return (_caArgs = []);
|
||||||
|
const found = CA_FALLBACKS.find(f => fs.existsSync(f));
|
||||||
|
return (_caArgs = found ? ['--cacert', found] : []);
|
||||||
|
};
|
||||||
|
|
||||||
|
const socksArgs = () => {
|
||||||
|
const socks = cfg.main?.socks;
|
||||||
|
if (!socks || socks === 'undefined') return [];
|
||||||
|
const host = socks.includes('://') ? socks.split('://')[1] : socks;
|
||||||
|
return ['--socks5-hostname', host];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a curl command for a 4chan URL with a randomized browser identity.
|
||||||
|
* @param {string} url
|
||||||
|
* @param {string[]} [extraArgs] additional curl flags (e.g. ['-o', file, '--max-time', '300'])
|
||||||
|
* @returns {{ bin: string, args: string[] }}
|
||||||
|
*/
|
||||||
|
export const chanCurl = (url, extraArgs = []) => {
|
||||||
|
const base = ['-s', '-f', '-L', ...extraArgs, ...socksArgs()];
|
||||||
|
const impersonate = findImpersonateBins();
|
||||||
|
if (impersonate.length) {
|
||||||
|
// Wrapper supplies its own UA, header order and TLS/HTTP2 fingerprint
|
||||||
|
return { bin: pick(impersonate), args: [...base, ...caArgs(), url] };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
bin: 'curl',
|
||||||
|
args: [...base, '-A', randomUserAgent(), '-H', `Accept-Language: ${pick(ACCEPT_LANGUAGES)}`, url]
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default { chanCurl };
|
||||||
+2
-2
@@ -4,7 +4,7 @@ import db from "./sql.mjs";
|
|||||||
|
|
||||||
import cfg from "./config.mjs";
|
import cfg from "./config.mjs";
|
||||||
import { createI18n } from "./i18n.mjs";
|
import { createI18n } from "./i18n.mjs";
|
||||||
import { getEnableAnonymousAccess, isAnonymizeSession, canAnonDo } from "./settings.mjs";
|
import { getEnableAnonymousAccess, isAnonymizeSession, canAnonDo, canUseChan } from "./settings.mjs";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -552,7 +552,7 @@ export default new class {
|
|||||||
}
|
}
|
||||||
return res.redirect('/login');
|
return res.redirect('/login');
|
||||||
}
|
}
|
||||||
const hasGroup = req.session.admin || (Array.isArray(req.session.groups) && req.session.groups.includes('4chan'));
|
const hasGroup = canUseChan(req.session);
|
||||||
if (!hasGroup) {
|
if (!hasGroup) {
|
||||||
if (isApi) {
|
if (isApi) {
|
||||||
return res.reply({ code: 403, body: JSON.stringify({ success: false, msg: "4chan group required" }), type: 'application/json' });
|
return res.reply({ code: 403, body: JSON.stringify({ success: false, msg: "4chan group required" }), type: 'application/json' });
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import db from "../sql.mjs";
|
import db from "../sql.mjs";
|
||||||
import lib from "../lib.mjs";
|
import lib from "../lib.mjs";
|
||||||
import cfg from "../config.mjs";
|
import cfg from "../config.mjs";
|
||||||
import { getEnableItemSlugs, canAnonDo, getAnonAllowedModes, getAnonAllowedMimes, isAnonSession } from "../settings.mjs";
|
import { getEnableItemSlugs, canAnonDo, getAnonAllowedModes, getAnonAllowedMimes, isAnonSession, getSessionOwnerName } from "../settings.mjs";
|
||||||
import { updateHallsCache } from "../halls_cache.mjs";
|
import { updateHallsCache } from "../halls_cache.mjs";
|
||||||
import queue from "../queue.mjs";
|
import queue from "../queue.mjs";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
@@ -129,6 +129,13 @@ const computeBaseMode = (mode, ratings, session) => {
|
|||||||
if (!allowedModes.includes('sfw')) {
|
if (!allowedModes.includes('sfw')) {
|
||||||
baseMode = `(${baseMode}) and not exists (select 1 from tags_assign where item_id = items.id and tag_id = 1)`;
|
baseMode = `(${baseMode}) and not exists (select 1 from tags_assign where item_id = items.id and tag_id = 1)`;
|
||||||
}
|
}
|
||||||
|
// Uploaders always see their own untagged items, even when 'untagged' isn't an allowed anon mode.
|
||||||
|
// The name is interpolated into raw SQL, so only accept the plain shadow-login charset.
|
||||||
|
// Only `items.id` may reference the outer row: some callers alias items (e.g. comments.mjs rewrites items.id → i.id).
|
||||||
|
const ownerName = getSessionOwnerName(session);
|
||||||
|
if (!allowedModes.includes('untagged') && ownerName && /^[a-z0-9_]+$/i.test(ownerName)) {
|
||||||
|
baseMode = `((${baseMode}) or items.id in (select own.id from items own where lower(own.username) = '${ownerName.toLowerCase()}' and not exists (select 1 from tags_assign ota where ota.item_id = own.id and ota.tag_id in (1, 2, ${nsflId}))))`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return baseMode;
|
return baseMode;
|
||||||
};
|
};
|
||||||
@@ -477,11 +484,11 @@ const buildFeedFilters = async ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isAdmin = !!session?.admin;
|
const isAdmin = !!session?.admin;
|
||||||
const isOwnerOrAdmin = (session && user && typeof user === 'string' && session.user && session.user.toLowerCase() === user.toLowerCase()) || (session && (session.admin || session.is_moderator));
|
const ownerName = getSessionOwnerName(session);
|
||||||
const visibilityFilter = isAdmin
|
const visibilityFilter = isAdmin
|
||||||
? db``
|
? db``
|
||||||
: (session && session.user
|
: (ownerName
|
||||||
? db`and (coalesce(items.visibility, 0) = 0 or (lower(items.username) = ${session.user.toLowerCase()} and items.visibility != 3))`
|
? db`and (coalesce(items.visibility, 0) = 0 or (lower(items.username) = ${ownerName.toLowerCase()} and items.visibility != 3))`
|
||||||
: db`and coalesce(items.visibility, 0) = 0`);
|
: db`and coalesce(items.visibility, 0) = 0`);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -1217,10 +1224,11 @@ const f0cklib = {
|
|||||||
// Helper to construct shared filter conditions
|
// Helper to construct shared filter conditions
|
||||||
const buildConditions = () => {
|
const buildConditions = () => {
|
||||||
const isAdmin = !!session?.admin;
|
const isAdmin = !!session?.admin;
|
||||||
|
const ownerName = getSessionOwnerName(session);
|
||||||
const visibilityFilter = isAdmin
|
const visibilityFilter = isAdmin
|
||||||
? db``
|
? db``
|
||||||
: (session && session.user
|
: (ownerName
|
||||||
? db`and (coalesce(items.visibility, 0) = 0 or (lower(items.username) = ${session.user.toLowerCase()} and items.visibility != 3))`
|
? db`and (coalesce(items.visibility, 0) = 0 or (lower(items.username) = ${ownerName.toLowerCase()} and items.visibility != 3))`
|
||||||
: db`and coalesce(items.visibility, 0) = 0`);
|
: db`and coalesce(items.visibility, 0) = 0`);
|
||||||
|
|
||||||
return db`
|
return db`
|
||||||
@@ -1284,8 +1292,9 @@ const f0cklib = {
|
|||||||
const itemid = actitem.id;
|
const itemid = actitem.id;
|
||||||
|
|
||||||
// Check visibility permissions:
|
// Check visibility permissions:
|
||||||
|
const ownerName = getSessionOwnerName(session);
|
||||||
const isOwnerOrAdmin = session && (
|
const isOwnerOrAdmin = session && (
|
||||||
(session.user && session.user.toLowerCase() === (actitem.username || '').toLowerCase()) ||
|
(ownerName && ownerName.toLowerCase() === (actitem.username || '').toLowerCase()) ||
|
||||||
session.admin || session.is_moderator
|
session.admin || session.is_moderator
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -286,8 +286,27 @@ export default (router, tpl) => {
|
|||||||
|
|
||||||
router.get(/^\/admin(\/)?$/, lib.auth, async (req, res) => { // frontpage
|
router.get(/^\/admin(\/)?$/, lib.auth, async (req, res) => { // frontpage
|
||||||
|
|
||||||
|
// Dashboard counters (cheap aggregate queries; failures just show 0)
|
||||||
|
const dash = { open_reports: 0, users: 0, anon_users: 0, trash: 0 };
|
||||||
|
try {
|
||||||
|
const [[r], [u], [t]] = await Promise.all([
|
||||||
|
db`SELECT count(*)::int AS n FROM reports WHERE status = 'pending'`,
|
||||||
|
db`SELECT count(*) FILTER (WHERE NOT EXISTS (SELECT 1 FROM anon_identities ai WHERE ai.user_id = "user".id))::int AS users,
|
||||||
|
count(*) FILTER (WHERE EXISTS (SELECT 1 FROM anon_identities ai WHERE ai.user_id = "user".id))::int AS anon
|
||||||
|
FROM "user"`,
|
||||||
|
db`SELECT count(*)::int AS n FROM items WHERE active = false AND is_deleted = true AND is_purged = false`
|
||||||
|
]);
|
||||||
|
dash.trash = t?.n || 0;
|
||||||
|
dash.open_reports = r?.n || 0;
|
||||||
|
dash.users = u?.users || 0;
|
||||||
|
dash.anon_users = u?.anon || 0;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[ADMIN] dashboard counters failed:', e.message);
|
||||||
|
}
|
||||||
|
|
||||||
res.reply({
|
res.reply({
|
||||||
body: tpl.render("admin", {
|
body: tpl.render("admin", {
|
||||||
|
dash,
|
||||||
totals: await lib.countf0cks(),
|
totals: await lib.countf0cks(),
|
||||||
session: req.session,
|
session: req.session,
|
||||||
manual_approval: getManualApproval(),
|
manual_approval: getManualApproval(),
|
||||||
|
|||||||
+13
-15
@@ -3,7 +3,7 @@ import lib from "../lib.mjs";
|
|||||||
import url from "url";
|
import url from "url";
|
||||||
import cfg from "../config.mjs";
|
import cfg from "../config.mjs";
|
||||||
import { createI18n } from "../i18n.mjs";
|
import { createI18n } from "../i18n.mjs";
|
||||||
import { isAnonymizeSession } from "../settings.mjs";
|
import { isAnonymizeSession, canAnonDo, getSessionOwnerName, isOnaraEnabledFor } from "../settings.mjs";
|
||||||
|
|
||||||
export default (router, tpl) => {
|
export default (router, tpl) => {
|
||||||
// ── Merged random + item load: single request instead of two ────────────
|
// ── Merged random + item load: single request instead of two ────────────
|
||||||
@@ -177,6 +177,8 @@ export default (router, tpl) => {
|
|||||||
if (data.item) {
|
if (data.item) {
|
||||||
const session = data.session;
|
const session = data.session;
|
||||||
const item = data.item;
|
const item = data.item;
|
||||||
|
// Keep the real uploader for permission checks — anonymization below overwrites item.username
|
||||||
|
const _realUsername = item.username;
|
||||||
if (isAnonymizeSession(req.session)) {
|
if (isAnonymizeSession(req.session)) {
|
||||||
if (item.src) item.src = null;
|
if (item.src) item.src = null;
|
||||||
item.username = 'anonymous';
|
item.username = 'anonymous';
|
||||||
@@ -205,6 +207,9 @@ export default (router, tpl) => {
|
|||||||
const isAnon = !!(session && (session.is_anon || (session.user && (session.user === 'anonymous' || session.user.startsWith('anon_')))));
|
const isAnon = !!(session && (session.is_anon || (session.user && (session.user === 'anonymous' || session.user.startsWith('anon_')))));
|
||||||
data.is_mod_or_admin = !!(session && (session.admin || session.is_moderator));
|
data.is_mod_or_admin = !!(session && (session.admin || session.is_moderator));
|
||||||
data.can_manage_item = !isAnon && !!(session && (session.admin || session.is_moderator || (session.user && item.username && session.user.toLowerCase() === item.username.toLowerCase())));
|
data.can_manage_item = !isAnon && !!(session && (session.admin || session.is_moderator || (session.user && item.username && session.user.toLowerCase() === item.username.toLowerCase())));
|
||||||
|
// Rating may also be changed by an anonymous uploader on their own item
|
||||||
|
const _ownerName = getSessionOwnerName(session);
|
||||||
|
data.can_rate_item = data.can_manage_item || (isAnon && canAnonDo('rate_item') && !!(_ownerName && _realUsername && _ownerName.toLowerCase() === _realUsername.toLowerCase()));
|
||||||
data.can_extract_meta = !!(item.mime && item.mime.indexOf('flash') === -1 && !(item.mime.startsWith('application/') && cfg.mimes[item.mime] && !['swf', 'pdf'].includes(cfg.mimes[item.mime])));
|
data.can_extract_meta = !!(item.mime && item.mime.indexOf('flash') === -1 && !(item.mime.startsWith('application/') && cfg.mimes[item.mime] && !['swf', 'pdf'].includes(cfg.mimes[item.mime])));
|
||||||
data.user_has_favorited = lib.userHasFavorited(session, item.favorites);
|
data.user_has_favorited = lib.userHasFavorited(session, item.favorites);
|
||||||
data.halls_slugs = Array.isArray(item.halls) ? item.halls.map(h => h.slug).join(',') : '';
|
data.halls_slugs = Array.isArray(item.halls) ? item.halls.map(h => h.slug).join(',') : '';
|
||||||
@@ -229,13 +234,7 @@ export default (router, tpl) => {
|
|||||||
console.log(`[AJAX-RANDOM] ${itemid} total=${tAjaxRender - tAjaxStart}ms | getRandom=${tRandom - tAjaxStart}ms | getf0ck=${tAjaxFetch - tRandom}ms | aux=${tAjaxAux - tAjaxFetch}ms | render=${tAjaxRender - tAjaxAux}ms`);
|
console.log(`[AJAX-RANDOM] ${itemid} total=${tAjaxRender - tAjaxStart}ms | getRandom=${tRandom - tAjaxStart}ms | getf0ck=${tAjaxFetch - tRandom}ms | aux=${tAjaxAux - tAjaxFetch}ms | render=${tAjaxRender - tAjaxAux}ms`);
|
||||||
|
|
||||||
let itemPage = null;
|
let itemPage = null;
|
||||||
const cfgOnara = cfg.onara !== undefined ? cfg.onara : cfg.websrv?.onara;
|
const effectiveOnara = isOnaraEnabledFor(req, query.onara === '1');
|
||||||
const cookieOnara = req.cookies?.f0ck_onara !== undefined
|
|
||||||
? (req.cookies.f0ck_onara === '1' || req.cookies.f0ck_onara === 'true')
|
|
||||||
: (req.cookies?.onara !== undefined ? (req.cookies.onara === '1' || req.cookies.onara === 'true') : null);
|
|
||||||
const effectiveOnara = (cfgOnara !== undefined && cfgOnara !== null)
|
|
||||||
? !!cfgOnara
|
|
||||||
: (query.onara === '1' || cookieOnara === true);
|
|
||||||
if (effectiveOnara) {
|
if (effectiveOnara) {
|
||||||
try {
|
try {
|
||||||
itemPage = await f0cklib.getItemPage({
|
itemPage = await f0cklib.getItemPage({
|
||||||
@@ -444,6 +443,8 @@ export default (router, tpl) => {
|
|||||||
if (data.item) {
|
if (data.item) {
|
||||||
const session = data.session;
|
const session = data.session;
|
||||||
const item = data.item;
|
const item = data.item;
|
||||||
|
// Keep the real uploader for permission checks — anonymization below overwrites item.username
|
||||||
|
const _realUsername = item.username;
|
||||||
// When guest or anon anonymization is active, suppress uploader identity, banner, avatar, and source URL
|
// When guest or anon anonymization is active, suppress uploader identity, banner, avatar, and source URL
|
||||||
if (isAnonymizeSession(req.session)) {
|
if (isAnonymizeSession(req.session)) {
|
||||||
if (item.src) item.src = null;
|
if (item.src) item.src = null;
|
||||||
@@ -483,6 +484,9 @@ export default (router, tpl) => {
|
|||||||
const isAnon = !!(session && (session.is_anon || (session.user && (session.user === 'anonymous' || session.user.startsWith('anon_')))));
|
const isAnon = !!(session && (session.is_anon || (session.user && (session.user === 'anonymous' || session.user.startsWith('anon_')))));
|
||||||
data.is_mod_or_admin = !!(session && (session.admin || session.is_moderator));
|
data.is_mod_or_admin = !!(session && (session.admin || session.is_moderator));
|
||||||
data.can_manage_item = !isAnon && !!(session && (session.admin || session.is_moderator || (session.user && item.username && session.user.toLowerCase() === item.username.toLowerCase())));
|
data.can_manage_item = !isAnon && !!(session && (session.admin || session.is_moderator || (session.user && item.username && session.user.toLowerCase() === item.username.toLowerCase())));
|
||||||
|
// Rating may also be changed by an anonymous uploader on their own item
|
||||||
|
const _ownerName = getSessionOwnerName(session);
|
||||||
|
data.can_rate_item = data.can_manage_item || (isAnon && canAnonDo('rate_item') && !!(_ownerName && _realUsername && _ownerName.toLowerCase() === _realUsername.toLowerCase()));
|
||||||
data.can_extract_meta = !!(item.mime && item.mime.indexOf('flash') === -1 && !(item.mime.startsWith('application/') && cfg.mimes[item.mime] && !['swf', 'pdf'].includes(cfg.mimes[item.mime])));
|
data.can_extract_meta = !!(item.mime && item.mime.indexOf('flash') === -1 && !(item.mime.startsWith('application/') && cfg.mimes[item.mime] && !['swf', 'pdf'].includes(cfg.mimes[item.mime])));
|
||||||
data.user_has_favorited = lib.userHasFavorited(session, item.favorites);
|
data.user_has_favorited = lib.userHasFavorited(session, item.favorites);
|
||||||
data.halls_slugs = Array.isArray(item.halls) ? item.halls.map(h => h.slug).join(',') : '';
|
data.halls_slugs = Array.isArray(item.halls) ? item.halls.map(h => h.slug).join(',') : '';
|
||||||
@@ -510,13 +514,7 @@ export default (router, tpl) => {
|
|||||||
- Render: ${tAjaxRender - tAjaxAux}ms`);
|
- Render: ${tAjaxRender - tAjaxAux}ms`);
|
||||||
|
|
||||||
let itemPage = null;
|
let itemPage = null;
|
||||||
const cfgOnara = cfg.onara !== undefined ? cfg.onara : cfg.websrv?.onara;
|
const effectiveOnara = isOnaraEnabledFor(req, query.onara === '1');
|
||||||
const cookieOnara = req.cookies?.f0ck_onara !== undefined
|
|
||||||
? (req.cookies.f0ck_onara === '1' || req.cookies.f0ck_onara === 'true')
|
|
||||||
: (req.cookies?.onara !== undefined ? (req.cookies.onara === '1' || req.cookies.onara === 'true') : null);
|
|
||||||
const effectiveOnara = (cfgOnara !== undefined && cfgOnara !== null)
|
|
||||||
? !!cfgOnara
|
|
||||||
: (query.onara === '1' || cookieOnara === true);
|
|
||||||
if (effectiveOnara || query.get_page === '1') {
|
if (effectiveOnara || query.get_page === '1') {
|
||||||
try {
|
try {
|
||||||
itemPage = await f0cklib.getItemPage({
|
itemPage = await f0cklib.getItemPage({
|
||||||
|
|||||||
@@ -16,6 +16,14 @@ import {
|
|||||||
} from '../../webauthn.mjs';
|
} from '../../webauthn.mjs';
|
||||||
import { getEnableAnonymousAccess } from '../../settings.mjs';
|
import { getEnableAnonymousAccess } from '../../settings.mjs';
|
||||||
|
|
||||||
|
// Maximum number of passkeys a single anonymous identity may hold
|
||||||
|
const MAX_ANON_PASSKEYS = 4;
|
||||||
|
|
||||||
|
const countPasskeys = async userId => {
|
||||||
|
const rows = await db`SELECT COUNT(*)::int AS n FROM passkey_credentials WHERE user_id = ${userId}`;
|
||||||
|
return rows[0]?.n || 0;
|
||||||
|
};
|
||||||
|
|
||||||
export default router => {
|
export default router => {
|
||||||
router.group(/^\/api\/v2\/anon/, group => {
|
router.group(/^\/api\/v2\/anon/, group => {
|
||||||
|
|
||||||
@@ -426,6 +434,128 @@ export default router => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── Add Passkey to current anonymous identity ────────────────────────────
|
||||||
|
|
||||||
|
// Resolves the logged-in anonymous user, or null if the session isn't an anon identity
|
||||||
|
const getAnonSessionUserId = async req => {
|
||||||
|
if (!req.session?.id) return null;
|
||||||
|
const rows = await db`SELECT 1 FROM anon_identities WHERE user_id = ${req.session.id} LIMIT 1`;
|
||||||
|
return rows.length > 0 ? req.session.id : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/v2/anon/passkey/add/begin
|
||||||
|
* Registration options for an additional passkey on the current anonymous identity.
|
||||||
|
*/
|
||||||
|
group.post(/\/passkey\/add\/begin$/, async (req, res) => {
|
||||||
|
try {
|
||||||
|
if (!getEnableAnonymousAccess()) {
|
||||||
|
return res.json({ success: false, msg: 'Anonymous access is disabled' }, 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = await getAnonSessionUserId(req);
|
||||||
|
if (!userId) {
|
||||||
|
return res.json({ success: false, msg: 'Not logged in as anonymous' }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await db`SELECT credential_id FROM passkey_credentials WHERE user_id = ${userId}`;
|
||||||
|
if (existing.length >= MAX_ANON_PASSKEYS) {
|
||||||
|
return res.json({ success: false, msg: `You can have at most ${MAX_ANON_PASSKEYS} passkeys.` }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const userHandle = base64url(Buffer.from(crypto.getRandomValues(new Uint8Array(16))));
|
||||||
|
const challenge = generateChallenge({ type: 'anon-add', userId });
|
||||||
|
|
||||||
|
const options = buildRegistrationOptions({
|
||||||
|
challenge,
|
||||||
|
userId: userHandle,
|
||||||
|
userName: `anon@${cfg.main?.url?.domain || 'f0ck.dev'}`,
|
||||||
|
displayName: 'Anonymous',
|
||||||
|
rpId: getRpIdFromHost(req.headers.host)
|
||||||
|
});
|
||||||
|
// Stop the authenticator from registering a second copy of a passkey it already holds
|
||||||
|
options.excludeCredentials = existing.map(r => ({ type: 'public-key', id: r.credential_id }));
|
||||||
|
|
||||||
|
return res.json({ success: true, options });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[ANON_PASSKEY] add/begin error:', err);
|
||||||
|
return res.json({ success: false, msg: err.message || 'Internal server error' }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/v2/anon/passkey/add/finish
|
||||||
|
* Verify attestation and attach the new passkey to the current anonymous identity.
|
||||||
|
*/
|
||||||
|
group.post(/\/passkey\/add\/finish$/, async (req, res) => {
|
||||||
|
try {
|
||||||
|
if (!getEnableAnonymousAccess()) {
|
||||||
|
return res.json({ success: false, msg: 'Anonymous access is disabled' }, 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = await getAnonSessionUserId(req);
|
||||||
|
if (!userId) {
|
||||||
|
return res.json({ success: false, msg: 'Not logged in as anonymous' }, 401);
|
||||||
|
}
|
||||||
|
if (!req.session.csrf_token || req.headers['x-csrf-token'] !== req.session.csrf_token) {
|
||||||
|
return res.json({ success: false, msg: 'Invalid CSRF token' }, 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = req.post || req.body || {};
|
||||||
|
const { challenge, clientDataJSON, attestationObject, credentialId, hw_fingerprint: hwFingerprint } = body;
|
||||||
|
if (!challenge || !clientDataJSON || !attestationObject || !credentialId) {
|
||||||
|
return res.json({ success: false, msg: 'Missing required WebAuthn fields' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
let challengeMeta;
|
||||||
|
try {
|
||||||
|
challengeMeta = consumeChallenge(challenge);
|
||||||
|
} catch (e) {
|
||||||
|
return res.json({ success: false, msg: 'Challenge expired or invalid' }, 400);
|
||||||
|
}
|
||||||
|
if (challengeMeta.type !== 'anon-add' || challengeMeta.userId !== userId) {
|
||||||
|
return res.json({ success: false, msg: 'Wrong challenge type' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-check here too: two add flows could have been started in parallel
|
||||||
|
if (await countPasskeys(userId) >= MAX_ANON_PASSKEYS) {
|
||||||
|
return res.json({ success: false, msg: `You can have at most ${MAX_ANON_PASSKEYS} passkeys.` }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
let regResult;
|
||||||
|
try {
|
||||||
|
regResult = await verifyRegistration({ challenge, clientDataJSON, attestationObject, credentialId, rpId: getRpIdFromHost(req.headers.host) });
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[ANON_PASSKEY] Add verification failed:', e.message);
|
||||||
|
return res.json({ success: false, msg: `Registration failed: ${e.message}` }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const taken = await db`SELECT user_id FROM passkey_credentials WHERE credential_id = ${credentialId} LIMIT 1`;
|
||||||
|
if (taken.length > 0) {
|
||||||
|
return res.json({ success: false, msg: 'This passkey is already registered.' }, 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fingerprint = 'SHA256:' + crypto.createHash('sha256').update(Buffer.from(credentialId)).digest().toString('base64').replace(/=+$/, '');
|
||||||
|
const auditIp = resolveAuditIP(req);
|
||||||
|
|
||||||
|
await db`
|
||||||
|
INSERT INTO passkey_credentials (user_id, credential_id, public_key_spki, sign_count, aaguid, name)
|
||||||
|
VALUES (${userId}, ${credentialId}, ${regResult.spki}, ${regResult.signCount}, ${regResult.aaguid || null}, ${'Passkey'})
|
||||||
|
`;
|
||||||
|
await db`
|
||||||
|
INSERT INTO anon_identities (user_id, credential_id, fingerprint, created_ip, last_ip, hw_fingerprint)
|
||||||
|
VALUES (${userId}, ${credentialId}, ${fingerprint}, ${auditIp}, ${auditIp}, ${hwFingerprint || null})
|
||||||
|
ON CONFLICT (credential_id) DO NOTHING
|
||||||
|
`;
|
||||||
|
|
||||||
|
const passkeyCount = await countPasskeys(userId);
|
||||||
|
return res.json({ success: true, passkey_count: passkeyCount, passkey_max: MAX_ANON_PASSKEYS });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[ANON_PASSKEY] add/finish error:', err);
|
||||||
|
return res.json({ success: false, msg: err.message || 'Internal server error' }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ─── Identity ─────────────────────────────────────────────────────────────
|
// ─── Identity ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -448,6 +578,7 @@ export default router => {
|
|||||||
FROM anon_identities ai
|
FROM anon_identities ai
|
||||||
LEFT JOIN passkey_credentials pc ON pc.credential_id = ai.credential_id
|
LEFT JOIN passkey_credentials pc ON pc.credential_id = ai.credential_id
|
||||||
WHERE ai.user_id = ${req.session.id}
|
WHERE ai.user_id = ${req.session.id}
|
||||||
|
ORDER BY ai.created_at ASC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -462,6 +593,8 @@ export default router => {
|
|||||||
hw_fingerprint: rows[0].hw_fingerprint,
|
hw_fingerprint: rows[0].hw_fingerprint,
|
||||||
credential_id: rows[0].credential_id,
|
credential_id: rows[0].credential_id,
|
||||||
passkey_name: rows[0].passkey_name,
|
passkey_name: rows[0].passkey_name,
|
||||||
|
passkey_count: await countPasskeys(req.session.id),
|
||||||
|
passkey_max: MAX_ANON_PASSKEYS,
|
||||||
csrf_token: req.session.csrf_token
|
csrf_token: req.session.csrf_token
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { promises as fs } from "fs";
|
|||||||
import db from '../../sql.mjs';
|
import db from '../../sql.mjs';
|
||||||
import lib from '../../lib.mjs';
|
import lib from '../../lib.mjs';
|
||||||
import cfg from '../../config.mjs';
|
import cfg from '../../config.mjs';
|
||||||
import { getEnableItemSlugs, canAnonDo, getAnonAllowedModes, isAnonSession, isAnonymizeSession } from '../../settings.mjs';
|
import { getEnableItemSlugs, canAnonDo, getAnonAllowedModes, isAnonSession, isAnonymizeSession, getSessionOwnerName } from '../../settings.mjs';
|
||||||
import queue from '../../queue.mjs';
|
import queue from '../../queue.mjs';
|
||||||
import search from '../../routeinc/search.mjs';
|
import search from '../../routeinc/search.mjs';
|
||||||
import path from "path";
|
import path from "path";
|
||||||
@@ -2315,7 +2315,8 @@ export default router => {
|
|||||||
return res.json({ success: false, msg: 'Item not found' }, 404);
|
return res.json({ success: false, msg: 'Item not found' }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isOwner = !!(item[0].username && req.session.user && item[0].username.toLowerCase() === req.session.user.toLowerCase());
|
const ownerName = getSessionOwnerName(req.session);
|
||||||
|
const isOwner = !!(item[0].username && ownerName && item[0].username.toLowerCase() === ownerName.toLowerCase());
|
||||||
const isAdmin = !!(req.session.admin || req.session.is_moderator);
|
const isAdmin = !!(req.session.admin || req.session.is_moderator);
|
||||||
|
|
||||||
if (!isOwner && !isAdmin) {
|
if (!isOwner && !isAdmin) {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import cfg from "../../config.mjs";
|
|||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { logAnonActivity } from "../../anon_auth.mjs";
|
import { logAnonActivity } from "../../anon_auth.mjs";
|
||||||
import { canAnonDo, isAnonSession } from "../../settings.mjs";
|
import { canAnonDo, isAnonSession, getSessionOwnerName } from "../../settings.mjs";
|
||||||
|
|
||||||
export default router => {
|
export default router => {
|
||||||
router.post(/^\/api\/v2\/tags\/bulk\/?$/, lib.loggedin, async (req, res) => {
|
router.post(/^\/api\/v2\/tags\/bulk\/?$/, lib.loggedin, async (req, res) => {
|
||||||
@@ -156,7 +156,7 @@ export default router => {
|
|||||||
WHERE item_id IN ${db(itemIds)}
|
WHERE item_id IN ${db(itemIds)}
|
||||||
AND tag_id IN ${db(tagIds)}
|
AND tag_id IN ${db(tagIds)}
|
||||||
AND item_id IN (
|
AND item_id IN (
|
||||||
SELECT id FROM items WHERE username = ${req.session.user}
|
SELECT id FROM items WHERE username = ${getSessionOwnerName(req.session)}
|
||||||
)
|
)
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@@ -206,7 +206,7 @@ export default router => {
|
|||||||
const ownedItems = await db`
|
const ownedItems = await db`
|
||||||
SELECT id FROM items
|
SELECT id FROM items
|
||||||
WHERE id IN ${db(itemIds)}
|
WHERE id IN ${db(itemIds)}
|
||||||
AND username = ${req.session.user}
|
AND username = ${getSessionOwnerName(req.session)}
|
||||||
AND active = true AND is_deleted = false
|
AND active = true AND is_deleted = false
|
||||||
`;
|
`;
|
||||||
eligibleIds = ownedItems.map(r => r.id);
|
eligibleIds = ownedItems.map(r => r.id);
|
||||||
@@ -440,7 +440,8 @@ export default router => {
|
|||||||
return res.json({ success: false, msg: 'Item not found' }, 404);
|
return res.json({ success: false, msg: 'Item not found' }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isOwner = !!(item[0].username && req.session.user && item[0].username.toLowerCase() === req.session.user.toLowerCase());
|
const ownerName = getSessionOwnerName(req.session);
|
||||||
|
const isOwner = !!(item[0].username && ownerName && item[0].username.toLowerCase() === ownerName.toLowerCase());
|
||||||
const isAdmin = !!(req.session.admin || req.session.is_moderator);
|
const isAdmin = !!(req.session.admin || req.session.is_moderator);
|
||||||
if (!isOwner && !isAdmin) {
|
if (!isOwner && !isAdmin) {
|
||||||
return res.json({ success: false, msg: 'Unauthorized' }, 403);
|
return res.json({ success: false, msg: 'Unauthorized' }, 403);
|
||||||
|
|||||||
+30
-25
@@ -2,6 +2,11 @@ import cfg from "../config.mjs";
|
|||||||
import db from "../sql.mjs";
|
import db from "../sql.mjs";
|
||||||
import lib from "../lib.mjs";
|
import lib from "../lib.mjs";
|
||||||
import queue from "../queue.mjs";
|
import queue from "../queue.mjs";
|
||||||
|
import { chanCurl } from "../chan_http.mjs";
|
||||||
|
import { getSessionOwnerName, getEnableItemSlugs, isOnaraEnabledFor } from "../settings.mjs";
|
||||||
|
|
||||||
|
// Link path for a local item: its slug when slugs are enabled, else the numeric id
|
||||||
|
const itemPath = (id, slug) => (getEnableItemSlugs() && slug) ? slug : id;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* chan.mjs — 4chan thread viewer & catalogue routes
|
* chan.mjs — 4chan thread viewer & catalogue routes
|
||||||
@@ -28,17 +33,8 @@ export default (router, tpl) => {
|
|||||||
* Helper to fetch data via curl respecting SOCKS5 proxy if configured.
|
* Helper to fetch data via curl respecting SOCKS5 proxy if configured.
|
||||||
*/
|
*/
|
||||||
async function fetchWithProxy(url) {
|
async function fetchWithProxy(url) {
|
||||||
const curlArgs = [
|
const { bin, args } = chanCurl(url, ['--max-time', '30']);
|
||||||
'-s', '-f', '-L',
|
const { stdout } = await queue.spawn(bin, args, { encoding: 'utf8' });
|
||||||
'-A', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
|
|
||||||
'--max-time', '30',
|
|
||||||
url
|
|
||||||
];
|
|
||||||
if (cfg.main.socks && cfg.main.socks !== 'undefined' && cfg.main.socks !== '') {
|
|
||||||
const proxyHost = cfg.main.socks.includes('://') ? cfg.main.socks.split('://')[1] : cfg.main.socks;
|
|
||||||
curlArgs.push('--socks5-hostname', proxyHost);
|
|
||||||
}
|
|
||||||
const { stdout } = await queue.spawn('curl', curlArgs, { encoding: 'utf8' });
|
|
||||||
const text = typeof stdout === 'string' ? stdout.trim() : stdout.toString().trim();
|
const text = typeof stdout === 'string' ? stdout.trim() : stdout.toString().trim();
|
||||||
if (!text.startsWith('{') && !text.startsWith('[')) {
|
if (!text.startsWith('{') && !text.startsWith('[')) {
|
||||||
throw new Error(`Expected JSON from ${url}, got: ${text.slice(0, 100)}`);
|
throw new Error(`Expected JSON from ${url}, got: ${text.slice(0, 100)}`);
|
||||||
@@ -123,10 +119,12 @@ export default (router, tpl) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
const rehosts = {};
|
const rehosts = {};
|
||||||
|
const rehostPaths = {};
|
||||||
if (opUrls.length > 0) {
|
if (opUrls.length > 0) {
|
||||||
try {
|
try {
|
||||||
const rows = await db`SELECT id, src FROM items WHERE src = ANY(${opUrls})`;
|
const rows = await db`SELECT id, src, slug FROM items WHERE src = ANY(${opUrls})`;
|
||||||
rows.forEach(r => {
|
rows.forEach(r => {
|
||||||
|
rehostPaths[r.id] = itemPath(r.id, r.slug);
|
||||||
rehosts[r.src] = r.id;
|
rehosts[r.src] = r.id;
|
||||||
const m = r.src.match(/(\d{13,20})/);
|
const m = r.src.match(/(\d{13,20})/);
|
||||||
if (m) rehosts[m[1]] = r.id;
|
if (m) rehosts[m[1]] = r.id;
|
||||||
@@ -135,6 +133,7 @@ export default (router, tpl) => {
|
|||||||
}
|
}
|
||||||
threads.forEach(t => {
|
threads.forEach(t => {
|
||||||
t.local_id = (t.tim && rehosts[t.tim]) || (t.tim && t.ext && rehosts[`https://i.4cdn.org/${board}/${t.tim}${t.ext}`]) || null;
|
t.local_id = (t.tim && rehosts[t.tim]) || (t.tim && t.ext && rehosts[`https://i.4cdn.org/${board}/${t.tim}${t.ext}`]) || null;
|
||||||
|
t.local_path = t.local_id ? rehostPaths[t.local_id] : null;
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
@@ -219,6 +218,7 @@ export default (router, tpl) => {
|
|||||||
is_video: isVideo,
|
is_video: isVideo,
|
||||||
is_image: isImage,
|
is_image: isImage,
|
||||||
local_id: localId,
|
local_id: localId,
|
||||||
|
local_path: rehostInfo ? rehostInfo.path : null,
|
||||||
rehosted: !!localId,
|
rehosted: !!localId,
|
||||||
user_has_favorited: localId ? userFavSet.has(Number(localId)) : false,
|
user_has_favorited: localId ? userFavSet.has(Number(localId)) : false,
|
||||||
is_onara_active: targetPostNo ? p.no === targetPostNo : false,
|
is_onara_active: targetPostNo ? p.no === targetPostNo : false,
|
||||||
@@ -255,11 +255,13 @@ export default (router, tpl) => {
|
|||||||
cdnUrls.push(`/api/v2/scroller/external/4chan/${board}/media/${p.tim}${ext}`);
|
cdnUrls.push(`/api/v2/scroller/external/4chan/${board}/media/${p.tim}${ext}`);
|
||||||
});
|
});
|
||||||
const rehostMap = {};
|
const rehostMap = {};
|
||||||
|
const rehostPaths = {};
|
||||||
if (cdnUrls.length > 0) {
|
if (cdnUrls.length > 0) {
|
||||||
try {
|
try {
|
||||||
const rows = await db`SELECT id, src, stamp FROM items WHERE src = ANY(${cdnUrls})`;
|
const rows = await db`SELECT id, src, stamp, slug FROM items WHERE src = ANY(${cdnUrls})`;
|
||||||
rows.forEach(r => {
|
rows.forEach(r => {
|
||||||
const info = { id: r.id, stamp: r.stamp };
|
const info = { id: r.id, stamp: r.stamp, path: itemPath(r.id, r.slug) };
|
||||||
|
rehostPaths[r.id] = info.path;
|
||||||
rehosts[r.src] = r.id;
|
rehosts[r.src] = r.id;
|
||||||
rehostMap[r.src] = info;
|
rehostMap[r.src] = info;
|
||||||
const m = r.src.match(/(\d{13,20})/);
|
const m = r.src.match(/(\d{13,20})/);
|
||||||
@@ -363,11 +365,13 @@ export default (router, tpl) => {
|
|||||||
});
|
});
|
||||||
const rehosts = {};
|
const rehosts = {};
|
||||||
const rehostMap = {};
|
const rehostMap = {};
|
||||||
|
const rehostPaths = {};
|
||||||
if (cdnUrls.length > 0) {
|
if (cdnUrls.length > 0) {
|
||||||
try {
|
try {
|
||||||
const rows = await db`SELECT id, src, stamp FROM items WHERE src = ANY(${cdnUrls})`;
|
const rows = await db`SELECT id, src, stamp, slug FROM items WHERE src = ANY(${cdnUrls})`;
|
||||||
rows.forEach(r => {
|
rows.forEach(r => {
|
||||||
const info = { id: r.id, stamp: r.stamp };
|
const info = { id: r.id, stamp: r.stamp, path: itemPath(r.id, r.slug) };
|
||||||
|
rehostPaths[r.id] = info.path;
|
||||||
rehosts[r.src] = r.id;
|
rehosts[r.src] = r.id;
|
||||||
rehostMap[r.src] = info;
|
rehostMap[r.src] = info;
|
||||||
const m = r.src.match(/(\d{13,20})/);
|
const m = r.src.match(/(\d{13,20})/);
|
||||||
@@ -412,7 +416,7 @@ export default (router, tpl) => {
|
|||||||
localThumb = `/t/${li.id}.webp`;
|
localThumb = `/t/${li.id}.webp`;
|
||||||
}
|
}
|
||||||
if (req.session) {
|
if (req.session) {
|
||||||
canManage = !!((li.username && req.session.user && li.username.toLowerCase() === req.session.user.toLowerCase()) ||
|
canManage = !!((li.username && getSessionOwnerName(req.session) && li.username.toLowerCase() === getSessionOwnerName(req.session).toLowerCase()) ||
|
||||||
req.session.admin || req.session.is_moderator);
|
req.session.admin || req.session.is_moderator);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -456,9 +460,11 @@ export default (router, tpl) => {
|
|||||||
external_board: board,
|
external_board: board,
|
||||||
external_tid: tid,
|
external_tid: tid,
|
||||||
external_id: targetPost.no,
|
external_id: targetPost.no,
|
||||||
|
external_thread_url: `https://boards.4chan.org/${board}/thread/${tid}#p${targetPost.no}`,
|
||||||
external_media_url: externalMediaUrl,
|
external_media_url: externalMediaUrl,
|
||||||
original_filename: targetPost.filename ? `${targetPost.filename}${ext}` : null,
|
original_filename: targetPost.filename ? `${targetPost.filename}${ext}` : null,
|
||||||
local_id: localId,
|
local_id: localId,
|
||||||
|
local_path: localId ? (rehostPaths[localId] || localId) : null,
|
||||||
rehosted: !!localId,
|
rehosted: !!localId,
|
||||||
is_sfw: itemRating === 'sfw',
|
is_sfw: itemRating === 'sfw',
|
||||||
is_nsfw: itemRating === 'nsfw',
|
is_nsfw: itemRating === 'nsfw',
|
||||||
@@ -510,6 +516,7 @@ export default (router, tpl) => {
|
|||||||
is_active: p.no === targetPost.no,
|
is_active: p.no === targetPost.no,
|
||||||
index: idx + 1,
|
index: idx + 1,
|
||||||
local_id: pLocalId,
|
local_id: pLocalId,
|
||||||
|
local_path: pLocalId ? (rehostPaths[pLocalId] || pLocalId) : null,
|
||||||
rehosted: !!pLocalId,
|
rehosted: !!pLocalId,
|
||||||
user_has_favorited: pLocalId ? userFavSet.has(Number(pLocalId)) : false,
|
user_has_favorited: pLocalId ? userFavSet.has(Number(pLocalId)) : false,
|
||||||
width: p.w || null,
|
width: p.w || null,
|
||||||
@@ -542,6 +549,7 @@ export default (router, tpl) => {
|
|||||||
user_alternative_steuerung: req.session?.user_alternative_steuerung,
|
user_alternative_steuerung: req.session?.user_alternative_steuerung,
|
||||||
user_alternative_infobox: req.session?.user_alternative_infobox,
|
user_alternative_infobox: req.session?.user_alternative_infobox,
|
||||||
can_manage_item: canManage,
|
can_manage_item: canManage,
|
||||||
|
can_rate_item: canManage,
|
||||||
can_extract_meta: !!(canManage && localId && item.mime && item.mime.indexOf('flash') === -1 && !(item.mime.startsWith('application/') && cfg.mimes[item.mime] && !['swf', 'pdf'].includes(cfg.mimes[item.mime]))),
|
can_extract_meta: !!(canManage && localId && item.mime && item.mime.indexOf('flash') === -1 && !(item.mime.startsWith('application/') && cfg.mimes[item.mime] && !['swf', 'pdf'].includes(cfg.mimes[item.mime]))),
|
||||||
user_has_favorited: userHasFavorited,
|
user_has_favorited: userHasFavorited,
|
||||||
isSubscribed: false,
|
isSubscribed: false,
|
||||||
@@ -583,13 +591,7 @@ export default (router, tpl) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Full page render: if Onara is active, render thread page with onara modal open
|
// Full page render: if Onara is active, render thread page with onara modal open
|
||||||
const cfgOnara = cfg.onara !== undefined ? cfg.onara : cfg.websrv?.onara;
|
const isOnara = isOnaraEnabledFor(req);
|
||||||
const cookieOnara = req.cookies?.f0ck_onara !== undefined
|
|
||||||
? (req.cookies.f0ck_onara === '1' || req.cookies.f0ck_onara === 'true')
|
|
||||||
: (req.cookies?.onara !== undefined ? (req.cookies.onara === '1' || req.cookies.onara === 'true') : null);
|
|
||||||
const isOnara = (cfgOnara !== undefined && cfgOnara !== null)
|
|
||||||
? !!cfgOnara
|
|
||||||
: (cookieOnara !== null ? cookieOnara : !!req.session?.onara);
|
|
||||||
|
|
||||||
if (isOnara) {
|
if (isOnara) {
|
||||||
const isModern = req.session?.use_new_layout;
|
const isModern = req.session?.use_new_layout;
|
||||||
@@ -677,10 +679,12 @@ export default (router, tpl) => {
|
|||||||
cdnUrls.push(`/api/v2/scroller/external/4chan/${board}/media/${p.tim}${ext}`);
|
cdnUrls.push(`/api/v2/scroller/external/4chan/${board}/media/${p.tim}${ext}`);
|
||||||
});
|
});
|
||||||
const rehosts = {};
|
const rehosts = {};
|
||||||
|
const rehostPaths = {};
|
||||||
if (cdnUrls.length > 0) {
|
if (cdnUrls.length > 0) {
|
||||||
try {
|
try {
|
||||||
const rows = await db`SELECT id, src FROM items WHERE src = ANY(${cdnUrls})`;
|
const rows = await db`SELECT id, src, slug FROM items WHERE src = ANY(${cdnUrls})`;
|
||||||
rows.forEach(r => {
|
rows.forEach(r => {
|
||||||
|
rehostPaths[r.id] = itemPath(r.id, r.slug);
|
||||||
rehosts[r.src] = r.id;
|
rehosts[r.src] = r.id;
|
||||||
const m = r.src.match(/(\d{13,20})/);
|
const m = r.src.match(/(\d{13,20})/);
|
||||||
if (m) rehosts[m[1]] = r.id;
|
if (m) rehosts[m[1]] = r.id;
|
||||||
@@ -705,6 +709,7 @@ export default (router, tpl) => {
|
|||||||
is_image: !isVideo,
|
is_image: !isVideo,
|
||||||
index: idx + 1,
|
index: idx + 1,
|
||||||
local_id: (p.tim && rehosts[p.tim]) || (externalMediaUrl && rehosts[externalMediaUrl]) || null,
|
local_id: (p.tim && rehosts[p.tim]) || (externalMediaUrl && rehosts[externalMediaUrl]) || null,
|
||||||
|
local_path: rehostPaths[(p.tim && rehosts[p.tim]) || (externalMediaUrl && rehosts[externalMediaUrl])] || null,
|
||||||
rehosted: !!((p.tim && rehosts[p.tim]) || (externalMediaUrl && rehosts[externalMediaUrl]))
|
rehosted: !!((p.tim && rehosts[p.tim]) || (externalMediaUrl && rehosts[externalMediaUrl]))
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
+33
-43
@@ -2,9 +2,17 @@ import cfg from "../config.mjs";
|
|||||||
import db from "../sql.mjs";
|
import db from "../sql.mjs";
|
||||||
import lib from "../lib.mjs";
|
import lib from "../lib.mjs";
|
||||||
import queue from "../queue.mjs";
|
import queue from "../queue.mjs";
|
||||||
|
import { chanCurl } from "../chan_http.mjs";
|
||||||
import { promises as fs } from "fs";
|
import { promises as fs } from "fs";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { getManualApproval, getBypassDuplicateCheck } from "../settings.mjs";
|
import { getManualApproval, getBypassDuplicateCheck, canUseChan, getSessionOwnerName, getEnableItemSlugs } from "../settings.mjs";
|
||||||
|
|
||||||
|
// Link path for a local item: its slug when slugs are enabled, else the numeric id
|
||||||
|
const itemPath = async (id, slug) => {
|
||||||
|
if (!getEnableItemSlugs()) return id;
|
||||||
|
if (slug === undefined) slug = (await db`SELECT slug FROM items WHERE id = ${id} LIMIT 1`)[0]?.slug;
|
||||||
|
return slug || id;
|
||||||
|
};
|
||||||
import { applyWordFilter } from "../wordfilter.mjs";
|
import { applyWordFilter } from "../wordfilter.mjs";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -45,18 +53,8 @@ export default (router) => {
|
|||||||
* This ensures we respect the SOCKS5 proxy for all external 4chan requests.
|
* This ensures we respect the SOCKS5 proxy for all external 4chan requests.
|
||||||
*/
|
*/
|
||||||
async function fetchWithProxy(url, asBuffer = false) {
|
async function fetchWithProxy(url, asBuffer = false) {
|
||||||
const curlArgs = [
|
const { bin, args } = chanCurl(url, ['--max-time', '30']);
|
||||||
'-s', '-f', '-L',
|
const { stdout } = await queue.spawn(bin, args, { encoding: asBuffer ? 'buffer' : 'utf8' });
|
||||||
'-A', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
|
|
||||||
'--max-time', '30',
|
|
||||||
url
|
|
||||||
];
|
|
||||||
if (cfg.main.socks && cfg.main.socks !== 'undefined' && cfg.main.socks !== '') {
|
|
||||||
const proxyHost = cfg.main.socks.includes('://') ? cfg.main.socks.split('://')[1] : cfg.main.socks;
|
|
||||||
curlArgs.push('--socks5-hostname', proxyHost);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { stdout } = await queue.spawn('curl', curlArgs, { encoding: asBuffer ? 'buffer' : 'utf8' });
|
|
||||||
if (asBuffer) return stdout;
|
if (asBuffer) return stdout;
|
||||||
const text = typeof stdout === 'string' ? stdout.trim() : stdout.toString().trim();
|
const text = typeof stdout === 'string' ? stdout.trim() : stdout.toString().trim();
|
||||||
if (!text.startsWith('{') && !text.startsWith('[')) {
|
if (!text.startsWith('{') && !text.startsWith('[')) {
|
||||||
@@ -86,6 +84,7 @@ export default (router) => {
|
|||||||
|
|
||||||
// Check which media URLs are already rehosted on this platform
|
// Check which media URLs are already rehosted on this platform
|
||||||
const rehosts = {};
|
const rehosts = {};
|
||||||
|
const rehostPaths = {};
|
||||||
const mediaPosts = posts.filter(p => p.tim && p.ext);
|
const mediaPosts = posts.filter(p => p.tim && p.ext);
|
||||||
const cdn4Urls = [];
|
const cdn4Urls = [];
|
||||||
mediaPosts.forEach(p => {
|
mediaPosts.forEach(p => {
|
||||||
@@ -98,7 +97,8 @@ export default (router) => {
|
|||||||
});
|
});
|
||||||
if (cdn4Urls.length > 0) {
|
if (cdn4Urls.length > 0) {
|
||||||
try {
|
try {
|
||||||
const rows = await db`SELECT id, src FROM items WHERE src = ANY(${cdn4Urls})`;
|
const rows = await db`SELECT id, src, slug FROM items WHERE src = ANY(${cdn4Urls})`;
|
||||||
|
for (const r of rows) rehostPaths[r.id] = await itemPath(r.id, r.slug);
|
||||||
rows.forEach(r => {
|
rows.forEach(r => {
|
||||||
rehosts[r.src] = r.id;
|
rehosts[r.src] = r.id;
|
||||||
const m = r.src.match(/(\d{13,20})/);
|
const m = r.src.match(/(\d{13,20})/);
|
||||||
@@ -111,7 +111,7 @@ export default (router) => {
|
|||||||
|
|
||||||
return res.reply({
|
return res.reply({
|
||||||
headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-cache' },
|
headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-cache' },
|
||||||
body: JSON.stringify({ success: true, posts, board, tid, rehosts })
|
body: JSON.stringify({ success: true, posts, board, tid, rehosts, rehost_paths: rehostPaths })
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -295,19 +295,10 @@ export default (router) => {
|
|||||||
};
|
};
|
||||||
const contentType = mimes[ext] || 'application/octet-stream';
|
const contentType = mimes[ext] || 'application/octet-stream';
|
||||||
|
|
||||||
const curlArgs = [
|
const { bin: curlBin, args: curlArgs } = chanCurl(url, ['--max-time', '60']);
|
||||||
'-s', '-f', '-L',
|
|
||||||
'-A', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
|
|
||||||
'--max-time', '60',
|
|
||||||
url
|
|
||||||
];
|
|
||||||
if (cfg.main.socks && cfg.main.socks !== 'undefined' && cfg.main.socks !== '') {
|
|
||||||
const proxyHost = cfg.main.socks.includes('://') ? cfg.main.socks.split('://')[1] : cfg.main.socks;
|
|
||||||
curlArgs.push('--socks5-hostname', proxyHost);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { spawn } = await import('child_process');
|
const { spawn } = await import('child_process');
|
||||||
const curl = spawn('curl', curlArgs);
|
const curl = spawn(curlBin, curlArgs);
|
||||||
|
|
||||||
res.writeHead(200, {
|
res.writeHead(200, {
|
||||||
'Content-Type': contentType,
|
'Content-Type': contentType,
|
||||||
@@ -355,25 +346,22 @@ export default (router) => {
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
const session = req.session;
|
const session = req.session;
|
||||||
|
// Anon sessions carry user = 'anonymous'; credit the upload to the real shadow account
|
||||||
|
const ownerName = getSessionOwnerName(session);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const uuid = await queue.genuuid();
|
const uuid = await queue.genuuid();
|
||||||
const tmpPath = path.join(cfg.paths.tmp, `${uuid}.tmp`);
|
const tmpPath = path.join(cfg.paths.tmp, `${uuid}.tmp`);
|
||||||
|
|
||||||
// Download via curl (lightweight)
|
// Download via curl (lightweight)
|
||||||
const curlArgs = [
|
const { bin: curlBin, args: curlArgs } = chanCurl(url, [
|
||||||
'-s', '-f', '-L', url, '-o', tmpPath,
|
'-o', tmpPath,
|
||||||
'--max-filesize', `${cfg.main.maxfilesize || 100 * 1024 * 1024}`,
|
'--max-filesize', `${cfg.main.maxfilesize || 100 * 1024 * 1024}`,
|
||||||
'--connect-timeout', '30',
|
'--connect-timeout', '30',
|
||||||
'--max-time', '300',
|
'--max-time', '300'
|
||||||
'--user-agent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
|
]);
|
||||||
];
|
|
||||||
if (cfg.main.socks && cfg.main.socks !== 'undefined' && cfg.main.socks !== '') {
|
|
||||||
const proxyHost = cfg.main.socks.includes('://') ? cfg.main.socks.split('://')[1] : cfg.main.socks;
|
|
||||||
curlArgs.push('--socks5-hostname', proxyHost);
|
|
||||||
}
|
|
||||||
|
|
||||||
await queue.spawn('curl', curlArgs);
|
await queue.spawn(curlBin, curlArgs);
|
||||||
|
|
||||||
// Detect MIME
|
// Detect MIME
|
||||||
const mime = (await queue.spawn('file', ['--mime-type', '-b', tmpPath])).stdout.trim();
|
const mime = (await queue.spawn('file', ['--mime-type', '-b', tmpPath])).stdout.trim();
|
||||||
@@ -402,7 +390,7 @@ export default (router) => {
|
|||||||
return res.reply({
|
return res.reply({
|
||||||
code: 200,
|
code: 200,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ success: true, repost: true, item_id: repost, msg: 'Already on site' })
|
body: JSON.stringify({ success: true, repost: true, item_id: repost, item_path: await itemPath(repost), msg: 'Already on site' })
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -424,7 +412,7 @@ export default (router) => {
|
|||||||
return res.reply({
|
return res.reply({
|
||||||
code: 200,
|
code: 200,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ success: true, repost: true, item_id: phashMatch, msg: 'Already on site (visual match)' })
|
body: JSON.stringify({ success: true, repost: true, item_id: phashMatch, item_path: await itemPath(phashMatch), msg: 'Already on site (visual match)' })
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -469,7 +457,7 @@ export default (router) => {
|
|||||||
size: (await fs.stat(path.join(destDir, filename))).size,
|
size: (await fs.stat(path.join(destDir, filename))).size,
|
||||||
checksum: insertChecksum,
|
checksum: insertChecksum,
|
||||||
phash: phash,
|
phash: phash,
|
||||||
username: session.user,
|
username: ownerName,
|
||||||
userchannel: 'web',
|
userchannel: 'web',
|
||||||
usernetwork: 'web',
|
usernetwork: 'web',
|
||||||
stamp: ~~(Date.now() / 1000),
|
stamp: ~~(Date.now() / 1000),
|
||||||
@@ -542,7 +530,7 @@ export default (router) => {
|
|||||||
id: itemid,
|
id: itemid,
|
||||||
dest: filename,
|
dest: filename,
|
||||||
mime: mime,
|
mime: mime,
|
||||||
username: session.user,
|
username: ownerName,
|
||||||
display_name: session.display_name || null,
|
display_name: session.display_name || null,
|
||||||
tag_id: rating ? (rating === 'sfw' ? 1 : (rating === 'nsfw' ? 2 : (cfg.nsfl_tag_id || 3))) : 0,
|
tag_id: rating ? (rating === 'sfw' ? 1 : (rating === 'nsfw' ? 2 : (cfg.nsfl_tag_id || 3))) : 0,
|
||||||
is_oc: false,
|
is_oc: false,
|
||||||
@@ -575,7 +563,7 @@ export default (router) => {
|
|||||||
|
|
||||||
return res.reply({
|
return res.reply({
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ success: true, item_id: itemid })
|
body: JSON.stringify({ success: true, item_id: itemid, item_path: await itemPath(itemid) })
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -630,9 +618,10 @@ export default (router) => {
|
|||||||
return res.reply({ code: 404, body: JSON.stringify({ success: false, msg: 'Item not found' }) });
|
return res.reply({ code: 404, body: JSON.stringify({ success: false, msg: 'Item not found' }) });
|
||||||
}
|
}
|
||||||
|
|
||||||
const isOwner = !!(rows[0].username && session.user && rows[0].username.toLowerCase() === session.user.toLowerCase());
|
const ownerName = getSessionOwnerName(session);
|
||||||
|
const isOwner = !!(rows[0].username && ownerName && rows[0].username.toLowerCase() === ownerName.toLowerCase());
|
||||||
const isAdmin = !!(session.admin || session.is_moderator);
|
const isAdmin = !!(session.admin || session.is_moderator);
|
||||||
const isChanUser = !!(session.admin || (Array.isArray(session.groups) && session.groups.includes('4chan')));
|
const isChanUser = canUseChan(session);
|
||||||
|
|
||||||
const hasTagsOrComment = (tags && Array.isArray(tags) ? tags.length > 0 : (typeof tags === 'string' && tags.trim().length > 0)) ||
|
const hasTagsOrComment = (tags && Array.isArray(tags) ? tags.length > 0 : (typeof tags === 'string' && tags.trim().length > 0)) ||
|
||||||
(comment && typeof comment === 'string' && comment.trim().length > 0);
|
(comment && typeof comment === 'string' && comment.trim().length > 0);
|
||||||
@@ -722,6 +711,7 @@ export default (router) => {
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
success: true,
|
success: true,
|
||||||
item_id: itemId,
|
item_id: itemId,
|
||||||
|
item_path: await itemPath(itemId),
|
||||||
rating: ratingTag ? ratingTag.normalized : 'untagged',
|
rating: ratingTag ? ratingTag.normalized : 'untagged',
|
||||||
tags: cleanTagObjects
|
tags: cleanTagObjects
|
||||||
})
|
})
|
||||||
|
|||||||
+11
-11
@@ -4,7 +4,7 @@ import lib from "../lib.mjs";
|
|||||||
import f0cklib from "../routeinc/f0cklib.mjs";
|
import f0cklib from "../routeinc/f0cklib.mjs";
|
||||||
import { createI18n } from "../i18n.mjs";
|
import { createI18n } from "../i18n.mjs";
|
||||||
import { render502 } from "../private_items.mjs";
|
import { render502 } from "../private_items.mjs";
|
||||||
import { canAnonDo, canAnonMode, isAnonSession, isAnonymizeSession } from "../settings.mjs";
|
import { canAnonDo, canAnonMode, isAnonSession, isAnonymizeSession, getSessionOwnerName, isOnaraEnabledFor } from "../settings.mjs";
|
||||||
|
|
||||||
const auth = async (req, res, next) => {
|
const auth = async (req, res, next) => {
|
||||||
if (!req.session)
|
if (!req.session)
|
||||||
@@ -18,8 +18,12 @@ export default (router, tpl) => {
|
|||||||
// But allow anon users to view their own profile
|
// But allow anon users to view their own profile
|
||||||
if (isAnonymizeSession(req.session)) {
|
if (isAnonymizeSession(req.session)) {
|
||||||
const requestedUser = decodeURIComponent(req.params.user).toLowerCase();
|
const requestedUser = decodeURIComponent(req.params.user).toLowerCase();
|
||||||
const sessionUser = (req.session?.user || req.session?.login || '').toLowerCase();
|
// Anon sessions have user = 'anonymous'; their real profile name is the shadow login (anon_xxxx)
|
||||||
if (requestedUser !== sessionUser) {
|
const isSelf = !!req.session && (
|
||||||
|
requestedUser === (req.session.user || '').toLowerCase() ||
|
||||||
|
requestedUser === (req.session.login || '').toLowerCase()
|
||||||
|
);
|
||||||
|
if (!isSelf) {
|
||||||
return req.session ? res.redirect('/') : res.redirect('/login');
|
return req.session ? res.redirect('/') : res.redirect('/login');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -427,6 +431,9 @@ export default (router, tpl) => {
|
|||||||
// Can the current user manage this item (owner, admin, or mod)?
|
// Can the current user manage this item (owner, admin, or mod)?
|
||||||
const isAnon = !!(session && (session.is_anon || (session.user && (session.user === 'anonymous' || session.user.startsWith('anon_')))));
|
const isAnon = !!(session && (session.is_anon || (session.user && (session.user === 'anonymous' || session.user.startsWith('anon_')))));
|
||||||
data.can_manage_item = !isAnon && !!(session && (session.admin || session.is_moderator || (session.user && item.username && session.user.toLowerCase() === item.username.toLowerCase())));
|
data.can_manage_item = !isAnon && !!(session && (session.admin || session.is_moderator || (session.user && item.username && session.user.toLowerCase() === item.username.toLowerCase())));
|
||||||
|
// Rating may also be changed by an anonymous uploader on their own item
|
||||||
|
const _ownerName = getSessionOwnerName(session);
|
||||||
|
data.can_rate_item = data.can_manage_item || (isAnon && canAnonDo('rate_item') && !!(_ownerName && item.username && _ownerName.toLowerCase() === item.username.toLowerCase()));
|
||||||
// Is the item's MIME type suitable for metadata extraction?
|
// Is the item's MIME type suitable for metadata extraction?
|
||||||
// YouTube items use oEmbed via /meta/fetch; all non-flash MIME types are eligible.
|
// YouTube items use oEmbed via /meta/fetch; all non-flash MIME types are eligible.
|
||||||
data.can_extract_meta = !!(item.mime && item.mime.indexOf('flash') === -1 && !(item.mime.startsWith('application/') && cfg.mimes[item.mime] && !['swf', 'pdf'].includes(cfg.mimes[item.mime])));
|
data.can_extract_meta = !!(item.mime && item.mime.indexOf('flash') === -1 && !(item.mime.startsWith('application/') && cfg.mimes[item.mime] && !['swf', 'pdf'].includes(cfg.mimes[item.mime])));
|
||||||
@@ -499,14 +506,7 @@ export default (router, tpl) => {
|
|||||||
return res.reply({ body });
|
return res.reply({ body });
|
||||||
}
|
}
|
||||||
|
|
||||||
const cfgOnara = cfg.onara !== undefined ? cfg.onara : cfg.websrv?.onara;
|
const isOnara = isOnaraEnabledFor(req);
|
||||||
const cookieOnara = req.cookies?.f0ck_onara !== undefined
|
|
||||||
? (req.cookies.f0ck_onara === '1' || req.cookies.f0ck_onara === 'true')
|
|
||||||
: (req.cookies?.onara !== undefined ? (req.cookies.onara === '1' || req.cookies.onara === 'true') : null);
|
|
||||||
// Global config onara overweighs always the user pref
|
|
||||||
const isOnara = (cfgOnara !== undefined && cfgOnara !== null)
|
|
||||||
? !!cfgOnara
|
|
||||||
: (cookieOnara !== null ? cookieOnara : !!req.session?.onara);
|
|
||||||
if (mode === 'item' && isOnara) {
|
if (mode === 'item' && isOnara) {
|
||||||
const itemHtml = tpl.render('ajax-item', data, req);
|
const itemHtml = tpl.render('ajax-item', data, req);
|
||||||
|
|
||||||
|
|||||||
+209
-134
@@ -16,12 +16,16 @@ export default (router, tpl) => {
|
|||||||
// Moderator Dashboard
|
// Moderator Dashboard
|
||||||
router.get(/^\/mod(\/)?$/, lib.modAuth, async (req, res) => {
|
router.get(/^\/mod(\/)?$/, lib.modAuth, async (req, res) => {
|
||||||
const pendingCount = (await db`select count(*) as c from "items" where active = false and is_deleted = false`)[0].c;
|
const pendingCount = (await db`select count(*) as c from "items" where active = false and is_deleted = false`)[0].c;
|
||||||
|
const trashCount = (await db`select count(*) as c from "items" where active = false and is_deleted = true and is_purged = false`)[0].c;
|
||||||
|
const reportsCount = (await db`select count(*)::int as c from reports where status = 'pending'`.catch(() => [{ c: 0 }]))[0].c;
|
||||||
|
|
||||||
|
|
||||||
res.reply({
|
res.reply({
|
||||||
body: tpl.render("mod", {
|
body: tpl.render("mod", {
|
||||||
session: req.session,
|
session: req.session,
|
||||||
pendingCount: parseInt(pendingCount),
|
pendingCount: parseInt(pendingCount),
|
||||||
|
trashCount: parseInt(trashCount),
|
||||||
|
reportsCount: parseInt(reportsCount) || 0,
|
||||||
manualApproval: getManualApproval(),
|
manualApproval: getManualApproval(),
|
||||||
tmp: null
|
tmp: null
|
||||||
}, req)
|
}, req)
|
||||||
@@ -39,11 +43,31 @@ export default (router, tpl) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Approval Queue (View only — GET is safe, no state change)
|
// Approval Queue (View only — GET is safe, no state change)
|
||||||
|
// Tag badge classes for queue cards (shared by the approval queue and soft-deleted views)
|
||||||
|
const processQueueItems = (items) => items.map(p => ({
|
||||||
|
...p,
|
||||||
|
tags: (p.tags || [])
|
||||||
|
.filter(t => t.tag !== null)
|
||||||
|
.map(t => {
|
||||||
|
let badge = "badge-light";
|
||||||
|
if (t.tag.startsWith(">")) badge = "badge-greentext badge-light";
|
||||||
|
else if (t.normalized === "ukraine") badge = "badge-ukraine badge-light";
|
||||||
|
else if (/[а-яё]/.test(t.normalized) || t.normalized === "russia") badge = "badge-russia badge-light";
|
||||||
|
else if (t.normalized === "german") badge = "badge-german badge-light";
|
||||||
|
else if (t.normalized === "dutch") badge = "badge-dutch badge-light";
|
||||||
|
else if (t.normalized === "sfw") badge = "badge-success";
|
||||||
|
else if (t.normalized === "nsfw") badge = "badge-danger";
|
||||||
|
return { ...t, badge };
|
||||||
|
})
|
||||||
|
}));
|
||||||
|
|
||||||
|
const QUEUE_PAGE_SIZE = 20;
|
||||||
|
const queuePage = (req) => Math.max(1, +req.url.qs.page || 1);
|
||||||
|
|
||||||
|
// Approval queue: uploads waiting for approval (not deleted)
|
||||||
router.get(/^\/mod\/approve\/?/, lib.modAuth, async (req, res) => {
|
router.get(/^\/mod\/approve\/?/, lib.modAuth, async (req, res) => {
|
||||||
// View Queue
|
const page = queuePage(req);
|
||||||
const page = +req.url.qs.page || 1;
|
const [{ n: total }] = await db`select count(*)::int as n from "items" where active = false and is_deleted = false`;
|
||||||
const limit = 20;
|
|
||||||
// Fetch Pending (not deleted)
|
|
||||||
const pending = await db`
|
const pending = await db`
|
||||||
select i.id, i.mime, i.username, i.dest, json_agg(json_build_object('tag', t.tag, 'normalized', t.normalized)) as tags
|
select i.id, i.mime, i.username, i.dest, json_agg(json_build_object('tag', t.tag, 'normalized', t.normalized)) as tags
|
||||||
from "items" i
|
from "items" i
|
||||||
@@ -52,10 +76,25 @@ export default (router, tpl) => {
|
|||||||
where i.active = false and i.is_deleted = false
|
where i.active = false and i.is_deleted = false
|
||||||
group by i.id
|
group by i.id
|
||||||
order by i.id desc
|
order by i.id desc
|
||||||
limit ${limit} offset ${(page - 1) * limit}
|
limit ${QUEUE_PAGE_SIZE} offset ${(page - 1) * QUEUE_PAGE_SIZE}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Fetch Trash (deleted)
|
res.reply({
|
||||||
|
body: tpl.render('mod/approve', {
|
||||||
|
pending: processQueueItems(pending),
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
pages: Math.max(1, Math.ceil(total / QUEUE_PAGE_SIZE)),
|
||||||
|
session: req.session,
|
||||||
|
tmp: null
|
||||||
|
}, req)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Soft deleted: removed items that still exist on disk and can be restored or purged
|
||||||
|
router.get(/^\/mod\/trash\/?$/, lib.modAuth, async (req, res) => {
|
||||||
|
const page = queuePage(req);
|
||||||
|
const [{ n: total }] = await db`select count(*)::int as n from "items" where active = false and is_deleted = true and is_purged = false`;
|
||||||
const trash = await db`
|
const trash = await db`
|
||||||
select i.id, i.mime, i.username, i.dest,
|
select i.id, i.mime, i.username, i.dest,
|
||||||
json_agg(json_build_object('tag', t.tag, 'normalized', t.normalized)) as tags,
|
json_agg(json_build_object('tag', t.tag, 'normalized', t.normalized)) as tags,
|
||||||
@@ -66,45 +105,121 @@ export default (router, tpl) => {
|
|||||||
where i.active = false and i.is_deleted = true and i.is_purged = false
|
where i.active = false and i.is_deleted = true and i.is_purged = false
|
||||||
group by i.id
|
group by i.id
|
||||||
order by i.id desc
|
order by i.id desc
|
||||||
limit 20
|
limit ${QUEUE_PAGE_SIZE} offset ${(page - 1) * QUEUE_PAGE_SIZE}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const processItems = (items) => {
|
|
||||||
return items.map(p => {
|
|
||||||
const tags = (p.tags || [])
|
|
||||||
.filter(t => t.tag !== null)
|
|
||||||
.map(t => {
|
|
||||||
let badge = "badge-light";
|
|
||||||
if (t.tag.startsWith(">")) badge = "badge-greentext badge-light";
|
|
||||||
else if (t.normalized === "ukraine") badge = "badge-ukraine badge-light";
|
|
||||||
else if (/[а-яё]/.test(t.normalized) || t.normalized === "russia") badge = "badge-russia badge-light";
|
|
||||||
else if (t.normalized === "german") badge = "badge-german badge-light";
|
|
||||||
else if (t.normalized === "dutch") badge = "badge-dutch badge-light";
|
|
||||||
else if (t.normalized === "sfw") badge = "badge-success";
|
|
||||||
else if (t.normalized === "nsfw") badge = "badge-danger";
|
|
||||||
|
|
||||||
return { ...t, badge };
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
...p,
|
|
||||||
tags
|
|
||||||
};
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
res.reply({
|
res.reply({
|
||||||
body: tpl.render('mod/approve', {
|
body: tpl.render('mod/trash', {
|
||||||
pending: processItems(pending),
|
trash: processQueueItems(trash),
|
||||||
trash: processItems(trash),
|
total,
|
||||||
page,
|
page,
|
||||||
stats: { total: pending.length + trash.length },
|
pages: Math.max(1, Math.ceil(total / QUEUE_PAGE_SIZE)),
|
||||||
session: req.session,
|
session: req.session,
|
||||||
tmp: null
|
tmp: null
|
||||||
}, req)
|
}, req)
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const jsonReply = (res, code, obj) => {
|
||||||
|
const body = JSON.stringify(obj);
|
||||||
|
return res.writeHead(code, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Move an item's media (and album sub-items) from the pending or deleted folder back to the public folders.
|
||||||
|
const moveItemFilesToPublic = async (item, id) => {
|
||||||
|
const movePaths = [
|
||||||
|
{ b: path.join(cfg.paths.pending, 'b', item.dest), t: path.join(cfg.paths.pending, 't', `${id}.webp`), ca: path.join(cfg.paths.pending, 'ca', `${id}.webp`) },
|
||||||
|
{ b: path.join(cfg.paths.deleted, 'b', item.dest), t: path.join(cfg.paths.deleted, 't', `${id}.webp`), ca: path.join(cfg.paths.deleted, 'ca', `${id}.webp`) }
|
||||||
|
];
|
||||||
|
|
||||||
|
const isYouTube = item.mime === 'video/youtube';
|
||||||
|
for (const p of movePaths) {
|
||||||
|
try {
|
||||||
|
if (isYouTube) {
|
||||||
|
await fs.access(p.t);
|
||||||
|
} else {
|
||||||
|
await fs.access(p.b);
|
||||||
|
}
|
||||||
|
console.log(`[MOD MOVE] Moving files for item ${id} from ${p.b.includes('pending') ? 'pending' : 'deleted'}`);
|
||||||
|
|
||||||
|
const moveSafe = async (src, dst) => {
|
||||||
|
try {
|
||||||
|
const lstat = await fs.lstat(src);
|
||||||
|
if (lstat.isSymbolicLink()) {
|
||||||
|
const target = await fs.readlink(src);
|
||||||
|
const absTarget = path.resolve(path.dirname(src), target);
|
||||||
|
const relTarget = path.relative(path.dirname(dst), absTarget);
|
||||||
|
await fs.symlink(relTarget, dst);
|
||||||
|
await fs.unlink(src).catch(() => {});
|
||||||
|
} else {
|
||||||
|
await fs.copyFile(src, dst);
|
||||||
|
await fs.unlink(src).catch(() => {});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (e.code !== 'ENOENT') {
|
||||||
|
console.warn(`[MOD MOVE ERROR] Failed to move ${src} to ${dst}:`, e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const bDst = path.join(cfg.paths.b, item.dest);
|
||||||
|
const tDst = path.join(cfg.paths.t, `${id}.webp`);
|
||||||
|
const blurDst = path.join(cfg.paths.t, `${id}_blur.webp`);
|
||||||
|
const caDst = path.join(cfg.paths.ca, `${id}.webp`);
|
||||||
|
|
||||||
|
if (!isYouTube) {
|
||||||
|
await moveSafe(p.b, bDst);
|
||||||
|
}
|
||||||
|
await moveSafe(p.t, tDst);
|
||||||
|
|
||||||
|
const blurSrc = p.t.replace('.webp', '_blur.webp');
|
||||||
|
await moveSafe(blurSrc, blurDst);
|
||||||
|
|
||||||
|
if (item.mime.startsWith('audio')) {
|
||||||
|
await moveSafe(p.ca, caDst);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.is_album) {
|
||||||
|
try {
|
||||||
|
const subItems = await db`SELECT dest FROM album_items WHERE item_id = ${id}`;
|
||||||
|
for (const sub of subItems) {
|
||||||
|
const subBase = sub.dest.replace(/\.[^.]+$/, '');
|
||||||
|
await moveSafe(path.join(cfg.paths.pending, 'b', sub.dest), path.join(cfg.paths.b, sub.dest));
|
||||||
|
await moveSafe(path.join(cfg.paths.pending, 't', `${subBase}.webp`), path.join(cfg.paths.t, `${subBase}.webp`));
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
} catch (e) { }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Permanently remove an item's files and comments and flag it purged (admin action).
|
||||||
|
const purgeItem = async (item, id) => {
|
||||||
|
await safeDeleteMediaFile(item.dest, id);
|
||||||
|
await fs.unlink(path.join(cfg.paths.t, `${id}.webp`)).catch(() => { });
|
||||||
|
await fs.unlink(path.join(cfg.paths.t, `${id}_blur.webp`)).catch(() => { });
|
||||||
|
await fs.unlink(path.join(cfg.paths.pending, 'b', item.dest)).catch(() => { });
|
||||||
|
await fs.unlink(path.join(cfg.paths.pending, 't', `${id}.webp`)).catch(() => { });
|
||||||
|
await fs.unlink(path.join(cfg.paths.deleted, 'b', item.dest)).catch(() => { });
|
||||||
|
await fs.unlink(path.join(cfg.paths.deleted, 't', `${id}.webp`)).catch(() => { });
|
||||||
|
await fs.unlink(path.join(cfg.paths.deleted, 't', `${id}_blur.webp`)).catch(() => { });
|
||||||
|
if (item.mime?.startsWith('audio')) {
|
||||||
|
await fs.unlink(path.join(cfg.paths.ca, `${id}.webp`)).catch(() => { });
|
||||||
|
await fs.unlink(path.join(cfg.paths.pending, 'ca', `${id}.webp`)).catch(() => { });
|
||||||
|
await fs.unlink(path.join(cfg.paths.deleted, 'ca', `${id}.webp`)).catch(() => { });
|
||||||
|
}
|
||||||
|
await db`update "items" set is_purged = true where id = ${id}`;
|
||||||
|
await db`delete from comments where item_id = ${id}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const uploaderInfoFor = async (username) => {
|
||||||
|
try {
|
||||||
|
const u = await db`select id, "user" as username from "user" where login = ${username} or "user" = ${username} limit 1`;
|
||||||
|
return u.length ? { uploader_id: u[0].id, uploader_name: u[0].username } : {};
|
||||||
|
} catch { return {}; }
|
||||||
|
};
|
||||||
|
|
||||||
// F-005 Security: Approve action — POST with CSRF protection
|
// F-005 Security: Approve action — POST with CSRF protection
|
||||||
router.post(/^\/mod\/approve\/?/, lib.modAuth, async (req, res) => {
|
router.post(/^\/mod\/approve\/?/, lib.modAuth, async (req, res) => {
|
||||||
const id = +(req.post?.id || 0);
|
const id = +(req.post?.id || 0);
|
||||||
@@ -120,12 +235,12 @@ export default (router, tpl) => {
|
|||||||
where ta2.item_id = i.id and t2.tag in ('sfw','nsfw','nsfl')
|
where ta2.item_id = i.id and t2.tag in ('sfw','nsfw','nsfl')
|
||||||
limit 1) as tag_id
|
limit 1) as tag_id
|
||||||
from "items" i
|
from "items" i
|
||||||
where i.id = ${id} and i.active = false
|
where i.id = ${id} and i.active = false and i.is_deleted = false
|
||||||
limit 1
|
limit 1
|
||||||
`;
|
`;
|
||||||
|
|
||||||
if (f0ck.length === 0) {
|
if (f0ck.length === 0) {
|
||||||
const body = JSON.stringify({ success: false, msg: `f0ck ${id}: f0ck not found` });
|
const body = JSON.stringify({ success: false, msg: `f0ck ${id}: not in the approval queue` });
|
||||||
return res.writeHead(404, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body);
|
return res.writeHead(404, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,72 +345,7 @@ export default (router, tpl) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Move files to public location
|
await moveItemFilesToPublic(f0ck[0], id);
|
||||||
const movePaths = [
|
|
||||||
{ b: path.join(cfg.paths.pending, 'b', f0ck[0].dest), t: path.join(cfg.paths.pending, 't', `${id}.webp`), ca: path.join(cfg.paths.pending, 'ca', `${id}.webp`) },
|
|
||||||
{ b: path.join(cfg.paths.deleted, 'b', f0ck[0].dest), t: path.join(cfg.paths.deleted, 't', `${id}.webp`), ca: path.join(cfg.paths.deleted, 'ca', `${id}.webp`) }
|
|
||||||
];
|
|
||||||
|
|
||||||
const isYouTube = f0ck[0].mime === 'video/youtube';
|
|
||||||
for (const p of movePaths) {
|
|
||||||
try {
|
|
||||||
if (isYouTube) {
|
|
||||||
await fs.access(p.t);
|
|
||||||
} else {
|
|
||||||
await fs.access(p.b);
|
|
||||||
}
|
|
||||||
console.log(`[MOD APPROVE] Moving files for item ${id} from ${p.b.includes('pending') ? 'pending' : 'deleted'}`);
|
|
||||||
|
|
||||||
const moveSafe = async (src, dst) => {
|
|
||||||
try {
|
|
||||||
const lstat = await fs.lstat(src);
|
|
||||||
if (lstat.isSymbolicLink()) {
|
|
||||||
const target = await fs.readlink(src);
|
|
||||||
const absTarget = path.resolve(path.dirname(src), target);
|
|
||||||
const relTarget = path.relative(path.dirname(dst), absTarget);
|
|
||||||
await fs.symlink(relTarget, dst);
|
|
||||||
await fs.unlink(src).catch(() => {});
|
|
||||||
} else {
|
|
||||||
await fs.copyFile(src, dst);
|
|
||||||
await fs.unlink(src).catch(() => {});
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if (e.code !== 'ENOENT') {
|
|
||||||
console.warn(`[MOD APPROVE ERROR] Failed to move ${src} to ${dst}:`, e.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const bDst = path.join(cfg.paths.b, f0ck[0].dest);
|
|
||||||
const tDst = path.join(cfg.paths.t, `${id}.webp`);
|
|
||||||
const blurDst = path.join(cfg.paths.t, `${id}_blur.webp`);
|
|
||||||
const caDst = path.join(cfg.paths.ca, `${id}.webp`);
|
|
||||||
|
|
||||||
if (!isYouTube) {
|
|
||||||
await moveSafe(p.b, bDst);
|
|
||||||
}
|
|
||||||
await moveSafe(p.t, tDst);
|
|
||||||
|
|
||||||
const blurSrc = p.t.replace('.webp', '_blur.webp');
|
|
||||||
await moveSafe(blurSrc, blurDst);
|
|
||||||
|
|
||||||
if (f0ck[0].mime.startsWith('audio')) {
|
|
||||||
await moveSafe(p.ca, caDst);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (f0ck[0].is_album) {
|
|
||||||
try {
|
|
||||||
const subItems = await db`SELECT dest FROM album_items WHERE item_id = ${id}`;
|
|
||||||
for (const sub of subItems) {
|
|
||||||
const subBase = sub.dest.replace(/\.[^.]+$/, '');
|
|
||||||
await moveSafe(path.join(cfg.paths.pending, 'b', sub.dest), path.join(cfg.paths.b, sub.dest));
|
|
||||||
await moveSafe(path.join(cfg.paths.pending, 't', `${subBase}.webp`), path.join(cfg.paths.t, `${subBase}.webp`));
|
|
||||||
}
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
} catch (e) { }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.headers['x-requested-with'] === 'XMLHttpRequest' || (req.headers.accept && req.headers.accept.includes('application/json'))) {
|
if (req.headers['x-requested-with'] === 'XMLHttpRequest' || (req.headers.accept && req.headers.accept.includes('application/json'))) {
|
||||||
const body = JSON.stringify({ success: true, item_id: id, msg: "Item approved" });
|
const body = JSON.stringify({ success: true, item_id: id, msg: "Item approved" });
|
||||||
@@ -320,26 +370,11 @@ export default (router, tpl) => {
|
|||||||
if (item.is_deleted) {
|
if (item.is_deleted) {
|
||||||
// PURGE LOGIC (Strict Admin)
|
// PURGE LOGIC (Strict Admin)
|
||||||
if (!req.session.admin) {
|
if (!req.session.admin) {
|
||||||
return res.reply({ success: false, msg: "Only admins can purge items permanently." });
|
const body = JSON.stringify({ success: false, msg: "Only admins can purge items permanently." });
|
||||||
|
return res.writeHead(403, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete files — respect symlink ownership
|
await purgeItem(item, id);
|
||||||
await safeDeleteMediaFile(item.dest, id);
|
|
||||||
await fs.unlink(path.join(cfg.paths.t, `${id}.webp`)).catch(() => { });
|
|
||||||
await fs.unlink(path.join(cfg.paths.pending, 'b', item.dest)).catch(() => { });
|
|
||||||
await fs.unlink(path.join(cfg.paths.pending, 't', `${id}.webp`)).catch(() => { });
|
|
||||||
await fs.unlink(path.join(cfg.paths.deleted, 'b', item.dest)).catch(() => { });
|
|
||||||
await fs.unlink(path.join(cfg.paths.deleted, 't', `${id}.webp`)).catch(() => { });
|
|
||||||
if (item.mime?.startsWith('audio')) {
|
|
||||||
await fs.unlink(path.join(cfg.paths.ca, `${id}.webp`)).catch(() => { });
|
|
||||||
await fs.unlink(path.join(cfg.paths.pending, 'ca', `${id}.webp`)).catch(() => { });
|
|
||||||
await fs.unlink(path.join(cfg.paths.deleted, 'ca', `${id}.webp`)).catch(() => { });
|
|
||||||
}
|
|
||||||
|
|
||||||
// DB Flag instead of delete
|
|
||||||
await db`update "items" set is_purged = true where id = ${id}`;
|
|
||||||
// Delete comments permanently on purge
|
|
||||||
await db`delete from comments where item_id = ${id}`;
|
|
||||||
|
|
||||||
// Fetch uploader details for audit log
|
// Fetch uploader details for audit log
|
||||||
let uploaderInfo = {};
|
let uploaderInfo = {};
|
||||||
@@ -701,26 +736,66 @@ export default (router, tpl) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Soft deleted: restore / purge (own endpoints, independent of the approval queue) ──
|
||||||
|
|
||||||
|
// Restore a soft-deleted item: back to public, no "approved" notification or webhook
|
||||||
|
router.post(/^\/mod\/trash\/restore\/?$/, lib.modAuth, async (req, res) => {
|
||||||
|
const id = +(req.post?.id || 0);
|
||||||
|
if (!id) return jsonReply(res, 400, { success: false, msg: 'No ID provided' });
|
||||||
|
|
||||||
|
const rows = await db`
|
||||||
|
select i.id, i.dest, i.mime, i.username, i.visibility, i.is_album, i.album_count, i.is_oc,
|
||||||
|
(select ta2.tag_id from tags_assign ta2 join tags t2 on t2.id = ta2.tag_id
|
||||||
|
where ta2.item_id = i.id and t2.tag in ('sfw','nsfw','nsfl') limit 1) as tag_id
|
||||||
|
from "items" i
|
||||||
|
where i.id = ${id} and i.is_deleted = true and i.is_purged = false
|
||||||
|
limit 1
|
||||||
|
`;
|
||||||
|
if (!rows.length) return jsonReply(res, 404, { success: false, msg: `Item ${id} is not soft deleted` });
|
||||||
|
const item = rows[0];
|
||||||
|
|
||||||
|
const result = await db`update "items" set active = true, is_deleted = false where id = ${id} and is_deleted = true`;
|
||||||
|
if (result.count !== 1) return jsonReply(res, 409, { success: false, msg: 'Item was already restored' });
|
||||||
|
|
||||||
|
await moveItemFilesToPublic(item, id);
|
||||||
|
await audit.log(req.session.id, 'restore_item', 'item', id, { filename: item.dest, ...(await uploaderInfoFor(item.username)) });
|
||||||
|
|
||||||
|
// Live grid update so the item reappears for open tabs
|
||||||
|
if ((item.visibility || 0) === 0) {
|
||||||
|
db`SELECT pg_notify('new_item', ${JSON.stringify({
|
||||||
|
id, dest: item.dest, mime: item.mime, username: item.username, tag_id: item.tag_id,
|
||||||
|
is_oc: !!item.is_oc, is_album: !!item.is_album, album_count: item.album_count || 0
|
||||||
|
})})`.catch(err => console.error('[MOD RESTORE] new_item notify failed:', err));
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonReply(res, 200, { success: true, item_id: id, msg: 'Item restored' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Permanently purge one soft-deleted item (admins only)
|
||||||
|
router.post(/^\/mod\/trash\/purge\/?$/, lib.auth, async (req, res) => {
|
||||||
|
const id = +(req.post?.id || 0);
|
||||||
|
if (!id) return jsonReply(res, 400, { success: false, msg: 'No ID provided' });
|
||||||
|
const reason = (req.post?.reason || '').toString().trim();
|
||||||
|
if (!reason) return jsonReply(res, 400, { success: false, msg: 'A reason is required' });
|
||||||
|
|
||||||
|
const rows = await db`select id, dest, mime, username from "items" where id = ${id} and is_deleted = true and is_purged = false limit 1`;
|
||||||
|
if (!rows.length) return jsonReply(res, 404, { success: false, msg: `Item ${id} is not soft deleted` });
|
||||||
|
const item = rows[0];
|
||||||
|
|
||||||
|
await purgeItem(item, id);
|
||||||
|
await audit.log(req.session.id, 'purge_item', 'item', id, { filename: item.dest, reason, ...(await uploaderInfoFor(item.username)) });
|
||||||
|
return jsonReply(res, 200, { success: true, item_id: id, msg: 'Item purged' });
|
||||||
|
});
|
||||||
|
|
||||||
// Purge Trash (POST) - Strict Admin
|
// Purge Trash (POST) - Strict Admin
|
||||||
router.post(/^\/mod\/purge-trash-all\/?/, lib.auth, async (req, res) => {
|
router.post(/^\/mod\/(?:purge-trash-all|trash\/purge-all)\/?$/, lib.auth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
// lib.auth already ensures session.admin
|
// lib.auth already ensures session.admin
|
||||||
const trash = await db`select id, dest, mime from "items" where active = false and is_deleted = true and is_purged = false`;
|
const trash = await db`select id, dest, mime from "items" where active = false and is_deleted = true and is_purged = false`;
|
||||||
let count = 0;
|
let count = 0;
|
||||||
for (const item of trash) {
|
for (const item of trash) {
|
||||||
try {
|
try {
|
||||||
await safeDeleteMediaFile(item.dest, item.id);
|
await purgeItem(item, item.id);
|
||||||
await fs.unlink(path.join(cfg.paths.t, `${item.id}.webp`)).catch(() => { });
|
|
||||||
await fs.unlink(path.join(cfg.paths.deleted, 'b', item.dest)).catch(() => { });
|
|
||||||
await fs.unlink(path.join(cfg.paths.deleted, 't', `${item.id}.webp`)).catch(() => { });
|
|
||||||
if (item.mime?.startsWith('audio')) {
|
|
||||||
await fs.unlink(path.join(cfg.paths.ca, `${item.id}.webp`)).catch(() => { });
|
|
||||||
await fs.unlink(path.join(cfg.paths.deleted, 'ca', `${item.id}.webp`)).catch(() => { });
|
|
||||||
}
|
|
||||||
|
|
||||||
await db`update "items" set is_purged = true where id = ${item.id}`;
|
|
||||||
// Delete comments permanently on purge
|
|
||||||
await db`delete from comments where item_id = ${item.id}`;
|
|
||||||
count++;
|
count++;
|
||||||
} catch (e) { }
|
} catch (e) { }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import db from "../sql.mjs";
|
import db from "../sql.mjs";
|
||||||
import cfg from "../config.mjs";
|
import cfg from "../config.mjs";
|
||||||
import lib from "../lib.mjs";
|
import lib from "../lib.mjs";
|
||||||
import { isAnonymizeSession } from "../settings.mjs";
|
import { isAnonymizeSession, canAnonDo, getSessionOwnerName } from "../settings.mjs";
|
||||||
import f0cklib from "../routeinc/f0cklib.mjs";
|
import f0cklib from "../routeinc/f0cklib.mjs";
|
||||||
import fs from "fs/promises";
|
import fs from "fs/promises";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
@@ -174,6 +174,9 @@ export default (router, tpl) => {
|
|||||||
const isAnon = !!(session && (session.is_anon || (session.user && (session.user === 'anonymous' || session.user.startsWith('anon_')))));
|
const isAnon = !!(session && (session.is_anon || (session.user && (session.user === 'anonymous' || session.user.startsWith('anon_')))));
|
||||||
data.is_mod_or_admin = !!(session && (session.admin || session.is_moderator));
|
data.is_mod_or_admin = !!(session && (session.admin || session.is_moderator));
|
||||||
data.can_manage_item = !isAnon && !!(session && (session.admin || session.is_moderator || (session.user && item.username && session.user.toLowerCase() === item.username.toLowerCase())));
|
data.can_manage_item = !isAnon && !!(session && (session.admin || session.is_moderator || (session.user && item.username && session.user.toLowerCase() === item.username.toLowerCase())));
|
||||||
|
// Rating may also be changed by an anonymous uploader on their own item
|
||||||
|
const _ownerName = getSessionOwnerName(session);
|
||||||
|
data.can_rate_item = data.can_manage_item || (isAnon && canAnonDo('rate_item') && !!(_ownerName && item.username && _ownerName.toLowerCase() === item.username.toLowerCase()));
|
||||||
data.can_extract_meta = !!(item.mime && item.mime.indexOf('flash') === -1 && !(item.mime.startsWith('application/') && cfg.mimes[item.mime] && !['swf', 'pdf'].includes(cfg.mimes[item.mime])));
|
data.can_extract_meta = !!(item.mime && item.mime.indexOf('flash') === -1 && !(item.mime.startsWith('application/') && cfg.mimes[item.mime] && !['swf', 'pdf'].includes(cfg.mimes[item.mime])));
|
||||||
data.user_has_favorited = lib.userHasFavorited(session, item.favorites);
|
data.user_has_favorited = lib.userHasFavorited(session, item.favorites);
|
||||||
data.halls_slugs = Array.isArray(item.halls) ? item.halls.map(h => h.slug).join(',') : '';
|
data.halls_slugs = Array.isArray(item.halls) ? item.halls.map(h => h.slug).join(',') : '';
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ export const DEFAULT_ANON_PERMISSIONS = Object.freeze({
|
|||||||
rate_item: false,
|
rate_item: false,
|
||||||
filter: true,
|
filter: true,
|
||||||
exclude_tags: true,
|
exclude_tags: true,
|
||||||
|
chan: false, // 4chan viewer & rehost ("4chan mode")
|
||||||
anonymize_users: false,
|
anonymize_users: false,
|
||||||
allowed_modes: ['sfw', 'nsfw', 'untagged', 'all', 'nsfl'],
|
allowed_modes: ['sfw', 'nsfw', 'untagged', 'all', 'nsfl'],
|
||||||
allowed_mimes: ['image', 'video', 'audio', 'flash', 'pdf']
|
allowed_mimes: ['image', 'video', 'audio', 'flash', 'pdf']
|
||||||
@@ -124,6 +125,37 @@ export const isAnonSession = (session) => {
|
|||||||
return !!(session.is_anon || session.user === 'anonymous' || (typeof session.user === 'string' && session.user.startsWith('anon_')));
|
return !!(session.is_anon || session.user === 'anonymous' || (typeof session.user === 'string' && session.user.startsWith('anon_')));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Onara viewer is a per-user setting (cookie f0ck_onara, legacy cookie onara, or session value).
|
||||||
|
// config `onara: false` disables it for everyone; `onara: true` is only the default for users
|
||||||
|
// without a stored preference. `forceOn` covers explicit requests like ?onara=1.
|
||||||
|
export const isOnaraEnabledFor = (req, forceOn = false) => {
|
||||||
|
const c = cfg.onara !== undefined ? cfg.onara : cfg.websrv?.onara;
|
||||||
|
if (c === false) return false;
|
||||||
|
if (forceOn) return true;
|
||||||
|
const ck = req?.cookies || {};
|
||||||
|
const raw = ck.f0ck_onara !== undefined ? ck.f0ck_onara : ck.onara;
|
||||||
|
if (raw !== undefined) return raw === '1' || raw === 'true';
|
||||||
|
if (req?.session && req.session.onara !== undefined && req.session.onara !== null) return !!req.session.onara;
|
||||||
|
return c === true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// The name items.username holds for this session's uploads. Anonymous sessions have
|
||||||
|
// user = 'anonymous' (shared by all anon users); their real account is the shadow login (anon_xxxx).
|
||||||
|
export const getSessionOwnerName = (session) => {
|
||||||
|
if (!session || typeof session !== 'object') return null;
|
||||||
|
if (session.is_anon) return session.anon_login || session.login || null;
|
||||||
|
return session.user || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 4chan viewer & rehost access: admins, members of the '4chan' group, and anonymous
|
||||||
|
// users when anonymous_permissions.chan is enabled
|
||||||
|
export const canUseChan = (session) => {
|
||||||
|
if (!session || typeof session !== 'object') return false;
|
||||||
|
if (session.admin) return true;
|
||||||
|
if (Array.isArray(session.groups) && session.groups.includes('4chan')) return true;
|
||||||
|
return !!(session.is_anon && canAnonDo('chan'));
|
||||||
|
};
|
||||||
|
|
||||||
export const checkAnonPermission = (session, action) => {
|
export const checkAnonPermission = (session, action) => {
|
||||||
if (!isAnonSession(session)) return true;
|
if (!isAnonSession(session)) return true;
|
||||||
return canAnonDo(action);
|
return canAnonDo(action);
|
||||||
|
|||||||
+40
-14
@@ -21,7 +21,7 @@ import { handleMetaExtract } from "./meta_extract_handler.mjs";
|
|||||||
import { handleMetaStrip } from "./meta_strip_handler.mjs";
|
import { handleMetaStrip } from "./meta_strip_handler.mjs";
|
||||||
import { handleCommentUpload, handleCommentUploadCancel } from "./comment_upload_handler.mjs";
|
import { handleCommentUpload, handleCommentUploadCancel } from "./comment_upload_handler.mjs";
|
||||||
import { handleDmAttachmentUpload, handleDmAttachmentDownload, handleDmAttachmentDelete } from "./dm_attachment_handler.mjs";
|
import { handleDmAttachmentUpload, handleDmAttachmentDownload, handleDmAttachmentDelete } from "./dm_attachment_handler.mjs";
|
||||||
import { getManualApproval, setManualApproval, getMinTags, setMinTags, getRegistrationOpen, setRegistrationOpen, getTrustedUploads, setTrustedUploads, getBypassDuplicateCheck, setBypassDuplicateCheck, getProtectFiles, setProtectFiles, getPrivateMessages, setPrivateMessages, getDmAttachments, setDmAttachments, getDmUnencrypted, setDmUnencrypted, getDefaultLayout, setDefaultLayout, getEnablePdf, setEnablePdf, getEnableCleanup, setEnableCleanup, getCleanupStartDate, setCleanupStartDate, getCleanupEndDate, setCleanupEndDate, getCleanupIncludeEngaged, setCleanupIncludeEngaged, getLogUserIps, setLogUserIps, getHashUserIps, setHashUserIps, getShitpostMode, setShitpostMode, getAllowCommentDeletion, setAllowCommentDeletion, getNsfpIds, setNsfpIds, getEnableExpiringUploads, getEnableItemSlugs, getEnableAnonymousAccess, getAnonPermissions, getAnonAnonymize, isAnonymizeSession, ensureAllItemsHaveSlugs, ensureAllAlbumItemsHaveSlugs, isAnonSession, canAnonDo, getAnonAllowedModes, getAnonAllowedMimes, getBrandImageUrl, setBrandImageUrl } from "./inc/settings.mjs";
|
import { getManualApproval, setManualApproval, getMinTags, setMinTags, getRegistrationOpen, setRegistrationOpen, getTrustedUploads, setTrustedUploads, getBypassDuplicateCheck, setBypassDuplicateCheck, getProtectFiles, setProtectFiles, getPrivateMessages, setPrivateMessages, getDmAttachments, setDmAttachments, getDmUnencrypted, setDmUnencrypted, getDefaultLayout, setDefaultLayout, getEnablePdf, setEnablePdf, getEnableCleanup, setEnableCleanup, getCleanupStartDate, setCleanupStartDate, getCleanupEndDate, setCleanupEndDate, getCleanupIncludeEngaged, setCleanupIncludeEngaged, getLogUserIps, setLogUserIps, getHashUserIps, setHashUserIps, getShitpostMode, setShitpostMode, getAllowCommentDeletion, setAllowCommentDeletion, getNsfpIds, setNsfpIds, getEnableExpiringUploads, getEnableItemSlugs, getEnableAnonymousAccess, getAnonPermissions, getAnonAnonymize, isAnonymizeSession, ensureAllItemsHaveSlugs, ensureAllAlbumItemsHaveSlugs, isAnonSession, canAnonDo, canUseChan, isOnaraEnabledFor, getAnonAllowedModes, getAnonAllowedMimes, getBrandImageUrl, setBrandImageUrl } from "./inc/settings.mjs";
|
||||||
import { updateHallsCache, getHalls } from "./inc/halls_cache.mjs";
|
import { updateHallsCache, getHalls } from "./inc/halls_cache.mjs";
|
||||||
import { createI18n } from "./inc/i18n.mjs";
|
import { createI18n } from "./inc/i18n.mjs";
|
||||||
import { safeDeleteMediaFile, purgeExpiredUploads } from "./inc/lib_delete.mjs";
|
import { safeDeleteMediaFile, purgeExpiredUploads } from "./inc/lib_delete.mjs";
|
||||||
@@ -647,6 +647,28 @@ process.on('uncaughtException', err => {
|
|||||||
await runMigration(db`CREATE INDEX IF NOT EXISTS idx_album_items_tags_assign_tag_id ON album_items_tags_assign(tag_id)`);
|
await runMigration(db`CREATE INDEX IF NOT EXISTS idx_album_items_tags_assign_tag_id ON album_items_tags_assign(tag_id)`);
|
||||||
await runMigration(db`CREATE INDEX IF NOT EXISTS idx_album_items_tags_assign_album_item_id ON album_items_tags_assign(album_item_id)`);
|
await runMigration(db`CREATE INDEX IF NOT EXISTS idx_album_items_tags_assign_album_item_id ON album_items_tags_assign(album_item_id)`);
|
||||||
|
|
||||||
|
// Ensure the NSFL rating tag exists under cfg.nsfl_tag_id — rating changes and feed filters
|
||||||
|
// all reference that id, and tags_assign has a FK on tags.id
|
||||||
|
if (cfg.enable_nsfl) {
|
||||||
|
const nsflId = parseInt(cfg.nsfl_tag_id, 10);
|
||||||
|
if (nsflId > 0) {
|
||||||
|
try {
|
||||||
|
const [byId] = await db`SELECT id, normalized FROM tags WHERE id = ${nsflId} LIMIT 1`;
|
||||||
|
const [byName] = await db`SELECT id FROM tags WHERE normalized = 'nsfl' LIMIT 1`;
|
||||||
|
if (byId && byId.normalized !== 'nsfl') {
|
||||||
|
console.warn(`[BOOT] nsfl_tag_id ${nsflId} points to tag "${byId.normalized}", not "nsfl" — check config`);
|
||||||
|
} else if (!byId && byName) {
|
||||||
|
console.warn(`[BOOT] NSFL tag exists as id ${byName.id} but nsfl_tag_id is ${nsflId} — set nsfl_tag_id: ${byName.id} in config`);
|
||||||
|
} else if (!byId) {
|
||||||
|
await db`INSERT INTO tags (id, tag) VALUES (${nsflId}, 'nsfl') ON CONFLICT DO NOTHING`;
|
||||||
|
console.log(`[BOOT] Created missing NSFL rating tag (id ${nsflId})`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[BOOT] NSFL tag check failed:', e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Initial halls cache (only if halls are enabled)
|
// Initial halls cache (only if halls are enabled)
|
||||||
if (cfg.websrv.halls_enabled !== false) {
|
if (cfg.websrv.halls_enabled !== false) {
|
||||||
try {
|
try {
|
||||||
@@ -1026,7 +1048,7 @@ process.on('uncaughtException', err => {
|
|||||||
}
|
}
|
||||||
// csrf_token is loaded from user_sessions table via the session query above
|
// csrf_token is loaded from user_sessions table via the session query above
|
||||||
if (req.session) {
|
if (req.session) {
|
||||||
req.session.can_chan = !!(req.session.admin || (Array.isArray(req.session.groups) && req.session.groups.includes('4chan')));
|
req.session.can_chan = canUseChan(req.session);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ban check (Session)
|
// Ban check (Session)
|
||||||
@@ -1955,9 +1977,11 @@ process.on('uncaughtException', err => {
|
|||||||
const c = cfg.onara !== undefined ? cfg.onara : cfg.websrv?.onara;
|
const c = cfg.onara !== undefined ? cfg.onara : cfg.websrv?.onara;
|
||||||
return !!c;
|
return !!c;
|
||||||
},
|
},
|
||||||
|
// onara_cfg locks the per-user setting; only `onara: false` (feature disabled) does that.
|
||||||
|
// `onara: true` is just the default for users who haven't chosen.
|
||||||
get onara_cfg() {
|
get onara_cfg() {
|
||||||
const c = cfg.onara !== undefined ? cfg.onara : cfg.websrv?.onara;
|
const c = cfg.onara !== undefined ? cfg.onara : cfg.websrv?.onara;
|
||||||
return (c !== undefined && c !== null) ? !!c : null;
|
return c === false ? false : null;
|
||||||
},
|
},
|
||||||
is_onara_item: false,
|
is_onara_item: false,
|
||||||
can_extract_meta: false,
|
can_extract_meta: false,
|
||||||
@@ -2120,7 +2144,7 @@ process.on('uncaughtException', err => {
|
|||||||
const activeSession = activeReq?.session || data?.session || null;
|
const activeSession = activeReq?.session || data?.session || null;
|
||||||
const isAnonymized = isAnonymizeSession(activeSession);
|
const isAnonymized = isAnonymizeSession(activeSession);
|
||||||
const anonAnonymize = getAnonAnonymize();
|
const anonAnonymize = getAnonAnonymize();
|
||||||
const canChan = !!(activeSession && (activeSession.admin || (Array.isArray(activeSession.groups) && activeSession.groups.includes('4chan'))));
|
const canChan = canUseChan(activeSession);
|
||||||
if (activeSession && typeof activeSession === 'object') {
|
if (activeSession && typeof activeSession === 'object') {
|
||||||
activeSession.can_chan = canChan;
|
activeSession.can_chan = canChan;
|
||||||
}
|
}
|
||||||
@@ -2135,6 +2159,13 @@ process.on('uncaughtException', err => {
|
|||||||
is_anonymized: isAnonymized,
|
is_anonymized: isAnonymized,
|
||||||
anon_anonymize: anonAnonymize,
|
anon_anonymize: anonAnonymize,
|
||||||
can_chan: canChan,
|
can_chan: canChan,
|
||||||
|
// Item partials read can_rate_item; routes that only set can_manage_item fall back to it
|
||||||
|
can_rate_item: !!(data && (data.can_rate_item ?? data.can_manage_item)),
|
||||||
|
// Moderator-area page (/mod…): the back bar points to the moderator dashboard instead of /admin
|
||||||
|
// Includes are rendered without req; keep the value the page render already computed
|
||||||
|
is_mod_area: (data && typeof data.is_mod_area === 'boolean')
|
||||||
|
? data.is_mod_area
|
||||||
|
: !!(activeReq?.url?.pathname && /^\/mod(\/|$)/.test(activeReq.url.pathname)),
|
||||||
item_has_dimensions: !!(data && data.item && data.item.width && data.item.height),
|
item_has_dimensions: !!(data && data.item && data.item.width && data.item.height),
|
||||||
user_alternative_infobox: useAltInfobox,
|
user_alternative_infobox: useAltInfobox,
|
||||||
user_alternative_steuerung: useAltSteuerung,
|
user_alternative_steuerung: useAltSteuerung,
|
||||||
@@ -2169,15 +2200,10 @@ process.on('uncaughtException', err => {
|
|||||||
data.max_file_size_bytes = Math.floor(cfg.main.maxfilesize * (activeReq.session?.admin ? cfg.main.adminmultiplier : 1));
|
data.max_file_size_bytes = Math.floor(cfg.main.maxfilesize * (activeReq.session?.admin ? cfg.main.adminmultiplier : 1));
|
||||||
data.web_url_upload = data.web_url_upload !== undefined ? data.web_url_upload : !!cfg.websrv.web_url_upload;
|
data.web_url_upload = data.web_url_upload !== undefined ? data.web_url_upload : !!cfg.websrv.web_url_upload;
|
||||||
|
|
||||||
|
// Onara is a per-user setting; config `onara: false` disables it, `onara: true` is only the default
|
||||||
const cfgOnara = cfg.onara !== undefined ? cfg.onara : cfg.websrv?.onara;
|
const cfgOnara = cfg.onara !== undefined ? cfg.onara : cfg.websrv?.onara;
|
||||||
const cookieOnara = activeReq.cookies?.f0ck_onara !== undefined
|
data.onara = isOnaraEnabledFor(activeReq);
|
||||||
? (activeReq.cookies.f0ck_onara === '1' || activeReq.cookies.f0ck_onara === 'true')
|
data.onara_cfg = cfgOnara === false ? false : null;
|
||||||
: (activeReq.cookies?.onara !== undefined ? (activeReq.cookies.onara === '1' || activeReq.cookies.onara === 'true') : null);
|
|
||||||
const effectiveOnara = (cfgOnara !== undefined && cfgOnara !== null)
|
|
||||||
? !!cfgOnara
|
|
||||||
: (cookieOnara !== null ? cookieOnara : !!activeReq.session?.onara);
|
|
||||||
data.onara = effectiveOnara;
|
|
||||||
data.onara_cfg = (cfgOnara !== undefined && cfgOnara !== null) ? !!cfgOnara : null;
|
|
||||||
} else {
|
} else {
|
||||||
data.recaptcha_enabled = perRequestRecaptcha;
|
data.recaptcha_enabled = perRequestRecaptcha;
|
||||||
data.theme = data.theme || cfg.websrv.theme || 'f0ck';
|
data.theme = data.theme || cfg.websrv.theme || 'f0ck';
|
||||||
@@ -2186,8 +2212,8 @@ process.on('uncaughtException', err => {
|
|||||||
globals.csrf_token = data.csrf_token || '';
|
globals.csrf_token = data.csrf_token || '';
|
||||||
|
|
||||||
const cfgOnara = cfg.onara !== undefined ? cfg.onara : cfg.websrv?.onara;
|
const cfgOnara = cfg.onara !== undefined ? cfg.onara : cfg.websrv?.onara;
|
||||||
data.onara = !!cfgOnara;
|
data.onara = cfgOnara === true;
|
||||||
data.onara_cfg = (cfgOnara !== undefined && cfgOnara !== null) ? !!cfgOnara : null;
|
data.onara_cfg = cfgOnara === false ? false : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return originalRender.call(tpl, view, data, (data.url && data.url !== req?.url) ? undefined : req);
|
return originalRender.call(tpl, view, data, (data.url && data.url !== req?.url) ? undefined : req);
|
||||||
|
|||||||
+205
-117
@@ -1,136 +1,230 @@
|
|||||||
@include(snippets/header)
|
@include(snippets/header)
|
||||||
<div class="pagewrapper">
|
<div class="pagewrapper">
|
||||||
<div id="main">
|
<div id="main">
|
||||||
<div class="container">
|
<div class="container adm">
|
||||||
<h1>ADMINBEREICH</h1>
|
|
||||||
<h5>Hallo, {{ session.user }}</h5>
|
<!-- Header -->
|
||||||
<span>Hier entsteht eine Internetpräsenz!</span><br>
|
<header class="adm-header">
|
||||||
<hr>
|
<div>
|
||||||
<p>f0ck stats: @if(typeof totals !== "undefined")total: {{ totals.total }} | tagged: {{ totals.tagged }} | untagged: {{ totals.untagged }} | sfw: {{ totals.sfw }} | nsfw: {{ totals.nsfw }}@endif</p>
|
<h1 class="adm-title">Admin</h1>
|
||||||
<hr>
|
<div class="adm-subtitle">Signed in as <strong>{{ session.user }}</strong></div>
|
||||||
<div class="admintools">
|
</div>
|
||||||
<p>Adminwerkzeuge</p>
|
<a href="/mod" class="adm-btn adm-btn-ghost"><i class="fa-solid fa-gavel"></i> Moderator panel</a>
|
||||||
<ul>
|
</header>
|
||||||
<li><a href="/mod/audit">Audit Log</a></li>
|
|
||||||
<li><a href="/admin/approve">Approval Queue</a></li>
|
<!-- Overview -->
|
||||||
<li><a href="/admin/sessions">Sessions</a></li>
|
<section class="adm-section">
|
||||||
<li><a href="/admin/tokens">Invite Tokens</a></li>
|
<h2 class="adm-section-title"><i class="fa-solid fa-chart-simple"></i> Overview</h2>
|
||||||
<li><a href="/admin/users">User Manager</a></li>
|
@if(typeof totals !== "undefined")
|
||||||
<li><a href="/admin/bans">Ban & Fingerprint Manager</a></li>
|
<div class="adm-stats">
|
||||||
<li><a href="/admin/emojis">Emoji Manager</a></li>
|
<div class="adm-stat"><span class="adm-stat-num">{{ totals.total }}</span><span class="adm-stat-label">Posts</span></div>
|
||||||
<li><a href="/admin/memes">Meme Manager</a></li>
|
<div class="adm-stat"><span class="adm-stat-num">{{ totals.sfw }}</span><span class="adm-stat-label">SFW</span></div>
|
||||||
<li><a href="/admin/koepfe">Köpfe Manager</a></li>
|
<div class="adm-stat"><span class="adm-stat-num">{{ totals.nsfw }}</span><span class="adm-stat-label">NSFW</span></div>
|
||||||
<li><a href="/admin/halls">Hall Manager</a></li>
|
@if(enable_nsfl)
|
||||||
<li><a href="/admin/motd">MOTD Manager</a></li>
|
<div class="adm-stat"><span class="adm-stat-num">{{ totals.nsfl }}</span><span class="adm-stat-label">NSFL</span></div>
|
||||||
<li><a href="/admin/wordfilter">Wordfilter Manager</a></li>
|
@endif
|
||||||
<li><a href="/admin/nsfp">NSFP Tag Manager</a></li>
|
<div class="adm-stat"><span class="adm-stat-num">{{ totals.untagged }}</span><span class="adm-stat-label">Untagged</span></div>
|
||||||
|
<a href="/admin/approve" class="adm-stat adm-stat-link @if(totals.pending > 0) is-hot @endif"><span class="adm-stat-num">{{ totals.pending }}</span><span class="adm-stat-label">Pending approval</span></a>
|
||||||
|
<a href="/mod/trash" class="adm-stat adm-stat-link"><span class="adm-stat-num">{{ dash.trash }}</span><span class="adm-stat-label">Soft deleted</span></a>
|
||||||
|
<a href="/mod/reports" class="adm-stat adm-stat-link @if(dash.open_reports > 0) is-hot @endif"><span class="adm-stat-num">{{ dash.open_reports }}</span><span class="adm-stat-label">Open reports</span></a>
|
||||||
|
<a href="/admin/users" class="adm-stat adm-stat-link"><span class="adm-stat-num">{{ dash.users }}</span><span class="adm-stat-label">Users <small>+{{ dash.anon_users }} anon</small></span></a>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Tools -->
|
||||||
|
<section class="adm-section">
|
||||||
|
<h2 class="adm-section-title"><i class="fa-solid fa-screwdriver-wrench"></i> Tools</h2>
|
||||||
|
|
||||||
|
<h3 class="adm-group-title">Moderation</h3>
|
||||||
|
<div class="adm-tools">
|
||||||
|
<a href="/admin/approve" class="adm-tool">
|
||||||
|
<i class="fa-solid fa-clock"></i>
|
||||||
|
<span class="adm-tool-text"><strong>Approval Queue @if(totals.pending > 0)<span class="adm-count">{{ totals.pending }}</span>@endif</strong><span>Review uploads waiting for approval</span></span>
|
||||||
|
</a>
|
||||||
|
<a href="/mod/trash" class="adm-tool">
|
||||||
|
<i class="fa-solid fa-trash-can"></i>
|
||||||
|
<span class="adm-tool-text"><strong>Soft Deleted @if(dash.trash > 0)<span class="adm-count">{{ dash.trash }}</span>@endif</strong><span>Restore or permanently purge removed items</span></span>
|
||||||
|
</a>
|
||||||
|
<a href="/mod/reports" class="adm-tool">
|
||||||
|
<i class="fa-solid fa-flag"></i>
|
||||||
|
<span class="adm-tool-text"><strong>User Reports @if(dash.open_reports > 0)<span class="adm-count">{{ dash.open_reports }}</span>@endif</strong><span>Reported posts, comments and users</span></span>
|
||||||
|
</a>
|
||||||
|
<a href="/mod/audit" class="adm-tool">
|
||||||
|
<i class="fa-solid fa-scroll"></i>
|
||||||
|
<span class="adm-tool-text"><strong>Audit Log</strong><span>Every moderation and admin action</span></span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="adm-group-title">Users & access</h3>
|
||||||
|
<div class="adm-tools">
|
||||||
|
<a href="/admin/users" class="adm-tool">
|
||||||
|
<i class="fa-solid fa-users"></i>
|
||||||
|
<span class="adm-tool-text"><strong>User Manager</strong><span>Accounts, roles and groups</span></span>
|
||||||
|
</a>
|
||||||
|
<a href="/admin/bans" class="adm-tool">
|
||||||
|
<i class="fa-solid fa-ban"></i>
|
||||||
|
<span class="adm-tool-text"><strong>Bans & Fingerprints</strong><span>IP, hardware and passkey bans</span></span>
|
||||||
|
</a>
|
||||||
|
<a href="/admin/sessions" class="adm-tool">
|
||||||
|
<i class="fa-solid fa-key"></i>
|
||||||
|
<span class="adm-tool-text"><strong>Sessions</strong><span>Active logins across all users</span></span>
|
||||||
|
</a>
|
||||||
|
<a href="/admin/tokens" class="adm-tool">
|
||||||
|
<i class="fa-solid fa-ticket"></i>
|
||||||
|
<span class="adm-tool-text"><strong>Invite Tokens</strong><span>Create and revoke invites</span></span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="adm-group-title">Content</h3>
|
||||||
|
<div class="adm-tools">
|
||||||
|
<a href="/admin/halls" class="adm-tool">
|
||||||
|
<i class="fa-solid fa-layer-group"></i>
|
||||||
|
<span class="adm-tool-text"><strong>Halls</strong><span>Curated collections</span></span>
|
||||||
|
</a>
|
||||||
|
<a href="/admin/emojis" class="adm-tool">
|
||||||
|
<i class="fa-solid fa-face-smile"></i>
|
||||||
|
<span class="adm-tool-text"><strong>Emojis</strong><span>Custom emojis and sticker packs</span></span>
|
||||||
|
</a>
|
||||||
|
<a href="/admin/memes" class="adm-tool">
|
||||||
|
<i class="fa-regular fa-image"></i>
|
||||||
|
<span class="adm-tool-text"><strong>Memes</strong><span>Meme creator templates</span></span>
|
||||||
|
</a>
|
||||||
|
<a href="/admin/koepfe" class="adm-tool">
|
||||||
|
<i class="fa-solid fa-masks-theater"></i>
|
||||||
|
<span class="adm-tool-text"><strong>Köpfe</strong><span>Floating head images</span></span>
|
||||||
|
</a>
|
||||||
|
<a href="/admin/nsfp" class="adm-tool">
|
||||||
|
<i class="fa-solid fa-eye-slash"></i>
|
||||||
|
<span class="adm-tool-text"><strong>NSFP Tags</strong><span>Tags hidden from the public</span></span>
|
||||||
|
</a>
|
||||||
|
<a href="/admin/wordfilter" class="adm-tool">
|
||||||
|
<i class="fa-solid fa-filter"></i>
|
||||||
|
<span class="adm-tool-text"><strong>Wordfilter</strong><span>Replace words in comments</span></span>
|
||||||
|
</a>
|
||||||
@if(enable_cleanup)
|
@if(enable_cleanup)
|
||||||
<li><a href="/admin/cleanup">Cleanup Manager</a></li>
|
<a href="/admin/cleanup" class="adm-tool">
|
||||||
|
<i class="fa-solid fa-broom"></i>
|
||||||
|
<span class="adm-tool-text"><strong>Cleanup</strong><span>Remove old unengaged uploads</span></span>
|
||||||
|
</a>
|
||||||
@endif
|
@endif
|
||||||
<li><a href="/admin/about">About Page</a></li>
|
</div>
|
||||||
<li><a href="/admin/rules">Rules Page</a></li>
|
|
||||||
<li><a href="/admin/terms">ToS Page</a></li>
|
<h3 class="adm-group-title">Site</h3>
|
||||||
|
<div class="adm-tools">
|
||||||
|
<a href="/admin/motd" class="adm-tool">
|
||||||
|
<i class="fa-solid fa-bullhorn"></i>
|
||||||
|
<span class="adm-tool-text"><strong>MOTD</strong><span>Message of the day banner</span></span>
|
||||||
|
</a>
|
||||||
|
<a href="/admin/about" class="adm-tool">
|
||||||
|
<i class="fa-solid fa-circle-info"></i>
|
||||||
|
<span class="adm-tool-text"><strong>About Page</strong><span>Edit /about</span></span>
|
||||||
|
</a>
|
||||||
|
<a href="/admin/rules" class="adm-tool">
|
||||||
|
<i class="fa-solid fa-list-check"></i>
|
||||||
|
<span class="adm-tool-text"><strong>Rules Page</strong><span>Edit /rules</span></span>
|
||||||
|
</a>
|
||||||
|
<a href="/admin/terms" class="adm-tool">
|
||||||
|
<i class="fa-solid fa-file-contract"></i>
|
||||||
|
<span class="adm-tool-text"><strong>ToS Page</strong><span>Edit /terms</span></span>
|
||||||
|
</a>
|
||||||
@if(enable_global_chat)
|
@if(enable_global_chat)
|
||||||
<li><a href="/admin/chat">Global Chat Manager</a></li>
|
<a href="/admin/chat" class="adm-tool">
|
||||||
|
<i class="fa-solid fa-comments"></i>
|
||||||
|
<span class="adm-tool-text"><strong>Global Chat</strong><span>Moderate the site chat</span></span>
|
||||||
|
</a>
|
||||||
@endif
|
@endif
|
||||||
</ul>
|
</div>
|
||||||
<hr style="margin: 20px 0; border: 0; border-top: 1px solid rgba(255,255,255,0.1);">
|
</section>
|
||||||
|
|
||||||
<!-- Navbar Brand Image -->
|
<!-- Settings -->
|
||||||
<div class="settings-item" id="brand-image-section" style="background: rgba(0,0,0,0.2); padding: 15px; border-radius: 4px; margin-top: 10px;">
|
<section class="adm-section">
|
||||||
<label style="display: block; font-weight: bold; color: var(--accent); margin-bottom: 8px;">Navbar Brand Image</label>
|
<div class="adm-section-head">
|
||||||
<p style="margin: 0 0 12px 0; font-size: 0.8em; color: #aaa;">Upload a logo to display in the site navbar instead of plain text. Accepted: gif, jpg, png, webp, svg — max 5 MB.</p>
|
<h2 class="adm-section-title"><i class="fa-solid fa-sliders"></i> Settings</h2>
|
||||||
|
<span id="settings-status" class="adm-status"></span>
|
||||||
<div style="display: flex; align-items: center; gap: 16px; flex-wrap: wrap;">
|
|
||||||
<!-- Preview -->
|
|
||||||
<div id="brand-preview-wrap" style="width: 160px; height: 54px; background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.12); border-radius: 4px; display: flex; align-items: center; justify-content: center; overflow: hidden; flex-shrink: 0;">
|
|
||||||
@if(current_brand_image)
|
|
||||||
<img id="brand-preview" src="{{ current_brand_image }}" alt="current brand" style="max-height: 48px; max-width: 150px; object-fit: contain;">
|
|
||||||
@else
|
|
||||||
<span id="brand-preview" style="font-size: 0.75em; color: #666; font-style: italic;">No image set</span>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Controls -->
|
|
||||||
<div style="display: flex; flex-direction: column; gap: 8px;">
|
|
||||||
<label for="brand-file-input" style="display: inline-block; background: var(--accent); color: #000; padding: 7px 16px; border-radius: 4px; cursor: pointer; font-size: 0.82em; font-weight: bold; transition: opacity 0.2s;"
|
|
||||||
onmouseover="this.style.opacity='0.85'" onmouseout="this.style.opacity='1'">Choose Image</label>
|
|
||||||
<input type="file" id="brand-file-input" accept="image/gif,image/jpeg,image/png,image/webp,image/svg+xml" style="display:none;" onchange="uploadBrandImage(this)">
|
|
||||||
<button id="brand-remove-btn" onclick="removeBrandImage()" style="background: rgba(220,53,69,0.15); border: 1px solid rgba(220,53,69,0.4); color: #dc3545; padding: 7px 16px; border-radius: 4px; cursor: pointer; font-size: 0.82em; font-weight: bold; transition: background 0.2s;"
|
|
||||||
onmouseover="this.style.background='rgba(220,53,69,0.3)'" onmouseout="this.style.background='rgba(220,53,69,0.15)'"@if(!current_brand_image) disabled style="background: rgba(220,53,69,0.05); border: 1px solid rgba(220,53,69,0.15); color: #884040; padding: 7px 16px; border-radius: 4px; cursor: not-allowed; font-size: 0.82em; font-weight: bold;"@endif>Remove</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span id="brand-status" style="display: block; margin-top: 10px; font-size: 0.8em; font-weight: bold;"></span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<hr style="margin: 20px 0; border: 0; border-top: 1px solid rgba(255,255,255,0.1);">
|
<h3 class="adm-group-title">Uploads</h3>
|
||||||
|
<div class="adm-panel">
|
||||||
|
<div class="adm-row">
|
||||||
<div class="settings-toggle" style="background: rgba(0,0,0,0.2); padding: 15px; border-radius: 4px; display: flex; align-items: center; justify-content: space-between;">
|
<div class="adm-row-text">
|
||||||
<div>
|
<label for="manual_approval_toggle">Manual upload approval</label>
|
||||||
<label style="display: block; font-weight: bold; color: var(--accent);">Manual Upload Approval</label>
|
<p>If enabled, mods must approve every upload.</p>
|
||||||
<p style="margin: 2px 0 0 0; font-size: 0.8em; color: #aaa;">If enabled, mods must approve every upload.</p>
|
</div>
|
||||||
|
<label class="adm-switch">
|
||||||
|
<input type="checkbox" id="manual_approval_toggle" {{ manual_approval ? 'checked' : '' }} onchange="saveAdminSettings()">
|
||||||
|
<span class="adm-slider"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="adm-row">
|
||||||
|
<div class="adm-row-text">
|
||||||
|
<label for="min_tags_input">Minimum tags</label>
|
||||||
|
<p>Minimum number of tags required per upload.</p>
|
||||||
|
</div>
|
||||||
|
<input type="number" id="min_tags_input" class="adm-number" value="{{ min_tags }}" min="0" max="20" onchange="saveAdminSettings()">
|
||||||
|
</div>
|
||||||
|
<div class="adm-row">
|
||||||
|
<div class="adm-row-text">
|
||||||
|
<label for="trusted_uploads_input">Trusted upload threshold</label>
|
||||||
|
<p>Users with fewer approved uploads than this still need manual approval, even if the toggle above is off. 0 disables it.</p>
|
||||||
|
</div>
|
||||||
|
<input type="number" id="trusted_uploads_input" class="adm-number" value="{{ trusted_uploads }}" min="0" max="999" onchange="saveAdminSettings()">
|
||||||
</div>
|
</div>
|
||||||
<label class="switch">
|
|
||||||
<input type="checkbox" id="manual_approval_toggle" {{ manual_approval ? 'checked' : '' }} onchange="saveAdminSettings()">
|
|
||||||
<span class="slider round"></span>
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@if(registration_web_toggle_enabled)
|
@if(registration_web_toggle_enabled)
|
||||||
<div class="settings-toggle" style="background: rgba(0,0,0,0.2); padding: 15px; border-radius: 4px; display: flex; align-items: center; justify-content: space-between; margin-top: 10px;">
|
<h3 class="adm-group-title">Registration</h3>
|
||||||
<div>
|
<div class="adm-panel">
|
||||||
<label style="display: block; font-weight: bold; color: var(--accent);">Open Registration</label>
|
<div class="adm-row">
|
||||||
<p style="margin: 2px 0 0 0; font-size: 0.8em; color: #aaa;">Allow registration without invite tokens. Requires email activation.</p>
|
<div class="adm-row-text">
|
||||||
|
<label for="registration_open_toggle">Open registration</label>
|
||||||
|
<p>Allow registration without invite tokens. Requires email activation.</p>
|
||||||
|
</div>
|
||||||
|
<label class="adm-switch">
|
||||||
|
<input type="checkbox" id="registration_open_toggle" {{ registration_open ? 'checked' : '' }} onchange="saveAdminSettings()">
|
||||||
|
<span class="adm-slider"></span>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<label class="switch">
|
|
||||||
<input type="checkbox" id="registration_open_toggle" {{ registration_open ? 'checked' : '' }} onchange="saveAdminSettings()">
|
|
||||||
<span class="slider round"></span>
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
|
<h3 class="adm-group-title">Branding</h3>
|
||||||
<div class="settings-item" style="background: rgba(0,0,0,0.2); padding: 15px; border-radius: 4px; display: flex; align-items: center; justify-content: space-between; margin-top: 10px;">
|
<div class="adm-panel" id="brand-image-section">
|
||||||
<div>
|
<div class="adm-row adm-row-wrap">
|
||||||
<label style="display: block; font-weight: bold; color: var(--accent);">Minimum Tags</label>
|
<div class="adm-row-text">
|
||||||
<p style="margin: 2px 0 0 0; font-size: 0.8em; color: #aaa;">Minimum number of tags required per upload.</p>
|
<label>Navbar brand image</label>
|
||||||
|
<p>Shown in the navbar instead of the site name. gif, jpg, png, webp or svg, max 5 MB.</p>
|
||||||
|
</div>
|
||||||
|
<div class="adm-brand">
|
||||||
|
<div id="brand-preview-wrap" class="adm-brand-preview">
|
||||||
|
@if(current_brand_image)
|
||||||
|
<img id="brand-preview" src="{{ current_brand_image }}" alt="current brand">
|
||||||
|
@else
|
||||||
|
<span id="brand-preview" class="adm-muted">No image set</span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
<label for="brand-file-input" class="adm-btn adm-btn-primary">Choose image</label>
|
||||||
|
<input type="file" id="brand-file-input" accept="image/gif,image/jpeg,image/png,image/webp,image/svg+xml" hidden onchange="uploadBrandImage(this)">
|
||||||
|
<button type="button" id="brand-remove-btn" class="adm-btn adm-btn-danger" onclick="removeBrandImage()" @if(!current_brand_image) disabled @endif>Remove</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<input type="number" id="min_tags_input" value="{{ min_tags }}" min="0" max="20" style="width: 60px; background: #333; border: 1px solid #444; color: #fff; padding: 5px; border-radius: 4px; text-align: center;" onchange="saveAdminSettings()">
|
<span id="brand-status" class="adm-status adm-status-row"></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="settings-item" style="background: rgba(0,0,0,0.2); padding: 15px; border-radius: 4px; display: flex; align-items: center; justify-content: space-between; margin-top: 10px;">
|
<h3 class="adm-group-title">Maintenance</h3>
|
||||||
<div>
|
<div class="adm-panel">
|
||||||
<label style="display: block; font-weight: bold; color: var(--accent);">Trusted Upload Threshold</label>
|
<div class="adm-row">
|
||||||
<p style="margin: 2px 0 0 0; font-size: 0.8em; color: #aaa;">New users with fewer than this many approved uploads must still go through manual approval, even if the global toggle is off. Set to 0 to disable.</p>
|
<div class="adm-row-text">
|
||||||
|
<label>Tag image cache</label>
|
||||||
|
<p>Regenerate thumbnails for the /tags page. Runs in the background and may take a while.</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" onclick="regenerateTagImages()" id="regen_tag_btn" class="adm-btn adm-btn-ghost">Regenerate all</button>
|
||||||
</div>
|
</div>
|
||||||
<input type="number" id="trusted_uploads_input" value="{{ trusted_uploads }}" min="0" max="999" style="width: 60px; background: #333; border: 1px solid #444; color: #fff; padding: 5px; border-radius: 4px; text-align: center;" onchange="saveAdminSettings()">
|
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div class="settings-item" style="background: rgba(0,0,0,0.2); padding: 15px; border-radius: 4px; display: flex; align-items: center; justify-content: space-between; margin-top: 10px;">
|
|
||||||
<div>
|
|
||||||
<label style="display: block; font-weight: bold; color: var(--accent);">Tag Image Cache</label>
|
|
||||||
<p style="margin: 2px 0 0 0; font-size: 0.8em; color: #aaa;">Regenerate thumbnails for the /tags page. This may take a while.</p>
|
|
||||||
</div>
|
|
||||||
<button onclick="regenerateTagImages()" id="regen_tag_btn" style="background: #007bff; border: 0; color: #fff; padding: 8px 16px; border-radius: 4px; cursor: pointer; font-size: 0.8em; font-weight: bold;">Regenerate All</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
<span id="settings-status" style="display: block; margin-top: 10px; font-size: 0.8em; font-weight: bold; text-align: right;"></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
@include(snippets/adm-dashboard-style)
|
||||||
.switch { position: relative; display: inline-block; width: 50px; height: 26px; }
|
|
||||||
.switch input { opacity: 0; width: 0; height: 0; }
|
|
||||||
.slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: #333; transition: .4s; border-radius: 34px; }
|
|
||||||
.slider:before { position: absolute; content: ""; height: 18px; width: 18px; left: 4px; bottom: 4px; background-color: white; transition: .4s; border-radius: 50%; }
|
|
||||||
input:checked + .slider { background-color: var(--accent); }
|
|
||||||
input:checked + .slider:before { transform: translateX(24px); background-color: #000; }
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
|
||||||
@@ -212,7 +306,7 @@
|
|||||||
btn.textContent = 'Started!';
|
btn.textContent = 'Started!';
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
btn.textContent = 'Regenerate All';
|
btn.textContent = 'Regenerate all';
|
||||||
status.textContent = '';
|
status.textContent = '';
|
||||||
}, 5000);
|
}, 5000);
|
||||||
} else {
|
} else {
|
||||||
@@ -223,7 +317,7 @@
|
|||||||
status.textContent = 'Error: ' + err.message;
|
status.textContent = 'Error: ' + err.message;
|
||||||
status.style.color = '#d9534f';
|
status.style.color = '#d9534f';
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
btn.textContent = 'Regenerate All';
|
btn.textContent = 'Regenerate all';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,7 +357,6 @@
|
|||||||
preview = document.createElement('img');
|
preview = document.createElement('img');
|
||||||
preview.id = 'brand-preview';
|
preview.id = 'brand-preview';
|
||||||
preview.alt = 'current brand';
|
preview.alt = 'current brand';
|
||||||
preview.style.cssText = 'max-height:48px;max-width:150px;object-fit:contain;';
|
|
||||||
wrap.appendChild(preview);
|
wrap.appendChild(preview);
|
||||||
}
|
}
|
||||||
preview.src = data.url;
|
preview.src = data.url;
|
||||||
@@ -271,9 +364,7 @@
|
|||||||
// Immediately update navbar brand logo
|
// Immediately update navbar brand logo
|
||||||
if (window.updateBrandImage) window.updateBrandImage(data.url);
|
if (window.updateBrandImage) window.updateBrandImage(data.url);
|
||||||
|
|
||||||
// Enable remove button
|
|
||||||
removeBtn.disabled = false;
|
removeBtn.disabled = false;
|
||||||
removeBtn.style.cssText = 'background:rgba(220,53,69,0.15);border:1px solid rgba(220,53,69,0.4);color:#dc3545;padding:7px 16px;border-radius:4px;cursor:pointer;font-size:0.82em;font-weight:bold;transition:background 0.2s;';
|
|
||||||
|
|
||||||
setTimeout(() => { status.textContent = ''; }, 3000);
|
setTimeout(() => { status.textContent = ''; }, 3000);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -311,14 +402,12 @@
|
|||||||
|
|
||||||
// Reset preview
|
// Reset preview
|
||||||
const wrap = document.getElementById('brand-preview-wrap');
|
const wrap = document.getElementById('brand-preview-wrap');
|
||||||
wrap.innerHTML = '<span id="brand-preview" style="font-size:0.75em;color:#666;font-style:italic;">No image set</span>';
|
wrap.innerHTML = '<span id="brand-preview" class="adm-muted">No image set</span>';
|
||||||
|
|
||||||
// Immediately update navbar brand logo (revert to text)
|
// Immediately update navbar brand logo (revert to text)
|
||||||
if (window.updateBrandImage) window.updateBrandImage(null);
|
if (window.updateBrandImage) window.updateBrandImage(null);
|
||||||
|
|
||||||
// Disable remove button
|
|
||||||
removeBtn.disabled = true;
|
removeBtn.disabled = true;
|
||||||
removeBtn.style.cssText = 'background:rgba(220,53,69,0.05);border:1px solid rgba(220,53,69,0.15);color:#884040;padding:7px 16px;border-radius:4px;cursor:not-allowed;font-size:0.82em;font-weight:bold;';
|
|
||||||
|
|
||||||
setTimeout(() => { status.textContent = ''; }, 3000);
|
setTimeout(() => { status.textContent = ''; }, 3000);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -327,7 +416,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
@include(snippets/header)
|
@include(snippets/header)
|
||||||
<div class="pagewrapper">
|
<div class="pagewrapper">
|
||||||
<div id="main" class="admin-container">
|
<div id="main" class="admin-container">
|
||||||
|
@include(snippets/admin-back)
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h2>About Page Content</h2>
|
<h2>About Page Content</h2>
|
||||||
<p style="color: #ccc; margin-bottom: 20px;">This text is displayed on the <strong>/about</strong> page. Supports Markdown. Leave empty to show the default static template.</p>
|
<p style="color: #ccc; margin-bottom: 20px;">This text is displayed on the <strong>/about</strong> page. Supports Markdown. Leave empty to show the default static template.</p>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
<div class="pagewrapper">
|
<div class="pagewrapper">
|
||||||
<div id="main" class="admin-container">
|
<div id="main" class="admin-container">
|
||||||
|
@include(snippets/admin-back)
|
||||||
<div class="container" style="max-width: 1200px; margin: 0 auto; padding: 20px 15px;">
|
<div class="container" style="max-width: 1200px; margin: 0 auto; padding: 20px 15px;">
|
||||||
<div style="margin-bottom: 25px;">
|
<div style="margin-bottom: 25px;">
|
||||||
<a href="/admin" style="color: var(--accent); text-decoration: none; font-size: 0.9rem; display: inline-flex; align-items: center; gap: 6px; margin-bottom: 12px;">
|
<a href="/admin" style="color: var(--accent); text-decoration: none; font-size: 0.9rem; display: inline-flex; align-items: center; gap: 6px; margin-bottom: 12px;">
|
||||||
@@ -473,9 +474,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
<!-- Modals, styles and script live inside #main: AJAX navigation only swaps #main -->
|
||||||
<!-- Modal: Ban Fingerprint or IP -->
|
<!-- Modal: Ban Fingerprint or IP -->
|
||||||
<div id="ban-modal" class="modal-overlay" style="display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.75); z-index: 10000; align-items: center; justify-content: center;">
|
<div id="ban-modal" class="modal-overlay" style="display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.75); z-index: 10000; align-items: center; justify-content: center;">
|
||||||
<div style="background: #1e1e1e; border: 1px solid rgba(255,255,255,0.15); border-radius: 8px; width: 92%; max-width: 500px; padding: 25px; box-shadow: 0 10px 30px rgba(0,0,0,0.8);">
|
<div style="background: #1e1e1e; border: 1px solid rgba(255,255,255,0.15); border-radius: 8px; width: 92%; max-width: 500px; padding: 25px; box-shadow: 0 10px 30px rgba(0,0,0,0.8);">
|
||||||
@@ -784,5 +784,7 @@ async function unbanIp(ip) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
@include(snippets/footer)
|
@include(snippets/footer)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
@include(snippets/header)
|
@include(snippets/header)
|
||||||
<div class="pagewrapper">
|
<div class="pagewrapper">
|
||||||
<div id="main">
|
<div id="main">
|
||||||
|
@include(snippets/admin-back)
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h1>ADMINBEREICH</h1>
|
<h1>ADMINBEREICH</h1>
|
||||||
<h5>Global Chat Management</h5>
|
<h5>Global Chat Management</h5>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
@include(snippets/header)
|
@include(snippets/header)
|
||||||
<div class="pagewrapper">
|
<div class="pagewrapper">
|
||||||
<div id="main" class="admin-container">
|
<div id="main" class="admin-container">
|
||||||
|
@include(snippets/admin-back)
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h2>Cleanup Manager</h2>
|
<h2>Cleanup Manager</h2>
|
||||||
<p style="color: #ccc; margin-bottom: 20px;">Delete old posts that have no engagement (no comments, no favorites, and no subscriptions) to regain disk space.</p>
|
<p style="color: #ccc; margin-bottom: 20px;">Delete old posts that have no engagement (no comments, no favorites, and no subscriptions) to regain disk space.</p>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
@include(snippets/header)
|
@include(snippets/header)
|
||||||
<div class="pagewrapper">
|
<div class="pagewrapper">
|
||||||
<div id="main" class="admin-container">
|
<div id="main" class="admin-container">
|
||||||
|
@include(snippets/admin-back)
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h2>Custom Emojis & Sticker Packs</h2>
|
<h2>Custom Emojis & Sticker Packs</h2>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
@include(snippets/header)
|
@include(snippets/header)
|
||||||
<div class="pagewrapper">
|
<div class="pagewrapper">
|
||||||
<div id="main">
|
<div id="main">
|
||||||
|
@include(snippets/admin-back)
|
||||||
<style>
|
<style>
|
||||||
.hm-btn {
|
.hm-btn {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
@include(snippets/header)
|
@include(snippets/header)
|
||||||
<div class="pagewrapper">
|
<div class="pagewrapper">
|
||||||
<div id="main" class="admin-container">
|
<div id="main" class="admin-container">
|
||||||
|
@include(snippets/admin-back)
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h2>Köpfe Manager</h2>
|
<h2>Köpfe Manager</h2>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
@include(snippets/header)
|
@include(snippets/header)
|
||||||
<div class="pagewrapper">
|
<div class="pagewrapper">
|
||||||
<div id="main" class="admin-container">
|
<div id="main" class="admin-container">
|
||||||
|
@include(snippets/admin-back)
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h2>Meme Manager</h2>
|
<h2>Meme Manager</h2>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
@include(snippets/header)
|
@include(snippets/header)
|
||||||
<div class="pagewrapper">
|
<div class="pagewrapper">
|
||||||
<div id="main" class="admin-container">
|
<div id="main" class="admin-container">
|
||||||
|
@include(snippets/admin-back)
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h2>Message of the Day (MOTD)</h2>
|
<h2>Message of the Day (MOTD)</h2>
|
||||||
<p style="color: #ccc; margin-bottom: 20px;">This message is displayed <strong>inside the navbar</strong> (at the bottom) so it stays visible while scrolling. Supports Markdown and HTML.</p>
|
<p style="color: #ccc; margin-bottom: 20px;">This message is displayed <strong>inside the navbar</strong> (at the bottom) so it stays visible while scrolling. Supports Markdown and HTML.</p>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
<div class="pagewrapper">
|
<div class="pagewrapper">
|
||||||
<div id="main" class="admin-container">
|
<div id="main" class="admin-container">
|
||||||
|
@include(snippets/admin-back)
|
||||||
<div class="container">
|
<div class="container">
|
||||||
|
|
||||||
<div class="admin-header-flex" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
|
<div class="admin-header-flex" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
@include(snippets/header)
|
@include(snippets/header)
|
||||||
<div class="pagewrapper">
|
<div class="pagewrapper">
|
||||||
<div id="main" class="admin-container">
|
<div id="main" class="admin-container">
|
||||||
|
@include(snippets/admin-back)
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h2>Rules Page Content</h2>
|
<h2>Rules Page Content</h2>
|
||||||
<p style="color: #ccc; margin-bottom: 20px;">This text is displayed on the <strong>/rules</strong> page. Supports Markdown. Leave empty to show the default static rules template.</p>
|
<p style="color: #ccc; margin-bottom: 20px;">This text is displayed on the <strong>/rules</strong> page. Supports Markdown. Leave empty to show the default static rules template.</p>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
@include(snippets/header)
|
@include(snippets/header)
|
||||||
<div class="pagewrapper">
|
<div class="pagewrapper">
|
||||||
<div id="main">
|
<div id="main">
|
||||||
|
@include(snippets/admin-back)
|
||||||
<div class="container session-grid">
|
<div class="container session-grid">
|
||||||
<h2 class="session-page-title">
|
<h2 class="session-page-title">
|
||||||
Sessions
|
Sessions
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
@include(snippets/header)
|
@include(snippets/header)
|
||||||
<div class="pagewrapper">
|
<div class="pagewrapper">
|
||||||
<div id="main" class="admin-container">
|
<div id="main" class="admin-container">
|
||||||
|
@include(snippets/admin-back)
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h2>Terms of Service Page Content</h2>
|
<h2>Terms of Service Page Content</h2>
|
||||||
<p style="color: #ccc; margin-bottom: 20px;">This text is displayed on the <strong>/terms</strong> page. Supports Markdown. Leave empty to show the default static template.</p>
|
<p style="color: #ccc; margin-bottom: 20px;">This text is displayed on the <strong>/terms</strong> page. Supports Markdown. Leave empty to show the default static template.</p>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
@include(snippets/header)
|
@include(snippets/header)
|
||||||
<div class="pagewrapper">
|
<div class="pagewrapper">
|
||||||
<div id="main">
|
<div id="main">
|
||||||
|
@include(snippets/admin-back)
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="admin-header-flex">
|
<div class="admin-header-flex">
|
||||||
<h2>Invite Tokens</h2>
|
<h2>Invite Tokens</h2>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
<div class="pagewrapper">
|
<div class="pagewrapper">
|
||||||
<div id="main" class="admin-container">
|
<div id="main" class="admin-container">
|
||||||
|
@include(snippets/admin-back)
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div style="margin-bottom: 30px;">
|
<div style="margin-bottom: 30px;">
|
||||||
<a href="/admin/users" style="color: #888; text-decoration: none; font-size: 0.9rem; display: inline-flex; align-items: center; gap: 5px; margin-bottom: 15px;">
|
<a href="/admin/users" style="color: #888; text-decoration: none; font-size: 0.9rem; display: inline-flex; align-items: center; gap: 5px; margin-bottom: 15px;">
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
<div class="pagewrapper">
|
<div class="pagewrapper">
|
||||||
<div id="main" class="admin-container">
|
<div id="main" class="admin-container">
|
||||||
|
@include(snippets/admin-back)
|
||||||
<style>
|
<style>
|
||||||
.admin-users-table {
|
.admin-users-table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
</style>
|
</style>
|
||||||
<div class="pagewrapper">
|
<div class="pagewrapper">
|
||||||
<div id="main" class="admin-container">
|
<div id="main" class="admin-container">
|
||||||
|
@include(snippets/admin-back)
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="admin-header-flex" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 25px;">
|
<div class="admin-header-flex" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 25px;">
|
||||||
<h2>Wordfilter Manager</h2>
|
<h2>Wordfilter Manager</h2>
|
||||||
|
|||||||
@@ -61,7 +61,7 @@
|
|||||||
<span class="chan-card-badge chan-badge-closed" title="Closed"><i class="fa-solid fa-lock"></i></span>
|
<span class="chan-card-badge chan-badge-closed" title="Closed"><i class="fa-solid fa-lock"></i></span>
|
||||||
@endif
|
@endif
|
||||||
@if(t.local_id)
|
@if(t.local_id)
|
||||||
<span class="chan-card-badge chan-badge-rehosted" title="OP Rehosted on f0ckm as Post #{{ t.local_id }}" style="background: rgba(16, 185, 129, 0.9); color: #fff; font-weight: 700;"><i class="fa-solid fa-check"></i> #{{ t.local_id }}</span>
|
<span class="chan-card-badge chan-badge-rehosted" title="OP rehosted on f0ckm as /{{ t.local_path || t.local_id }}" style="background: rgba(16, 185, 129, 0.9); color: #fff; font-weight: 700;"><i class="fa-solid fa-check"></i> /{{ t.local_path || t.local_id }}</span>
|
||||||
@endif
|
@endif
|
||||||
<span class="chan-card-badge chan-badge-stats">
|
<span class="chan-card-badge chan-badge-stats">
|
||||||
<span title="Replies"><i class="fa-solid fa-comment"></i> {{ t.replies }}</span>
|
<span title="Replies"><i class="fa-solid fa-comment"></i> {{ t.replies }}</span>
|
||||||
|
|||||||
+95
-62
@@ -67,8 +67,8 @@
|
|||||||
<time class="chan-post-time timeago" data-iso="{{ p.iso_str }}" tooltip="{{ p.iso_str }}" title="{{ p.date_str }}">{{ p.timeago }}</time>
|
<time class="chan-post-time timeago" data-iso="{{ p.iso_str }}" tooltip="{{ p.iso_str }}" title="{{ p.date_str }}">{{ p.timeago }}</time>
|
||||||
<a href="#p{{ p.no }}" class="chan-post-no">No. {{ p.no }}</a>
|
<a href="#p{{ p.no }}" class="chan-post-no">No. {{ p.no }}</a>
|
||||||
@if(p.local_id)
|
@if(p.local_id)
|
||||||
<a href="/{{ p.local_id }}" target="_blank" class="chan-post-rehosted-pill" title="Already rehosted on f0ckm as Post #{{ p.local_id }}">
|
<a href="/{{ p.local_path || p.local_id }}" target="_blank" class="chan-post-rehosted-pill" title="Already rehosted on f0ckm as /{{ p.local_path || p.local_id }}">
|
||||||
<i class="fa-solid fa-check"></i> #{{ p.local_id }}
|
<i class="fa-solid fa-check"></i> /{{ p.local_path || p.local_id }}
|
||||||
</a>
|
</a>
|
||||||
@endif
|
@endif
|
||||||
@if(p.sub && p.no !== tid)
|
@if(p.sub && p.no !== tid)
|
||||||
@@ -92,6 +92,43 @@
|
|||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- File info: one row above the thumbnail -->
|
||||||
|
@if(p.has_media)
|
||||||
|
<div class="chan-file-info">
|
||||||
|
<div class="chan-filename-row">
|
||||||
|
<a href="{{ p.external_media_url }}" target="_blank" rel="noopener noreferrer" class="chan-file-download" title="Open full media in new tab">
|
||||||
|
<i class="fa-solid fa-arrow-up-right-from-square"></i>
|
||||||
|
<span class="chan-file-title">{{ p.filename || ('File ' + p.tim + p.ext) }}</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="chan-file-meta-row">
|
||||||
|
@if(p.fsize)<span>{{ p.fsize }}</span>@endif
|
||||||
|
@if(p.w && p.h)<span>{{ p.w }}×{{ p.h }}</span>@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Rehost & Favorite actions -->
|
||||||
|
<div class="chan-rehost-container" id="rehost-box-{{ p.no }}">
|
||||||
|
@if(p.local_id)
|
||||||
|
<span class="chan-rehosted-wrap" style="display:inline-flex;gap:4px;align-items:center;flex:1;">
|
||||||
|
<a href="/{{ p.local_path || p.local_id }}" class="chan-rehost-btn rehosted" target="_blank">
|
||||||
|
<i class="fa-solid fa-check"></i> Rehosted /{{ p.local_path || p.local_id }}
|
||||||
|
</a>
|
||||||
|
<button type="button" class="chan-rehost-edit-btn" data-item-id="{{ p.local_id }}" title="Edit rating" style="padding:4px 7px;border-radius:0;background:rgba(255,255,255,0.1);color:#fff;border:1px solid rgba(255,255,255,0.2);cursor:pointer;font-size:0.75rem;">
|
||||||
|
<i class="fa-solid fa-pen"></i>
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
@else
|
||||||
|
<button type="button" class="chan-rehost-btn" data-url="{{ p.external_media_url }}" data-board="{{ board }}" data-tid="{{ tid }}" data-post="{{ p.no }}" data-filename="{{ p.filename || '' }}" data-width="{{ p.w || '' }}" data-height="{{ p.h || '' }}">
|
||||||
|
<i class="fa-solid fa-cloud-arrow-up"></i> Rehost
|
||||||
|
</button>
|
||||||
|
@endif
|
||||||
|
<button type="button" class="chan-card-fav-btn {{ p.user_has_favorited ? 'favorited' : '' }}" data-post="{{ p.no }}" data-local-id="{{ p.local_id || '' }}" data-url="{{ p.external_media_url }}" data-board="{{ board }}" data-tid="{{ tid }}" data-filename="{{ p.filename || '' }}" data-width="{{ p.w || '' }}" data-height="{{ p.h || '' }}" title="{{ p.user_has_favorited ? 'Remove from favorites' : 'Favorite' }}">
|
||||||
|
<i class="fa-{{ p.user_has_favorited ? 'solid' : 'regular' }} fa-heart"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
<!-- Post Content (Media + Comment) -->
|
<!-- Post Content (Media + Comment) -->
|
||||||
<div class="chan-post-content">
|
<div class="chan-post-content">
|
||||||
@if(p.has_media)
|
@if(p.has_media)
|
||||||
@@ -107,44 +144,11 @@
|
|||||||
<span class="chan-video-indicator"><i class="fa-solid fa-play"></i></span>
|
<span class="chan-video-indicator"><i class="fa-solid fa-play"></i></span>
|
||||||
@endif
|
@endif
|
||||||
@if(p.local_id)
|
@if(p.local_id)
|
||||||
<span class="chan-thumb-rehost-badge" title="Rehosted on f0ckm as Post #{{ p.local_id }}"><i class="fa-solid fa-check"></i> #{{ p.local_id }}</span>
|
<span class="chan-thumb-rehost-badge" title="Rehosted on f0ckm as /{{ p.local_path || p.local_id }}"><i class="fa-solid fa-check"></i> /{{ p.local_path || p.local_id }}</span>
|
||||||
@endif
|
@endif
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="chan-file-info">
|
|
||||||
<div class="chan-filename-row">
|
|
||||||
<a href="{{ p.external_media_url }}" target="_blank" rel="noopener noreferrer" class="chan-file-download" title="Open full media in new tab">
|
|
||||||
<i class="fa-solid fa-arrow-up-right-from-square"></i>
|
|
||||||
<span class="chan-file-title">{{ p.filename || ('File ' + p.tim + p.ext) }}</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div class="chan-file-meta-row">
|
|
||||||
@if(p.fsize)<span>{{ p.fsize }}</span>@endif
|
|
||||||
@if(p.w && p.h)<span>{{ p.w }}×{{ p.h }}</span>@endif
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Rehost & Favorite actions -->
|
|
||||||
<div class="chan-rehost-container" id="rehost-box-{{ p.no }}">
|
|
||||||
@if(p.local_id)
|
|
||||||
<span class="chan-rehosted-wrap" style="display:inline-flex;gap:4px;align-items:center;flex:1;">
|
|
||||||
<a href="/{{ p.local_id }}" class="chan-rehost-btn rehosted" target="_blank">
|
|
||||||
<i class="fa-solid fa-check"></i> Rehosted #{{ p.local_id }}
|
|
||||||
</a>
|
|
||||||
<button type="button" class="chan-rehost-edit-btn" data-item-id="{{ p.local_id }}" title="Edit rating" style="padding:4px 7px;border-radius:4px;background:rgba(255,255,255,0.1);color:#fff;border:1px solid rgba(255,255,255,0.2);cursor:pointer;font-size:0.75rem;">
|
|
||||||
<i class="fa-solid fa-pen"></i>
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
@else
|
|
||||||
<button type="button" class="chan-rehost-btn" data-url="{{ p.external_media_url }}" data-board="{{ board }}" data-tid="{{ tid }}" data-post="{{ p.no }}" data-filename="{{ p.filename || '' }}" data-width="{{ p.w || '' }}" data-height="{{ p.h || '' }}">
|
|
||||||
<i class="fa-solid fa-cloud-arrow-up"></i> Rehost
|
|
||||||
</button>
|
|
||||||
@endif
|
|
||||||
<button type="button" class="chan-card-fav-btn {{ p.user_has_favorited ? 'favorited' : '' }}" data-post="{{ p.no }}" data-local-id="{{ p.local_id || '' }}" data-url="{{ p.external_media_url }}" data-board="{{ board }}" data-tid="{{ tid }}" data-filename="{{ p.filename || '' }}" data-width="{{ p.w || '' }}" data-height="{{ p.h || '' }}" title="{{ p.user_has_favorited ? 'Remove from favorites' : 'Favorite' }}">
|
|
||||||
<i class="fa-{{ p.user_has_favorited ? 'solid' : 'regular' }} fa-heart"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
@@ -176,7 +180,7 @@
|
|||||||
margin-bottom: 25px;
|
margin-bottom: 25px;
|
||||||
background: var(--bg-card, rgba(25, 25, 25, 0.85));
|
background: var(--bg-card, rgba(25, 25, 25, 0.85));
|
||||||
border: 1px solid var(--border-color, rgba(255, 255, 255, 0.08));
|
border: 1px solid var(--border-color, rgba(255, 255, 255, 0.08));
|
||||||
border-radius: 12px;
|
border-radius: 0;
|
||||||
padding: 16px 20px;
|
padding: 16px 20px;
|
||||||
backdrop-filter: blur(8px);
|
backdrop-filter: blur(8px);
|
||||||
}
|
}
|
||||||
@@ -192,7 +196,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 6px 14px;
|
padding: 6px 14px;
|
||||||
border-radius: 8px;
|
border-radius: 0;
|
||||||
background: rgba(255, 255, 255, 0.06);
|
background: rgba(255, 255, 255, 0.06);
|
||||||
color: var(--text-main, #fff);
|
color: var(--text-main, #fff);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
@@ -216,7 +220,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
padding: 6px 12px;
|
padding: 6px 12px;
|
||||||
border-radius: 8px;
|
border-radius: 0;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
background: rgba(255, 255, 255, 0.06);
|
background: rgba(255, 255, 255, 0.06);
|
||||||
@@ -272,7 +276,7 @@
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
background: rgba(255, 255, 255, 0.05);
|
background: rgba(255, 255, 255, 0.05);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
border-radius: 8px;
|
border-radius: 0;
|
||||||
padding: 3px;
|
padding: 3px;
|
||||||
gap: 3px;
|
gap: 3px;
|
||||||
}
|
}
|
||||||
@@ -280,7 +284,7 @@
|
|||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
padding: 5px 12px;
|
padding: 5px 12px;
|
||||||
border-radius: 6px;
|
border-radius: 0;
|
||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #aaa;
|
color: #aaa;
|
||||||
@@ -301,7 +305,7 @@
|
|||||||
.chan-post-card {
|
.chan-post-card {
|
||||||
background: var(--bg-card, rgba(25, 25, 25, 0.85));
|
background: var(--bg-card, rgba(25, 25, 25, 0.85));
|
||||||
border: 1px solid var(--border-color, rgba(255, 255, 255, 0.08));
|
border: 1px solid var(--border-color, rgba(255, 255, 255, 0.08));
|
||||||
border-radius: 10px;
|
border-radius: 0;
|
||||||
padding: 14px 18px;
|
padding: 14px 18px;
|
||||||
transition: border-color 0.2s, background-color 0.2s, box-shadow 0.2s;
|
transition: border-color 0.2s, background-color 0.2s, box-shadow 0.2s;
|
||||||
scroll-margin-top: 80px;
|
scroll-margin-top: 80px;
|
||||||
@@ -388,7 +392,7 @@ body.onara-modal-closing .chan-thumb-link.onara-active .chan-thumb-img {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
padding: 2px 7px;
|
padding: 2px 7px;
|
||||||
border-radius: 4px;
|
border-radius: 0;
|
||||||
background: rgba(74, 222, 128, 0.2);
|
background: rgba(74, 222, 128, 0.2);
|
||||||
color: #4ade80;
|
color: #4ade80;
|
||||||
border: 1px solid rgba(74, 222, 128, 0.4);
|
border: 1px solid rgba(74, 222, 128, 0.4);
|
||||||
@@ -411,7 +415,7 @@ body.onara-modal-closing .chan-thumb-link.onara-active .chan-thumb-img {
|
|||||||
font-size: 0.68rem;
|
font-size: 0.68rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
border-radius: 4px;
|
border-radius: 0;
|
||||||
backdrop-filter: blur(4px);
|
backdrop-filter: blur(4px);
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -506,7 +510,7 @@ body.onara-modal-closing .chan-thumb-link.onara-active .chan-thumb-img {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
aspect-ratio: 16 / 10;
|
aspect-ratio: 16 / 10;
|
||||||
background: #0d0d0d;
|
background: #0d0d0d;
|
||||||
border-radius: 8px;
|
border-radius: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
}
|
}
|
||||||
@@ -549,7 +553,7 @@ body.onara-modal-closing .chan-thumb-link.onara-active .chan-thumb-img {
|
|||||||
right: 6px;
|
right: 6px;
|
||||||
width: 24px;
|
width: 24px;
|
||||||
height: 24px;
|
height: 24px;
|
||||||
border-radius: 50%;
|
border-radius: 0;
|
||||||
background: rgba(0, 0, 0, 0.75);
|
background: rgba(0, 0, 0, 0.75);
|
||||||
backdrop-filter: blur(4px);
|
backdrop-filter: blur(4px);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
@@ -566,9 +570,29 @@ body.onara-modal-closing .chan-thumb-link.onara-active .chan-thumb-img {
|
|||||||
/* File Info (filename, dimensions, size) */
|
/* File Info (filename, dimensions, size) */
|
||||||
.chan-file-info {
|
.chan-file-info {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: row;
|
||||||
gap: 4px;
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.chan-file-info .chan-filename-row {
|
||||||
|
flex: 0 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.chan-file-info .chan-file-meta-row {
|
||||||
|
flex-shrink: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.chan-file-info .chan-rehost-container {
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-left: auto;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
.chan-file-info .chan-rehost-btn {
|
||||||
|
width: auto;
|
||||||
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
.chan-filename-row {
|
.chan-filename-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -617,7 +641,7 @@ body.onara-modal-closing .chan-thumb-link.onara-active .chan-thumb-img {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
padding: 4px 10px;
|
padding: 4px 10px;
|
||||||
border-radius: 6px;
|
border-radius: 0;
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
background: rgba(74, 222, 128, 0.15);
|
background: rgba(74, 222, 128, 0.15);
|
||||||
@@ -671,7 +695,7 @@ body.onara-modal-closing .chan-thumb-link.onara-active .chan-thumb-img {
|
|||||||
width: 28px;
|
width: 28px;
|
||||||
min-width: 28px;
|
min-width: 28px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border-radius: 6px;
|
border-radius: 0;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
background: rgba(255, 255, 255, 0.08);
|
background: rgba(255, 255, 255, 0.08);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
@@ -715,7 +739,7 @@ body.onara-modal-closing .chan-thumb-link.onara-active .chan-thumb-img {
|
|||||||
gap: 4px 6px;
|
gap: 4px 6px;
|
||||||
background: rgba(255, 255, 255, 0.03);
|
background: rgba(255, 255, 255, 0.03);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.07);
|
border: 1px solid rgba(255, 255, 255, 0.07);
|
||||||
border-radius: 6px;
|
border-radius: 0;
|
||||||
padding: 2px 7px;
|
padding: 2px 7px;
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
@@ -743,7 +767,7 @@ body.onara-modal-closing .chan-thumb-link.onara-active .chan-thumb-img {
|
|||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 1px 5px;
|
padding: 1px 5px;
|
||||||
border-radius: 4px;
|
border-radius: 0;
|
||||||
background: rgba(239, 68, 68, 0.1);
|
background: rgba(239, 68, 68, 0.1);
|
||||||
border: 1px solid rgba(239, 68, 68, 0.2);
|
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||||
transition: all 0.15s ease;
|
transition: all 0.15s ease;
|
||||||
@@ -773,7 +797,7 @@ body.onara-modal-closing .chan-thumb-link.onara-active .chan-thumb-img {
|
|||||||
background: #333;
|
background: #333;
|
||||||
color: #333;
|
color: #333;
|
||||||
padding: 0 3px;
|
padding: 0 3px;
|
||||||
border-radius: 2px;
|
border-radius: 0;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: color 0.15s;
|
transition: color 0.15s;
|
||||||
@@ -793,7 +817,7 @@ body.onara-modal-closing .chan-thumb-link.onara-active .chan-thumb-img {
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
background: rgba(22, 22, 24, 0.98);
|
background: rgba(22, 22, 24, 0.98);
|
||||||
border: 1px solid #4ade80;
|
border: 1px solid #4ade80;
|
||||||
border-radius: 10px;
|
border-radius: 0;
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
box-shadow: 0 12px 35px rgba(0, 0, 0, 0.8), 0 0 20px rgba(74, 222, 128, 0.2);
|
box-shadow: 0 12px 35px rgba(0, 0, 0, 0.8), 0 0 20px rgba(74, 222, 128, 0.2);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
@@ -823,6 +847,14 @@ body.onara-modal-closing .chan-thumb-link.onara-active .chan-thumb-img {
|
|||||||
.chan-quote-preview-popup .chan-post-backlinks {
|
.chan-quote-preview-popup .chan-post-backlinks {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Style guide: no rounded corners anywhere in the thread (covers site CSS, inline and JS-inserted elements) */
|
||||||
|
.chan-thread-page,
|
||||||
|
.chan-thread-page *,
|
||||||
|
.chan-thread-page *::before,
|
||||||
|
.chan-thread-page *::after {
|
||||||
|
border-radius: 0 !important;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@@ -899,9 +931,9 @@ body.onara-modal-closing .chan-thumb-link.onara-active .chan-thumb-img {
|
|||||||
btn.classList.remove('loading');
|
btn.classList.remove('loading');
|
||||||
btn.classList.add('rehosted');
|
btn.classList.add('rehosted');
|
||||||
btn.outerHTML = '<span class="chan-rehosted-wrap" style="display:inline-flex;gap:4px;align-items:center;">' +
|
btn.outerHTML = '<span class="chan-rehosted-wrap" style="display:inline-flex;gap:4px;align-items:center;">' +
|
||||||
'<a href="/' + data.item_id + '" class="chan-rehost-btn rehosted" target="_blank">' +
|
'<a href="/' + (data.item_path || data.item_id) + '" class="chan-rehost-btn rehosted" target="_blank">' +
|
||||||
'<i class="fa-solid fa-check"></i> Rehosted #' + data.item_id + '</a>' +
|
'<i class="fa-solid fa-check"></i> Rehosted /' + (data.item_path || data.item_id) + '</a>' +
|
||||||
'<button type="button" class="chan-rehost-edit-btn" data-item-id="' + data.item_id + '" title="Edit rating" style="padding:4px 7px;border-radius:4px;background:rgba(255,255,255,0.1);color:#fff;border:1px solid rgba(255,255,255,0.2);cursor:pointer;font-size:0.75rem;">' +
|
'<button type="button" class="chan-rehost-edit-btn" data-item-id="' + data.item_id + '" title="Edit rating" style="padding:4px 7px;border-radius:0;background:rgba(255,255,255,0.1);color:#fff;border:1px solid rgba(255,255,255,0.2);cursor:pointer;font-size:0.75rem;">' +
|
||||||
'<i class="fa-solid fa-pen"></i></button></span>';
|
'<i class="fa-solid fa-pen"></i></button></span>';
|
||||||
|
|
||||||
const postCard = document.getElementById('p' + postNo) || btn.closest('.chan-post-card');
|
const postCard = document.getElementById('p' + postNo) || btn.closest('.chan-post-card');
|
||||||
@@ -1003,6 +1035,7 @@ body.onara-modal-closing .chan-thumb-link.onara-active .chan-thumb-img {
|
|||||||
const rehostData = await rehostResp.json();
|
const rehostData = await rehostResp.json();
|
||||||
if (rehostData.success && rehostData.item_id) {
|
if (rehostData.success && rehostData.item_id) {
|
||||||
localId = rehostData.item_id;
|
localId = rehostData.item_id;
|
||||||
|
const localPath = rehostData.item_path || localId;
|
||||||
favBtn.dataset.localId = localId;
|
favBtn.dataset.localId = localId;
|
||||||
|
|
||||||
// Update rehost box
|
// Update rehost box
|
||||||
@@ -1011,9 +1044,9 @@ body.onara-modal-closing .chan-thumb-link.onara-active .chan-thumb-img {
|
|||||||
const rehostBtn = rehostBox.querySelector('.chan-rehost-btn');
|
const rehostBtn = rehostBox.querySelector('.chan-rehost-btn');
|
||||||
if (rehostBtn) {
|
if (rehostBtn) {
|
||||||
rehostBtn.outerHTML = '<span class="chan-rehosted-wrap" style="display:inline-flex;gap:4px;align-items:center;flex:1;">' +
|
rehostBtn.outerHTML = '<span class="chan-rehosted-wrap" style="display:inline-flex;gap:4px;align-items:center;flex:1;">' +
|
||||||
'<a href="/' + localId + '" class="chan-rehost-btn rehosted" target="_blank">' +
|
'<a href="/' + localPath + '" class="chan-rehost-btn rehosted" target="_blank">' +
|
||||||
'<i class="fa-solid fa-check"></i> Rehosted #' + localId + '</a>' +
|
'<i class="fa-solid fa-check"></i> Rehosted /' + localPath + '</a>' +
|
||||||
'<button type="button" class="chan-rehost-edit-btn" data-item-id="' + localId + '" title="Edit rating" style="padding:4px 7px;border-radius:4px;background:rgba(255,255,255,0.1);color:#fff;border:1px solid rgba(255,255,255,0.2);cursor:pointer;font-size:0.75rem;">' +
|
'<button type="button" class="chan-rehost-edit-btn" data-item-id="' + localId + '" title="Edit rating" style="padding:4px 7px;border-radius:0;background:rgba(255,255,255,0.1);color:#fff;border:1px solid rgba(255,255,255,0.2);cursor:pointer;font-size:0.75rem;">' +
|
||||||
'<i class="fa-solid fa-pen"></i></button></span>';
|
'<i class="fa-solid fa-pen"></i></button></span>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1024,8 +1057,8 @@ body.onara-modal-closing .chan-thumb-link.onara-active .chan-thumb-img {
|
|||||||
if (thumbLink && !thumbLink.querySelector('.chan-thumb-rehost-badge')) {
|
if (thumbLink && !thumbLink.querySelector('.chan-thumb-rehost-badge')) {
|
||||||
const badge = document.createElement('span');
|
const badge = document.createElement('span');
|
||||||
badge.className = 'chan-thumb-rehost-badge';
|
badge.className = 'chan-thumb-rehost-badge';
|
||||||
badge.title = 'Rehosted on f0ckm as Post #' + localId;
|
badge.title = 'Rehosted on f0ckm as /' + localPath;
|
||||||
badge.innerHTML = '<i class="fa-solid fa-check"></i> #' + localId;
|
badge.innerHTML = '<i class="fa-solid fa-check"></i> /' + localPath;
|
||||||
thumbLink.appendChild(badge);
|
thumbLink.appendChild(badge);
|
||||||
}
|
}
|
||||||
const timeEl = postCard.querySelector('.chan-post-time, .timeago');
|
const timeEl = postCard.querySelector('.chan-post-time, .timeago');
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<div class="item-main-content">
|
<div class="item-main-content">
|
||||||
|
|
||||||
<div class="_204863">
|
<div class="_204863">
|
||||||
<div class="location">@if(item.is_chan)<a href="/4/{{ item.external_board }}/catalogue" class="location-link" title="Board /{{ item.external_board }}/ catalogue">/4</a><a href="/4/{{ item.external_board }}/catalogue" class="location-link" title="Board /{{ item.external_board }}/ catalogue">/{{ item.external_board }}</a><a href="/4/{{ item.external_board }}/{{ item.external_tid }}" class="location-link" title="Thread #{{ item.external_tid }} overview">/{{ item.external_tid }}</a><a href="/4/{{ item.external_board }}/{{ item.external_tid }}/{{ item.external_id }}" class="location-link" data-inherit-context="false" title="Post #{{ item.external_id }}">/{{ item.external_id }}</a>@if(item.local_id)<span class="location-sep" style="opacity: 0.6; margin: 0 4px;">→</span><a href="/{{ item.local_id }}" class="location-link is-rehosted" title="View rehosted post #{{ item.local_id }} on f0ckm" style="color: #4ade80; font-weight: 700;"><i class="fa-solid fa-check"></i> #{{ item.local_id }}</a>@endif@elseif(tmp && tmp.tag)<a href="/tags" class="location-link" title="All tags">/tag</a><a href="/tag/{{ encodeURIComponent(tmp.tag) }}{{ link.suffix || '' }}" class="location-link" title="Tag: {{ tmp.tag }}">/{{ tmp.tag }}</a><a href="/tag/{{ encodeURIComponent(tmp.tag) }}/{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}{{ link.suffix || '' }}{{ item.requested_subf0ck_slug ? ('#' + item.requested_subf0ck_slug) : '' }}" class="location-link" title="Post #{{ item.id }}">/{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}{{ item.requested_subf0ck_slug ? ('#' + item.requested_subf0ck_slug) : '' }}</a>@else{{ link.mainDisplay || link.main }}{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}{{ link.suffix }}{{ item.requested_subf0ck_slug ? ('#' + item.requested_subf0ck_slug) : '' }}@endif</div>
|
<div class="location">@if(item.is_chan)<a href="/4/{{ item.external_board }}/catalogue" class="location-link" title="Board /{{ item.external_board }}/ catalogue">/4</a><a href="/4/{{ item.external_board }}/catalogue" class="location-link" title="Board /{{ item.external_board }}/ catalogue">/{{ item.external_board }}</a><a href="/4/{{ item.external_board }}/{{ item.external_tid }}" class="location-link" title="Thread #{{ item.external_tid }} overview">/{{ item.external_tid }}</a><a href="/4/{{ item.external_board }}/{{ item.external_tid }}/{{ item.external_id }}" class="location-link" data-inherit-context="false" title="Post #{{ item.external_id }}">/{{ item.external_id }}</a>@if(item.local_id)<span class="location-sep" style="opacity: 0.6; margin: 0 4px;">→</span><a href="/{{ item.local_path || item.local_id }}" class="location-link is-rehosted" title="View rehosted post /{{ item.local_path || item.local_id }} on f0ckm" style="color: #4ade80; font-weight: 700;"><i class="fa-solid fa-check"></i> /{{ item.local_path || item.local_id }}</a>@endif@elseif(tmp && tmp.tag)<a href="/tags" class="location-link" title="All tags">/tag</a><a href="/tag/{{ encodeURIComponent(tmp.tag) }}{{ link.suffix || '' }}" class="location-link" title="Tag: {{ tmp.tag }}">/{{ tmp.tag }}</a><a href="/tag/{{ encodeURIComponent(tmp.tag) }}/{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}{{ link.suffix || '' }}{{ item.requested_subf0ck_slug ? ('#' + item.requested_subf0ck_slug) : '' }}" class="location-link" title="Post #{{ item.id }}">/{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}{{ item.requested_subf0ck_slug ? ('#' + item.requested_subf0ck_slug) : '' }}</a>@else{{ link.mainDisplay || link.main }}{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}{{ link.suffix }}{{ item.requested_subf0ck_slug ? ('#' + item.requested_subf0ck_slug) : '' }}@endif</div>
|
||||||
<div class="gapLeft"></div>
|
<div class="gapLeft"></div>
|
||||||
</div>
|
</div>
|
||||||
@if(enable_item_title)
|
@if(enable_item_title)
|
||||||
@@ -129,7 +129,7 @@
|
|||||||
@if(item.is_chan)
|
@if(item.is_chan)
|
||||||
@if(item.local_id)
|
@if(item.local_id)
|
||||||
<span class="chan-rehosted-wrap" style="display:inline-flex;gap:4px;align-items:center;">
|
<span class="chan-rehosted-wrap" style="display:inline-flex;gap:4px;align-items:center;">
|
||||||
<a href="/{{ item.local_id }}" class="chan-rehost-action-btn rehosted" title="Already rehosted on f0ckm (Post #{{ item.local_id }})" style="display:inline-flex;align-items:center;justify-content:center;gap:6px;padding:4px 12px;border-radius:6px;font-size:0.8rem;background:rgba(74,222,128,0.2);color:#4ade80;border:1px solid rgba(74,222,128,0.4);text-decoration:none;font-weight:600;white-space:nowrap;flex-shrink:0;"><i class="fa-solid fa-check"></i> #{{ item.local_id }}</a>
|
<a href="/{{ item.local_path || item.local_id }}" class="chan-rehost-action-btn rehosted" title="Already rehosted on f0ckm (/{{ item.local_path || item.local_id }})" style="display:inline-flex;align-items:center;justify-content:center;gap:6px;padding:4px 12px;border-radius:6px;font-size:0.8rem;background:rgba(74,222,128,0.2);color:#4ade80;border:1px solid rgba(74,222,128,0.4);text-decoration:none;font-weight:600;white-space:nowrap;flex-shrink:0;"><i class="fa-solid fa-check"></i> /{{ item.local_path || item.local_id }}</a>
|
||||||
<button type="button" class="chan-rehost-edit-btn" data-item-id="{{ item.local_id }}" title="Edit rating" style="display:inline-flex;align-items:center;padding:3px 7px;border-radius:6px;font-size:0.8rem;background:rgba(255,255,255,0.1);color:#fff;border:1px solid rgba(255,255,255,0.2);cursor:pointer;"><i class="fa-solid fa-pen"></i></button>
|
<button type="button" class="chan-rehost-edit-btn" data-item-id="{{ item.local_id }}" title="Edit rating" style="display:inline-flex;align-items:center;padding:3px 7px;border-radius:6px;font-size:0.8rem;background:rgba(255,255,255,0.1);color:#fff;border:1px solid rgba(255,255,255,0.2);cursor:pointer;"><i class="fa-solid fa-pen"></i></button>
|
||||||
</span>
|
</span>
|
||||||
@else
|
@else
|
||||||
@@ -190,7 +190,7 @@
|
|||||||
@if(!user_alternative_infobox && item.is_chan) — <span class="badge badge-dark chan-post-badge" style="background: rgba(74, 222, 128, 0.15); border: 1px solid rgba(74, 222, 128, 0.3); display: inline-flex; align-items: center; gap: 6px;">
|
@if(!user_alternative_infobox && item.is_chan) — <span class="badge badge-dark chan-post-badge" style="background: rgba(74, 222, 128, 0.15); border: 1px solid rgba(74, 222, 128, 0.3); display: inline-flex; align-items: center; gap: 6px;">
|
||||||
<a href="/4/{{ item.external_board }}/{{ item.external_tid }}" style="color: #4ade80; text-decoration: none; font-weight: 600;" title="View thread overview"><i class="fa-solid fa-clover"></i> /{{ item.external_board }}/{{ item.external_tid }}</a>
|
<a href="/4/{{ item.external_board }}/{{ item.external_tid }}" style="color: #4ade80; text-decoration: none; font-weight: 600;" title="View thread overview"><i class="fa-solid fa-clover"></i> /{{ item.external_board }}/{{ item.external_tid }}</a>
|
||||||
@if(item.local_id)
|
@if(item.local_id)
|
||||||
<a href="/{{ item.local_id }}" class="chan-rehost-action-btn rehosted" title="Already rehosted on f0ckm (Post #{{ item.local_id }})" style="display:inline-flex;align-items:center;justify-content:center;gap:5px;padding:3px 10px;border-radius:4px;font-size:0.75rem;background:rgba(74,222,128,0.2);color:#4ade80;border:1px solid rgba(74,222,128,0.4);text-decoration:none;font-weight:600;white-space:nowrap;flex-shrink:0;"><i class="fa-solid fa-check"></i> #{{ item.local_id }}</a>
|
<a href="/{{ item.local_path || item.local_id }}" class="chan-rehost-action-btn rehosted" title="Already rehosted on f0ckm (/{{ item.local_path || item.local_id }})" style="display:inline-flex;align-items:center;justify-content:center;gap:5px;padding:3px 10px;border-radius:4px;font-size:0.75rem;background:rgba(74,222,128,0.2);color:#4ade80;border:1px solid rgba(74,222,128,0.4);text-decoration:none;font-weight:600;white-space:nowrap;flex-shrink:0;"><i class="fa-solid fa-check"></i> /{{ item.local_path || item.local_id }}</a>
|
||||||
@else
|
@else
|
||||||
<button type="button" class="chan-rehost-action-btn" id="chan-item-rehost-btn" data-url="{{ item.external_media_url }}" data-board="{{ item.external_board }}" data-tid="{{ item.external_tid }}" data-post="{{ item.external_id }}" data-filename="{{ item.original_filename || '' }}" data-width="{{ item.width || '' }}" data-height="{{ item.height || '' }}" title="Rehost to f0ckm" style="display:inline-flex;align-items:center;justify-content:center;gap:5px;padding:3px 12px;min-width:88px;box-sizing:border-box;white-space:nowrap;flex-shrink:0;border-radius:4px;font-size:0.75rem;background:rgba(74,222,128,0.15);color:#4ade80;border:1px solid rgba(74,222,128,0.3);cursor:pointer;font-weight:600;"><i class="fa-solid fa-cloud-arrow-up"></i> Rehost</button>
|
<button type="button" class="chan-rehost-action-btn" id="chan-item-rehost-btn" data-url="{{ item.external_media_url }}" data-board="{{ item.external_board }}" data-tid="{{ item.external_tid }}" data-post="{{ item.external_id }}" data-filename="{{ item.original_filename || '' }}" data-width="{{ item.width || '' }}" data-height="{{ item.height || '' }}" title="Rehost to f0ckm" style="display:inline-flex;align-items:center;justify-content:center;gap:5px;padding:3px 12px;min-width:88px;box-sizing:border-box;white-space:nowrap;flex-shrink:0;border-radius:4px;font-size:0.75rem;background:rgba(74,222,128,0.15);color:#4ade80;border:1px solid rgba(74,222,128,0.3);cursor:pointer;font-weight:600;"><i class="fa-solid fa-cloud-arrow-up"></i> Rehost</button>
|
||||||
@endif
|
@endif
|
||||||
@@ -201,17 +201,56 @@
|
|||||||
</span>@endif</span>
|
</span>@endif</span>
|
||||||
|
|
||||||
|
|
||||||
<span class="badge badge-dark" id="tags" data-item-id="{{ item.local_id || item.id }}" data-can-manage="{{ can_manage_item ? 'true' : 'false' }}">
|
{{-- Action buttons when the alternative user infobox is off (the infobox renders its own set) --}}
|
||||||
|
@if(!user_alternative_infobox)
|
||||||
|
<div class="gapRight blahlol-actions" style="display:flex;width:100%;justify-content:center;margin:4px 0;">
|
||||||
|
@if(session)
|
||||||
|
@if(user_has_favorited)
|
||||||
|
<i class="iconset fa-solid fa-heart" id="a_favo" data-item-id="{{ item.local_id || item.id }}" @if(item.is_chan) data-is-chan="true" data-local-id="{{ item.local_id || '' }}" data-chan-url="{{ item.external_media_url }}" data-chan-board="{{ item.external_board }}" data-chan-tid="{{ item.external_tid }}" data-chan-post="{{ item.external_id }}" data-filename="{{ item.original_filename || '' }}" data-width="{{ item.width || '' }}" data-height="{{ item.height || '' }}" @endif title="Favorite"></i>
|
||||||
|
@else
|
||||||
|
<i class="iconset fa-regular fa-heart" id="a_favo" data-item-id="{{ item.local_id || item.id }}" @if(item.is_chan) data-is-chan="true" data-local-id="{{ item.local_id || '' }}" data-chan-url="{{ item.external_media_url }}" data-chan-board="{{ item.external_board }}" data-chan-tid="{{ item.external_tid }}" data-chan-post="{{ item.external_id }}" data-filename="{{ item.original_filename || '' }}" data-width="{{ item.width || '' }}" data-height="{{ item.height || '' }}" @endif title="Favorite"></i>
|
||||||
|
@endif
|
||||||
|
<i class="iconset fa-solid fa-circle-info" id="a_info" data-item-id="{{ item.id }}" title="{{ t('info_modal.button_title') || 'Post & File Info' }}"></i>
|
||||||
|
@if(enable_comments)
|
||||||
|
<i class="iconset {{ isSubscribed ? 'fa-solid' : 'fa-regular' }} fa-bell" id="subscribe-btn" data-item-id="{{ item.id }}" title="{{ isSubscribed ? 'Subscribed' : 'Subscribe' }}"></i>
|
||||||
|
@endif
|
||||||
|
<i class="iconset fa-solid fa-triangle-exclamation report-item-btn" data-item-id="{{ item.id }}" title="Report this post"></i>
|
||||||
|
@if(halls_enabled)
|
||||||
|
<i class="iconset fa-solid fa-layer-group" id="a_hall" data-item-id="{{ item.id }}" data-halls="{{ halls_slugs }}" data-user-halls="{{ user_halls_slugs }}" data-current-hall="{{ (tmp.hall && typeof tmp.hall === 'object') ? tmp.hall.slug : (tmp.hall || '') }}" data-current-user-hall="{{ (tmp.userHall && typeof tmp.userHall === 'object') ? tmp.userHall.slug : (tmp.userHall || '') }}" data-current-user-hall-owner="{{ tmp.userHallOwner || '' }}" title="Add to Hall"></i>
|
||||||
|
@endif
|
||||||
|
@if(can_manage_item)
|
||||||
|
@if(enable_oc)
|
||||||
|
<i class="iconset {{ item.is_oc ? 'fa-solid' : 'fa-regular' }} fa-star" id="a_oc" data-item-id="{{ item.id }}" data-is-oc="{{ item.is_oc }}" title="{{ item.is_oc ? 'Remove OC status' : 'Mark as OC' }}"></i>
|
||||||
|
@endif
|
||||||
|
@if(typeof can_extract_meta !== 'undefined' && can_extract_meta)
|
||||||
|
<i class="iconset fa-solid fa-magic" id="a_metadata" data-item-id="{{ item.id }}" @if(item.mime === 'video/youtube') data-src="https://www.youtube.com/watch?v={{ item.dest.replace('yt:', '') }}" @endif title="Extract Metadata"></i>
|
||||||
|
@endif
|
||||||
|
@if(item.mime === 'application/x-shockwave-flash' || item.mime === 'application/vnd.adobe.flash.movie')
|
||||||
|
<i class="iconset fa-solid fa-image" id="a_rethumb" data-item-id="{{ item.id }}" title="Re-upload Thumbnail"></i>
|
||||||
|
@endif
|
||||||
|
@endif
|
||||||
|
@if(is_mod_or_admin)
|
||||||
|
<i class="iconset fa-solid fa-thumbtack{{ item.is_pinned ? ' active' : '' }}" id="a_pin" data-pinned="{{ item.is_pinned }}" title="{{ item.is_pinned ? 'Unpin from main' : 'Pin to main' }}"></i>
|
||||||
|
<i class="iconset fa-solid fa-ban{{ item.visibility === 3 ? ' active' : '' }}" id="a_unavailable" data-item-id="{{ item.id }}" data-visibility="{{ item.visibility || 0 }}" title="{{ item.visibility === 3 ? 'Make Available (Public)' : 'Make Unavailable (451)' }}" @if(item.visibility === 3) style="color: var(--danger, #ff4444);" @endif></i>
|
||||||
|
<i class="iconset fa-solid fa-xmark" id="a_delete" title="Delete"></i>
|
||||||
|
@endif
|
||||||
|
@else
|
||||||
|
<i class="iconset fa-solid fa-circle-info" id="a_info" data-item-id="{{ item.id }}" title="{{ t('info_modal.button_title') || 'Post & File Info' }}"></i>
|
||||||
|
<i class="iconset fa-solid fa-triangle-exclamation report-item-btn" data-item-id="{{ item.id }}" title="Report this post"></i>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
<span class="badge badge-dark" id="tags" data-item-id="{{ item.local_id || item.id }}" data-can-manage="{{ can_manage_item ? 'true' : 'false' }}" data-can-rate="{{ can_rate_item ? 'true' : 'false' }}">
|
||||||
<span class="tags-inner">
|
<span class="tags-inner">
|
||||||
@if(!item.is_sfw && !item.is_nsfw && !item.is_nsfl)
|
@if(!item.is_sfw && !item.is_nsfw && !item.is_nsfl)
|
||||||
<span class="badge badge-untagged rating-tag is-untagged @if(can_manage_item) can-cycle @endif" data-item-id="{{ item.local_id || item.id }}" data-rating="untagged">
|
<span class="badge badge-untagged rating-tag is-untagged @if(can_rate_item) can-cycle @endif" data-item-id="{{ item.local_id || item.id }}" data-rating="untagged">
|
||||||
<span class="rating-label">?</span>
|
<span class="rating-label">?</span>
|
||||||
</span>
|
</span>
|
||||||
@endif
|
@endif
|
||||||
@if(typeof item.tags !== "undefined")
|
@if(typeof item.tags !== "undefined")
|
||||||
@each(item.tags as tag)
|
@each(item.tags as tag)
|
||||||
@if(tag.normalized === 'sfw' || tag.normalized === 'nsfw' || tag.normalized === 'nsfl')
|
@if(tag.normalized === 'sfw' || tag.normalized === 'nsfw' || tag.normalized === 'nsfl')
|
||||||
<span class="badge {{ tag.badge }} rating-tag is-{{ tag.normalized }} @if(can_manage_item) can-cycle @endif" data-item-id="{{ item.local_id || item.id }}" data-rating="{{ tag.normalized }}">
|
<span class="badge {{ tag.badge }} rating-tag is-{{ tag.normalized }} @if(can_rate_item) can-cycle @endif" data-item-id="{{ item.local_id || item.id }}" data-rating="{{ tag.normalized }}">
|
||||||
<span class="rating-label">{!! tag.tag !!}</span>
|
<span class="rating-label">{!! tag.tag !!}</span>
|
||||||
</span>
|
</span>
|
||||||
@else
|
@else
|
||||||
@@ -285,51 +324,4 @@
|
|||||||
@include(snippets/info-modal)
|
@include(snippets/info-modal)
|
||||||
|
|
||||||
|
|
||||||
<div id="visibility-modal" class="modal-overlay" style="display: none;">
|
@include(snippets/visibility-modal)
|
||||||
<div class="modal-content" style="max-width: 420px;">
|
|
||||||
<div class="modal-header" style="margin-bottom: 15px; border-bottom: 1px solid var(--border-color, #333); padding-bottom: 10px;">
|
|
||||||
<h3 style="margin: 0; font-size: 1.1em; color: var(--text-color, #fff);"><i class="fa-solid fa-eye"></i> Change Visibility</h3>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body" style="padding: 10px 0; text-align: left;">
|
|
||||||
<form id="visibility-form" onsubmit="return false;">
|
|
||||||
<input type="hidden" id="visibility-item-id" value="{{ item.id }}" />
|
|
||||||
<div class="visibility-options" style="display: flex; flex-direction: column; gap: 10px;">
|
|
||||||
<label class="visibility-option" style="display: flex; align-items: flex-start; gap: 12px; padding: 12px; border: 1px solid var(--nav-border-color, #444); border-radius: 6px; cursor: pointer; background: var(--bg-secondary, rgba(255,255,255,0.03));">
|
|
||||||
<input type="radio" name="visibility" value="0" style="margin-top: 3px;" />
|
|
||||||
<div>
|
|
||||||
<strong style="display: block; color: var(--text-color, #fff);"><i class="fa-solid fa-globe"></i> Public</strong>
|
|
||||||
<span style="font-size: 0.85em; color: var(--text-muted, #aaa);">Visible to everyone in main feeds, search, and rankings.</span>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
<label class="visibility-option" style="display: flex; align-items: flex-start; gap: 12px; padding: 12px; border: 1px solid var(--nav-border-color, #444); border-radius: 6px; cursor: pointer; background: var(--bg-secondary, rgba(255,255,255,0.03));">
|
|
||||||
<input type="radio" name="visibility" value="1" style="margin-top: 3px;" />
|
|
||||||
<div>
|
|
||||||
<strong style="display: block; color: var(--text-color, #fff);"><i class="fa-solid fa-link"></i> Unlisted</strong>
|
|
||||||
<span style="font-size: 0.85em; color: var(--text-muted, #aaa);">Hidden from main feeds; accessible only via direct link or slug.</span>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
<label class="visibility-option" style="display: flex; align-items: flex-start; gap: 12px; padding: 12px; border: 1px solid var(--nav-border-color, #444); border-radius: 6px; cursor: pointer; background: var(--bg-secondary, rgba(255,255,255,0.03));">
|
|
||||||
<input type="radio" name="visibility" value="2" style="margin-top: 3px;" />
|
|
||||||
<div>
|
|
||||||
<strong style="display: block; color: var(--text-color, #fff);"><i class="fa-solid fa-lock"></i> Private</strong>
|
|
||||||
<span style="font-size: 0.85em; color: var(--text-muted, #aaa);">Only visible to you and site moderators.</span>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
@if(is_mod_or_admin)
|
|
||||||
<label class="visibility-option" style="display: flex; align-items: flex-start; gap: 12px; padding: 12px; border: 1px solid var(--nav-border-color, #444); border-radius: 6px; cursor: pointer; background: var(--bg-secondary, rgba(255,255,255,0.03));">
|
|
||||||
<input type="radio" name="visibility" value="3" style="margin-top: 3px;" />
|
|
||||||
<div>
|
|
||||||
<strong style="display: block; color: var(--text-color, #fff);"><i class="fa-solid fa-ban" style="color: var(--danger, #ff4444);"></i> Unavailable (451)</strong>
|
|
||||||
<span style="font-size: 0.85em; color: var(--text-muted, #aaa);">Only visible to administrators. Serves HTTP 451 on direct media for all other users.</span>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
<div class="modal-actions" style="display: flex; justify-content: flex-end; gap: 10px; margin-top: 15px;">
|
|
||||||
<button class="btn-secondary" id="visibility-modal-cancel">Cancel</button>
|
|
||||||
<button class="btn-primary" id="visibility-modal-save">Save Visibility</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|||||||
@@ -40,16 +40,16 @@
|
|||||||
@endif
|
@endif
|
||||||
|
|
||||||
<div class="sidebar-tags-container">
|
<div class="sidebar-tags-container">
|
||||||
<span class="badge badge-dark" id="tags" data-item-id="{{ item.local_id || item.id }}" data-can-manage="{{ can_manage_item ? 'true' : 'false' }}" style="display: flex; flex-wrap: wrap; gap: 5px; background: transparent; padding: 0; text-align: left; white-space: normal;">
|
<span class="badge badge-dark" id="tags" data-item-id="{{ item.local_id || item.id }}" data-can-manage="{{ can_manage_item ? 'true' : 'false' }}" data-can-rate="{{ can_rate_item ? 'true' : 'false' }}" style="display: flex; flex-wrap: wrap; gap: 5px; background: transparent; padding: 0; text-align: left; white-space: normal;">
|
||||||
@if(!item.is_sfw && !item.is_nsfw && !item.is_nsfl)
|
@if(!item.is_sfw && !item.is_nsfw && !item.is_nsfl)
|
||||||
<span class="badge badge-untagged rating-tag is-untagged @if(can_manage_item) can-cycle @endif" data-item-id="{{ item.local_id || item.id }}" data-rating="untagged">
|
<span class="badge badge-untagged rating-tag is-untagged @if(can_rate_item) can-cycle @endif" data-item-id="{{ item.local_id || item.id }}" data-rating="untagged">
|
||||||
<span class="rating-label">?</span>
|
<span class="rating-label">?</span>
|
||||||
</span>
|
</span>
|
||||||
@endif
|
@endif
|
||||||
@if(typeof item.tags !== "undefined")
|
@if(typeof item.tags !== "undefined")
|
||||||
@each(item.tags as tag)
|
@each(item.tags as tag)
|
||||||
@if(tag.normalized === 'sfw' || tag.normalized === 'nsfw' || tag.normalized === 'nsfl')
|
@if(tag.normalized === 'sfw' || tag.normalized === 'nsfw' || tag.normalized === 'nsfl')
|
||||||
<span class="badge {{ tag.badge }} rating-tag is-{{ tag.normalized }} @if(can_manage_item) can-cycle @endif" data-item-id="{{ item.local_id || item.id }}" data-rating="{{ tag.normalized }}">
|
<span class="badge {{ tag.badge }} rating-tag is-{{ tag.normalized }} @if(can_rate_item) can-cycle @endif" data-item-id="{{ item.local_id || item.id }}" data-rating="{{ tag.normalized }}">
|
||||||
<span class="rating-label">{!! tag.tag !!}</span>
|
<span class="rating-label">{!! tag.tag !!}</span>
|
||||||
</span>
|
</span>
|
||||||
@else
|
@else
|
||||||
@@ -70,7 +70,7 @@
|
|||||||
<div class="item-main-content">
|
<div class="item-main-content">
|
||||||
|
|
||||||
<div class="_204863">
|
<div class="_204863">
|
||||||
<div class="location">@if(item.is_chan)<a href="/4/{{ item.external_board }}/catalogue" class="location-link" title="Board /{{ item.external_board }}/ catalogue">/4</a><a href="/4/{{ item.external_board }}/catalogue" class="location-link" title="Board /{{ item.external_board }}/ catalogue">/{{ item.external_board }}</a><a href="/4/{{ item.external_board }}/{{ item.external_tid }}" class="location-link" title="Thread #{{ item.external_tid }} overview">/{{ item.external_tid }}</a><a href="/4/{{ item.external_board }}/{{ item.external_tid }}/{{ item.external_id }}" class="location-link" data-inherit-context="false" title="Post #{{ item.external_id }}">/{{ item.external_id }}</a>@if(item.local_id)<span class="location-sep" style="opacity: 0.6; margin: 0 4px;">→</span><a href="/{{ item.local_id }}" class="location-link is-rehosted" title="View rehosted post #{{ item.local_id }} on f0ckm" style="color: #4ade80; font-weight: 700;"><i class="fa-solid fa-check"></i> #{{ item.local_id }}</a>@endif@elseif(tmp && tmp.tag)<a href="/tags" class="location-link" title="All tags">/tag</a><a href="/tag/{{ encodeURIComponent(tmp.tag) }}{{ link.suffix || '' }}" class="location-link" title="Tag: {{ tmp.tag }}">/{{ tmp.tag }}</a><a href="/tag/{{ encodeURIComponent(tmp.tag) }}/{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}{{ link.suffix || '' }}{{ item.requested_subf0ck_slug ? ('#' + item.requested_subf0ck_slug) : '' }}" class="location-link" title="Post #{{ item.id }}">/{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}{{ item.requested_subf0ck_slug ? ('#' + item.requested_subf0ck_slug) : '' }}</a>@else{{ link.mainDisplay || link.main }}{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}{{ link.suffix }}{{ item.requested_subf0ck_slug ? ('#' + item.requested_subf0ck_slug) : '' }}@endif</div>
|
<div class="location">@if(item.is_chan)<a href="/4/{{ item.external_board }}/catalogue" class="location-link" title="Board /{{ item.external_board }}/ catalogue">/4</a><a href="/4/{{ item.external_board }}/catalogue" class="location-link" title="Board /{{ item.external_board }}/ catalogue">/{{ item.external_board }}</a><a href="/4/{{ item.external_board }}/{{ item.external_tid }}" class="location-link" title="Thread #{{ item.external_tid }} overview">/{{ item.external_tid }}</a><a href="/4/{{ item.external_board }}/{{ item.external_tid }}/{{ item.external_id }}" class="location-link" data-inherit-context="false" title="Post #{{ item.external_id }}">/{{ item.external_id }}</a>@if(item.local_id)<span class="location-sep" style="opacity: 0.6; margin: 0 4px;">→</span><a href="/{{ item.local_path || item.local_id }}" class="location-link is-rehosted" title="View rehosted post /{{ item.local_path || item.local_id }} on f0ckm" style="color: #4ade80; font-weight: 700;"><i class="fa-solid fa-check"></i> /{{ item.local_path || item.local_id }}</a>@endif@elseif(tmp && tmp.tag)<a href="/tags" class="location-link" title="All tags">/tag</a><a href="/tag/{{ encodeURIComponent(tmp.tag) }}{{ link.suffix || '' }}" class="location-link" title="Tag: {{ tmp.tag }}">/{{ tmp.tag }}</a><a href="/tag/{{ encodeURIComponent(tmp.tag) }}/{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}{{ link.suffix || '' }}{{ item.requested_subf0ck_slug ? ('#' + item.requested_subf0ck_slug) : '' }}" class="location-link" title="Post #{{ item.id }}">/{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}{{ item.requested_subf0ck_slug ? ('#' + item.requested_subf0ck_slug) : '' }}</a>@else{{ link.mainDisplay || link.main }}{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}{{ link.suffix }}{{ item.requested_subf0ck_slug ? ('#' + item.requested_subf0ck_slug) : '' }}@endif</div>
|
||||||
<div class="gapLeft"></div>
|
<div class="gapLeft"></div>
|
||||||
</div>
|
</div>
|
||||||
@if(enable_item_title)
|
@if(enable_item_title)
|
||||||
@@ -161,7 +161,7 @@
|
|||||||
@if(item.is_chan)
|
@if(item.is_chan)
|
||||||
@if(item.local_id)
|
@if(item.local_id)
|
||||||
<span class="chan-rehosted-wrap" style="display:inline-flex;gap:4px;align-items:center;">
|
<span class="chan-rehosted-wrap" style="display:inline-flex;gap:4px;align-items:center;">
|
||||||
<a href="/{{ item.local_id }}" class="chan-rehost-action-btn rehosted" title="Already rehosted on f0ckm (Post #{{ item.local_id }})" style="display:inline-flex;align-items:center;justify-content:center;gap:6px;padding:4px 12px;border-radius:6px;font-size:0.8rem;background:rgba(74,222,128,0.2);color:#4ade80;border:1px solid rgba(74,222,128,0.4);text-decoration:none;font-weight:600;white-space:nowrap;flex-shrink:0;"><i class="fa-solid fa-check"></i> #{{ item.local_id }}</a>
|
<a href="/{{ item.local_path || item.local_id }}" class="chan-rehost-action-btn rehosted" title="Already rehosted on f0ckm (/{{ item.local_path || item.local_id }})" style="display:inline-flex;align-items:center;justify-content:center;gap:6px;padding:4px 12px;border-radius:6px;font-size:0.8rem;background:rgba(74,222,128,0.2);color:#4ade80;border:1px solid rgba(74,222,128,0.4);text-decoration:none;font-weight:600;white-space:nowrap;flex-shrink:0;"><i class="fa-solid fa-check"></i> /{{ item.local_path || item.local_id }}</a>
|
||||||
<button type="button" class="chan-rehost-edit-btn" data-item-id="{{ item.local_id }}" title="Edit rating" style="display:inline-flex;align-items:center;padding:3px 7px;border-radius:6px;font-size:0.8rem;background:rgba(255,255,255,0.1);color:#fff;border:1px solid rgba(255,255,255,0.2);cursor:pointer;"><i class="fa-solid fa-pen"></i></button>
|
<button type="button" class="chan-rehost-edit-btn" data-item-id="{{ item.local_id }}" title="Edit rating" style="display:inline-flex;align-items:center;padding:3px 7px;border-radius:6px;font-size:0.8rem;background:rgba(255,255,255,0.1);color:#fff;border:1px solid rgba(255,255,255,0.2);cursor:pointer;"><i class="fa-solid fa-pen"></i></button>
|
||||||
</span>
|
</span>
|
||||||
@else
|
@else
|
||||||
@@ -233,51 +233,4 @@
|
|||||||
@include(snippets/info-modal)
|
@include(snippets/info-modal)
|
||||||
|
|
||||||
|
|
||||||
<div id="visibility-modal" class="modal-overlay" style="display: none;">
|
@include(snippets/visibility-modal)
|
||||||
<div class="modal-content" style="max-width: 420px;">
|
|
||||||
<div class="modal-header" style="margin-bottom: 15px; border-bottom: 1px solid var(--border-color, #333); padding-bottom: 10px;">
|
|
||||||
<h3 style="margin: 0; font-size: 1.1em; color: var(--text-color, #fff);"><i class="fa-solid fa-eye"></i> Change Visibility</h3>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body" style="padding: 10px 0; text-align: left;">
|
|
||||||
<form id="visibility-form" onsubmit="return false;">
|
|
||||||
<input type="hidden" id="visibility-item-id" value="{{ item.id }}" />
|
|
||||||
<div class="visibility-options" style="display: flex; flex-direction: column; gap: 10px;">
|
|
||||||
<label class="visibility-option" style="display: flex; align-items: flex-start; gap: 12px; padding: 12px; border: 1px solid var(--nav-border-color, #444); border-radius: 6px; cursor: pointer; background: var(--bg-secondary, rgba(255,255,255,0.03));">
|
|
||||||
<input type="radio" name="visibility" value="0" style="margin-top: 3px;" />
|
|
||||||
<div>
|
|
||||||
<strong style="display: block; color: var(--text-color, #fff);"><i class="fa-solid fa-globe"></i> Public</strong>
|
|
||||||
<span style="font-size: 0.85em; color: var(--text-muted, #aaa);">Visible to everyone in main feeds, search, and rankings.</span>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
<label class="visibility-option" style="display: flex; align-items: flex-start; gap: 12px; padding: 12px; border: 1px solid var(--nav-border-color, #444); border-radius: 6px; cursor: pointer; background: var(--bg-secondary, rgba(255,255,255,0.03));">
|
|
||||||
<input type="radio" name="visibility" value="1" style="margin-top: 3px;" />
|
|
||||||
<div>
|
|
||||||
<strong style="display: block; color: var(--text-color, #fff);"><i class="fa-solid fa-link"></i> Unlisted</strong>
|
|
||||||
<span style="font-size: 0.85em; color: var(--text-muted, #aaa);">Hidden from main feeds; accessible only via direct link or slug.</span>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
<label class="visibility-option" style="display: flex; align-items: flex-start; gap: 12px; padding: 12px; border: 1px solid var(--nav-border-color, #444); border-radius: 6px; cursor: pointer; background: var(--bg-secondary, rgba(255,255,255,0.03));">
|
|
||||||
<input type="radio" name="visibility" value="2" style="margin-top: 3px;" />
|
|
||||||
<div>
|
|
||||||
<strong style="display: block; color: var(--text-color, #fff);"><i class="fa-solid fa-lock"></i> Private</strong>
|
|
||||||
<span style="font-size: 0.85em; color: var(--text-muted, #aaa);">Only visible to you and site moderators.</span>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
@if(is_mod_or_admin)
|
|
||||||
<label class="visibility-option" style="display: flex; align-items: flex-start; gap: 12px; padding: 12px; border: 1px solid var(--nav-border-color, #444); border-radius: 6px; cursor: pointer; background: var(--bg-secondary, rgba(255,255,255,0.03));">
|
|
||||||
<input type="radio" name="visibility" value="3" style="margin-top: 3px;" />
|
|
||||||
<div>
|
|
||||||
<strong style="display: block; color: var(--text-color, #fff);"><i class="fa-solid fa-ban" style="color: var(--danger, #ff4444);"></i> Unavailable (451)</strong>
|
|
||||||
<span style="font-size: 0.85em; color: var(--text-muted, #aaa);">Only visible to administrators. Serves HTTP 451 on direct media for all other users.</span>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
<div class="modal-actions" style="display: flex; justify-content: flex-end; gap: 10px; margin-top: 15px;">
|
|
||||||
<button class="btn-secondary" id="visibility-modal-cancel">Cancel</button>
|
|
||||||
<button class="btn-primary" id="visibility-modal-save">Save Visibility</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
+64
-21
@@ -1,29 +1,72 @@
|
|||||||
@include(snippets/header)
|
@include(snippets/header)
|
||||||
<div class="pagewrapper">
|
<div class="pagewrapper">
|
||||||
<div id="main">
|
<div id="main">
|
||||||
<div class="container">
|
<div class="container adm">
|
||||||
<h1>MODERATOR DASHBOARD</h1>
|
|
||||||
<h5>Hello, {!! session.user !!}</h5>
|
<!-- Header -->
|
||||||
<hr>
|
<header class="adm-header">
|
||||||
|
<div>
|
||||||
|
<h1 class="adm-title">Moderator</h1>
|
||||||
|
<div class="adm-subtitle">Signed in as <strong>{!! session.user !!}</strong></div>
|
||||||
|
</div>
|
||||||
|
@if(session.admin)
|
||||||
|
<a href="/admin" class="adm-btn adm-btn-ghost"><i class="fa-solid fa-shield-halved"></i> Admin dashboard</a>
|
||||||
|
@endif
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Overview -->
|
||||||
|
<section class="adm-section">
|
||||||
|
<h2 class="adm-section-title"><i class="fa-solid fa-chart-simple"></i> Overview</h2>
|
||||||
|
<div class="adm-stats">
|
||||||
@if(manualApproval)
|
@if(manualApproval)
|
||||||
<p>Stats: Pending Uploads: {!! pendingCount !!}</p>
|
<a href="/mod/approve" class="adm-stat adm-stat-link @if(pendingCount > 0) is-hot @endif"><span class="adm-stat-num">{!! pendingCount !!}</span><span class="adm-stat-label">Pending approval</span></a>
|
||||||
<hr>
|
|
||||||
@endif
|
@endif
|
||||||
<div class="admintools">
|
<a href="/mod/trash" class="adm-stat adm-stat-link"><span class="adm-stat-num">{!! trashCount !!}</span><span class="adm-stat-label">Soft deleted</span></a>
|
||||||
<p>Tools</p>
|
<a href="/mod/reports" class="adm-stat adm-stat-link @if(reportsCount > 0) is-hot @endif"><span class="adm-stat-num">{!! reportsCount !!}</span><span class="adm-stat-label">Open reports</span></a>
|
||||||
<ul>
|
</div>
|
||||||
@if(manualApproval)
|
</section>
|
||||||
<li><a href="/mod/approve">Approval Queue @if(pendingCount > 0) <span class="badge badge-danger">{!! pendingCount !!}</span> @endif</a></li>
|
|
||||||
@endif
|
<!-- Tools -->
|
||||||
@if(session.admin || session.is_moderator)
|
<section class="adm-section">
|
||||||
<li><a href="/mod/audit">Audit Log</a></li>
|
<h2 class="adm-section-title"><i class="fa-solid fa-screwdriver-wrench"></i> Tools</h2>
|
||||||
<li><a href="/mod/reports">User Reports</a></li>
|
|
||||||
<li><a href="/mod/motd">MOTD</a></li>
|
<h3 class="adm-group-title">Moderation</h3>
|
||||||
<li><a href="/mod/halls">Hall Manager</a></li>
|
<div class="adm-tools">
|
||||||
@endif
|
@if(manualApproval)
|
||||||
</ul>
|
<a href="/mod/approve" class="adm-tool">
|
||||||
</div>
|
<i class="fa-solid fa-clock"></i>
|
||||||
</div>
|
<span class="adm-tool-text"><strong>Approval Queue @if(pendingCount > 0)<span class="adm-count">{!! pendingCount !!}</span>@endif</strong><span>Review uploads waiting for approval</span></span>
|
||||||
|
</a>
|
||||||
|
@endif
|
||||||
|
<a href="/mod/trash" class="adm-tool">
|
||||||
|
<i class="fa-solid fa-trash-can"></i>
|
||||||
|
<span class="adm-tool-text"><strong>Soft Deleted @if(trashCount > 0)<span class="adm-count">{!! trashCount !!}</span>@endif</strong><span>Restore removed items@if(session.admin) or purge them for good@endif</span></span>
|
||||||
|
</a>
|
||||||
|
<a href="/mod/reports" class="adm-tool">
|
||||||
|
<i class="fa-solid fa-flag"></i>
|
||||||
|
<span class="adm-tool-text"><strong>User Reports @if(reportsCount > 0)<span class="adm-count">{!! reportsCount !!}</span>@endif</strong><span>Reported posts, comments and users</span></span>
|
||||||
|
</a>
|
||||||
|
<a href="/mod/audit" class="adm-tool">
|
||||||
|
<i class="fa-solid fa-scroll"></i>
|
||||||
|
<span class="adm-tool-text"><strong>Audit Log</strong><span>Every moderation and admin action</span></span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="adm-group-title">Site</h3>
|
||||||
|
<div class="adm-tools">
|
||||||
|
<a href="/mod/motd" class="adm-tool">
|
||||||
|
<i class="fa-solid fa-bullhorn"></i>
|
||||||
|
<span class="adm-tool-text"><strong>MOTD</strong><span>Message of the day banner</span></span>
|
||||||
|
</a>
|
||||||
|
<a href="/mod/halls" class="adm-tool">
|
||||||
|
<i class="fa-solid fa-layer-group"></i>
|
||||||
|
<span class="adm-tool-text"><strong>Halls</strong><span>Curated collections</span></span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@include(snippets/adm-dashboard-style)
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@include(snippets/footer)
|
@include(snippets/footer)
|
||||||
+69
-307
@@ -2,349 +2,111 @@
|
|||||||
|
|
||||||
<div class="pagewrapper">
|
<div class="pagewrapper">
|
||||||
<div id="main">
|
<div id="main">
|
||||||
|
@include(snippets/admin-back)
|
||||||
|
<div class="container mq">
|
||||||
|
<header class="mq-header">
|
||||||
|
<div>
|
||||||
|
<h1 class="mq-title">Approval Queue</h1>
|
||||||
|
<div class="mq-subtitle">{{ total }} upload@if(total !== 1)s@endif waiting for approval.</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
<div class="container">
|
|
||||||
<h1>APPROVAL QUEUE</h1>
|
|
||||||
<p>Items here are pending approval.</p>
|
|
||||||
|
|
||||||
@if(pending.length > 0)
|
@if(pending.length > 0)
|
||||||
<h2>Pending Uploads</h2>
|
<div class="mq-grid">
|
||||||
<div class="approval-grid">
|
|
||||||
@each(pending as post)
|
@each(pending as post)
|
||||||
<div class="approval-card">
|
<div class="mq-card">
|
||||||
<div class="approval-card-media">
|
<div class="mq-media">
|
||||||
@if(post.mime === 'video/youtube')
|
@if(post.mime === 'video/youtube')
|
||||||
<div class="embed-responsive embed-responsive-16by9" style="width: 100%;">
|
<div class="embed-responsive embed-responsive-16by9">
|
||||||
<iframe class="embed-responsive-item" src="https://www.youtube.com/embed/{!! post.dest.replace('yt:', '') !!}" frameborder="0" allowfullscreen></iframe>
|
<iframe class="embed-responsive-item" src="https://www.youtube.com/embed/{!! post.dest.replace('yt:', '') !!}" frameborder="0" allowfullscreen></iframe>
|
||||||
</div>
|
</div>
|
||||||
@elseif(post.mime.startsWith('video'))
|
@elseif(post.mime.startsWith('video'))
|
||||||
<video controls loop muted preload="none" poster="/mod/pending/t/{!! post.id !!}.webp">
|
<video controls loop muted preload="none" poster="/mod/pending/t/{!! post.id !!}.webp">
|
||||||
<source src="/mod/pending/b/{!! post.dest !!}" type="{!! post.mime !!}">
|
<source src="/mod/pending/b/{!! post.dest !!}" type="{!! post.mime !!}">
|
||||||
Your browser does not support the video tag.
|
|
||||||
</video>
|
</video>
|
||||||
@elseif(post.mime === 'application/pdf')
|
@elseif(post.mime === 'application/pdf')
|
||||||
<div class="embed-responsive embed-responsive-16by9" style="width: 100%;">
|
<div class="embed-responsive embed-responsive-16by9">
|
||||||
<iframe class="embed-responsive-item" src="/mod/pending/b/{!! post.dest !!}#toolbar=0" loading="lazy" style="border: none;"></iframe>
|
<iframe class="embed-responsive-item" src="/mod/pending/b/{!! post.dest !!}#toolbar=0" loading="lazy" style="border: none;"></iframe>
|
||||||
</div>
|
</div>
|
||||||
@else
|
@else
|
||||||
<img src="/mod/pending/t/{!! post.id !!}.webp" alt="Preview">
|
<img src="/mod/pending/t/{!! post.id !!}.webp" alt="Preview" loading="lazy">
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
<div class="approval-card-body">
|
<div class="mq-body">
|
||||||
<div class="approval-card-info">
|
<div class="mq-meta">
|
||||||
<div><strong>ID:</strong> {!! post.id !!}</div>
|
<span><strong>#{!! post.id !!}</strong></span>
|
||||||
<div><strong>User:</strong> {!! post.username !!}</div>
|
<span>by <strong>{!! post.username !!}</strong></span>
|
||||||
<div><strong>Type:</strong> {!! post.mime !!}</div>
|
<span>{!! post.mime !!}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="approval-card-tags">
|
<div class="mq-tags">
|
||||||
@each(post.tags as tag)
|
@each(post.tags as tag)
|
||||||
<span class="badge {!! tag.badge !!}">{!! tag.tag !!}</span>
|
<span class="badge {!! tag.badge !!}">{!! tag.tag !!}</span>
|
||||||
@endeach
|
@endeach
|
||||||
</div>
|
</div>
|
||||||
<div class="approval-card-actions" style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px;">
|
<div class="mq-actions">
|
||||||
<button data-id="{!! post.id !!}" class="badge badge-success btn-approve-async" style="margin: 0; text-align: center; border: none; cursor: pointer;">Approve</button>
|
<button type="button" data-id="{!! post.id !!}" class="mq-btn mq-btn-primary btn-approve-async">Approve</button>
|
||||||
<button data-id="{!! post.id !!}" class="badge badge-danger btn-deny-async" style="margin: 0; text-align: center; border: none; cursor: pointer;">Deny / Delete</button>
|
<button type="button" data-id="{!! post.id !!}" class="mq-btn mq-btn-danger btn-deny-async">Deny</button>
|
||||||
<a href="/api/v2/tags/{!! post.id !!}/toggle" class="badge btn-rating-toggle-async" style="grid-column: span 2; background: #444; color: #ccc; margin: 0; text-align: center;">Rating</a>
|
<button type="button" data-id="{!! post.id !!}" class="mq-btn mq-btn-ghost mq-btn-rating btn-rating-toggle-async"><i class="fa-solid fa-shield-halved"></i> Toggle rating</button>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@endeach
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-top: 40px; margin-bottom: 20px;">
|
|
||||||
<h2 style="color: #ff6b6b; margin: 0;">Soft Deleted</h2>
|
|
||||||
@if(trash.length > 0 && session.admin)
|
|
||||||
<button id="btn-purge-trash" class="badge badge-danger" style="border: none; padding: 10px 15px; cursor: pointer; font-size: 14px;">Purge All Soft-Deleted</button>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
<p class="text-muted">These items are in the deleted folder but not purged from DB. Approving them will restore them.</p>
|
|
||||||
@if(trash.length > 0)
|
|
||||||
<div class="approval-grid" style="opacity: 0.8;">
|
|
||||||
@each(trash as post)
|
|
||||||
<div class="approval-card">
|
|
||||||
<div class="approval-card-media">
|
|
||||||
@if(post.mime === 'video/youtube')
|
|
||||||
<div class="embed-responsive embed-responsive-16by9" style="width: 100%;">
|
|
||||||
<iframe class="embed-responsive-item" src="https://www.youtube.com/embed/{!! post.dest.replace('yt:', '') !!}" frameborder="0" allowfullscreen></iframe>
|
|
||||||
</div>
|
|
||||||
@elseif(post.mime.startsWith('video'))
|
|
||||||
<video controls loop muted preload="none" poster="/mod/deleted/t/{!! post.id !!}.webp">
|
|
||||||
<source src="/mod/deleted/b/{!! post.dest !!}" type="{!! post.mime !!}">
|
|
||||||
Your browser does not support the video tag.
|
|
||||||
</video>
|
|
||||||
@elseif(post.mime === 'application/pdf')
|
|
||||||
<div class="embed-responsive embed-responsive-16by9" style="width: 100%;">
|
|
||||||
<iframe class="embed-responsive-item" src="/mod/deleted/b/{!! post.dest !!}#toolbar=0" loading="lazy" style="border: none;"></iframe>
|
|
||||||
</div>
|
|
||||||
@else
|
|
||||||
<img src="/mod/deleted/t/{!! post.id !!}.webp" style="filter: grayscale(50%);" alt="Preview">
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
<div class="approval-card-body">
|
|
||||||
<div class="approval-card-info">
|
|
||||||
<div><strong>ID:</strong> {!! post.id !!}</div>
|
|
||||||
<div><strong>User:</strong> {!! post.username !!}</div>
|
|
||||||
<div><strong>Type:</strong> {!! post.mime !!}</div>
|
|
||||||
@if(post.delete_reason)
|
|
||||||
<div style="color: #ffb8b8;"><strong>Reason:</strong> {!! post.delete_reason !!}</div>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
<div class="approval-card-tags">
|
|
||||||
@each(post.tags as tag)
|
|
||||||
<span class="badge {!! tag.badge !!}">{!! tag.tag !!}</span>
|
|
||||||
@endeach
|
|
||||||
</div>
|
|
||||||
<div class="approval-card-actions" style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px;">
|
|
||||||
<button data-id="{!! post.id !!}" class="badge badge-warning btn-approve-async" style="margin: 0; text-align: center; border: none; cursor: pointer;">Restore</button>
|
|
||||||
@if(session.admin)
|
|
||||||
<button data-id="{!! post.id !!}" class="badge badge-danger btn-deny-async" style="margin: 0; text-align: center; border: none; cursor: pointer;">Purge</button>
|
|
||||||
@else
|
|
||||||
<span></span>
|
|
||||||
@endif
|
|
||||||
<a href="/api/v2/tags/{!! post.id !!}/toggle" class="badge btn-rating-toggle-async" style="grid-column: span 2; background: #444; color: #ccc; margin: 0; text-align: center;">Rating</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@endeach
|
@endeach
|
||||||
</div>
|
</div>
|
||||||
@else
|
@else
|
||||||
<p style="padding: 20px; border: 1px dashed #444; color: #888;">Trash is empty.</p>
|
<div class="mq-empty"><strong>No pending uploads.</strong>Go touch grass?</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
@if(pending.length === 0 && trash.length === 0)
|
@if(pages > 1)
|
||||||
<div style="text-align: center; padding: 50px;">
|
<div class="mq-pager">
|
||||||
<h3>No pending items.</h3>
|
|
||||||
<p>Go touch grass?</p>
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
<br>
|
|
||||||
@if(typeof pages !== 'undefined' && pages > 1)
|
|
||||||
<div class="pagination" style="display: flex; gap: 10px; align-items: center; justify-content: center;">
|
|
||||||
@if(page > 1)
|
@if(page > 1)
|
||||||
<a href="/mod/approve?page={!! page - 1 !!}" class="badge badge-secondary">« Prev</a>
|
<a href="/mod/approve?page={!! page - 1 !!}" class="mq-btn mq-btn-ghost">« Prev</a>
|
||||||
@endif
|
@endif
|
||||||
<span>Page {!! page !!} of {!! pages !!}</span>
|
<span>Page {!! page !!} of {!! pages !!}</span>
|
||||||
@if(page < pages) <a href="/mod/approve?page={!! page + 1 !!}" class="badge badge-secondary">Next »</a>@endif
|
@if(page < pages)
|
||||||
|
<a href="/mod/approve?page={!! page + 1 !!}" class="mq-btn mq-btn-ghost">Next »</a>
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
<br>
|
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
<!-- Custom Modal -->
|
|
||||||
<div id="custom-modal" style="display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.7); justify-content: center; align-items: center; z-index: 1000;">
|
|
||||||
<div style="background: #222; color: #fff; padding: 20px; border-radius: 8px; max-width: 400px; text-align: center; border: 1px solid #444;">
|
|
||||||
<h3 id="modal-title" style="margin-top: 0;">Confirm Action</h3>
|
|
||||||
<p id="modal-text">Are you sure?</p>
|
|
||||||
<div id="modal-reason-container" style="display: none; margin-bottom: 15px; text-align: left;">
|
|
||||||
<label style="display: block; margin-bottom: 5px; font-size: 0.9em; color: #aaa;">Reason (required):</label>
|
|
||||||
<textarea id="modal-reason" style="width: 100%; background: #333; color: #fff; border: 1px solid #444; border-radius: 4px; padding: 8px; resize: none;" rows="3" placeholder="Enter reason for denial..."></textarea>
|
|
||||||
</div>
|
|
||||||
<div style="display: flex; justify-content: space-around; margin-top: 20px;">
|
|
||||||
<button id="modal-cancel" class="badge badge-secondary" style="border: none; padding: 10px 20px; cursor: pointer;">Cancel</button>
|
|
||||||
<button id="modal-confirm" class="badge badge-danger" style="border: none; padding: 10px 20px; cursor: pointer;">Confirm</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
(() => {
|
|
||||||
const modal = document.getElementById('custom-modal');
|
|
||||||
const modalTitle = document.getElementById('modal-title');
|
|
||||||
const modalText = document.getElementById('modal-text');
|
|
||||||
const modalReasonContainer = document.getElementById('modal-reason-container');
|
|
||||||
const modalReasonInput = document.getElementById('modal-reason');
|
|
||||||
const btnConfirm = document.getElementById('modal-confirm');
|
|
||||||
const btnCancel = document.getElementById('modal-cancel');
|
|
||||||
|
|
||||||
let pendingAction = null;
|
|
||||||
|
|
||||||
const showModal = (title, text, action, showReason = false) => {
|
|
||||||
modalTitle.innerText = title;
|
|
||||||
modalText.innerText = text;
|
|
||||||
pendingAction = action;
|
|
||||||
|
|
||||||
if (showReason) {
|
|
||||||
modalReasonContainer.style.display = 'block';
|
|
||||||
modalReasonInput.value = '';
|
|
||||||
modalReasonInput.focus();
|
|
||||||
} else {
|
|
||||||
modalReasonContainer.style.display = 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
modal.style.display = 'flex';
|
|
||||||
|
|
||||||
btnConfirm.onclick = async () => {
|
|
||||||
if (!pendingAction) return;
|
|
||||||
btnConfirm.disabled = true;
|
|
||||||
btnConfirm.innerText = 'Processing...';
|
|
||||||
try {
|
|
||||||
let reason = null;
|
|
||||||
if (showReason) {
|
|
||||||
reason = modalReasonInput.value.trim();
|
|
||||||
if (!reason) {
|
|
||||||
modalReasonInput.style.borderColor = '#ff4444';
|
|
||||||
alert('Please provide a reason for denial');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
modalReasonInput.style.borderColor = '#444';
|
|
||||||
}
|
|
||||||
await pendingAction(reason);
|
|
||||||
closeModal();
|
|
||||||
} catch (e) {
|
|
||||||
alert('Error: ' + e.message);
|
|
||||||
} finally {
|
|
||||||
btnConfirm.disabled = false;
|
|
||||||
btnConfirm.innerText = 'Confirm';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const closeModal = () => {
|
|
||||||
modal.style.display = 'none';
|
|
||||||
pendingAction = null;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (btnCancel) btnCancel.onclick = closeModal;
|
|
||||||
|
|
||||||
// Single Deny
|
|
||||||
document.querySelectorAll('.btn-deny-async').forEach(btn => {
|
|
||||||
btn.addEventListener('click', e => {
|
|
||||||
e.preventDefault();
|
|
||||||
const itemId = btn.getAttribute('data-id');
|
|
||||||
const card = btn.closest('.approval-card');
|
|
||||||
showModal('{!! t('mod.confirm_action') !!}', '{!! t('mod.confirm_action') !!}?', async (reason) => {
|
|
||||||
const res = await fetch('/mod/deny', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-Requested-With': 'XMLHttpRequest',
|
|
||||||
'Accept': 'application/json',
|
|
||||||
'X-CSRF-Token': window.f0ckSession?.csrf_token || ''
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ id: itemId, reason })
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
if (res.ok && data.success) {
|
|
||||||
if (window.flashMessage) window.flashMessage('{!! t('toast.item_deleted_success') !!}', 2000, 'success');
|
|
||||||
card.style.opacity = '0';
|
|
||||||
setTimeout(() => card.remove(), 300);
|
|
||||||
} else {
|
|
||||||
if (window.flashMessage) window.flashMessage(data.msg || '{!! t('toast.report_error') !!}', 3000, 'error');
|
|
||||||
throw new Error(data.msg || 'Request failed');
|
|
||||||
}
|
|
||||||
}, true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Single Approve / Restore
|
|
||||||
document.querySelectorAll('.btn-approve-async').forEach(btn => {
|
|
||||||
btn.addEventListener('click', async e => {
|
|
||||||
e.preventDefault();
|
|
||||||
const itemId = btn.getAttribute('data-id');
|
|
||||||
const card = btn.closest('.approval-card');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch('/mod/approve', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-Requested-With': 'XMLHttpRequest',
|
|
||||||
'Accept': 'application/json',
|
|
||||||
'X-CSRF-Token': window.f0ckSession?.csrf_token || ''
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ id: itemId })
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
if (data.success) {
|
|
||||||
if (window.flashMessage) window.flashMessage('{!! t('toast.approve_success') !!}', 2000, 'success');
|
|
||||||
card.style.opacity = '0';
|
|
||||||
setTimeout(() => card.remove(), 300);
|
|
||||||
} else {
|
|
||||||
if (window.flashMessage) window.flashMessage(data.msg || '{!! t('toast.approve_error') !!}', 3000, 'error');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
if (window.flashMessage) window.flashMessage('{!! t('toast.network_error') !!}', 3000, 'error');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Rating Toggle
|
|
||||||
document.querySelectorAll('.btn-rating-toggle-async').forEach(btn => {
|
|
||||||
btn.addEventListener('click', async e => {
|
|
||||||
e.preventDefault();
|
|
||||||
const url = btn.getAttribute('href');
|
|
||||||
const card = btn.closest('.approval-card');
|
|
||||||
const tagsContainer = card.querySelector('.approval-card-tags');
|
|
||||||
|
|
||||||
btn.style.opacity = '0.5';
|
|
||||||
btn.style.pointerEvents = 'none';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch(url, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: {
|
|
||||||
'X-Requested-With': 'XMLHttpRequest',
|
|
||||||
'Accept': 'application/json'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
if (data.success && data.tags) {
|
|
||||||
// Update tags UI
|
|
||||||
tagsContainer.innerHTML = '';
|
|
||||||
|
|
||||||
data.tags.forEach(tag => {
|
|
||||||
const span = document.createElement('span');
|
|
||||||
let badgeClass = 'badge-light';
|
|
||||||
if (tag.normalized === 'sfw') badgeClass = 'badge-success';
|
|
||||||
else if (tag.normalized === 'nsfw') badgeClass = 'badge-danger';
|
|
||||||
else if (tag.tag.startsWith('>')) badgeClass = 'badge-greentext badge-light';
|
|
||||||
else if (tag.normalized === 'ukraine') badgeClass = 'badge-ukraine badge-light';
|
|
||||||
else if (/[а-яё]/.test(tag.normalized) || tag.normalized === 'russia') badgeClass = 'badge-russia badge-light';
|
|
||||||
else if (tag.normalized === 'german') badgeClass = 'badge-german badge-light';
|
|
||||||
else if (tag.normalized === 'dutch') badgeClass = 'badge-dutch badge-light';
|
|
||||||
|
|
||||||
span.className = 'badge ' + badgeClass;
|
|
||||||
span.innerText = tag.tag;
|
|
||||||
tagsContainer.appendChild(span);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
alert('Error: ' + (data.msg || 'Unknown error'));
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
alert('Network error');
|
|
||||||
} finally {
|
|
||||||
btn.style.opacity = '1';
|
|
||||||
btn.style.pointerEvents = 'auto';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Purge All Trash
|
|
||||||
const btnPurgeTrash = document.getElementById('btn-purge-trash');
|
|
||||||
if (btnPurgeTrash) {
|
|
||||||
btnPurgeTrash.addEventListener('click', () => {
|
|
||||||
showModal('Purge Trash', 'Permanently delete ALL items in the trash? This cannot be undone.', async () => {
|
|
||||||
const res = await fetch('/mod/purge-trash-all', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'X-CSRF-Token': window.f0ckSession?.csrf_token || ''
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
if (data.success) {
|
|
||||||
location.reload();
|
|
||||||
} else {
|
|
||||||
throw new Error(data.msg || 'Purge failed');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
</div>
|
</div>
|
||||||
|
{{-- Inside #main so AJAX navigation (which only swaps #main) brings the styles, modal and scripts along --}}
|
||||||
|
@include(snippets/mod-cards)
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
// Approve → public
|
||||||
|
document.querySelectorAll('.btn-approve-async').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async e => {
|
||||||
|
e.preventDefault();
|
||||||
|
btn.disabled = true;
|
||||||
|
try {
|
||||||
|
await mq.post('/mod/approve', { id: btn.dataset.id });
|
||||||
|
mq.flash('{!! t('toast.approve_success') !!}', 'success');
|
||||||
|
mq.removeCard(btn.closest('.mq-card'));
|
||||||
|
} catch (err) {
|
||||||
|
mq.flash(err.message || '{!! t('toast.approve_error') !!}', 'error');
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
// Deny → soft deleted (uploader is notified with the reason)
|
||||||
|
document.querySelectorAll('.btn-deny-async').forEach(btn => {
|
||||||
|
btn.addEventListener('click', e => {
|
||||||
|
e.preventDefault();
|
||||||
|
mq.confirm({
|
||||||
|
heading: 'Deny upload',
|
||||||
|
body: 'Deny this upload? It moves to Soft Deleted and the uploader is notified with your reason.',
|
||||||
|
confirmLabel: 'Deny',
|
||||||
|
withReason: true,
|
||||||
|
action: async (reason) => {
|
||||||
|
await mq.post('/mod/deny', { id: btn.dataset.id, reason });
|
||||||
|
mq.flash('Upload denied', 'success');
|
||||||
|
mq.removeCard(btn.closest('.mq-card'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@include(snippets/footer)
|
@include(snippets/footer)
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
@include(snippets/header)
|
@include(snippets/header)
|
||||||
<div id="main">
|
<div id="main">
|
||||||
|
@include(snippets/admin-back)
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h1>AUDIT LOG</h1>
|
<h1>AUDIT LOG</h1>
|
||||||
<p>Actions performed by moderators and admins.</p>
|
<p>Actions performed by moderators and admins.</p>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
@include(snippets/header)
|
@include(snippets/header)
|
||||||
<div class="pagewrapper">
|
<div class="pagewrapper">
|
||||||
<div id="main">
|
<div id="main">
|
||||||
|
@include(snippets/admin-back)
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h2>Moderator Message of the Day (MOTD)</h2>
|
<h2>Moderator Message of the Day (MOTD)</h2>
|
||||||
<p style="color: #ccc; margin-bottom: 20px;">This message is displayed site-wide. It will automatically be tagged with your name (<code>t. {!! session.display_name || session.user !!}</code>).</p>
|
<p style="color: #ccc; margin-bottom: 20px;">This message is displayed site-wide. It will automatically be tagged with your name (<code>t. {!! session.display_name || session.user !!}</code>).</p>
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
@include(snippets/header)
|
||||||
|
|
||||||
|
<div class="pagewrapper">
|
||||||
|
<div id="main">
|
||||||
|
@include(snippets/admin-back)
|
||||||
|
<div class="container mq mq-trash">
|
||||||
|
<header class="mq-header">
|
||||||
|
<div>
|
||||||
|
<h1 class="mq-title">Soft Deleted</h1>
|
||||||
|
<div class="mq-subtitle">{{ total }} removed item@if(total !== 1)s@endif still on disk. Restore makes an item public again@if(session.admin); purge deletes it and its files for good@endif.</div>
|
||||||
|
</div>
|
||||||
|
@if(trash.length > 0 && session.admin)
|
||||||
|
<button type="button" id="btn-purge-trash" class="mq-btn mq-btn-danger"><i class="fa-solid fa-trash"></i> Purge all</button>
|
||||||
|
@endif
|
||||||
|
</header>
|
||||||
|
|
||||||
|
|
||||||
|
@if(trash.length > 0)
|
||||||
|
<div class="mq-grid">
|
||||||
|
@each(trash as post)
|
||||||
|
<div class="mq-card">
|
||||||
|
<div class="mq-media">
|
||||||
|
@if(post.mime === 'video/youtube')
|
||||||
|
<div class="embed-responsive embed-responsive-16by9">
|
||||||
|
<iframe class="embed-responsive-item" src="https://www.youtube.com/embed/{!! post.dest.replace('yt:', '') !!}" frameborder="0" allowfullscreen></iframe>
|
||||||
|
</div>
|
||||||
|
@elseif(post.mime.startsWith('video'))
|
||||||
|
<video controls loop muted preload="none" poster="/mod/deleted/t/{!! post.id !!}.webp">
|
||||||
|
<source src="/mod/deleted/b/{!! post.dest !!}" type="{!! post.mime !!}">
|
||||||
|
</video>
|
||||||
|
@elseif(post.mime === 'application/pdf')
|
||||||
|
<div class="embed-responsive embed-responsive-16by9">
|
||||||
|
<iframe class="embed-responsive-item" src="/mod/deleted/b/{!! post.dest !!}#toolbar=0" loading="lazy" style="border: none;"></iframe>
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<img src="/mod/deleted/t/{!! post.id !!}.webp" alt="Preview" loading="lazy">
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
<div class="mq-body">
|
||||||
|
<div class="mq-meta">
|
||||||
|
<span><strong>#{!! post.id !!}</strong></span>
|
||||||
|
<span>by <strong>{!! post.username !!}</strong></span>
|
||||||
|
<span>{!! post.mime !!}</span>
|
||||||
|
</div>
|
||||||
|
@if(post.delete_reason)
|
||||||
|
<div class="mq-reason"><strong>Reason:</strong> {!! post.delete_reason !!}</div>
|
||||||
|
@endif
|
||||||
|
<div class="mq-tags">
|
||||||
|
@each(post.tags as tag)
|
||||||
|
<span class="badge {!! tag.badge !!}">{!! tag.tag !!}</span>
|
||||||
|
@endeach
|
||||||
|
</div>
|
||||||
|
<div class="mq-actions">
|
||||||
|
<button type="button" data-id="{!! post.id !!}" class="mq-btn mq-btn-primary btn-approve-async">Restore</button>
|
||||||
|
@if(session.admin)
|
||||||
|
<button type="button" data-id="{!! post.id !!}" class="mq-btn mq-btn-danger btn-deny-async">Purge</button>
|
||||||
|
@else
|
||||||
|
<span></span>
|
||||||
|
@endif
|
||||||
|
<button type="button" data-id="{!! post.id !!}" class="mq-btn mq-btn-ghost mq-btn-rating btn-rating-toggle-async"><i class="fa-solid fa-shield-halved"></i> Toggle rating</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endeach
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<div class="mq-empty"><strong>Nothing soft deleted.</strong>Removed items show up here until they are restored or purged.</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if(pages > 1)
|
||||||
|
<div class="mq-pager">
|
||||||
|
@if(page > 1)
|
||||||
|
<a href="/mod/trash?page={!! page - 1 !!}" class="mq-btn mq-btn-ghost">« Prev</a>
|
||||||
|
@endif
|
||||||
|
<span>Page {!! page !!} of {!! pages !!}</span>
|
||||||
|
@if(page < pages)
|
||||||
|
<a href="/mod/trash?page={!! page + 1 !!}" class="mq-btn mq-btn-ghost">Next »</a>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
{{-- Inside #main so AJAX navigation (which only swaps #main) brings the styles, modal and scripts along --}}
|
||||||
|
@include(snippets/mod-cards)
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
// Restore → public again (no "approved" notification)
|
||||||
|
document.querySelectorAll('.btn-approve-async').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async e => {
|
||||||
|
e.preventDefault();
|
||||||
|
btn.disabled = true;
|
||||||
|
try {
|
||||||
|
await mq.post('/mod/trash/restore', { id: btn.dataset.id });
|
||||||
|
mq.flash('Item restored', 'success');
|
||||||
|
mq.removeCard(btn.closest('.mq-card'));
|
||||||
|
} catch (err) {
|
||||||
|
mq.flash(err.message, 'error');
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
// Purge one item permanently (admins)
|
||||||
|
document.querySelectorAll('.btn-deny-async').forEach(btn => {
|
||||||
|
btn.addEventListener('click', e => {
|
||||||
|
e.preventDefault();
|
||||||
|
mq.confirm({
|
||||||
|
heading: 'Purge item',
|
||||||
|
body: 'Permanently delete this item, its files and its comments? This cannot be undone.',
|
||||||
|
confirmLabel: 'Purge',
|
||||||
|
withReason: true,
|
||||||
|
action: async (reason) => {
|
||||||
|
await mq.post('/mod/trash/purge', { id: btn.dataset.id, reason });
|
||||||
|
mq.flash('{!! t('toast.item_deleted_success') !!}', 'success');
|
||||||
|
mq.removeCard(btn.closest('.mq-card'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
// Purge everything (admins)
|
||||||
|
const purgeAll = document.getElementById('btn-purge-trash');
|
||||||
|
if (purgeAll) {
|
||||||
|
purgeAll.addEventListener('click', () => {
|
||||||
|
mq.confirm({
|
||||||
|
heading: 'Purge all',
|
||||||
|
body: 'Permanently delete ALL soft-deleted items, their files and comments? This cannot be undone.',
|
||||||
|
confirmLabel: 'Purge all',
|
||||||
|
action: async () => {
|
||||||
|
const data = await mq.post('/mod/trash/purge-all');
|
||||||
|
mq.flash('Purged ' + (data.count || 0) + ' item(s)', 'success');
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@include(snippets/footer)
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
<div class="pagewrapper">
|
<div class="pagewrapper">
|
||||||
<div id="main">
|
<div id="main">
|
||||||
|
@include(snippets/admin-back)
|
||||||
<style>
|
<style>
|
||||||
.rp-page { max-width: 900px; margin: 0 auto; padding: 30px 15px 60px; }
|
.rp-page { max-width: 900px; margin: 0 auto; padding: 30px 15px 60px; }
|
||||||
.rp-header { margin-bottom: 28px; }
|
.rp-header { margin-bottom: 28px; }
|
||||||
|
|||||||
+2
-4
@@ -10,9 +10,7 @@
|
|||||||
<a href="#profile"><i class="fa-solid fa-user-pen"></i> {{ t('settings.profile') }}</a>
|
<a href="#profile"><i class="fa-solid fa-user-pen"></i> {{ t('settings.profile') }}</a>
|
||||||
@endif
|
@endif
|
||||||
<a href="#preferences"><i class="fa-solid fa-sliders"></i> {{ t('settings.preferences') }}</a>
|
<a href="#preferences"><i class="fa-solid fa-sliders"></i> {{ t('settings.preferences') }}</a>
|
||||||
@if(session.is_anon)
|
|
||||||
<a href="#anon-ssh"><i class="fa-solid fa-key"></i> SSH Identity</a>
|
|
||||||
@endif
|
|
||||||
@if(!session.is_anon && enable_data_export)
|
@if(!session.is_anon && enable_data_export)
|
||||||
<a href="#export"><i class="fa-solid fa-file-export"></i> {{ t('settings.export_data_title') }}</a>
|
<a href="#export"><i class="fa-solid fa-file-export"></i> {{ t('settings.export_data_title') }}</a>
|
||||||
@endif
|
@endif
|
||||||
@@ -472,7 +470,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if(allow_user_upload_visibility !== false)
|
@if(allow_user_upload_visibility !== false && !session.is_anon)
|
||||||
<div class="setting-item" style="margin-top: 18px;">
|
<div class="setting-item" style="margin-top: 18px;">
|
||||||
<label for="default_upload_visibility_select" style="display: block; font-weight: bold; margin-bottom: 5px;">
|
<label for="default_upload_visibility_select" style="display: block; font-weight: bold; margin-bottom: 5px;">
|
||||||
<span>{{ t('settings.default_upload_visibility') }}</span>
|
<span>{{ t('settings.default_upload_visibility') }}</span>
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
{{-- Shared look of the admin (/admin) and moderator (/mod) dashboards: .adm-* classes --}}
|
||||||
|
<style>
|
||||||
|
/* Admin & moderator dashboards — square, flat, theme colours (same rules as the modals) */
|
||||||
|
.adm {
|
||||||
|
--adm-accent: var(--accent, #0096ff);
|
||||||
|
--adm-text: var(--text-color, var(--white, #fff));
|
||||||
|
--adm-muted: var(--text-muted, #8a8f98);
|
||||||
|
--adm-surface: rgba(255, 255, 255, 0.04);
|
||||||
|
--adm-border: rgba(255, 255, 255, 0.1);
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 24px 16px 60px;
|
||||||
|
text-align: left;
|
||||||
|
color: var(--adm-text);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.adm *, .adm *::before, .adm *::after { border-radius: 0 !important; }
|
||||||
|
|
||||||
|
.adm-header {
|
||||||
|
display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; flex-wrap: wrap;
|
||||||
|
padding-bottom: 18px; margin-bottom: 26px; border-bottom: 1px solid var(--adm-border);
|
||||||
|
}
|
||||||
|
.adm-title { margin: 0; font-size: 1.8em; font-weight: 800; letter-spacing: -0.01em; line-height: 1.1; }
|
||||||
|
.adm-subtitle { margin-top: 6px; font-size: 0.85em; color: var(--adm-muted); }
|
||||||
|
.adm-subtitle strong { color: var(--adm-text); }
|
||||||
|
|
||||||
|
.adm-section { margin-bottom: 34px; }
|
||||||
|
.adm-section-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||||
|
.adm-section-title {
|
||||||
|
display: flex; align-items: center; gap: 10px; margin: 0 0 14px;
|
||||||
|
font-size: 0.78em; font-weight: 700; text-transform: uppercase; letter-spacing: 0.14em; color: var(--adm-muted);
|
||||||
|
}
|
||||||
|
.adm-section-title i { color: var(--adm-accent); }
|
||||||
|
.adm-section-title::after { content: ''; flex: 1; height: 1px; background: var(--adm-border); }
|
||||||
|
.adm-section-head .adm-section-title { flex: 1; }
|
||||||
|
.adm-group-title { margin: 18px 0 8px; font-size: 0.95em; font-weight: 700; color: var(--adm-text); }
|
||||||
|
|
||||||
|
/* Overview tiles */
|
||||||
|
.adm-stats { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 8px; }
|
||||||
|
.adm-stat {
|
||||||
|
display: flex; flex-direction: column; gap: 2px; padding: 14px 16px;
|
||||||
|
background: var(--adm-surface); border: 1px solid var(--adm-border);
|
||||||
|
color: var(--adm-text); text-decoration: none;
|
||||||
|
}
|
||||||
|
.adm-stat-num { font-size: 1.6em; font-weight: 800; line-height: 1.1; font-variant-numeric: tabular-nums; }
|
||||||
|
.adm-stat-label { font-size: 0.72em; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: var(--adm-muted); }
|
||||||
|
.adm-stat-label small { text-transform: none; letter-spacing: 0; font-weight: 500; }
|
||||||
|
.adm-stat-link { transition: border-color 0.15s; }
|
||||||
|
.adm-stat-link:hover { border-color: var(--adm-accent); text-decoration: none; color: var(--adm-text); }
|
||||||
|
.adm-stat.is-hot { border-color: var(--adm-accent); border-left: 3px solid var(--adm-accent); }
|
||||||
|
.adm-stat.is-hot .adm-stat-num { color: var(--adm-accent); }
|
||||||
|
|
||||||
|
/* Tool cards */
|
||||||
|
.adm-tools { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 8px; }
|
||||||
|
.adm-tool {
|
||||||
|
display: flex; align-items: flex-start; gap: 12px; padding: 12px 14px;
|
||||||
|
background: var(--adm-surface); border: 1px solid var(--adm-border);
|
||||||
|
color: var(--adm-text); text-decoration: none; transition: border-color 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
.adm-tool:hover { border-color: var(--adm-accent); background: rgba(255, 255, 255, 0.06); text-decoration: none; color: var(--adm-text); }
|
||||||
|
.adm-tool > i { width: 20px; margin-top: 3px; text-align: center; color: var(--adm-accent); flex-shrink: 0; }
|
||||||
|
.adm-tool-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
||||||
|
.adm-tool-text strong { display: flex; align-items: center; gap: 8px; font-size: 0.92em; }
|
||||||
|
.adm-tool-text > span { font-size: 0.78em; color: var(--adm-muted); }
|
||||||
|
.adm-count { padding: 0 6px; font-size: 0.8em; line-height: 1.6; background: var(--adm-accent); color: #000; font-weight: 800; }
|
||||||
|
|
||||||
|
/* Settings panels */
|
||||||
|
.adm-panel { background: var(--adm-surface); border: 1px solid var(--adm-border); }
|
||||||
|
.adm-row { display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 14px 16px; }
|
||||||
|
.adm-row + .adm-row { border-top: 1px solid var(--adm-border); }
|
||||||
|
.adm-row-wrap { flex-wrap: wrap; }
|
||||||
|
.adm-row-text { min-width: 0; flex: 1 1 260px; }
|
||||||
|
.adm-row-text label { display: block; margin: 0; font-weight: 700; font-size: 0.92em; color: var(--adm-text); }
|
||||||
|
.adm-row-text p { margin: 3px 0 0; font-size: 0.8em; color: var(--adm-muted); }
|
||||||
|
.adm-muted { font-size: 0.78em; font-style: italic; color: var(--adm-muted); }
|
||||||
|
|
||||||
|
.adm-number {
|
||||||
|
width: 76px; height: 36px; padding: 0 8px; text-align: center;
|
||||||
|
background: rgba(0, 0, 0, 0.3); border: 1px solid var(--adm-border); color: var(--adm-text);
|
||||||
|
font: inherit; outline: none; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.adm-number:focus { border-color: var(--adm-accent); }
|
||||||
|
|
||||||
|
.adm-switch { position: relative; display: inline-block; width: 46px; height: 24px; flex-shrink: 0; cursor: pointer; }
|
||||||
|
.adm-switch input { opacity: 0; width: 0; height: 0; position: absolute; }
|
||||||
|
.adm-slider { position: absolute; inset: 0; background: rgba(255, 255, 255, 0.12); border: 1px solid var(--adm-border); transition: background 0.2s, border-color 0.2s; }
|
||||||
|
.adm-slider::before { content: ''; position: absolute; width: 16px; height: 16px; left: 3px; top: 3px; background: var(--adm-muted); transition: transform 0.2s, background 0.2s; }
|
||||||
|
.adm-switch input:checked + .adm-slider { background: var(--adm-accent); border-color: var(--adm-accent); }
|
||||||
|
.adm-switch input:checked + .adm-slider::before { transform: translateX(22px); background: #000; }
|
||||||
|
.adm-switch input:focus-visible + .adm-slider { outline: 2px solid var(--adm-accent); outline-offset: 2px; }
|
||||||
|
|
||||||
|
.adm-brand { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||||
|
.adm-brand-preview {
|
||||||
|
width: 160px; height: 46px; display: flex; align-items: center; justify-content: center; overflow: hidden;
|
||||||
|
background: rgba(0, 0, 0, 0.3); border: 1px solid var(--adm-border);
|
||||||
|
}
|
||||||
|
.adm-brand-preview img { max-height: 40px; max-width: 150px; object-fit: contain; }
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.adm-btn {
|
||||||
|
display: inline-flex; align-items: center; justify-content: center; gap: 8px; height: 36px; margin: 0; padding: 0 16px;
|
||||||
|
font: inherit; font-size: 0.82em; font-weight: 700; cursor: pointer; text-decoration: none; white-space: nowrap;
|
||||||
|
border: 1px solid var(--adm-border); background: transparent; color: var(--adm-text);
|
||||||
|
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
.adm-btn:hover { text-decoration: none; }
|
||||||
|
.adm-btn-primary { background: var(--adm-accent); border-color: var(--adm-accent); color: #000; text-transform: uppercase; letter-spacing: 0.06em; }
|
||||||
|
.adm-btn-primary:hover { background: transparent; color: var(--adm-accent); }
|
||||||
|
.adm-btn-ghost { color: var(--adm-muted); }
|
||||||
|
.adm-btn-ghost:hover { color: var(--adm-text); border-color: var(--adm-accent); }
|
||||||
|
.adm-btn-danger { border-color: rgba(220, 53, 69, 0.5); color: #ff6b6b; }
|
||||||
|
.adm-btn-danger:hover { background: rgba(220, 53, 69, 0.15); }
|
||||||
|
.adm-btn:disabled { opacity: 0.4; cursor: not-allowed; background: transparent; }
|
||||||
|
.adm-btn:focus-visible { outline: 2px solid var(--adm-accent); outline-offset: 2px; }
|
||||||
|
|
||||||
|
.adm-status { font-size: 0.8em; font-weight: 700; min-height: 1em; }
|
||||||
|
.adm-status-row { display: block; padding: 0 16px 12px; }
|
||||||
|
.adm-status-row:empty { display: none; }
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.adm-row { flex-wrap: wrap; }
|
||||||
|
.adm-tools { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
{{-- Back-to-dashboard bar for admin/mod subpages: /mod… pages (and non-admins) go to /mod, /admin… pages to /admin. Include right after #main opens (AJAX nav only swaps #main). --}}
|
||||||
|
<nav class="adm-back" aria-label="Breadcrumb">
|
||||||
|
@if(session && session.admin && !is_mod_area)
|
||||||
|
<a href="/admin" class="adm-back-link">
|
||||||
|
<span class="adm-back-arrow"><i class="fa-solid fa-arrow-left"></i></span>
|
||||||
|
<span class="adm-back-text"><small>Back to</small>Admin dashboard</span>
|
||||||
|
</a>
|
||||||
|
@else
|
||||||
|
<a href="/mod" class="adm-back-link">
|
||||||
|
<span class="adm-back-arrow"><i class="fa-solid fa-arrow-left"></i></span>
|
||||||
|
<span class="adm-back-text"><small>Back to</small>Moderator dashboard</span>
|
||||||
|
</a>
|
||||||
|
@endif
|
||||||
|
<span class="adm-back-crumb" aria-current="page"></span>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
@include(snippets/admin-skin)
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.adm-back {
|
||||||
|
--ab-accent: var(--accent, #0096ff);
|
||||||
|
display: flex; align-items: center; justify-content: space-between; gap: 12px;
|
||||||
|
max-width: 1200px; margin: 0 auto; padding: 18px 16px 0; box-sizing: border-box;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.adm-back, .adm-back * { border-radius: 0 !important; }
|
||||||
|
.adm-back-link {
|
||||||
|
position: relative; display: inline-flex; align-items: stretch; overflow: hidden;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12); background: rgba(255, 255, 255, 0.03);
|
||||||
|
color: var(--text-color, var(--white, #fff)); text-decoration: none;
|
||||||
|
transition: border-color 0.2s, background 0.2s;
|
||||||
|
}
|
||||||
|
.adm-back-link::after {
|
||||||
|
content: ''; position: absolute; left: 0; bottom: 0; height: 2px; width: 100%;
|
||||||
|
background: var(--ab-accent); transform: scaleX(0); transform-origin: left; transition: transform 0.25s ease;
|
||||||
|
}
|
||||||
|
.adm-back-link:hover, .adm-back-link:focus-visible {
|
||||||
|
border-color: var(--ab-accent); background: color-mix(in srgb, var(--ab-accent) 8%, transparent);
|
||||||
|
color: var(--text-color, var(--white, #fff)); text-decoration: none; outline: none;
|
||||||
|
}
|
||||||
|
.adm-back-link:hover::after, .adm-back-link:focus-visible::after { transform: scaleX(1); }
|
||||||
|
.adm-back-arrow {
|
||||||
|
display: flex; align-items: center; justify-content: center; width: 38px;
|
||||||
|
background: var(--ab-accent); color: #000; font-size: 0.9em;
|
||||||
|
}
|
||||||
|
.adm-back-arrow i { transition: transform 0.2s ease; }
|
||||||
|
.adm-back-link:hover .adm-back-arrow i { transform: translateX(-3px); }
|
||||||
|
.adm-back-text {
|
||||||
|
display: flex; flex-direction: column; justify-content: center; padding: 6px 14px 7px 12px;
|
||||||
|
font-size: 0.86em; font-weight: 700; line-height: 1.2; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.adm-back-text small {
|
||||||
|
font-size: 0.72em; font-weight: 700; text-transform: uppercase; letter-spacing: 0.12em;
|
||||||
|
color: var(--text-muted, #8a8f98);
|
||||||
|
}
|
||||||
|
.adm-back-crumb {
|
||||||
|
min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||||
|
font-size: 0.72em; font-weight: 700; text-transform: uppercase; letter-spacing: 0.14em;
|
||||||
|
color: var(--text-muted, #8a8f98);
|
||||||
|
}
|
||||||
|
.adm-back-crumb:not(:empty)::before { content: '/ '; color: var(--ab-accent); }
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.adm-back-link::after, .adm-back-arrow i { transition: none; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
// Breadcrumb = the page's own heading (first h1/h2 in #main). The rest of the page isn't in the
|
||||||
|
// DOM yet when this runs (parser / AJAX insert), so wait until it is.
|
||||||
|
const fill = () => {
|
||||||
|
const crumb = document.querySelector('#main .adm-back-crumb');
|
||||||
|
const main = document.getElementById('main');
|
||||||
|
if (!crumb || !main) return;
|
||||||
|
const heading = Array.from(main.querySelectorAll('h1, h2')).find(h => !h.closest('.adm-back') && h.textContent.trim());
|
||||||
|
if (heading) crumb.textContent = heading.innerText.trim().slice(0, 60); // innerText already collapses whitespace (no regex: the template engine strips backslashes)
|
||||||
|
};
|
||||||
|
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', fill, { once: true });
|
||||||
|
else setTimeout(fill, 0);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
{{-- Admin/mod subpage skin (style guide: square, flat, theme colours). Loaded by snippets/admin-back, scoped to pages that show the back bar, except the .mq card pages which have their own design. --}}
|
||||||
|
<style>
|
||||||
|
/* Scope root: #main of an admin subpage. Most rules sit in :where() (zero specificity) so a page's own
|
||||||
|
classes still win; only corners and form-control surfaces are forced because pages hard-code them inline. */
|
||||||
|
#main:has(> .adm-back):not(:has(.mq)) {
|
||||||
|
--as-accent: var(--accent, #0096ff);
|
||||||
|
--as-text: var(--text-color, var(--white, #fff));
|
||||||
|
--as-muted: var(--text-muted, #8a8f98);
|
||||||
|
--as-surface: rgba(255, 255, 255, 0.04);
|
||||||
|
--as-border: rgba(255, 255, 255, 0.1);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Corners: none, anywhere ─────────────────────────────────────────── */
|
||||||
|
#main:has(> .adm-back):not(:has(.mq)) *,
|
||||||
|
#main:has(> .adm-back):not(:has(.mq)) *::before,
|
||||||
|
#main:has(> .adm-back):not(:has(.mq)) *::after { border-radius: 0 !important; }
|
||||||
|
|
||||||
|
/* ── Layout ──────────────────────────────────────────────────────────── */
|
||||||
|
:where(#main:has(> .adm-back):not(:has(.mq))) > .container {
|
||||||
|
max-width: 1200px; margin: 0 auto; padding: 20px 16px 60px; box-sizing: border-box; text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Headings ────────────────────────────────────────────────────────── */
|
||||||
|
:where(#main:has(> .adm-back):not(:has(.mq))) :is(.container > h1, .container > h2, .admin-header-flex h2, .session-page-title) {
|
||||||
|
margin: 0 0 6px; font-size: 1.8em; font-weight: 800; letter-spacing: -0.01em; line-height: 1.15; color: var(--as-text);
|
||||||
|
text-transform: none;
|
||||||
|
}
|
||||||
|
:where(#main:has(> .adm-back):not(:has(.mq))) :is(h3, h4) { font-weight: 700; color: var(--as-text); }
|
||||||
|
:where(#main:has(> .adm-back):not(:has(.mq))) .admin-header-flex {
|
||||||
|
display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; flex-wrap: wrap;
|
||||||
|
padding-bottom: 16px; margin-bottom: 20px; border-bottom: 1px solid var(--as-border);
|
||||||
|
}
|
||||||
|
:where(#main:has(> .adm-back):not(:has(.mq))) hr { border: 0; border-top: 1px solid var(--as-border); margin: 20px 0; }
|
||||||
|
:where(#main:has(> .adm-back):not(:has(.mq))) label { color: var(--as-text); }
|
||||||
|
|
||||||
|
/* ── Cards / panels ──────────────────────────────────────────────────── */
|
||||||
|
:where(#main:has(> .adm-back):not(:has(.mq))) :is(.upload-form, .admin-form-container, .admin-motd-form, .settings-section, .admin-card) {
|
||||||
|
background: var(--as-surface); border: 1px solid var(--as-border); padding: 18px; box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Tables ──────────────────────────────────────────────────────────── */
|
||||||
|
:where(#main:has(> .adm-back):not(:has(.mq))) table { width: 100%; border-collapse: collapse; background: transparent; color: var(--as-text); }
|
||||||
|
:where(#main:has(> .adm-back):not(:has(.mq))) :is(th, thead td) {
|
||||||
|
padding: 10px 12px; text-align: left; font-size: 0.72em; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em;
|
||||||
|
color: var(--as-muted); background: var(--as-surface); border-bottom: 1px solid var(--as-border);
|
||||||
|
}
|
||||||
|
:where(#main:has(> .adm-back):not(:has(.mq))) tbody td { padding: 10px 12px; border-bottom: 1px solid var(--as-border); vertical-align: middle; }
|
||||||
|
:where(#main:has(> .adm-back):not(:has(.mq))) tbody tr { background: transparent; transition: background 0.12s; }
|
||||||
|
:where(#main:has(> .adm-back):not(:has(.mq))) tbody tr:hover { background: var(--as-surface); }
|
||||||
|
|
||||||
|
/* ── Form controls (forced: pages hard-code #333 backgrounds inline) ── */
|
||||||
|
#main:has(> .adm-back):not(:has(.mq)) :is(input:not([type=checkbox]):not([type=radio]):not([type=file]):not([type=range]):not([type=color]):not([type=hidden]), select, textarea) {
|
||||||
|
background: var(--as-surface) !important;
|
||||||
|
border: 1px solid var(--as-border) !important;
|
||||||
|
color: var(--as-text) !important;
|
||||||
|
font-family: inherit; box-shadow: none !important; outline: none;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
:where(#main:has(> .adm-back):not(:has(.mq))) :is(input:not([type=checkbox]):not([type=radio]):not([type=file]):not([type=range]):not([type=color]):not([type=hidden]), select) {
|
||||||
|
min-height: 34px; padding: 6px 10px; box-sizing: border-box;
|
||||||
|
}
|
||||||
|
:where(#main:has(> .adm-back):not(:has(.mq))) textarea { padding: 10px 12px; box-sizing: border-box; }
|
||||||
|
#main:has(> .adm-back):not(:has(.mq)) :is(input, select, textarea):focus { border-color: var(--as-accent) !important; }
|
||||||
|
#main:has(> .adm-back):not(:has(.mq)) select option { background: var(--bg, #111); color: var(--as-text); }
|
||||||
|
:where(#main:has(> .adm-back):not(:has(.mq))) :is(input[type=checkbox], input[type=radio]) { accent-color: var(--as-accent); }
|
||||||
|
|
||||||
|
/* ── Buttons ─────────────────────────────────────────────────────────── */
|
||||||
|
:where(#main:has(> .adm-back):not(:has(.mq))) :is(button, .btn, input[type=submit]) { font-family: inherit; cursor: pointer; }
|
||||||
|
:where(#main:has(> .adm-back):not(:has(.mq))) :is(.btn-upload, .btn-primary, button[type=submit], input[type=submit]) {
|
||||||
|
min-height: 36px; padding: 0 18px; border: 1px solid var(--as-accent); background: var(--as-accent); color: #000;
|
||||||
|
font-weight: 800; text-transform: uppercase; letter-spacing: 0.06em; font-size: 0.82em;
|
||||||
|
transition: background 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
:where(#main:has(> .adm-back):not(:has(.mq))) :is(.btn-upload, .btn-primary, button[type=submit], input[type=submit]):hover:not(:disabled) {
|
||||||
|
background: transparent; color: var(--as-accent);
|
||||||
|
}
|
||||||
|
:where(#main:has(> .adm-back):not(:has(.mq))) :is(button, .btn):disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
#main:has(> .adm-back):not(:has(.mq)) :is(button, a, input, select, textarea):focus-visible { outline: 2px solid var(--as-accent); outline-offset: 2px; }
|
||||||
|
|
||||||
|
/* ── Modals opened from admin pages ──────────────────────────────────── */
|
||||||
|
#main:has(> .adm-back):not(:has(.mq)) :is(.modal-content, .modal-overlay > div:first-child) {
|
||||||
|
background: var(--bg, #111) !important;
|
||||||
|
border: 1px solid var(--as-accent) !important;
|
||||||
|
box-shadow: 0 0 20px rgba(0, 0, 0, 0.5) !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
+126
-22
@@ -68,36 +68,45 @@
|
|||||||
@endif
|
@endif
|
||||||
@if(!private_society || session)
|
@if(!private_society || session)
|
||||||
<div id="report-modal" class="modal-overlay" style="display:none;">
|
<div id="report-modal" class="modal-overlay" style="display:none;">
|
||||||
<div class="modal-content" style="max-height: 90vh; overflow-y: auto;">
|
<div class="modal-content report-modal-content">
|
||||||
<h3>{{ t('report.title') }}</h3>
|
<div class="rm-header">
|
||||||
<p style="margin-bottom: 12px; color: var(--text-muted, #aaa); font-size: 0.9em;">Select one or more reasons and optionally add details below.</p>
|
<h3>{{ t('report.title') }}</h3>
|
||||||
|
<p>Select one or more reasons and optionally add details below.</p>
|
||||||
|
</div>
|
||||||
|
<div class="rm-body">
|
||||||
<input type="hidden" id="report-item-id">
|
<input type="hidden" id="report-item-id">
|
||||||
<input type="hidden" id="report-comment-id">
|
<input type="hidden" id="report-comment-id">
|
||||||
<input type="hidden" id="report-user-id">
|
<input type="hidden" id="report-user-id">
|
||||||
<div id="report-categories" style="display: flex; flex-direction: column; gap: 8px; margin-bottom: 14px;">
|
<div id="report-categories">
|
||||||
<label class="report-category-option" style="display: flex; align-items: center; gap: 10px; padding: 9px 12px; border: 1px solid var(--nav-border-color, #444); border-radius: 6px; cursor: pointer; background: var(--bg-secondary, rgba(255,255,255,0.03)); transition: border-color 0.15s, background 0.15s; user-select: none;">
|
<label class="report-category-option">
|
||||||
<input type="checkbox" name="report_category" value="wrong_rating" class="report-cat-check" style="accent-color: var(--accent, #e65c00); width: 16px; height: 16px; cursor: pointer; flex-shrink: 0;">
|
<input type="checkbox" name="report_category" value="wrong_rating" class="report-cat-check">
|
||||||
<span style="font-size: 0.9em;"><strong style="color: var(--text-color, #fff);">Wrong Rating</strong> <span style="color: var(--text-muted, #aaa);">— Content is marked SFW but should be NSFW, or vice versa.</span></span>
|
<span class="rm-check"><i class="fa-solid fa-check"></i></span>
|
||||||
|
<span class="rm-text"><strong>Wrong Rating</strong><span>Content is marked SFW but should be NSFW, or vice versa.</span></span>
|
||||||
</label>
|
</label>
|
||||||
<label class="report-category-option" style="display: flex; align-items: center; gap: 10px; padding: 9px 12px; border: 1px solid var(--nav-border-color, #444); border-radius: 6px; cursor: pointer; background: var(--bg-secondary, rgba(255,255,255,0.03)); transition: border-color 0.15s, background 0.15s; user-select: none;">
|
<label class="report-category-option">
|
||||||
<input type="checkbox" name="report_category" value="spam" class="report-cat-check" style="accent-color: var(--accent, #e65c00); width: 16px; height: 16px; cursor: pointer; flex-shrink: 0;">
|
<input type="checkbox" name="report_category" value="spam" class="report-cat-check">
|
||||||
<span style="font-size: 0.9em;"><strong style="color: var(--text-color, #fff);">Spam</strong> <span style="color: var(--text-muted, #aaa);">— Repeated, unwanted, or promotional content.</span></span>
|
<span class="rm-check"><i class="fa-solid fa-check"></i></span>
|
||||||
|
<span class="rm-text"><strong>Spam</strong><span>Repeated, unwanted, or promotional content.</span></span>
|
||||||
</label>
|
</label>
|
||||||
<label class="report-category-option" style="display: flex; align-items: center; gap: 10px; padding: 9px 12px; border: 1px solid var(--nav-border-color, #444); border-radius: 6px; cursor: pointer; background: var(--bg-secondary, rgba(255,255,255,0.03)); transition: border-color 0.15s, background 0.15s; user-select: none;">
|
<label class="report-category-option">
|
||||||
<input type="checkbox" name="report_category" value="duplicate" class="report-cat-check" style="accent-color: var(--accent, #e65c00); width: 16px; height: 16px; cursor: pointer; flex-shrink: 0;">
|
<input type="checkbox" name="report_category" value="duplicate" class="report-cat-check">
|
||||||
<span style="font-size: 0.9em;"><strong style="color: var(--text-color, #fff);">Duplicate</strong> <span style="color: var(--text-muted, #aaa);">— This content has already been posted before.</span></span>
|
<span class="rm-check"><i class="fa-solid fa-check"></i></span>
|
||||||
|
<span class="rm-text"><strong>Duplicate</strong><span>This content has already been posted before.</span></span>
|
||||||
</label>
|
</label>
|
||||||
<label class="report-category-option" style="display: flex; align-items: center; gap: 10px; padding: 9px 12px; border: 1px solid var(--nav-border-color, #444); border-radius: 6px; cursor: pointer; background: var(--bg-secondary, rgba(255,255,255,0.03)); transition: border-color 0.15s, background 0.15s; user-select: none;">
|
<label class="report-category-option">
|
||||||
<input type="checkbox" name="report_category" value="copyright" class="report-cat-check" style="accent-color: var(--accent, #e65c00); width: 16px; height: 16px; cursor: pointer; flex-shrink: 0;">
|
<input type="checkbox" name="report_category" value="copyright" class="report-cat-check">
|
||||||
<span style="font-size: 0.9em;"><strong style="color: var(--text-color, #fff);">Copyright</strong> <span style="color: var(--text-muted, #aaa);">— Content infringes on intellectual property rights.</span></span>
|
<span class="rm-check"><i class="fa-solid fa-check"></i></span>
|
||||||
|
<span class="rm-text"><strong>Copyright</strong><span>Content infringes on intellectual property rights.</span></span>
|
||||||
</label>
|
</label>
|
||||||
<label class="report-category-option" style="display: flex; align-items: center; gap: 10px; padding: 9px 12px; border: 1px solid var(--nav-border-color, #444); border-radius: 6px; cursor: pointer; background: var(--bg-secondary, rgba(255,255,255,0.03)); transition: border-color 0.15s, background 0.15s; user-select: none;">
|
<label class="report-category-option rm-danger">
|
||||||
<input type="checkbox" name="report_category" value="illegal" class="report-cat-check" style="accent-color: var(--accent, #e65c00); width: 16px; height: 16px; cursor: pointer; flex-shrink: 0;">
|
<input type="checkbox" name="report_category" value="illegal" class="report-cat-check">
|
||||||
<span style="font-size: 0.9em;"><strong style="color: var(--text-color, #fff); color: var(--danger, #ff4444);">Illegal Content</strong> <span style="color: var(--text-muted, #aaa);">— Content that may violate laws or platform rules.</span></span>
|
<span class="rm-check"><i class="fa-solid fa-check"></i></span>
|
||||||
|
<span class="rm-text"><strong>Illegal Content</strong><span>Content that may violate laws or platform rules.</span></span>
|
||||||
</label>
|
</label>
|
||||||
<label class="report-category-option" style="display: flex; align-items: center; gap: 10px; padding: 9px 12px; border: 1px solid var(--nav-border-color, #444); border-radius: 6px; cursor: pointer; background: var(--bg-secondary, rgba(255,255,255,0.03)); transition: border-color 0.15s, background 0.15s; user-select: none;">
|
<label class="report-category-option">
|
||||||
<input type="checkbox" name="report_category" value="other" class="report-cat-check" style="accent-color: var(--accent, #e65c00); width: 16px; height: 16px; cursor: pointer; flex-shrink: 0;">
|
<input type="checkbox" name="report_category" value="other" class="report-cat-check">
|
||||||
<span style="font-size: 0.9em;"><strong style="color: var(--text-color, #fff);">Other</strong> <span style="color: var(--text-muted, #aaa);">— Something else not listed above.</span></span>
|
<span class="rm-check"><i class="fa-solid fa-check"></i></span>
|
||||||
|
<span class="rm-text"><strong>Other</strong><span>Something else not listed above.</span></span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<textarea id="report-reason" class="mod-reason" placeholder="{{ t('report.placeholder') }}"></textarea>
|
<textarea id="report-reason" class="mod-reason" placeholder="{{ t('report.placeholder') }}"></textarea>
|
||||||
@@ -105,6 +114,7 @@
|
|||||||
<div id="modal-report-recaptcha" data-sitekey="{{ recaptcha_site_key }}" style="margin: 10px 0; display: flex; justify-content: center;"></div>
|
<div id="modal-report-recaptcha" data-sitekey="{{ recaptcha_site_key }}" style="margin: 10px 0; display: flex; justify-content: center;"></div>
|
||||||
@endif
|
@endif
|
||||||
<div id="report-error" class="error-msg"></div>
|
<div id="report-error" class="error-msg"></div>
|
||||||
|
</div>
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button id="report-cancel" class="btn-secondary">{{ t('common.cancel') }}</button>
|
<button id="report-cancel" class="btn-secondary">{{ t('common.cancel') }}</button>
|
||||||
<button id="report-submit" class="">{{ t('report.submit') }}</button>
|
<button id="report-submit" class="">{{ t('report.submit') }}</button>
|
||||||
@@ -112,6 +122,100 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Report modal — same style rules as the login/info modals: square, flat, accent primary button */
|
||||||
|
#report-modal .report-modal-content {
|
||||||
|
--rm-accent: var(--accent, #0096ff);
|
||||||
|
--rm-text: var(--text-color, #fff);
|
||||||
|
--rm-muted: var(--text-muted, #8a8f98);
|
||||||
|
--rm-surface: rgba(255, 255, 255, 0.04);
|
||||||
|
--rm-border: rgba(255, 255, 255, 0.1);
|
||||||
|
width: min(460px, 92vw);
|
||||||
|
max-height: min(90vh, 90dvh);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 0;
|
||||||
|
text-align: left;
|
||||||
|
border-radius: 0 !important;
|
||||||
|
border: 1px solid var(--accent) !important;
|
||||||
|
background: var(--bg) !important;
|
||||||
|
box-shadow: 0 0 20px rgba(0, 0, 0, 0.5) !important;
|
||||||
|
color: var(--rm-text);
|
||||||
|
}
|
||||||
|
#report-modal .report-modal-content * { border-radius: 0 !important; }
|
||||||
|
#report-modal .rm-header { flex-shrink: 0; padding: 20px 24px 14px; border-bottom: 1px solid var(--rm-border); }
|
||||||
|
#report-modal .rm-body { flex: 1 1 auto; min-height: 0; overflow-y: auto; overscroll-behavior: contain; padding: 16px 24px; }
|
||||||
|
#report-modal .rm-body > :last-child { margin-bottom: 0; }
|
||||||
|
#report-modal .rm-header h3 { margin: 0; font-size: 1.25em; font-weight: 700; color: var(--rm-text); }
|
||||||
|
#report-modal .rm-header p { margin: 6px 0 0; font-size: 0.85em; line-height: 1.5; color: var(--rm-muted); }
|
||||||
|
|
||||||
|
#report-modal #report-categories { display: flex; flex-direction: column; gap: 6px; margin-bottom: 14px; }
|
||||||
|
#report-modal .report-category-option {
|
||||||
|
display: flex; align-items: flex-start; gap: 11px; padding: 10px 12px;
|
||||||
|
background: var(--rm-surface); border: 1px solid var(--rm-border);
|
||||||
|
cursor: pointer; user-select: none; transition: border-color 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
#report-modal .report-category-option:hover { border-color: rgba(255, 255, 255, 0.22); }
|
||||||
|
#report-modal .report-category-option:has(.report-cat-check:checked) {
|
||||||
|
border-color: var(--rm-accent);
|
||||||
|
background: color-mix(in srgb, var(--rm-accent) 8%, transparent);
|
||||||
|
}
|
||||||
|
#report-modal .report-cat-check { position: absolute; opacity: 0; width: 0; height: 0; margin: 0; }
|
||||||
|
#report-modal .rm-check {
|
||||||
|
width: 18px; height: 18px; flex-shrink: 0; margin-top: 1px;
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.25); background: var(--rm-surface);
|
||||||
|
font-size: 0.7em; color: transparent; transition: background 0.15s, border-color 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
#report-modal .report-cat-check:checked + .rm-check { background: var(--rm-accent); border-color: var(--rm-accent); color: #000; }
|
||||||
|
#report-modal .report-cat-check:focus-visible + .rm-check { outline: 2px solid var(--rm-accent); outline-offset: 2px; }
|
||||||
|
#report-modal .rm-text { display: flex; flex-direction: column; gap: 2px; font-size: 0.88em; line-height: 1.4; }
|
||||||
|
#report-modal .rm-text strong { color: var(--rm-text); font-weight: 700; }
|
||||||
|
#report-modal .rm-text span { color: var(--rm-muted); }
|
||||||
|
#report-modal .rm-danger .rm-text strong { color: var(--danger, #ff5c5c); }
|
||||||
|
#report-modal .rm-danger:has(.report-cat-check:checked) { border-color: var(--danger, #ff5c5c); background: rgba(255, 92, 92, 0.08); }
|
||||||
|
#report-modal .rm-danger .report-cat-check:checked + .rm-check { background: var(--danger, #ff5c5c); border-color: var(--danger, #ff5c5c); }
|
||||||
|
|
||||||
|
#report-modal textarea.mod-reason {
|
||||||
|
width: 100%; min-height: 90px; box-sizing: border-box; margin: 0; padding: 10px 12px;
|
||||||
|
background: var(--rm-surface); border: 1px solid var(--rm-border); color: var(--rm-text);
|
||||||
|
font: inherit; font-size: 0.9em; resize: vertical; outline: none; box-shadow: none;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
#report-modal textarea.mod-reason:focus { border-color: var(--rm-accent); }
|
||||||
|
#report-modal textarea.mod-reason::placeholder { color: var(--rm-muted); }
|
||||||
|
|
||||||
|
#report-modal .modal-actions {
|
||||||
|
flex-shrink: 0; display: flex; justify-content: flex-end; gap: 8px; margin: 0;
|
||||||
|
padding: 14px 24px; border-top: 1px solid var(--rm-border); background: rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
#report-modal { padding: 0 !important; align-items: stretch !important; }
|
||||||
|
#report-modal .report-modal-content {
|
||||||
|
width: 100vw; max-width: none;
|
||||||
|
height: 100vh; height: 100dvh; max-height: none;
|
||||||
|
border: 0 !important; box-shadow: none !important;
|
||||||
|
}
|
||||||
|
#report-modal .modal-actions { padding-bottom: calc(14px + env(safe-area-inset-bottom, 0px)); }
|
||||||
|
#report-modal .modal-actions button { flex: 1; }
|
||||||
|
}
|
||||||
|
#report-modal .modal-actions button {
|
||||||
|
height: 40px; margin: 0; padding: 0 18px; font: inherit; font-size: 0.85em; cursor: pointer;
|
||||||
|
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
|
||||||
|
transition: background 0.18s, color 0.18s, border-color 0.18s;
|
||||||
|
}
|
||||||
|
#report-modal #report-cancel { background: transparent; border: 1px solid var(--rm-border); color: var(--rm-muted); font-weight: 600; }
|
||||||
|
#report-modal #report-cancel:hover { color: var(--rm-text); border-color: rgba(255, 255, 255, 0.25); background: var(--rm-surface); }
|
||||||
|
#report-modal #report-submit {
|
||||||
|
background: var(--rm-accent); border: 1px solid var(--rm-accent); color: #000;
|
||||||
|
font-weight: 800; text-transform: uppercase; letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
#report-modal #report-submit:hover:not(:disabled) { background: transparent; color: var(--rm-accent); }
|
||||||
|
#report-modal #report-submit:disabled { opacity: 0.6; cursor: progress; }
|
||||||
|
#report-modal button:focus-visible { outline: 2px solid var(--rm-accent); outline-offset: 2px; }
|
||||||
|
</style>
|
||||||
|
|
||||||
<div id="warning-modal" class="modal-overlay" style="display:none; z-index: 100100;">
|
<div id="warning-modal" class="modal-overlay" style="display:none; z-index: 100100;">
|
||||||
<div class="modal-content content-warning-content">
|
<div class="modal-content content-warning-content">
|
||||||
<h3 style="color: var(--danger);">{{ t('account_warning.title') }}</h3>
|
<h3 style="color: var(--danger);">{{ t('account_warning.title') }}</h3>
|
||||||
|
|||||||
@@ -107,9 +107,15 @@
|
|||||||
<div class="infobox-card" id="info-card-directlink">
|
<div class="infobox-card" id="info-card-directlink">
|
||||||
<label class="infobox-card-label"><i class="fa-solid fa-arrow-up-right-from-square"></i> Direct Link</label>
|
<label class="infobox-card-label"><i class="fa-solid fa-arrow-up-right-from-square"></i> Direct Link</label>
|
||||||
<div class="infobox-card-value">
|
<div class="infobox-card-value">
|
||||||
|
@if(item.external_thread_url)
|
||||||
|
<a href="{{ item.external_thread_url }}" target="_blank" rel="noopener noreferrer" class="infobox-link-pill" id="info-file-direct-link" title="{{ item.external_thread_url }}">
|
||||||
|
<i class="fa-solid fa-up-right-from-square"></i> {{ t('info_modal.view_file') || 'Open File' }}
|
||||||
|
</a>
|
||||||
|
@else
|
||||||
<a href="{{ item.mime === 'video/youtube' ? 'https://www.youtube.com/watch?v=' + item.dest.replace('yt:', '') : item.dest }}" target="_blank" class="infobox-link-pill" id="info-file-direct-link">
|
<a href="{{ item.mime === 'video/youtube' ? 'https://www.youtube.com/watch?v=' + item.dest.replace('yt:', '') : item.dest }}" target="_blank" class="infobox-link-pill" id="info-file-direct-link">
|
||||||
<i class="fa-solid fa-up-right-from-square"></i> {{ t('info_modal.view_file') || 'Open File' }}
|
<i class="fa-solid fa-up-right-from-square"></i> {{ t('info_modal.view_file') || 'Open File' }}
|
||||||
</a>
|
</a>
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -167,3 +173,195 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Info modal — same style rules as the login/register/anon modals: square, flat, no glow */
|
||||||
|
#info-modal .infobox-modal-content {
|
||||||
|
--im-accent: var(--accent, #0096ff);
|
||||||
|
--im-text: var(--text-color, #fff);
|
||||||
|
--im-muted: var(--text-muted, #8a8f98);
|
||||||
|
--im-surface: rgba(255, 255, 255, 0.04);
|
||||||
|
--im-border: rgba(255, 255, 255, 0.1);
|
||||||
|
padding: 0;
|
||||||
|
/* Same box as .login-modal-content (f0ckm.css); !important beats the older infobox rules */
|
||||||
|
border-radius: 0 !important;
|
||||||
|
border: 1px solid var(--accent) !important;
|
||||||
|
background: var(--bg) !important;
|
||||||
|
box-shadow: 0 0 20px rgba(0, 0, 0, 0.5) !important;
|
||||||
|
color: var(--im-text);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
#info-modal .infobox-modal-content * { border-radius: 0 !important; }
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
#info-modal .infobox-modal-content .infobox-modal-header {
|
||||||
|
display: flex; align-items: flex-start; justify-content: space-between; gap: 16px;
|
||||||
|
margin: 0; padding: 22px 24px 18px;
|
||||||
|
background: none;
|
||||||
|
border-bottom: 1px solid var(--im-border);
|
||||||
|
}
|
||||||
|
#info-modal .infobox-modal-content .infobox-modal-header h3 {
|
||||||
|
margin: 0; font-size: 1.25em; font-weight: 700; letter-spacing: -0.01em; color: var(--im-text);
|
||||||
|
}
|
||||||
|
#info-modal .infobox-modal-content .infobox-sub-heading {
|
||||||
|
display: block; margin-top: 5px; font-size: 0.8em; color: var(--im-muted);
|
||||||
|
}
|
||||||
|
#info-modal .infobox-modal-content .infobox-sub-heading code {
|
||||||
|
padding: 1px 6px; background: var(--im-surface); border: 1px solid var(--im-border);
|
||||||
|
color: var(--im-accent); font-size: 1em;
|
||||||
|
}
|
||||||
|
#info-modal .infobox-modal-content .infobox-close-icon-btn {
|
||||||
|
flex-shrink: 0; width: 34px; height: 34px; margin: -4px -8px 0 0; padding: 0;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
background: transparent; border: 1px solid transparent;
|
||||||
|
color: var(--im-muted); font-size: 1.05em; cursor: pointer;
|
||||||
|
transition: color 0.15s, background 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
#info-modal .infobox-modal-content .infobox-close-icon-btn:hover {
|
||||||
|
color: var(--im-text); background: var(--im-surface); border-color: var(--im-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Body + sections */
|
||||||
|
#info-modal .infobox-modal-content .infobox-modal-body {
|
||||||
|
padding: 20px 24px; display: flex; flex-direction: column; gap: 22px;
|
||||||
|
}
|
||||||
|
#info-modal .infobox-modal-content .infobox-section { margin: 0; padding: 0; background: none; border: 0; }
|
||||||
|
#info-modal .infobox-modal-content .infobox-section-header {
|
||||||
|
display: flex; align-items: center; gap: 8px; margin: 0 0 10px; padding: 0;
|
||||||
|
font-size: 0.72em; font-weight: 700; text-transform: uppercase; letter-spacing: 0.12em;
|
||||||
|
color: var(--im-muted); background: none; border: 0;
|
||||||
|
}
|
||||||
|
#info-modal .infobox-modal-content .infobox-section-header i { color: var(--im-accent); }
|
||||||
|
#info-modal .infobox-modal-content .infobox-section-header::after {
|
||||||
|
content: ''; flex: 1; height: 1px; background: var(--im-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cards */
|
||||||
|
#info-modal .infobox-modal-content .infobox-cards-grid {
|
||||||
|
display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 8px;
|
||||||
|
}
|
||||||
|
#info-modal .infobox-modal-content .infobox-card {
|
||||||
|
margin: 0; padding: 12px 14px;
|
||||||
|
background: var(--im-surface); border: 1px solid var(--im-border); box-shadow: none;
|
||||||
|
display: flex; flex-direction: column; gap: 8px; min-width: 0;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
#info-modal .infobox-modal-content .infobox-card:hover { border-color: rgba(255, 255, 255, 0.18); }
|
||||||
|
#info-modal .infobox-modal-content .infobox-card.full-width { grid-column: 1 / -1; }
|
||||||
|
#info-modal .infobox-modal-content .infobox-card-label {
|
||||||
|
display: flex; align-items: center; gap: 6px; margin: 0;
|
||||||
|
font-size: 0.7em; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: var(--im-muted);
|
||||||
|
}
|
||||||
|
#info-modal .infobox-modal-content .infobox-card-label i { color: var(--im-accent); opacity: 0.85; width: 1em; text-align: center; }
|
||||||
|
#info-modal .infobox-modal-content .infobox-card-label-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||||
|
#info-modal .infobox-modal-content .infobox-card-value { font-size: 0.95em; color: var(--im-text); overflow-wrap: anywhere; }
|
||||||
|
#info-modal .infobox-modal-content .infobox-card-value-row,
|
||||||
|
#info-modal .infobox-modal-content .infobox-expiry-content {
|
||||||
|
display: flex; align-items: center; justify-content: space-between; gap: 8px; flex-wrap: wrap; font-size: 0.92em;
|
||||||
|
}
|
||||||
|
#info-modal .infobox-modal-content .info-expiry-edit-controls { display: flex; gap: 6px; width: 100%; }
|
||||||
|
|
||||||
|
/* Small controls inside cards */
|
||||||
|
#info-modal .infobox-modal-content .infobox-action-btn,
|
||||||
|
#info-modal .infobox-modal-content .copy-hash-btn {
|
||||||
|
height: 28px; margin: 0; padding: 0 10px;
|
||||||
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
|
background: transparent; border: 1px solid var(--im-border);
|
||||||
|
color: var(--im-muted); font: inherit; font-size: 0.78em; font-weight: 600; cursor: pointer;
|
||||||
|
transition: color 0.15s, border-color 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
#info-modal .infobox-modal-content .infobox-action-btn:hover,
|
||||||
|
#info-modal .infobox-modal-content .copy-hash-btn:hover {
|
||||||
|
color: var(--im-accent); border-color: var(--im-accent); background: transparent;
|
||||||
|
}
|
||||||
|
#info-modal .infobox-modal-content .infobox-select {
|
||||||
|
flex: 1; min-width: 0; height: 28px; margin: 0; padding: 0 8px;
|
||||||
|
background: rgba(0, 0, 0, 0.35); border: 1px solid var(--im-border);
|
||||||
|
color: var(--im-text); font: inherit; font-size: 0.82em; outline: none;
|
||||||
|
}
|
||||||
|
#info-modal .infobox-modal-content .infobox-select:focus { border-color: var(--im-accent); }
|
||||||
|
|
||||||
|
#info-modal .infobox-modal-content .mime-pill {
|
||||||
|
display: inline-block; padding: 2px 8px;
|
||||||
|
background: rgba(0, 0, 0, 0.35); border: 1px solid var(--im-border);
|
||||||
|
color: var(--im-text); font-size: 0.85em;
|
||||||
|
}
|
||||||
|
#info-modal .infobox-modal-content .infobox-link-pill,
|
||||||
|
#info-modal .infobox-modal-content .infobox-source-link {
|
||||||
|
display: inline-flex; align-items: center; gap: 7px; max-width: 100%;
|
||||||
|
color: var(--im-accent); text-decoration: none; font-weight: 600; font-size: 0.92em;
|
||||||
|
background: none; border: 0; padding: 0;
|
||||||
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||||
|
}
|
||||||
|
#info-modal .infobox-modal-content .infobox-link-pill:hover,
|
||||||
|
#info-modal .infobox-modal-content .infobox-source-link:hover { text-decoration: underline; text-underline-offset: 3px; }
|
||||||
|
#info-modal .infobox-modal-content .info-hash-codeblock {
|
||||||
|
margin: 0; padding: 10px 12px;
|
||||||
|
background: rgba(0, 0, 0, 0.4); border: 1px solid var(--im-border); border-left: 2px solid var(--im-accent);
|
||||||
|
font-size: 0.8em; line-height: 1.5; color: var(--im-text); word-break: break-all;
|
||||||
|
}
|
||||||
|
#info-modal .infobox-modal-content .info-hash-codeblock code { background: none; border: 0; padding: 0; color: inherit; }
|
||||||
|
|
||||||
|
/* Reposts */
|
||||||
|
#info-modal .infobox-modal-content .infobox-reposts-list { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||||
|
#info-modal .infobox-modal-content .repost-pill {
|
||||||
|
display: inline-flex; align-items: center; gap: 6px; padding: 5px 10px;
|
||||||
|
background: var(--im-surface); border: 1px solid var(--im-border);
|
||||||
|
color: var(--im-text); font-size: 0.82em; text-decoration: none;
|
||||||
|
transition: border-color 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
#info-modal .infobox-modal-content .repost-pill:hover { border-color: var(--im-accent); color: var(--im-accent); }
|
||||||
|
#info-modal .infobox-modal-content .repost-pill.exact i { color: var(--im-accent); }
|
||||||
|
|
||||||
|
/* Footer */
|
||||||
|
#info-modal .infobox-modal-content .infobox-modal-footer {
|
||||||
|
display: flex; justify-content: flex-end; align-items: center; gap: 8px; flex-wrap: wrap;
|
||||||
|
margin: 0; padding: 16px 24px;
|
||||||
|
background: rgba(0, 0, 0, 0.25); border-top: 1px solid var(--im-border);
|
||||||
|
}
|
||||||
|
#info-modal .infobox-modal-content .btn-infobox-action,
|
||||||
|
#info-modal .infobox-modal-content .btn-infobox-close {
|
||||||
|
height: 40px; margin: 0; padding: 0 18px;
|
||||||
|
display: inline-flex; align-items: center; justify-content: center; gap: 8px;
|
||||||
|
font: inherit; font-size: 0.85em; cursor: pointer; box-shadow: none;
|
||||||
|
transition: background 0.18s, color 0.18s, border-color 0.18s;
|
||||||
|
}
|
||||||
|
#info-modal .infobox-modal-content .btn-infobox-action {
|
||||||
|
background: transparent; border: 1px solid var(--im-border); color: var(--im-muted); font-weight: 600;
|
||||||
|
}
|
||||||
|
#info-modal .infobox-modal-content .btn-infobox-action:hover { color: var(--im-text); border-color: rgba(255, 255, 255, 0.25); background: var(--im-surface); }
|
||||||
|
#info-modal .infobox-modal-content .btn-infobox-close {
|
||||||
|
background: var(--im-accent); border: 1px solid var(--im-accent); color: #000;
|
||||||
|
font-weight: 800; text-transform: uppercase; letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
#info-modal .infobox-modal-content .btn-infobox-close:hover { background: transparent; color: var(--im-accent); }
|
||||||
|
#info-modal .infobox-modal-content :is(button, a, select):focus-visible { outline: 2px solid var(--im-accent); outline-offset: 2px; }
|
||||||
|
|
||||||
|
/* Fixed header + footer, only the body scrolls; full screen on phones */
|
||||||
|
#info-modal .infobox-modal-content {
|
||||||
|
display: flex !important;
|
||||||
|
flex-direction: column !important;
|
||||||
|
max-height: min(90vh, 90dvh) !important;
|
||||||
|
}
|
||||||
|
#info-modal .infobox-modal-content .infobox-modal-header,
|
||||||
|
#info-modal .infobox-modal-content .infobox-modal-footer { flex-shrink: 0; }
|
||||||
|
#info-modal .infobox-modal-content .infobox-modal-body {
|
||||||
|
flex: 1 1 auto; min-height: 0; max-height: none; overflow-y: auto; overscroll-behavior: contain;
|
||||||
|
}
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
#info-modal { padding: 0 !important; align-items: stretch !important; }
|
||||||
|
#info-modal .infobox-modal-content {
|
||||||
|
width: 100vw !important; max-width: none !important;
|
||||||
|
height: 100vh !important; height: 100dvh !important; max-height: none !important;
|
||||||
|
border: 0 !important; box-shadow: none !important;
|
||||||
|
}
|
||||||
|
#info-modal .infobox-modal-content .infobox-modal-footer { padding-bottom: calc(16px + env(safe-area-inset-bottom, 0px)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 520px) {
|
||||||
|
#info-modal .infobox-modal-content .infobox-modal-header,
|
||||||
|
#info-modal .infobox-modal-content .infobox-modal-body,
|
||||||
|
#info-modal .infobox-modal-content .infobox-modal-footer { padding-left: 16px; padding-right: 16px; }
|
||||||
|
#info-modal .infobox-modal-content .infobox-modal-footer > * { flex: 1; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,205 @@
|
|||||||
|
{{-- Shared card UI for mod/approve.html and mod/trash.html: styles, confirmation modal, rating toggle and helpers on window.mq (page actions live in each page) --}}
|
||||||
|
<div id="mq-modal" class="mq-modal" style="display:none;">
|
||||||
|
<div class="mq-modal-box">
|
||||||
|
<h3 id="mq-modal-title">Confirm action</h3>
|
||||||
|
<p id="mq-modal-text">Are you sure?</p>
|
||||||
|
<div id="mq-modal-reason-wrap" style="display:none;">
|
||||||
|
<label for="mq-modal-reason">Reason (required)</label>
|
||||||
|
<textarea id="mq-modal-reason" rows="3" placeholder="Enter a reason…"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="mq-modal-actions">
|
||||||
|
<button type="button" id="mq-modal-cancel" class="mq-btn mq-btn-ghost">Cancel</button>
|
||||||
|
<button type="button" id="mq-modal-confirm" class="mq-btn mq-btn-danger">Confirm</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Moderation queues — square, flat, theme colours */
|
||||||
|
.mq {
|
||||||
|
--mq-accent: var(--accent, #0096ff);
|
||||||
|
--mq-text: var(--text-color, var(--white, #fff));
|
||||||
|
--mq-muted: var(--text-muted, #8a8f98);
|
||||||
|
--mq-surface: rgba(255, 255, 255, 0.04);
|
||||||
|
--mq-border: rgba(255, 255, 255, 0.1);
|
||||||
|
--mq-danger: #ff5c5c;
|
||||||
|
max-width: 1200px; margin: 0 auto; padding: 24px 16px 60px;
|
||||||
|
text-align: left; color: var(--mq-text); line-height: 1.5;
|
||||||
|
}
|
||||||
|
.mq *, .mq *::before, .mq *::after, .mq-modal *, .mq-modal *::before, .mq-modal *::after { border-radius: 0 !important; }
|
||||||
|
|
||||||
|
.mq-header { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; flex-wrap: wrap; margin-bottom: 18px; }
|
||||||
|
.mq-title { margin: 0; font-size: 1.8em; font-weight: 800; line-height: 1.1; }
|
||||||
|
.mq-subtitle { margin-top: 6px; font-size: 0.85em; color: var(--mq-muted); }
|
||||||
|
|
||||||
|
|
||||||
|
.mq-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 10px; }
|
||||||
|
.mq-card { display: flex; flex-direction: column; background: var(--mq-surface); border: 1px solid var(--mq-border); transition: opacity 0.3s, border-color 0.15s; }
|
||||||
|
.mq-card:hover { border-color: rgba(255, 255, 255, 0.2); }
|
||||||
|
.mq-media { position: relative; aspect-ratio: 16 / 10; background: #000; display: flex; align-items: center; justify-content: center; overflow: hidden; }
|
||||||
|
.mq-media img, .mq-media video { width: 100%; height: 100%; object-fit: contain; display: block; }
|
||||||
|
.mq-media .embed-responsive { width: 100%; }
|
||||||
|
.mq-trash .mq-media img, .mq-trash .mq-media video { filter: grayscale(50%); }
|
||||||
|
.mq-body { display: flex; flex-direction: column; gap: 8px; padding: 12px; flex: 1; }
|
||||||
|
.mq-meta { display: flex; flex-wrap: wrap; gap: 4px 12px; font-size: 0.8em; color: var(--mq-muted); }
|
||||||
|
.mq-meta strong { color: var(--mq-text); font-weight: 700; }
|
||||||
|
.mq-reason { padding: 6px 8px; font-size: 0.8em; color: #ffb8b8; background: rgba(255, 92, 92, 0.08); border-left: 2px solid var(--mq-danger); }
|
||||||
|
.mq-tags { display: flex; flex-wrap: wrap; gap: 4px; min-height: 22px; }
|
||||||
|
.mq-tags .badge { margin: 0; }
|
||||||
|
.mq-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; margin-top: auto; }
|
||||||
|
.mq-actions .mq-btn-rating { grid-column: span 2; }
|
||||||
|
|
||||||
|
.mq-btn {
|
||||||
|
display: inline-flex; align-items: center; justify-content: center; gap: 6px; height: 34px; margin: 0; padding: 0 12px;
|
||||||
|
font: inherit; font-size: 0.8em; font-weight: 700; cursor: pointer; text-decoration: none; white-space: nowrap;
|
||||||
|
border: 1px solid var(--mq-border); background: transparent; color: var(--mq-text);
|
||||||
|
transition: background 0.15s, color 0.15s, border-color 0.15s, opacity 0.15s;
|
||||||
|
}
|
||||||
|
.mq-btn:hover { text-decoration: none; }
|
||||||
|
.mq-btn:disabled { opacity: 0.5; cursor: progress; }
|
||||||
|
.mq-btn-primary { background: var(--mq-accent); border-color: var(--mq-accent); color: #000; text-transform: uppercase; letter-spacing: 0.06em; }
|
||||||
|
.mq-btn-primary:hover:not(:disabled) { background: transparent; color: var(--mq-accent); }
|
||||||
|
.mq-btn-danger { border-color: rgba(255, 92, 92, 0.5); color: var(--mq-danger); }
|
||||||
|
.mq-btn-danger:hover:not(:disabled) { background: rgba(255, 92, 92, 0.15); }
|
||||||
|
.mq-btn-ghost { color: var(--mq-muted); }
|
||||||
|
.mq-btn-ghost:hover:not(:disabled) { color: var(--mq-text); border-color: rgba(255, 255, 255, 0.25); background: var(--mq-surface); }
|
||||||
|
.mq-btn:focus-visible { outline: 2px solid var(--mq-accent); outline-offset: 2px; }
|
||||||
|
|
||||||
|
.mq-note { margin: 0 0 16px; font-size: 0.85em; color: var(--mq-muted); }
|
||||||
|
.mq-empty { padding: 40px 20px; text-align: center; color: var(--mq-muted); border: 1px dashed var(--mq-border); }
|
||||||
|
.mq-empty strong { display: block; margin-bottom: 4px; color: var(--mq-text); font-size: 1.05em; }
|
||||||
|
|
||||||
|
.mq-pager { display: flex; align-items: center; justify-content: center; gap: 10px; margin-top: 24px; font-size: 0.85em; color: var(--mq-muted); }
|
||||||
|
|
||||||
|
.mq-modal { position: fixed; inset: 0; z-index: 100100; display: flex; align-items: center; justify-content: center; background: rgba(0, 0, 0, 0.8); backdrop-filter: blur(4px); padding: 16px; }
|
||||||
|
.mq-modal-box {
|
||||||
|
width: min(420px, 100%); padding: 22px; text-align: left; color: var(--text-color, #fff);
|
||||||
|
background: var(--bg); border: 1px solid var(--accent); box-shadow: 0 0 20px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
.mq-modal-box h3 { margin: 0 0 6px; font-size: 1.15em; font-weight: 700; }
|
||||||
|
.mq-modal-box p { margin: 0 0 14px; font-size: 0.88em; color: var(--text-muted, #8a8f98); }
|
||||||
|
.mq-modal-box label { display: block; margin-bottom: 6px; font-size: 0.78em; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-muted, #8a8f98); }
|
||||||
|
.mq-modal-box textarea {
|
||||||
|
width: 100%; box-sizing: border-box; padding: 10px 12px; margin-bottom: 14px; resize: vertical;
|
||||||
|
background: rgba(255, 255, 255, 0.04); border: 1px solid rgba(255, 255, 255, 0.1); color: inherit; font: inherit; font-size: 0.9em; outline: none;
|
||||||
|
}
|
||||||
|
.mq-modal-box textarea:focus { border-color: var(--accent); }
|
||||||
|
.mq-modal-box textarea.is-invalid { border-color: #ff5c5c; }
|
||||||
|
.mq-modal-actions { display: flex; justify-content: flex-end; gap: 8px; }
|
||||||
|
.mq-modal .mq-btn { height: 38px; padding: 0 16px; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Shared helpers for the moderation card pages. Re-assigned on every (AJAX) page load.
|
||||||
|
window.mq = (() => {
|
||||||
|
const modal = document.getElementById('mq-modal');
|
||||||
|
const title = document.getElementById('mq-modal-title');
|
||||||
|
const text = document.getElementById('mq-modal-text');
|
||||||
|
const reasonWrap = document.getElementById('mq-modal-reason-wrap');
|
||||||
|
const reasonInput = document.getElementById('mq-modal-reason');
|
||||||
|
const btnConfirm = document.getElementById('mq-modal-confirm');
|
||||||
|
const btnCancel = document.getElementById('mq-modal-cancel');
|
||||||
|
|
||||||
|
const csrf = () => (window.f0ckSession && window.f0ckSession.csrf_token) || '';
|
||||||
|
const flash = (msg, type) => { if (window.flashMessage) window.flashMessage(msg, type === 'error' ? 3000 : 2000, type); };
|
||||||
|
|
||||||
|
let pendingAction = null;
|
||||||
|
let needsReason = false;
|
||||||
|
|
||||||
|
const close = () => { modal.style.display = 'none'; pendingAction = null; };
|
||||||
|
const confirm = ({ heading, body, confirmLabel = 'Confirm', withReason = false, action }) => {
|
||||||
|
title.innerText = heading;
|
||||||
|
text.innerText = body;
|
||||||
|
pendingAction = action;
|
||||||
|
needsReason = withReason;
|
||||||
|
btnConfirm.innerText = confirmLabel;
|
||||||
|
btnConfirm.dataset.label = confirmLabel;
|
||||||
|
reasonWrap.style.display = withReason ? 'block' : 'none';
|
||||||
|
reasonInput.value = '';
|
||||||
|
reasonInput.classList.remove('is-invalid');
|
||||||
|
modal.style.display = 'flex';
|
||||||
|
if (withReason) reasonInput.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
btnConfirm.addEventListener('click', async () => {
|
||||||
|
if (!pendingAction) return;
|
||||||
|
let reason = null;
|
||||||
|
if (needsReason) {
|
||||||
|
reason = reasonInput.value.trim();
|
||||||
|
if (!reason) { reasonInput.classList.add('is-invalid'); reasonInput.focus(); return; }
|
||||||
|
}
|
||||||
|
btnConfirm.disabled = true;
|
||||||
|
btnConfirm.innerText = 'Processing…';
|
||||||
|
try {
|
||||||
|
await pendingAction(reason);
|
||||||
|
close();
|
||||||
|
} catch (e) {
|
||||||
|
flash('Error: ' + e.message, 'error');
|
||||||
|
} finally {
|
||||||
|
btnConfirm.disabled = false;
|
||||||
|
btnConfirm.innerText = btnConfirm.dataset.label || 'Confirm';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
btnCancel.addEventListener('click', close);
|
||||||
|
modal.addEventListener('click', e => { if (e.target === modal) close(); });
|
||||||
|
document.addEventListener('keydown', e => { if (e.key === 'Escape' && modal.style.display !== 'none') close(); });
|
||||||
|
|
||||||
|
const post = async (url, body) => {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json', 'X-CSRF-Token': csrf() },
|
||||||
|
body: JSON.stringify(body || {})
|
||||||
|
});
|
||||||
|
let data = {};
|
||||||
|
try { data = await res.json(); } catch {}
|
||||||
|
if (!res.ok || !data.success) throw new Error(data.msg || ('Request failed (' + res.status + ')'));
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeCard = (card) => {
|
||||||
|
if (!card) return;
|
||||||
|
card.style.opacity = '0';
|
||||||
|
setTimeout(() => {
|
||||||
|
card.remove();
|
||||||
|
const grid = document.querySelector('.mq-grid');
|
||||||
|
if (grid && !grid.querySelector('.mq-card')) location.reload();
|
||||||
|
}, 300);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Rating toggle (same on both pages)
|
||||||
|
document.querySelectorAll('.btn-rating-toggle-async').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async e => {
|
||||||
|
e.preventDefault();
|
||||||
|
const tagsContainer = btn.closest('.mq-card')?.querySelector('.mq-tags');
|
||||||
|
btn.disabled = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v2/tags/' + btn.dataset.id + '/toggle', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json', 'X-CSRF-Token': csrf() }
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!data.success || !data.tags) throw new Error(data.msg || 'Unknown error');
|
||||||
|
if (tagsContainer) {
|
||||||
|
tagsContainer.innerHTML = '';
|
||||||
|
data.tags.forEach(tag => {
|
||||||
|
let cls = 'badge-light';
|
||||||
|
if (tag.normalized === 'sfw') cls = 'badge-success';
|
||||||
|
else if (tag.normalized === 'nsfw') cls = 'badge-danger';
|
||||||
|
else if (tag.tag.startsWith('>')) cls = 'badge-greentext badge-light';
|
||||||
|
const span = document.createElement('span');
|
||||||
|
span.className = 'badge ' + cls;
|
||||||
|
span.innerText = tag.tag;
|
||||||
|
tagsContainer.appendChild(span);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
flash('Error: ' + err.message, 'error');
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return { confirm, post, flash, removeCard };
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
+392
-128
@@ -69,7 +69,7 @@
|
|||||||
@endif
|
@endif
|
||||||
@endif
|
@endif
|
||||||
@if(enable_anonymous_access && session.is_anon)
|
@if(enable_anonymous_access && session.is_anon)
|
||||||
<a href="#" id="nav-user-anon-identity-btn" onclick="event.preventDefault(); if(window.f0ckAnonPasskey) { window.f0ckAnonPasskey.openModal(); } else { const m = document.getElementById('anon-passkey-modal'); if (m) m.style.display='flex'; }"><i class="fa-solid fa-fingerprint"></i> Passkey Identity</a>
|
<a href="#" id="nav-user-anon-identity-btn" style="white-space: nowrap;" onclick="event.preventDefault(); if(window.f0ckAnonPasskey) { window.f0ckAnonPasskey.openModal(); } else { const m = document.getElementById('anon-passkey-modal'); if (m) m.style.display='flex'; }">Passkey Identity</a>
|
||||||
@endif
|
@endif
|
||||||
<a href="/user/{{ (session.is_anon && session.login ? session.login : session.user).toLowerCase() }}/favs" class="mobile-only">{{ t('nav.favs') }}</a>
|
<a href="/user/{{ (session.is_anon && session.login ? session.login : session.user).toLowerCase() }}/favs" class="mobile-only">{{ t('nav.favs') }}</a>
|
||||||
<a href="/settings" class="mobile-only">{{ t('nav.settings') }}</a>
|
<a href="/settings" class="mobile-only">{{ t('nav.settings') }}</a>
|
||||||
@@ -385,7 +385,7 @@ async function loginWithPasskey() {
|
|||||||
errEl.textContent = (e && e.message) || 'Passkey login failed.';
|
errEl.textContent = (e && e.message) || 'Passkey login failed.';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (btn) { btn.disabled = false; btn.innerHTML = '<i class="fa-solid fa-fingerprint"></i> Login with passkey'; }
|
if (btn) { btn.disabled = false; btn.innerHTML = '<i class="fa-solid fa-fingerprint"></i> Use my passkey'; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -396,76 +396,319 @@ async function loginWithPasskey() {
|
|||||||
|
|
||||||
<!-- Login View -->
|
<!-- Login View -->
|
||||||
<div id="modal-login-view">
|
<div id="modal-login-view">
|
||||||
<h2 style="text-align: center; margin-bottom: 18px;">{{ t('auth.login_title') }}</h2>
|
<div class="lm-header">
|
||||||
|
<h2 class="lm-title">{{ t('auth.login_title') }}</h2>
|
||||||
|
<div class="lm-subtitle">{{ domain }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Tab bar -->
|
<!-- Tab bar -->
|
||||||
<div id="login-tabs" style="display: flex; border-bottom: 1px solid rgba(255,255,255,0.1); margin-bottom: 20px;">
|
<div id="login-tabs" class="lm-tabs" role="tablist">
|
||||||
<button type="button" id="login-tab-creds" onclick="switchLoginTab('creds')"
|
<button type="button" id="login-tab-creds" class="lm-tab is-active" role="tab" aria-selected="true" onclick="switchLoginTab('creds')">
|
||||||
style="flex: 1; background: none; border: none; border-bottom: 2px solid var(--accent, #0096ff); color: var(--text-color, #fff); padding: 8px 0; font-size: 0.9em; cursor: pointer; font-weight: 600; transition: color 0.15s;">
|
<i class="fa-solid fa-key"></i>Credentials
|
||||||
<i class="fa-solid fa-key" style="margin-right:5px;"></i>Credentials
|
|
||||||
</button>
|
</button>
|
||||||
<button type="button" id="login-tab-passkey" onclick="switchLoginTab('passkey')"
|
<button type="button" id="login-tab-passkey" class="lm-tab" role="tab" aria-selected="false" onclick="switchLoginTab('passkey')">
|
||||||
style="flex: 1; background: none; border: none; border-bottom: 2px solid transparent; color: var(--text-muted, #888); padding: 8px 0; font-size: 0.9em; cursor: pointer; font-weight: 600; transition: color 0.15s;">
|
<i class="fa-solid fa-fingerprint"></i>Passkey
|
||||||
<i class="fa-solid fa-fingerprint" style="margin-right:5px;"></i>Passkey
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tab: Credentials -->
|
<!-- Tab: Credentials -->
|
||||||
<div id="login-panel-creds">
|
<div id="login-panel-creds" class="lm-panel">
|
||||||
<form class="login-form" method="post" action="/login" novalidate style="padding: 0; background: none; box-shadow: none; border: none;">
|
<form class="login-form lm-form" method="post" action="/login" novalidate>
|
||||||
<input type="text" name="username" placeholder="{{ t('auth.username_or_email') }}" autocomplete="off" required />
|
<label class="lm-field">
|
||||||
<input type="password" name="password" placeholder="{{ t('auth.password_placeholder_min') }}" autocomplete="off" required />
|
<i class="fa-solid fa-user lm-field-icon"></i>
|
||||||
<p style="text-align: left; font-size: 0.9em; margin: 0;"><input type="checkbox" id="kmsi-modal" name="kmsi" />
|
<input class="lm-input" type="text" name="username" placeholder="{{ t('auth.username_or_email') }}" aria-label="{{ t('auth.username_or_email') }}" autocomplete="username" autocapitalize="off" spellcheck="false" required />
|
||||||
<label for="kmsi-modal">{{ t('auth.stay_signed_in') }}</label>
|
</label>
|
||||||
</p>
|
<label class="lm-field">
|
||||||
<button type="submit">{{ t('auth.login_title') }}</button>
|
<i class="fa-solid fa-lock lm-field-icon"></i>
|
||||||
@if(smtp_enabled)
|
<input class="lm-input" type="password" name="password" id="modal-login-password" placeholder="{{ t('auth.password_placeholder_min') }}" aria-label="{{ t('auth.password_placeholder_min') }}" autocomplete="current-password" required />
|
||||||
<div style="text-align: center; margin-top: 10px;">
|
<button type="button" class="lm-reveal" onclick="toggleLoginPassword(this)" aria-label="Show password" title="Show password">
|
||||||
<a href="#" id="modal-forgot-btn" style="font-size: 0.85em; color: var(--accent); text-decoration: underline;">{{ t('auth.forgot_password') }}</a>
|
<i class="fa-solid fa-eye"></i>
|
||||||
|
</button>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="lm-row">
|
||||||
|
<label class="lm-check" for="kmsi-modal">
|
||||||
|
<input type="checkbox" id="kmsi-modal" name="kmsi" />
|
||||||
|
<span class="lm-check-box"><i class="fa-solid fa-check"></i></span>
|
||||||
|
<span>{{ t('auth.stay_signed_in') }}</span>
|
||||||
|
</label>
|
||||||
|
@if(smtp_enabled)
|
||||||
|
<a href="#" id="modal-forgot-btn" class="lm-link lm-link-muted">{{ t('auth.forgot_password') }}</a>
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
@endif
|
|
||||||
@if(registration_open || private_society)
|
<button type="submit" class="lm-btn lm-btn-primary">
|
||||||
<p style="text-align: center; font-size: 0.9em; margin-top: 15px; color: #888;">
|
{{ t('auth.login_title') }} <i class="fa-solid fa-arrow-right"></i>
|
||||||
{{ t('auth.no_account') }} <a href="#" id="login-to-register" style="color: var(--accent); text-decoration: underline;">{{ t('auth.register_now') }}</a>
|
</button>
|
||||||
</p>
|
|
||||||
@endif
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tab: Passkey -->
|
<!-- Tab: Passkey -->
|
||||||
<div id="login-panel-passkey" style="display: none; text-align: center; padding: 8px 0 4px;">
|
<div id="login-panel-passkey" class="lm-panel lm-panel-passkey" style="display: none;">
|
||||||
<div style="font-size: 2.6em; margin-bottom: 14px;">🔑</div>
|
<div class="lm-passkey-icon"><i class="fa-solid fa-fingerprint"></i></div>
|
||||||
<p style="font-size: 0.88em; color: var(--text-muted, #aaa); margin: 0 0 20px; line-height: 1.55;">
|
<p class="lm-passkey-text">
|
||||||
Use a saved passkey from Bitwarden, iCloud Keychain, or your OS to sign in without a password.
|
Use a saved passkey from Bitwarden, iCloud Keychain, or your OS to sign in without a password.
|
||||||
</p>
|
</p>
|
||||||
<button type="button" id="modal-login-passkey-btn"
|
<button type="button" id="modal-login-passkey-btn" class="lm-btn lm-btn-primary" onclick="loginWithPasskey()">
|
||||||
style="width: 100%; padding: 11px; background: var(--accent, #0096ff); color: #fff; border: none; border-radius: 5px; font-size: 0.95em; font-weight: 600; cursor: pointer; margin-bottom: 8px;"
|
|
||||||
onclick="loginWithPasskey()">
|
|
||||||
<i class="fa-solid fa-fingerprint"></i> Use my passkey
|
<i class="fa-solid fa-fingerprint"></i> Use my passkey
|
||||||
</button>
|
</button>
|
||||||
<div id="modal-passkey-error" style="display:none; color:#e06c75; font-size:0.82em; margin-top:6px;"></div>
|
<div id="modal-passkey-error" class="lm-error" style="display:none;"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if(enable_anonymous_access)
|
@if(enable_anonymous_access)
|
||||||
<div style="margin-top: 16px; border-top: 1px solid rgba(255,255,255,0.08); padding-top: 12px; text-align: center;">
|
<div class="lm-anon">
|
||||||
<button type="button" id="modal-login-as-anon-btn"
|
<div class="lm-divider"><span>or</span></div>
|
||||||
style="background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.15); color: #999; border-radius: 4px; padding: 7px 14px; font-size: 0.85em; cursor: pointer; width: 100%; transition: background 0.2s;"
|
<button type="button" id="modal-login-as-anon-btn" class="lm-btn lm-btn-ghost"
|
||||||
onclick="event.preventDefault(); if(window.f0ckAnonPasskey) window.f0ckAnonPasskey.openSetupModal(); else { const m=document.getElementById('anon-setup-modal'); if(m) m.style.display='flex'; }">
|
onclick="event.preventDefault(); if(window.f0ckAnonPasskey) window.f0ckAnonPasskey.openSetupModal(); else { const m=document.getElementById('anon-setup-modal'); if(m) m.style.display='flex'; }">
|
||||||
<i class="fa-solid fa-user-secret"></i> Login as anonymous
|
<i class="fa-solid fa-user-secret"></i> Login as anonymous
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
|
@if(registration_open || private_society)
|
||||||
|
<p class="lm-footer">
|
||||||
|
{{ t('auth.no_account') }} <a href="#" id="login-to-register" class="lm-link">{{ t('auth.register_now') }}</a>
|
||||||
|
</p>
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:is(#login-modal #modal-login-view, #register-modal #modal-register-view, #anon-setup-modal #anon-setup-view, #anon-passkey-modal #anon-passkey-view) {
|
||||||
|
--lm-accent: var(--accent, #0096ff);
|
||||||
|
--lm-text: var(--text-color, #fff);
|
||||||
|
--lm-muted: var(--text-muted, #8a8f98);
|
||||||
|
--lm-surface: rgba(255, 255, 255, 0.04);
|
||||||
|
--lm-border: rgba(255, 255, 255, 0.1);
|
||||||
|
--lm-radius: 0;
|
||||||
|
padding: 28px 28px 24px;
|
||||||
|
font-family: inherit;
|
||||||
|
color: var(--lm-text);
|
||||||
|
}
|
||||||
|
#login-modal .login-modal-content,
|
||||||
|
#register-modal .login-modal-content { border-radius: 0; }
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
:is(#login-modal #modal-login-view, #register-modal #modal-register-view, #anon-setup-modal #anon-setup-view, #anon-passkey-modal #anon-passkey-view) .lm-header { text-align: center; margin-bottom: 22px; }
|
||||||
|
:is(#login-modal #modal-login-view, #register-modal #modal-register-view, #anon-setup-modal #anon-setup-view, #anon-passkey-modal #anon-passkey-view) .lm-title {
|
||||||
|
margin: 0; font-size: 1.5em; font-weight: 700; letter-spacing: -0.01em; color: var(--lm-text);
|
||||||
|
}
|
||||||
|
:is(#login-modal #modal-login-view, #register-modal #modal-register-view, #anon-setup-modal #anon-setup-view, #anon-passkey-modal #anon-passkey-view) .lm-subtitle {
|
||||||
|
margin-top: 4px; font-size: 0.82em; color: var(--lm-muted); letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Segmented tabs */
|
||||||
|
:is(#login-modal #modal-login-view, #register-modal #modal-register-view, #anon-setup-modal #anon-setup-view, #anon-passkey-modal #anon-passkey-view) .lm-tabs {
|
||||||
|
display: flex; gap: 4px; padding: 4px; margin-bottom: 20px;
|
||||||
|
background: rgba(0, 0, 0, 0.35);
|
||||||
|
border: 1px solid var(--lm-border);
|
||||||
|
border-radius: var(--lm-radius);
|
||||||
|
}
|
||||||
|
:is(#login-modal #modal-login-view, #register-modal #modal-register-view, #anon-setup-modal #anon-setup-view, #anon-passkey-modal #anon-passkey-view) .lm-tab {
|
||||||
|
flex: 1; display: inline-flex; align-items: center; justify-content: center; gap: 7px;
|
||||||
|
padding: 8px 0; margin: 0;
|
||||||
|
background: transparent; border: 0; border-radius: var(--lm-radius);
|
||||||
|
color: var(--lm-muted); font: inherit; font-size: 0.88em; font-weight: 600;
|
||||||
|
cursor: pointer; transition: background 0.18s, color 0.18s, box-shadow 0.18s;
|
||||||
|
}
|
||||||
|
:is(#login-modal #modal-login-view, #register-modal #modal-register-view, #anon-setup-modal #anon-setup-view, #anon-passkey-modal #anon-passkey-view) .lm-tab:hover { color: var(--lm-text); }
|
||||||
|
:is(#login-modal #modal-login-view, #register-modal #modal-register-view, #anon-setup-modal #anon-setup-view, #anon-passkey-modal #anon-passkey-view) .lm-tab.is-active {
|
||||||
|
color: var(--lm-text);
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.06) inset, 0 2px 8px rgba(0, 0, 0, 0.35);
|
||||||
|
}
|
||||||
|
:is(#login-modal #modal-login-view, #register-modal #modal-register-view, #anon-setup-modal #anon-setup-view, #anon-passkey-modal #anon-passkey-view) .lm-tab.is-active i { color: var(--lm-accent); }
|
||||||
|
:is(#login-modal #modal-login-view, #register-modal #modal-register-view, #anon-setup-modal #anon-setup-view, #anon-passkey-modal #anon-passkey-view) .lm-panel { animation: lm-fade 0.2s ease; }
|
||||||
|
@keyframes lm-fade { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
|
||||||
|
|
||||||
|
/* Form */
|
||||||
|
:is(#login-modal #modal-login-view, #register-modal #modal-register-view, #anon-setup-modal #anon-setup-view, #anon-passkey-modal #anon-passkey-view) form.lm-form {
|
||||||
|
display: flex; flex-direction: column; gap: 12px;
|
||||||
|
padding: 0; margin: 0; background: none; border: 0; box-shadow: none; width: 100%;
|
||||||
|
}
|
||||||
|
:is(#login-modal #modal-login-view, #register-modal #modal-register-view, #anon-setup-modal #anon-setup-view, #anon-passkey-modal #anon-passkey-view) .lm-field { position: relative; display: block; margin: 0; }
|
||||||
|
:is(#login-modal #modal-login-view, #register-modal #modal-register-view, #anon-setup-modal #anon-setup-view, #anon-passkey-modal #anon-passkey-view) .lm-field-icon {
|
||||||
|
position: absolute; left: 14px; top: 50%; transform: translateY(-50%);
|
||||||
|
color: var(--lm-muted); font-size: 0.9em; pointer-events: none; transition: color 0.15s;
|
||||||
|
}
|
||||||
|
:is(#login-modal #modal-login-view, #register-modal #modal-register-view, #anon-setup-modal #anon-setup-view, #anon-passkey-modal #anon-passkey-view) input.lm-input {
|
||||||
|
width: 100%; box-sizing: border-box; height: 46px; margin: 0;
|
||||||
|
padding: 0 42px 0 40px;
|
||||||
|
background: var(--lm-surface);
|
||||||
|
border: 1px solid var(--lm-border);
|
||||||
|
border-radius: var(--lm-radius);
|
||||||
|
color: var(--lm-text); font: inherit; font-size: 0.95em;
|
||||||
|
outline: none; box-shadow: none;
|
||||||
|
transition: border-color 0.15s, background 0.15s, box-shadow 0.15s;
|
||||||
|
}
|
||||||
|
:is(#login-modal #modal-login-view, #register-modal #modal-register-view, #anon-setup-modal #anon-setup-view, #anon-passkey-modal #anon-passkey-view) input.lm-input::placeholder { color: var(--lm-muted); opacity: 0.8; }
|
||||||
|
:is(#login-modal #modal-login-view, #register-modal #modal-register-view, #anon-setup-modal #anon-setup-view, #anon-passkey-modal #anon-passkey-view) input.lm-input:hover { border-color: rgba(255, 255, 255, 0.18); }
|
||||||
|
:is(#login-modal #modal-login-view, #register-modal #modal-register-view, #anon-setup-modal #anon-setup-view, #anon-passkey-modal #anon-passkey-view) input.lm-input:focus {
|
||||||
|
border-color: var(--lm-accent);
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
box-shadow: 0 0 0 3px color-mix(in srgb, var(--lm-accent) 22%, transparent);
|
||||||
|
}
|
||||||
|
:is(#login-modal #modal-login-view, #register-modal #modal-register-view, #anon-setup-modal #anon-setup-view, #anon-passkey-modal #anon-passkey-view) .lm-field:focus-within .lm-field-icon { color: var(--lm-accent); }
|
||||||
|
:is(#login-modal #modal-login-view, #register-modal #modal-register-view, #anon-setup-modal #anon-setup-view, #anon-passkey-modal #anon-passkey-view) .lm-reveal {
|
||||||
|
position: absolute; right: 6px; top: 50%; transform: translateY(-50%);
|
||||||
|
width: 34px; height: 34px; margin: 0; padding: 0;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
background: transparent; border: 0; border-radius: var(--lm-radius);
|
||||||
|
color: var(--lm-muted); cursor: pointer; transition: color 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
:is(#login-modal #modal-login-view, #register-modal #modal-register-view, #anon-setup-modal #anon-setup-view, #anon-passkey-modal #anon-passkey-view) .lm-reveal:hover { color: var(--lm-text); background: rgba(255, 255, 255, 0.06); }
|
||||||
|
|
||||||
|
/* Remember me + forgot */
|
||||||
|
:is(#login-modal #modal-login-view, #register-modal #modal-register-view, #anon-setup-modal #anon-setup-view, #anon-passkey-modal #anon-passkey-view) .lm-row {
|
||||||
|
display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap;
|
||||||
|
font-size: 0.85em; margin: 2px 0 4px;
|
||||||
|
}
|
||||||
|
:is(#login-modal #modal-login-view, #register-modal #modal-register-view, #anon-setup-modal #anon-setup-view, #anon-passkey-modal #anon-passkey-view) .lm-check {
|
||||||
|
display: inline-flex; align-items: center; gap: 9px; cursor: pointer; color: var(--lm-muted); user-select: none;
|
||||||
|
}
|
||||||
|
:is(#login-modal #modal-login-view, #register-modal #modal-register-view, #anon-setup-modal #anon-setup-view, #anon-passkey-modal #anon-passkey-view) .lm-check input { position: absolute; opacity: 0; width: 0; height: 0; margin: 0; }
|
||||||
|
:is(#login-modal #modal-login-view, #register-modal #modal-register-view, #anon-setup-modal #anon-setup-view, #anon-passkey-modal #anon-passkey-view) .lm-check-box {
|
||||||
|
width: 18px; height: 18px; flex-shrink: 0;
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.25); border-radius: var(--lm-radius);
|
||||||
|
background: var(--lm-surface); font-size: 0.7em; color: transparent;
|
||||||
|
transition: background 0.15s, border-color 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
:is(#login-modal #modal-login-view, #register-modal #modal-register-view, #anon-setup-modal #anon-setup-view, #anon-passkey-modal #anon-passkey-view) .lm-check:hover .lm-check-box { border-color: var(--lm-accent); }
|
||||||
|
:is(#login-modal #modal-login-view, #register-modal #modal-register-view, #anon-setup-modal #anon-setup-view, #anon-passkey-modal #anon-passkey-view) .lm-check input:checked + .lm-check-box { background: var(--lm-accent); border-color: var(--lm-accent); color: #000; }
|
||||||
|
:is(#login-modal #modal-login-view, #register-modal #modal-register-view, #anon-setup-modal #anon-setup-view, #anon-passkey-modal #anon-passkey-view) .lm-check input:focus-visible + .lm-check-box { box-shadow: 0 0 0 3px color-mix(in srgb, var(--lm-accent) 30%, transparent); }
|
||||||
|
:is(#login-modal #modal-login-view, #register-modal #modal-register-view, #anon-setup-modal #anon-setup-view, #anon-passkey-modal #anon-passkey-view) .lm-check input:checked ~ span:last-child { color: var(--lm-text); }
|
||||||
|
|
||||||
|
/* Links */
|
||||||
|
:is(#login-modal #modal-login-view, #register-modal #modal-register-view, #anon-setup-modal #anon-setup-view, #anon-passkey-modal #anon-passkey-view) .lm-link {
|
||||||
|
color: var(--lm-accent); text-decoration: none; font-weight: 600; transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
:is(#login-modal #modal-login-view, #register-modal #modal-register-view, #anon-setup-modal #anon-setup-view, #anon-passkey-modal #anon-passkey-view) .lm-link:hover { text-decoration: underline; text-underline-offset: 3px; }
|
||||||
|
:is(#login-modal #modal-login-view, #register-modal #modal-register-view, #anon-setup-modal #anon-setup-view, #anon-passkey-modal #anon-passkey-view) .lm-link-muted { color: var(--lm-muted); font-weight: 500; }
|
||||||
|
:is(#login-modal #modal-login-view, #register-modal #modal-register-view, #anon-setup-modal #anon-setup-view, #anon-passkey-modal #anon-passkey-view) .lm-link-muted:hover { color: var(--lm-text); }
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
:is(#login-modal #modal-login-view, #register-modal #modal-register-view, #anon-setup-modal #anon-setup-view, #anon-passkey-modal #anon-passkey-view) .lm-btn {
|
||||||
|
width: 100%; height: 46px; margin: 0; padding: 0 16px;
|
||||||
|
display: inline-flex; align-items: center; justify-content: center; gap: 9px;
|
||||||
|
border-radius: var(--lm-radius); font: inherit; font-size: 0.95em; font-weight: 700;
|
||||||
|
cursor: pointer; transition: transform 0.12s, box-shadow 0.18s, background 0.18s, border-color 0.18s, filter 0.18s;
|
||||||
|
}
|
||||||
|
:is(#login-modal #modal-login-view, #register-modal #modal-register-view, #anon-setup-modal #anon-setup-view, #anon-passkey-modal #anon-passkey-view) .lm-btn-primary {
|
||||||
|
margin-top: 6px;
|
||||||
|
border: 1px solid var(--lm-accent); color: #000;
|
||||||
|
background: var(--lm-accent);
|
||||||
|
box-shadow: none;
|
||||||
|
text-transform: uppercase; letter-spacing: 0.08em; font-weight: 800;
|
||||||
|
}
|
||||||
|
:is(#login-modal #modal-login-view, #register-modal #modal-register-view, #anon-setup-modal #anon-setup-view, #anon-passkey-modal #anon-passkey-view) .lm-btn-primary:hover { background: transparent; color: var(--lm-accent); }
|
||||||
|
:is(#login-modal #modal-login-view, #register-modal #modal-register-view, #anon-setup-modal #anon-setup-view, #anon-passkey-modal #anon-passkey-view) .lm-btn-primary:active { transform: translateY(1px); }
|
||||||
|
:is(#login-modal #modal-login-view, #register-modal #modal-register-view, #anon-setup-modal #anon-setup-view, #anon-passkey-modal #anon-passkey-view) .lm-btn-primary .fa-arrow-right { font-size: 0.85em; transition: transform 0.18s; }
|
||||||
|
:is(#login-modal #modal-login-view, #register-modal #modal-register-view, #anon-setup-modal #anon-setup-view, #anon-passkey-modal #anon-passkey-view) .lm-btn-primary:hover .fa-arrow-right { transform: translateX(3px); }
|
||||||
|
:is(#login-modal #modal-login-view, #register-modal #modal-register-view, #anon-setup-modal #anon-setup-view, #anon-passkey-modal #anon-passkey-view) .lm-btn:disabled { opacity: 0.6; cursor: progress; filter: none; transform: none; }
|
||||||
|
:is(#login-modal #modal-login-view, #register-modal #modal-register-view, #anon-setup-modal #anon-setup-view, #anon-passkey-modal #anon-passkey-view) .lm-btn:focus-visible { outline: 2px solid var(--lm-accent); outline-offset: 2px; }
|
||||||
|
:is(#login-modal #modal-login-view, #register-modal #modal-register-view, #anon-setup-modal #anon-setup-view, #anon-passkey-modal #anon-passkey-view) .lm-btn-ghost {
|
||||||
|
background: var(--lm-surface); border: 1px solid var(--lm-border); color: var(--lm-muted); font-weight: 600;
|
||||||
|
}
|
||||||
|
:is(#login-modal #modal-login-view, #register-modal #modal-register-view, #anon-setup-modal #anon-setup-view, #anon-passkey-modal #anon-passkey-view) .lm-btn-ghost:hover { color: var(--lm-text); border-color: rgba(255, 255, 255, 0.22); background: rgba(255, 255, 255, 0.07); }
|
||||||
|
|
||||||
|
/* Passkey panel */
|
||||||
|
:is(#login-modal #modal-login-view, #register-modal #modal-register-view, #anon-setup-modal #anon-setup-view, #anon-passkey-modal #anon-passkey-view) .lm-panel-passkey { text-align: center; padding-top: 4px; }
|
||||||
|
:is(#login-modal #modal-login-view, #register-modal #modal-register-view, #anon-setup-modal #anon-setup-view, #anon-passkey-modal #anon-passkey-view) .lm-passkey-icon {
|
||||||
|
width: 76px; height: 76px; margin: 0 auto 16px;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
border-radius: var(--lm-radius); font-size: 2.2em; color: var(--lm-accent);
|
||||||
|
background: radial-gradient(circle, color-mix(in srgb, var(--lm-accent) 20%, transparent) 0%, transparent 70%);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--lm-accent) 30%, transparent);
|
||||||
|
}
|
||||||
|
:is(#login-modal #modal-login-view, #register-modal #modal-register-view, #anon-setup-modal #anon-setup-view, #anon-passkey-modal #anon-passkey-view) .lm-passkey-text {
|
||||||
|
margin: 0 auto 20px; max-width: 320px; font-size: 0.88em; line-height: 1.6; color: var(--lm-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Errors (also covers the .flash-error the login JS injects) */
|
||||||
|
:is(#login-modal #modal-login-view, #register-modal #modal-register-view, #anon-setup-modal #anon-setup-view, #anon-passkey-modal #anon-passkey-view) .lm-error,
|
||||||
|
:is(#login-modal #modal-login-view, #register-modal #modal-register-view, #anon-setup-modal #anon-setup-view, #anon-passkey-modal #anon-passkey-view) .flash-error {
|
||||||
|
margin: 10px 0 0; padding: 10px 12px; text-align: left;
|
||||||
|
font-size: 0.84em; line-height: 1.45; color: #ff8a8a;
|
||||||
|
background: rgba(224, 108, 117, 0.1);
|
||||||
|
border: 1px solid rgba(224, 108, 117, 0.3);
|
||||||
|
border-radius: var(--lm-radius);
|
||||||
|
}
|
||||||
|
:is(#login-modal #modal-login-view, #register-modal #modal-register-view, #anon-setup-modal #anon-setup-view, #anon-passkey-modal #anon-passkey-view) form .flash-error { margin: 0; }
|
||||||
|
#register-modal #modal-register-view #register-status:empty { display: none; }
|
||||||
|
#register-modal #modal-register-view #register-status.flash-success {
|
||||||
|
padding: 10px 12px; font-size: 0.84em; line-height: 1.45; color: #7ee2a0;
|
||||||
|
background: rgba(74, 222, 128, 0.1);
|
||||||
|
border: 1px solid rgba(74, 222, 128, 0.3);
|
||||||
|
border-radius: var(--lm-radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Anon setup */
|
||||||
|
#anon-setup-modal #anon-setup-view .lm-note {
|
||||||
|
margin: 0 0 20px; padding: 9px 12px; text-align: left;
|
||||||
|
font-size: 0.8em; line-height: 1.5; color: var(--lm-muted);
|
||||||
|
background: var(--lm-surface); border: 1px solid var(--lm-border); border-left: 2px solid var(--lm-accent);
|
||||||
|
}
|
||||||
|
#anon-setup-modal #anon-setup-view .lm-note i { color: var(--lm-accent); margin-right: 4px; }
|
||||||
|
#anon-setup-modal #anon-setup-view .lm-textbtn {
|
||||||
|
margin: 0; padding: 0; background: none; border: 0;
|
||||||
|
font: inherit; font-weight: 600; color: var(--lm-accent); cursor: pointer;
|
||||||
|
}
|
||||||
|
#anon-setup-modal #anon-setup-view .lm-textbtn:hover { text-decoration: underline; text-underline-offset: 3px; }
|
||||||
|
|
||||||
|
/* Anon passkey identity */
|
||||||
|
#anon-passkey-modal #anon-passkey-view .lm-stat {
|
||||||
|
display: flex; align-items: center; justify-content: space-between; gap: 12px;
|
||||||
|
margin: 0 0 16px; padding: 12px 14px;
|
||||||
|
background: var(--lm-surface); border: 1px solid var(--lm-border);
|
||||||
|
}
|
||||||
|
#anon-passkey-modal #anon-passkey-view .lm-stat-label {
|
||||||
|
font-size: 0.72em; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: var(--lm-muted);
|
||||||
|
}
|
||||||
|
#anon-passkey-modal #anon-passkey-view .lm-stat code { color: var(--lm-accent); font-size: 0.95em; }
|
||||||
|
|
||||||
|
/* Register-only */
|
||||||
|
#register-modal #modal-register-view .lm-check-multiline { align-items: flex-start; line-height: 1.5; }
|
||||||
|
#register-modal #modal-register-view .lm-check-multiline .lm-check-box { margin-top: 1px; }
|
||||||
|
#register-modal #modal-register-view .lm-recaptcha { display: flex; justify-content: center; }
|
||||||
|
|
||||||
|
/* Divider + footer */
|
||||||
|
:is(#login-modal #modal-login-view, #register-modal #modal-register-view, #anon-setup-modal #anon-setup-view, #anon-passkey-modal #anon-passkey-view) .lm-divider {
|
||||||
|
display: flex; align-items: center; gap: 12px; margin: 20px 0 14px;
|
||||||
|
color: var(--lm-muted); font-size: 0.75em; text-transform: uppercase; letter-spacing: 0.12em;
|
||||||
|
}
|
||||||
|
:is(#login-modal #modal-login-view, #register-modal #modal-register-view, #anon-setup-modal #anon-setup-view, #anon-passkey-modal #anon-passkey-view) .lm-divider::before,
|
||||||
|
:is(#login-modal #modal-login-view, #register-modal #modal-register-view, #anon-setup-modal #anon-setup-view, #anon-passkey-modal #anon-passkey-view) .lm-divider::after { content: ''; flex: 1; height: 1px; background: var(--lm-border); }
|
||||||
|
:is(#login-modal #modal-login-view, #register-modal #modal-register-view, #anon-setup-modal #anon-setup-view, #anon-passkey-modal #anon-passkey-view) .lm-footer {
|
||||||
|
margin: 20px 0 0; padding-top: 16px; border-top: 1px solid var(--lm-border);
|
||||||
|
text-align: center; font-size: 0.88em; color: var(--lm-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
:is(#login-modal #modal-login-view, #register-modal #modal-register-view, #anon-setup-modal #anon-setup-view, #anon-passkey-modal #anon-passkey-view) * { animation: none !important; transition: none !important; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
function switchLoginTab(tab) {
|
function switchLoginTab(tab) {
|
||||||
var isCreds = tab === 'creds';
|
var isCreds = tab === 'creds';
|
||||||
document.getElementById('login-panel-creds').style.display = isCreds ? '' : 'none';
|
document.getElementById('login-panel-creds').style.display = isCreds ? '' : 'none';
|
||||||
document.getElementById('login-panel-passkey').style.display = isCreds ? 'none' : '';
|
document.getElementById('login-panel-passkey').style.display = isCreds ? 'none' : '';
|
||||||
var tCreds = document.getElementById('login-tab-creds');
|
[['login-tab-creds', isCreds], ['login-tab-passkey', !isCreds]].forEach(function (t) {
|
||||||
var tPk = document.getElementById('login-tab-passkey');
|
var el = document.getElementById(t[0]);
|
||||||
if (tCreds) { tCreds.style.borderBottomColor = isCreds ? 'var(--accent, #0096ff)' : 'transparent'; tCreds.style.color = isCreds ? 'var(--text-color, #fff)' : 'var(--text-muted, #888)'; }
|
if (!el) return;
|
||||||
if (tPk) { tPk.style.borderBottomColor = isCreds ? 'transparent' : 'var(--accent, #0096ff)'; tPk.style.color = isCreds ? 'var(--text-muted, #888)' : 'var(--text-color, #fff)'; }
|
el.classList.toggle('is-active', t[1]);
|
||||||
|
el.setAttribute('aria-selected', t[1] ? 'true' : 'false');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function toggleLoginPassword(btn) {
|
||||||
|
var input = btn.parentNode.querySelector('input');
|
||||||
|
if (!input) return;
|
||||||
|
var show = input.type === 'password';
|
||||||
|
input.type = show ? 'text' : 'password';
|
||||||
|
btn.innerHTML = show ? '<i class="fa-solid fa-eye-slash"></i>' : '<i class="fa-solid fa-eye"></i>';
|
||||||
|
btn.setAttribute('aria-label', show ? 'Hide password' : 'Show password');
|
||||||
|
btn.title = show ? 'Hide password' : 'Show password';
|
||||||
|
input.focus();
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -508,40 +751,25 @@ async function loginWithPasskey() {
|
|||||||
@if(enable_anonymous_access)
|
@if(enable_anonymous_access)
|
||||||
<!-- Anonymous Passkey Identity Modal -->
|
<!-- Anonymous Passkey Identity Modal -->
|
||||||
<div id="anon-passkey-modal" style="display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.75); backdrop-filter: blur(4px); z-index: 99999; align-items: center; justify-content: center;">
|
<div id="anon-passkey-modal" style="display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.75); backdrop-filter: blur(4px); z-index: 99999; align-items: center; justify-content: center;">
|
||||||
<div class="login-modal-content" style="max-width: 500px; width: 92vw; max-height: 90vh; overflow-y: auto; text-align: left; padding: 25px; border: 1px solid rgba(255,255,255,0.15); border-radius: 8px; background: var(--bg-primary, #111); box-shadow: 0 10px 40px rgba(0,0,0,0.8); position: relative;">
|
<div style="max-width: 380px; width: 92vw; max-height: 90vh; overflow-y: auto; padding: 0; border: 1px solid var(--accent); border-radius: 0; background: var(--bg); box-shadow: 0 0 20px rgba(0,0,0,0.5); position: relative;">
|
||||||
<button id="anon-passkey-modal-close" style="position: absolute; top: 15px; right: 15px; background: none; border: none; color: var(--text-muted, #aaa); font-size: 1.4em; cursor: pointer; line-height: 1;">×</button>
|
<button id="anon-passkey-modal-close" style="position: absolute; top: 14px; right: 14px; background: none; border: none; color: var(--text-muted, #aaa); font-size: 1.3em; cursor: pointer; line-height: 1;">×</button>
|
||||||
|
|
||||||
<div style="display: flex; align-items: center; gap: 10px; margin-bottom: 8px;">
|
<div id="anon-passkey-view">
|
||||||
<i class="fa-solid fa-fingerprint" style="color: var(--accent, #0096ff); font-size: 1.3em;"></i>
|
<div class="lm-header">
|
||||||
<h3 style="margin: 0; font-size: 1.25em; color: var(--text-color, #fff);">Anonymous Passkey Identity</h3>
|
<h2 class="lm-title">Passkeys</h2>
|
||||||
</div>
|
<div class="lm-subtitle"><span id="anon-pk-count">–</span> / <span id="anon-pk-max">4</span> in use</div>
|
||||||
<p style="margin: 0 0 18px 0; font-size: 0.85em; color: var(--text-muted, #aaa); line-height: 1.4;">
|
|
||||||
Your anonymous identity is protected by a <strong>passkey</strong> stored in your OS or password manager (e.g. Bitwarden).
|
|
||||||
No private key ever touches this browser's storage.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<!-- Identity info -->
|
|
||||||
<div style="margin-bottom: 14px; padding: 12px; background: rgba(0,0,0,0.35); border-radius: 6px; border: 1px solid rgba(255,255,255,0.07);">
|
|
||||||
<div style="margin-bottom: 8px;">
|
|
||||||
<label style="display: block; font-size: 0.75em; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted, #888); margin-bottom: 3px;">Short Fingerprint</label>
|
|
||||||
<code id="anon-pk-fp-display" style="font-family: monospace; font-size: 1em; color: var(--accent, #00d2ff);">—</code>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<label style="display: block; font-size: 0.75em; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted, #888); margin-bottom: 3px;">Credential ID (short)</label>
|
|
||||||
<code id="anon-pk-cred-display" style="font-family: monospace; font-size: 0.78em; color: var(--text-muted, #aaa); word-break: break-all;">—</code>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Actions -->
|
<div class="lm-stat">
|
||||||
<div style="display: flex; flex-wrap: wrap; gap: 8px;">
|
<span class="lm-stat-label">Fingerprint</span>
|
||||||
<button type="button" id="anon-pk-add-btn" class="btn btn-sm btn-primary" style="font-size: 0.85em; padding: 6px 14px;">
|
<code id="anon-pk-fp-display">—</code>
|
||||||
<i class="fa-solid fa-plus"></i> Add another passkey
|
</div>
|
||||||
|
|
||||||
|
<button type="button" id="anon-pk-add-btn" class="lm-btn lm-btn-primary">
|
||||||
|
<i class="fa-solid fa-plus"></i> Add passkey
|
||||||
</button>
|
</button>
|
||||||
|
<div id="anon-pk-error" class="lm-error" style="display: none;"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p style="margin: 14px 0 0; font-size: 0.8em; color: var(--text-muted, #888); line-height: 1.4;">
|
|
||||||
To use this identity on another device, simply sign in with your passkey manager (Bitwarden, iCloud Keychain, etc.) — it syncs automatically.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -578,43 +806,45 @@ async function loginWithPasskey() {
|
|||||||
@if(enable_anonymous_access)
|
@if(enable_anonymous_access)
|
||||||
<!-- Anonymous Passkey Setup Modal — shown when user clicks "Login as anonymous" -->
|
<!-- Anonymous Passkey Setup Modal — shown when user clicks "Login as anonymous" -->
|
||||||
<div id="anon-setup-modal" style="display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.8); backdrop-filter: blur(4px); z-index: 99999; align-items: center; justify-content: center;">
|
<div id="anon-setup-modal" style="display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.8); backdrop-filter: blur(4px); z-index: 99999; align-items: center; justify-content: center;">
|
||||||
<div style="max-width: 420px; width: 92vw; padding: 28px; border: 1px solid rgba(255,255,255,0.12); border-radius: 10px; background: var(--bg-primary, #111); box-shadow: 0 12px 48px rgba(0,0,0,0.9); position: relative; text-align: center;">
|
<div style="max-width: 420px; width: 92vw; padding: 0; border: 1px solid var(--accent); border-radius: 0; background: var(--bg); box-shadow: 0 0 20px rgba(0,0,0,0.5); position: relative;">
|
||||||
<button id="anon-setup-modal-close" style="position: absolute; top: 14px; right: 14px; background: none; border: none; color: var(--text-muted, #aaa); font-size: 1.3em; cursor: pointer; line-height: 1;">×</button>
|
<button id="anon-setup-modal-close" style="position: absolute; top: 14px; right: 14px; background: none; border: none; color: var(--text-muted, #aaa); font-size: 1.3em; cursor: pointer; line-height: 1;">×</button>
|
||||||
|
|
||||||
<!-- New user view (no passkey yet) -->
|
<div id="anon-setup-view">
|
||||||
<div id="anon-setup-new">
|
<!-- New user view (no passkey yet) -->
|
||||||
<div style="font-size: 2.4em; margin-bottom: 12px;">🔑</div>
|
<div id="anon-setup-new" class="lm-panel lm-panel-passkey">
|
||||||
<h3 style="margin: 0 0 10px; font-size: 1.15em; color: var(--text-color, #fff);">Create an anonymous passkey</h3>
|
<div class="lm-passkey-icon"><i class="fa-solid fa-fingerprint"></i></div>
|
||||||
<p style="margin: 0 0 18px; font-size: 0.88em; color: var(--text-muted, #aaa); line-height: 1.55;">
|
<h2 class="lm-title">Create an anonymous passkey</h2>
|
||||||
Your browser will ask you to save a <strong>passkey</strong> — a secure credential stored in Bitwarden, iCloud Keychain, or your OS.
|
<p class="lm-passkey-text" style="margin-top: 12px;">
|
||||||
No account, no email, no password. Your comments and favorites are tied to this passkey.
|
Your browser will ask you to save a <strong>passkey</strong> — a secure credential stored in Bitwarden, iCloud Keychain, or your OS.
|
||||||
</p>
|
No account, no email, no password. Your comments and favorites are tied to this passkey.
|
||||||
<p style="margin: 0 0 20px; font-size: 0.82em; color: var(--text-muted, #888); line-height: 1.4;">
|
</p>
|
||||||
You can use it across devices if your passkey manager syncs (e.g. Bitwarden).
|
<p class="lm-note">
|
||||||
</p>
|
<i class="fa-solid fa-rotate"></i> You can use it across devices if your passkey manager syncs (e.g. Bitwarden).
|
||||||
<button type="button" id="anon-setup-create-btn" style="width: 100%; padding: 11px; background: var(--accent, #0096ff); color: #fff; border: none; border-radius: 5px; font-size: 0.95em; font-weight: 600; cursor: pointer; margin-bottom: 10px;">
|
</p>
|
||||||
<i class="fa-solid fa-fingerprint"></i> Create my passkey
|
<button type="button" id="anon-setup-create-btn" class="lm-btn lm-btn-primary">
|
||||||
</button>
|
<i class="fa-solid fa-fingerprint"></i> Create my passkey
|
||||||
<div id="anon-setup-new-error" style="display: none; color: #e06c75; font-size: 0.83em; margin-top: 6px;"></div>
|
</button>
|
||||||
<button type="button" id="anon-setup-switch-existing" style="background: none; border: none; color: var(--text-muted, #888); font-size: 0.8em; cursor: pointer; text-decoration: underline; margin-top: 4px;">
|
<div id="anon-setup-new-error" class="lm-error" style="display: none;"></div>
|
||||||
Already have one? Use existing passkey
|
<p class="lm-footer">
|
||||||
</button>
|
<button type="button" id="anon-setup-switch-existing" class="lm-textbtn">Already have one? Use existing passkey</button>
|
||||||
</div>
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Returning user view (has passkey) -->
|
<!-- Returning user view (has passkey) -->
|
||||||
<div id="anon-setup-returning" style="display: none;">
|
<div id="anon-setup-returning" class="lm-panel lm-panel-passkey" style="display: none;">
|
||||||
<div style="font-size: 2.4em; margin-bottom: 12px;">👤</div>
|
<div class="lm-passkey-icon"><i class="fa-solid fa-user-secret"></i></div>
|
||||||
<h3 style="margin: 0 0 10px; font-size: 1.15em; color: var(--text-color, #fff);">Use your anonymous passkey</h3>
|
<h2 class="lm-title">Use your anonymous passkey</h2>
|
||||||
<p style="margin: 0 0 20px; font-size: 0.88em; color: var(--text-muted, #aaa); line-height: 1.55;">
|
<p class="lm-passkey-text" style="margin-top: 12px;">
|
||||||
Pick your saved passkey from Bitwarden, iCloud Keychain, or your OS to continue as the same anonymous user.
|
Pick your saved passkey from Bitwarden, iCloud Keychain, or your OS to continue as the same anonymous user.
|
||||||
</p>
|
</p>
|
||||||
<button type="button" id="anon-setup-auth-btn" style="width: 100%; padding: 11px; background: var(--accent, #0096ff); color: #fff; border: none; border-radius: 5px; font-size: 0.95em; font-weight: 600; cursor: pointer; margin-bottom: 10px;">
|
<button type="button" id="anon-setup-auth-btn" class="lm-btn lm-btn-primary">
|
||||||
<i class="fa-solid fa-fingerprint"></i> Use my passkey
|
<i class="fa-solid fa-fingerprint"></i> Use my passkey
|
||||||
</button>
|
</button>
|
||||||
<div id="anon-setup-ret-error" style="display: none; color: #e06c75; font-size: 0.83em; margin-top: 6px;"></div>
|
<div id="anon-setup-ret-error" class="lm-error" style="display: none;"></div>
|
||||||
<button type="button" id="anon-setup-switch-new" style="background: none; border: none; color: var(--text-muted, #888); font-size: 0.8em; cursor: pointer; text-decoration: underline; margin-top: 4px;">
|
<p class="lm-footer">
|
||||||
Create a new passkey instead
|
<button type="button" id="anon-setup-switch-new" class="lm-textbtn">Create a new passkey instead</button>
|
||||||
</button>
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -733,31 +963,65 @@ async function loginWithPasskey() {
|
|||||||
<div id="register-modal" style="display: none;">
|
<div id="register-modal" style="display: none;">
|
||||||
<div class="login-modal-content">
|
<div class="login-modal-content">
|
||||||
<button id="register-modal-close">×</button>
|
<button id="register-modal-close">×</button>
|
||||||
<form class="login-form" id="modal-register-form" method="post" action="/register" novalidate>
|
<div id="modal-register-view">
|
||||||
<h2 style="text-align: center; margin-bottom: 20px;">{{ t('auth.register_title') }}</h2>
|
<div class="lm-header">
|
||||||
<div id="register-status" style="margin-bottom: 15px; text-align: center; font-size: 0.9em;"></div>
|
<h2 class="lm-title">{{ t('auth.register_title') }}</h2>
|
||||||
<input type="text" name="username" placeholder="{{ t('auth.username_placeholder') }}" autocomplete="off" required />
|
<div class="lm-subtitle">{{ domain }}</div>
|
||||||
<input type="password" name="password" placeholder="{{ t('auth.password_placeholder') }}" autocomplete="off" required minlength="20"
|
|
||||||
title="Must be at least 20 characters long." />
|
|
||||||
<input type="password" name="password_confirm" placeholder="{{ t('auth.confirm_password') }}" autocomplete="off" required minlength="20" />
|
|
||||||
@if(!registration_open)
|
|
||||||
<input type="text" name="token" placeholder="{{ t('auth.invite_token') }}" autocomplete="off" required />
|
|
||||||
@elseif(registration_require_mail_andor_token)
|
|
||||||
<input type="email" name="email" placeholder="{{ t('auth.email_placeholder') }}" autocomplete="off" required />
|
|
||||||
@endif
|
|
||||||
<input type="text" name="email_confirm_field" style="display: none !important;" tabindex="-1" autocomplete="off" />
|
|
||||||
<p style="text-align: left; font-size: 0.9em; margin: 0; color: #fff;">
|
|
||||||
<input type="checkbox" id="tos-modal" name="tos" required />
|
|
||||||
<label for="tos-modal">@if(private_society){{ t('auth.tos_private') }}@else{{ t('auth.tos_public') }} <a href="/terms" target="_blank" style="color: var(--accent); text-decoration: underline;">{{ t('auth.tos_terms') }}</a>, <a href="/rules" target="_blank" style="color: var(--accent); text-decoration: underline;">{{ t('auth.tos_rules') }}</a> {{ t('auth.tos_age') }}@endif</label>
|
|
||||||
</p>
|
|
||||||
@if(recaptcha_enabled)
|
|
||||||
<div id="modal-register-recaptcha" style="margin: 10px 0;"></div>
|
|
||||||
@endif
|
|
||||||
<button type="submit">{{ t('auth.create_account') }}</button>
|
|
||||||
<div style="text-align: center; margin-top: 20px;">
|
|
||||||
<a href="#" id="register-to-login" style="font-size: 0.9em; color: var(--accent); text-decoration: underline;">{{ t('auth.back_to_login') }}</a>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
|
||||||
|
<form class="login-form lm-form" id="modal-register-form" method="post" action="/register" novalidate>
|
||||||
|
<div id="register-status"></div>
|
||||||
|
<label class="lm-field">
|
||||||
|
<i class="fa-solid fa-user lm-field-icon"></i>
|
||||||
|
<input class="lm-input" type="text" name="username" placeholder="{{ t('auth.username_placeholder') }}" aria-label="{{ t('auth.username_placeholder') }}" autocomplete="username" autocapitalize="off" spellcheck="false" required />
|
||||||
|
</label>
|
||||||
|
<label class="lm-field">
|
||||||
|
<i class="fa-solid fa-lock lm-field-icon"></i>
|
||||||
|
<input class="lm-input" type="password" name="password" placeholder="{{ t('auth.password_placeholder') }}" aria-label="{{ t('auth.password_placeholder') }}" autocomplete="new-password" required minlength="20"
|
||||||
|
title="Must be at least 20 characters long." />
|
||||||
|
<button type="button" class="lm-reveal" onclick="toggleLoginPassword(this)" aria-label="Show password" title="Show password">
|
||||||
|
<i class="fa-solid fa-eye"></i>
|
||||||
|
</button>
|
||||||
|
</label>
|
||||||
|
<label class="lm-field">
|
||||||
|
<i class="fa-solid fa-lock lm-field-icon"></i>
|
||||||
|
<input class="lm-input" type="password" name="password_confirm" placeholder="{{ t('auth.confirm_password') }}" aria-label="{{ t('auth.confirm_password') }}" autocomplete="new-password" required minlength="20" />
|
||||||
|
<button type="button" class="lm-reveal" onclick="toggleLoginPassword(this)" aria-label="Show password" title="Show password">
|
||||||
|
<i class="fa-solid fa-eye"></i>
|
||||||
|
</button>
|
||||||
|
</label>
|
||||||
|
@if(!registration_open)
|
||||||
|
<label class="lm-field">
|
||||||
|
<i class="fa-solid fa-ticket lm-field-icon"></i>
|
||||||
|
<input class="lm-input" type="text" name="token" placeholder="{{ t('auth.invite_token') }}" aria-label="{{ t('auth.invite_token') }}" autocomplete="off" spellcheck="false" required />
|
||||||
|
</label>
|
||||||
|
@elseif(registration_require_mail_andor_token)
|
||||||
|
<label class="lm-field">
|
||||||
|
<i class="fa-solid fa-envelope lm-field-icon"></i>
|
||||||
|
<input class="lm-input" type="email" name="email" placeholder="{{ t('auth.email_placeholder') }}" aria-label="{{ t('auth.email_placeholder') }}" autocomplete="email" required />
|
||||||
|
</label>
|
||||||
|
@endif
|
||||||
|
<input type="text" name="email_confirm_field" style="display: none !important;" tabindex="-1" autocomplete="off" />
|
||||||
|
|
||||||
|
<div class="lm-row">
|
||||||
|
<label class="lm-check lm-check-multiline" for="tos-modal">
|
||||||
|
<input type="checkbox" id="tos-modal" name="tos" required />
|
||||||
|
<span class="lm-check-box"><i class="fa-solid fa-check"></i></span>
|
||||||
|
<span>@if(private_society){{ t('auth.tos_private') }}@else{{ t('auth.tos_public') }} <a href="/terms" target="_blank" class="lm-link">{{ t('auth.tos_terms') }}</a>, <a href="/rules" target="_blank" class="lm-link">{{ t('auth.tos_rules') }}</a> {{ t('auth.tos_age') }}@endif</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if(recaptcha_enabled)
|
||||||
|
<div id="modal-register-recaptcha" class="lm-recaptcha"></div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<button type="submit" class="lm-btn lm-btn-primary">{{ t('auth.create_account') }}</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p class="lm-footer">
|
||||||
|
<a href="#" id="register-to-login" class="lm-link">{{ t('auth.back_to_login') }}</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
{{-- Item visibility modal (shared by item-partial-modern / item-partial-legacy). JS hooks: #visibility-modal, #visibility-item-id, input[name=visibility], #visibility-modal-cancel/-save --}}
|
||||||
|
<div id="visibility-modal" class="modal-overlay" style="display: none;">
|
||||||
|
<div class="modal-content vis-modal-content">
|
||||||
|
<div class="vis-header">
|
||||||
|
<h3><i class="fa-solid fa-eye"></i> Change visibility</h3>
|
||||||
|
<p>Choose who can see this post.</p>
|
||||||
|
</div>
|
||||||
|
<div class="vis-body">
|
||||||
|
<form id="visibility-form" onsubmit="return false;">
|
||||||
|
<input type="hidden" id="visibility-item-id" value="{{ item.id }}" />
|
||||||
|
<div class="visibility-options">
|
||||||
|
<label class="visibility-option">
|
||||||
|
<input type="radio" name="visibility" value="0" />
|
||||||
|
<span class="vis-radio"></span>
|
||||||
|
<span class="vis-text">
|
||||||
|
<strong><i class="fa-solid fa-globe"></i> Public</strong>
|
||||||
|
<span>Visible to everyone in main feeds, search, and rankings.</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label class="visibility-option">
|
||||||
|
<input type="radio" name="visibility" value="1" />
|
||||||
|
<span class="vis-radio"></span>
|
||||||
|
<span class="vis-text">
|
||||||
|
<strong><i class="fa-solid fa-link"></i> Unlisted</strong>
|
||||||
|
<span>Hidden from main feeds; accessible only via direct link or slug.</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label class="visibility-option">
|
||||||
|
<input type="radio" name="visibility" value="2" />
|
||||||
|
<span class="vis-radio"></span>
|
||||||
|
<span class="vis-text">
|
||||||
|
<strong><i class="fa-solid fa-lock"></i> Private</strong>
|
||||||
|
<span>Only visible to you and site moderators.</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
@if(is_mod_or_admin)
|
||||||
|
<label class="visibility-option vis-danger">
|
||||||
|
<input type="radio" name="visibility" value="3" />
|
||||||
|
<span class="vis-radio"></span>
|
||||||
|
<span class="vis-text">
|
||||||
|
<strong><i class="fa-solid fa-ban"></i> Unavailable (451)</strong>
|
||||||
|
<span>Only visible to administrators. Serves HTTP 451 on direct media for all other users.</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="modal-actions vis-footer">
|
||||||
|
<button type="button" class="btn-secondary" id="visibility-modal-cancel">Cancel</button>
|
||||||
|
<button type="button" class="btn-primary" id="visibility-modal-save">Save visibility</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Visibility modal — same rules as the report/info modals: square, flat, fixed header/footer, full screen on phones */
|
||||||
|
#visibility-modal .vis-modal-content {
|
||||||
|
--vm-accent: var(--accent, #0096ff);
|
||||||
|
--vm-text: var(--text-color, #fff);
|
||||||
|
--vm-muted: var(--text-muted, #8a8f98);
|
||||||
|
--vm-surface: rgba(255, 255, 255, 0.04);
|
||||||
|
--vm-border: rgba(255, 255, 255, 0.1);
|
||||||
|
--vm-danger: #ff5c5c;
|
||||||
|
width: min(440px, 92vw);
|
||||||
|
max-height: min(90vh, 90dvh);
|
||||||
|
display: flex; flex-direction: column; overflow: hidden;
|
||||||
|
padding: 0; text-align: left;
|
||||||
|
border-radius: 0 !important;
|
||||||
|
border: 1px solid var(--accent) !important;
|
||||||
|
background: var(--bg) !important;
|
||||||
|
box-shadow: 0 0 20px rgba(0, 0, 0, 0.5) !important;
|
||||||
|
color: var(--vm-text);
|
||||||
|
}
|
||||||
|
#visibility-modal .vis-modal-content * { border-radius: 0 !important; }
|
||||||
|
|
||||||
|
#visibility-modal .vis-header { flex-shrink: 0; padding: 20px 24px 14px; border-bottom: 1px solid var(--vm-border); }
|
||||||
|
#visibility-modal .vis-header h3 { display: flex; align-items: center; gap: 10px; margin: 0; font-size: 1.2em; font-weight: 700; color: var(--vm-text); }
|
||||||
|
#visibility-modal .vis-header h3 i { color: var(--vm-accent); font-size: 0.9em; }
|
||||||
|
#visibility-modal .vis-header p { margin: 6px 0 0; font-size: 0.85em; color: var(--vm-muted); }
|
||||||
|
|
||||||
|
#visibility-modal .vis-body { flex: 1 1 auto; min-height: 0; overflow-y: auto; overscroll-behavior: contain; padding: 16px 24px; }
|
||||||
|
#visibility-modal .visibility-options { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
#visibility-modal .visibility-option {
|
||||||
|
display: flex; align-items: flex-start; gap: 12px; margin: 0; padding: 12px 14px;
|
||||||
|
background: var(--vm-surface); border: 1px solid var(--vm-border);
|
||||||
|
cursor: pointer; user-select: none; transition: border-color 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
#visibility-modal .visibility-option:hover { border-color: rgba(255, 255, 255, 0.22); }
|
||||||
|
#visibility-modal .visibility-option:has(input:checked) { border-color: var(--vm-accent); background: color-mix(in srgb, var(--vm-accent) 8%, transparent); }
|
||||||
|
#visibility-modal .visibility-option input { position: absolute; opacity: 0; width: 0; height: 0; margin: 0; }
|
||||||
|
#visibility-modal .vis-radio {
|
||||||
|
position: relative; width: 18px; height: 18px; flex-shrink: 0; margin-top: 1px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.28); background: var(--vm-surface); transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
#visibility-modal .vis-radio::after {
|
||||||
|
content: ''; position: absolute; inset: 4px; background: var(--vm-accent); transform: scale(0); transition: transform 0.15s;
|
||||||
|
}
|
||||||
|
#visibility-modal .visibility-option input:checked + .vis-radio { border-color: var(--vm-accent); }
|
||||||
|
#visibility-modal .visibility-option input:checked + .vis-radio::after { transform: scale(1); }
|
||||||
|
#visibility-modal .visibility-option input:focus-visible + .vis-radio { outline: 2px solid var(--vm-accent); outline-offset: 2px; }
|
||||||
|
#visibility-modal .vis-text { display: flex; flex-direction: column; gap: 3px; font-size: 0.88em; line-height: 1.4; }
|
||||||
|
#visibility-modal .vis-text strong { display: flex; align-items: center; gap: 7px; color: var(--vm-text); font-weight: 700; }
|
||||||
|
#visibility-modal .vis-text strong i { width: 14px; text-align: center; color: var(--vm-muted); }
|
||||||
|
#visibility-modal .visibility-option:has(input:checked) .vis-text strong i { color: var(--vm-accent); }
|
||||||
|
#visibility-modal .vis-text > span { color: var(--vm-muted); }
|
||||||
|
|
||||||
|
#visibility-modal .vis-danger .vis-text strong,
|
||||||
|
#visibility-modal .vis-danger .vis-text strong i { color: var(--vm-danger); }
|
||||||
|
#visibility-modal .vis-danger:has(input:checked) { border-color: var(--vm-danger); background: rgba(255, 92, 92, 0.08); }
|
||||||
|
#visibility-modal .vis-danger input:checked + .vis-radio { border-color: var(--vm-danger); }
|
||||||
|
#visibility-modal .vis-danger .vis-radio::after { background: var(--vm-danger); }
|
||||||
|
|
||||||
|
#visibility-modal .vis-footer {
|
||||||
|
flex-shrink: 0; display: flex; justify-content: flex-end; gap: 8px; margin: 0;
|
||||||
|
padding: 14px 24px; border-top: 1px solid var(--vm-border); background: rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
#visibility-modal .vis-footer button {
|
||||||
|
height: 40px; margin: 0; padding: 0 18px; font: inherit; font-size: 0.85em; cursor: pointer;
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
transition: background 0.18s, color 0.18s, border-color 0.18s;
|
||||||
|
}
|
||||||
|
#visibility-modal #visibility-modal-cancel { background: transparent; border: 1px solid var(--vm-border); color: var(--vm-muted); font-weight: 600; }
|
||||||
|
#visibility-modal #visibility-modal-cancel:hover { color: var(--vm-text); border-color: rgba(255, 255, 255, 0.25); background: var(--vm-surface); }
|
||||||
|
#visibility-modal #visibility-modal-save {
|
||||||
|
background: var(--vm-accent); border: 1px solid var(--vm-accent); color: #000;
|
||||||
|
font-weight: 800; text-transform: uppercase; letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
#visibility-modal #visibility-modal-save:hover:not(:disabled) { background: transparent; color: var(--vm-accent); }
|
||||||
|
#visibility-modal #visibility-modal-save:disabled { opacity: 0.6; cursor: progress; }
|
||||||
|
#visibility-modal .vis-footer button:focus-visible { outline: 2px solid var(--vm-accent); outline-offset: 2px; }
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
#visibility-modal { padding: 0 !important; align-items: stretch !important; }
|
||||||
|
#visibility-modal .vis-modal-content {
|
||||||
|
width: 100vw; max-width: none;
|
||||||
|
height: 100vh; height: 100dvh; max-height: none;
|
||||||
|
border: 0 !important; box-shadow: none !important;
|
||||||
|
}
|
||||||
|
#visibility-modal .vis-footer { padding-bottom: calc(14px + env(safe-area-inset-bottom, 0px)); }
|
||||||
|
#visibility-modal .vis-footer button { flex: 1; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user