Files
f0ckm/views/admin.html
T
2026-09-19 06:20:37 +02:00

328 lines
16 KiB
HTML

@include(snippets/header)
<div class="pagewrapper">
<div id="main">
<div class="container">
<h1>ADMINBEREICH</h1>
<h5>Hallo, {{ session.user }}</h5>
<span>Hier entsteht eine Internetpräsenz!</span><br>
<hr>
<p>f0ck stats: @if(typeof totals !== "undefined")total: {{ totals.total }} | tagged: {{ totals.tagged }} | untagged: {{ totals.untagged }} | sfw: {{ totals.sfw }} | nsfw: {{ totals.nsfw }}@endif</p>
<hr>
<div class="admintools">
<p>Adminwerkzeuge</p>
<ul>
<li><a href="/mod/audit">Audit Log</a></li>
<li><a href="/admin/approve">Approval Queue</a></li>
<li><a href="/admin/sessions">Sessions</a></li>
<li><a href="/admin/tokens">Invite Tokens</a></li>
<li><a href="/admin/users">User Manager</a></li>
<li><a href="/admin/bans">Ban & Fingerprint Manager</a></li>
<li><a href="/admin/emojis">Emoji Manager</a></li>
<li><a href="/admin/memes">Meme Manager</a></li>
<li><a href="/admin/koepfe">Köpfe Manager</a></li>
<li><a href="/admin/halls">Hall Manager</a></li>
<li><a href="/admin/motd">MOTD Manager</a></li>
<li><a href="/admin/wordfilter">Wordfilter Manager</a></li>
<li><a href="/admin/nsfp">NSFP Tag Manager</a></li>
@if(enable_cleanup)
<li><a href="/admin/cleanup">Cleanup Manager</a></li>
@endif
<li><a href="/admin/about">About Page</a></li>
<li><a href="/admin/rules">Rules Page</a></li>
<li><a href="/admin/terms">ToS Page</a></li>
@if(enable_global_chat)
<li><a href="/admin/chat">Global Chat Manager</a></li>
@endif
</ul>
<hr style="margin: 20px 0; border: 0; border-top: 1px solid rgba(255,255,255,0.1);">
<!-- Navbar Brand Image -->
<div class="settings-item" id="brand-image-section" style="background: rgba(0,0,0,0.2); padding: 15px; border-radius: 4px; margin-top: 10px;">
<label style="display: block; font-weight: bold; color: var(--accent); margin-bottom: 8px;">Navbar Brand Image</label>
<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 &mdash; max 2&nbsp;MB.</p>
<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>
<hr style="margin: 20px 0; border: 0; border-top: 1px solid rgba(255,255,255,0.1);">
<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>
<label style="display: block; font-weight: bold; color: var(--accent);">Manual Upload Approval</label>
<p style="margin: 2px 0 0 0; font-size: 0.8em; color: #aaa;">If enabled, mods must approve every upload.</p>
</div>
<label class="switch">
<input type="checkbox" id="manual_approval_toggle" {{ manual_approval ? 'checked' : '' }} onchange="saveAdminSettings()">
<span class="slider round"></span>
</label>
</div>
@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;">
<div>
<label style="display: block; font-weight: bold; color: var(--accent);">Open Registration</label>
<p style="margin: 2px 0 0 0; font-size: 0.8em; color: #aaa;">Allow registration without invite tokens. Requires email activation.</p>
</div>
<label class="switch">
<input type="checkbox" id="registration_open_toggle" {{ registration_open ? 'checked' : '' }} onchange="saveAdminSettings()">
<span class="slider round"></span>
</label>
</div>
@endif
<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);">Minimum Tags</label>
<p style="margin: 2px 0 0 0; font-size: 0.8em; color: #aaa;">Minimum number of tags required per upload.</p>
</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()">
</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;">
<div>
<label style="display: block; font-weight: bold; color: var(--accent);">Trusted Upload Threshold</label>
<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>
<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 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>
<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>
async function saveAdminSettings() {
const status = document.getElementById('settings-status');
const approvalToggle = document.getElementById('manual_approval_toggle');
const registrationToggle = document.getElementById('registration_open_toggle');
const minTagsInput = document.getElementById('min_tags_input');
const trustedUploadsInput = document.getElementById('trusted_uploads_input');
status.textContent = 'Saving...';
status.style.color = 'var(--accent)';
try {
const csrfToken = (window.f0ckSession && window.f0ckSession.csrf_token) || '{{ csrf_token }}';
if (!csrfToken || csrfToken.startsWith('{')) {
status.textContent = 'CSRF token missing. Please refresh the page.';
status.style.color = '#d9534f';
return;
}
const res = await fetch('/admin/settings', {
method: 'POST',
headers: {
'X-Requested-With': 'XMLHttpRequest',
'Content-Type': 'application/x-www-form-urlencoded',
'X-CSRF-Token': csrfToken
},
body: new URLSearchParams({
manual_approval: approvalToggle ? (approvalToggle.checked ? 'on' : 'off') : 'off',
...(registrationToggle ? { registration_open: registrationToggle.checked ? 'on' : 'off' } : {}),
min_tags: minTagsInput ? minTagsInput.value : '3',
trusted_uploads: trustedUploadsInput ? trustedUploadsInput.value : '3',
csrf_token: csrfToken
}).toString()
});
if (!res.ok) {
if (res.status === 403) {
throw new Error('CSRF validation failed or session expired. Please reload the page.');
}
throw new Error('Server returned ' + res.status);
}
const data = await res.json();
if (data.success) {
status.textContent = 'Saved!';
status.style.color = '#28a745';
setTimeout(() => { status.textContent = ''; }, 2000);
} else {
throw new Error(data.msg || 'Save failed');
}
} catch (err) {
console.error('Settings save error:', err);
status.textContent = 'Error: ' + err.message;
status.style.color = '#d9534f';
}
}
async function regenerateTagImages() {
const btn = document.getElementById('regen_tag_btn');
const status = document.getElementById('settings-status');
if (!confirm('Are you sure you want to trigger a full regeneration of all tag images? This will run in the background.')) return;
btn.disabled = true;
btn.textContent = 'Processing...';
status.textContent = 'Triggering regeneration...';
status.style.color = 'var(--accent)';
try {
const res = await fetch('/admin/tag_image/regenerate_all', {
headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
const data = await res.json();
if (data.success) {
status.textContent = data.message;
status.style.color = '#28a745';
btn.textContent = 'Started!';
setTimeout(() => {
btn.disabled = false;
btn.textContent = 'Regenerate All';
status.textContent = '';
}, 5000);
} else {
throw new Error(data.msg || 'Action failed');
}
} catch (err) {
console.error('Regeneration error:', err);
status.textContent = 'Error: ' + err.message;
status.style.color = '#d9534f';
btn.disabled = false;
btn.textContent = 'Regenerate All';
}
}
async function uploadBrandImage(input) {
const file = input.files[0];
if (!file) return;
const status = document.getElementById('brand-status');
const removeBtn = document.getElementById('brand-remove-btn');
status.textContent = 'Uploading…';
status.style.color = 'var(--accent)';
const csrfToken = (window.f0ckSession && window.f0ckSession.csrf_token) || '{{ csrf_token }}';
const fd = new FormData();
fd.append('file', file);
try {
const res = await fetch('/admin/brand_image/upload', {
method: 'POST',
headers: {
'X-Requested-With': 'XMLHttpRequest',
'X-CSRF-Token': csrfToken
},
body: fd
});
const data = await res.json();
if (!data.success) throw new Error(data.msg || 'Upload failed');
status.textContent = '✓ Brand image updated!';
status.style.color = '#28a745';
// Update preview
const wrap = document.getElementById('brand-preview-wrap');
let preview = document.getElementById('brand-preview');
if (!preview || preview.tagName !== 'IMG') {
wrap.innerHTML = '';
preview = document.createElement('img');
preview.id = 'brand-preview';
preview.alt = 'current brand';
preview.style.cssText = 'max-height:48px;max-width:150px;object-fit:contain;';
wrap.appendChild(preview);
}
preview.src = data.url;
// Enable remove button
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);
} catch (err) {
status.textContent = 'Error: ' + err.message;
status.style.color = '#d9534f';
} finally {
input.value = '';
}
}
async function removeBrandImage() {
const status = document.getElementById('brand-status');
const removeBtn = document.getElementById('brand-remove-btn');
if (!confirm('Remove the custom brand image? The navbar will revert to text.')) return;
status.textContent = 'Removing…';
status.style.color = 'var(--accent)';
const csrfToken = (window.f0ckSession && window.f0ckSession.csrf_token) || '{{ csrf_token }}';
try {
const res = await fetch('/admin/brand_image/delete', {
method: 'POST',
headers: {
'X-Requested-With': 'XMLHttpRequest',
'X-CSRF-Token': csrfToken
}
});
const data = await res.json();
if (!data.success) throw new Error(data.msg || 'Remove failed');
status.textContent = '✓ Brand image removed.';
status.style.color = '#28a745';
// Reset preview
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>';
// Disable remove button
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);
} catch (err) {
status.textContent = 'Error: ' + err.message;
status.style.color = '#d9534f';
}
}
</script>
</div>
</div>
@include(snippets/footer)