init f0ckm

This commit is contained in:
2026-04-25 19:51:52 +02:00
commit b646107eb7
241 changed files with 70364 additions and 0 deletions

68
views/admin/about.html Normal file
View File

@@ -0,0 +1,68 @@
@include(snippets/header)
<div class="pagewrapper">
<div id="main" class="admin-container">
<div class="container">
<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>
<div class="admin-motd-form">
<form id="about-form" action="/admin/about" method="POST" onsubmit="event.preventDefault(); savePage(this, 'about');">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div style="margin-bottom: 20px;">
<label for="about-text" style="display: block; margin-bottom: 8px; color: var(--accent);">About Page Content (Markdown supported)</label>
<textarea id="about-text" name="about_text" style="width: 100%; min-height: 300px; background: rgba(0,0,0,0.3); border: 1px solid rgba(255,255,255,0.1); color: #fff; padding: 15px; border-radius: 4px; font-family: inherit; font-size: 1.1em; resize: vertical;">{!! about_text !!}</textarea>
</div>
<div style="display: flex; gap: 10px; align-items: center;">
<button type="submit" class="btn-primary" style="width: auto; padding: 10px 30px;">Save</button>
<button type="button" class="btn-remove" style="width: auto; padding: 10px 20px;" onclick="document.getElementById('about-text').value=''; savePage(document.getElementById('about-form'), 'about');">Clear</button>
<span id="about-status" style="margin-left: 10px; font-weight: bold; display: none;"></span>
</div>
</form>
</div>
<div style="margin-top: 40px; padding: 20px; background: rgba(0,0,0,0.2); border-left: 4px solid var(--accent); border-radius: 4px;">
<h4 style="color: var(--accent); margin-top: 0;">Tips</h4>
<p style="margin-bottom: 0;">Use <strong>Markdown</strong> syntax like <code># Header</code>, <code>**bold**</code>, <code>[links](url)</code>, or plain HTML.<br>
If empty, the default static about page is shown instead.</p>
</div>
</div>
<script>
async function savePage(form, page) {
const statusId = page + '-status';
const status = document.getElementById(statusId);
status.textContent = 'Saving...';
status.style.color = 'var(--accent)';
status.style.display = 'inline';
try {
const res = await fetch(form.action, {
method: 'POST',
headers: {
'X-Requested-With': 'XMLHttpRequest',
'Content-Type': 'application/x-www-form-urlencoded',
'X-CSRF-Token': window.f0ckSession?.csrf_token
},
body: new URLSearchParams(new FormData(form))
});
const data = await res.json();
if (data.success) {
status.textContent = 'Saved!';
status.style.color = '#28a745';
setTimeout(() => { status.style.display = 'none'; }, 2000);
} else {
throw new Error(data.msg || 'Unknown error');
}
} catch (err) {
console.error('Save Error:', err);
status.textContent = 'Error: ' + err.message;
status.style.color = '#d9534f';
}
}
</script>
</div>
</div>
@include(snippets/footer)

244
views/admin/approve.html Normal file
View File

@@ -0,0 +1,244 @@
@include(snippets/header)
<div id="main">
<div class="container">
<h1>APPROVAL QUEUE</h1>
<p>Items here are pending approval.</p>
@if(pending.length > 0)
<h2>Pending Uploads</h2>
<table class="table" style="width: 100%; margin-bottom: 30px;">
<thead>
<tr>
<td>Preview</td>
<td>ID</td>
<td>Uploader</td>
<td>Type</td>
<td>Tags</td>
<td>Action</td>
</tr>
</thead>
<tbody>
@each(pending as post)
<tr>
<td>
<video controls loop muted preload="metadata" style="max-height: 200px; max-width: 300px;">
<source src="/b/{{ post.dest }}" type="{{ post.mime }}">
</video>
</td>
<td>{{ post.id }}</td>
<td>{!! post.username !!}</td>
<td>{{ post.mime }}</td>
<td>
@each(post.tags as tag)
<span class="badge badge-secondary" style="margin-right: 5px;">{!! tag !!}</span>
@endeach
</td>
<td>
<a href="/admin/approve/?id={{ post.id }}" class="badge badge-success btn-approve-async">Approve</a>
<a href="/admin/deny/?id={{ post.id }}" class="badge badge-danger btn-deny-async">Deny / Delete</a>
</td>
</tr>
@endeach
</tbody>
</table>
@endif
<div style="display: flex; justify-content: space-between; align-items: center; margin-top: 40px;">
<h2 style="color: #ff6b6b; margin: 0;">Soft Deleted</h2>
@if(trash.length > 0)
<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)
<table class="table" style="width: 100%; opacity: 0.8;">
<thead>
<tr>
<td>Preview</td>
<td>ID</td>
<td>Uploader</td>
<td>Type</td>
<td>Tags</td>
<td>Action</td>
</tr>
</thead>
<tbody>
@each(trash as post)
<tr>
<td>
@if(post.thumbnail)
<img src="data:image/webp;base64,{{ post.thumbnail }}" style="max-height: 150px; opacity: 0.6;">
@else
<span style="color:red;">[File Missing]</span>
@endif
</td>
<td>{{ post.id }}</td>
<td>{!! post.username !!}</td>
<td>{{ post.mime }}</td>
<td>
@each(post.tags as tag)
<span class="badge badge-secondary" style="margin-right: 5px;">{!! tag !!}</span>
@endeach
</td>
<td>
<a href="/admin/approve/?id={{ post.id }}" class="badge badge-warning btn-approve-async">Restore</a>
<a href="/admin/deny/?id={{ post.id }}" class="badge badge-danger btn-deny-async">Purge</a>
</td>
</tr>
@endeach
</tbody>
</table>
@else
<p style="padding: 20px; border: 1px dashed #444; color: #888;">Trash is empty.</p>
@endif
@if(pending.length === 0 && trash.length === 0)
<div style="text-align: center; padding: 50px;">
<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)
<a href="/admin/approve?page={{ page - 1 }}" class="badge badge-secondary">&laquo; Prev</a>
@endif
<span>Page {{ page }} of {{ pages }}</span>
@if(page < pages) <a href="/admin/approve?page={{ page + 1 }}" class="badge badge-secondary">Next &raquo;</a>
@endif
</div>
<br>
@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 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 btnConfirm = document.getElementById('modal-confirm');
const btnCancel = document.getElementById('modal-cancel');
let pendingAction = null;
const showModal = (title, text, action) => {
modalTitle.innerText = title;
modalText.innerText = text;
pendingAction = action;
modal.style.display = 'flex';
btnConfirm.onclick = async () => {
if (!pendingAction) return;
btnConfirm.disabled = true;
btnConfirm.innerText = 'Processing...';
try {
await pendingAction();
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 url = btn.getAttribute('href');
const row = btn.closest('tr');
showModal('Deny Item', 'Permanently delete this item?', async () => {
const res = await fetch(url, {
headers: {
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'application/json'
}
});
const data = await res.json();
if (res.ok && data.success) {
if (window.flashMessage) window.flashMessage('{!! t('toast.item_deleted_success') !!}', 2000, 'success');
row.style.opacity = '0';
setTimeout(() => row.remove(), 300);
} else {
if (window.flashMessage) window.flashMessage(data.msg || '{!! t('toast.report_error') !!}', 3000, 'error');
throw new Error(data.msg || 'Request failed');
}
});
});
});
// Single Approve / Restore
document.querySelectorAll('.btn-approve-async').forEach(btn => {
btn.addEventListener('click', async e => {
e.preventDefault();
const url = btn.getAttribute('href');
const row = btn.closest('tr');
try {
const res = await fetch(url, {
headers: {
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'application/json'
}
});
const data = await res.json();
if (data.success) {
if (window.flashMessage) window.flashMessage('{!! t('toast.approve_success') !!}', 2000, 'success');
row.style.opacity = '0';
setTimeout(() => row.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');
}
});
});
// 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('/admin/purge-trash-all', { method: 'POST' });
const data = await res.json();
if (data.success) {
location.reload();
} else {
throw new Error(data.msg || 'Purge failed');
}
});
});
}
})();
</script>
</div>
</div>
@include(snippets/footer)

135
views/admin/emojis.html Normal file
View File

@@ -0,0 +1,135 @@
@include(snippets/header)
<div class="pagewrapper">
<div id="main" class="admin-container">
<div class="container">
<h2>Custom Emojis</h2>
<div class="admin-form-container"
style="margin-bottom: 20px; text-align: left; background: var(--dropdown-bg); padding: 15px; border: 1px solid var(--nav-border-color);">
<h4>Add New Emoji</h4>
<div style="display: flex; gap: 10px; flex-wrap: wrap; align-items: flex-end;">
<div>
<label style="display: block; font-size: 0.8em; margin-bottom: 5px; opacity: 0.7;">Name</label>
<input type="text" id="emoji-name" placeholder="" style="background: var(--bg); border: 1px solid var(--black); padding: 5px; color: var(--white);">
</div>
<div>
<label style="display: block; font-size: 0.8em; margin-bottom: 5px; opacity: 0.7;">Image File</label>
<input type="file" id="emoji-file" style="background: var(--bg); border: 1px solid var(--black); padding: 4px; color: var(--white);">
</div>
<div style="flex-grow: 1;">
<label style="display: block; font-size: 0.8em; margin-bottom: 5px; opacity: 0.7;">OR Image URL</label>
<input type="text" id="emoji-url" placeholder="" style="background: var(--bg); border: 1px solid var(--black); padding: 5px; color: var(--white); width: 100%;">
</div>
<button id="add-emoji" class="btn-upload" style="width: auto; padding: 7px 20px; border: 1px solid var(--nav-border-color); background: var(--bg); color: var(--white); cursor: pointer;">Add</button>
</div>
</div>
<div id="emoji-list" class="emoji-grid">
<!-- Populated by JS -->
</div>
</div>
<script>
(() => {
var i18n = window.f0ckI18n || {};
const esc = (s) => (s || '').toString().replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#039;');
const loadEmojis = async () => {
try {
const res = await fetch('/api/v2/emojis');
const data = await res.json();
if (data.success) {
const grid = document.getElementById('emoji-list');
if (!grid) return;
grid.innerHTML = data.emojis.reverse().map(e =>
'<div class="emoji-card">' +
'<button class="emoji-delete" onclick="window.emojiAdmin.deleteEmoji(' + e.id + ')" title="Delete">✕</button>' +
'<img class="emoji-preview" src="' + e.url + '" alt=":' + esc(e.name) + ':">' +
'<span class="emoji-label">:' + esc(e.name) + ':</span>' +
'<span class="emoji-url">' + esc(e.url) + '</span>' +
'</div>'
).join('');
}
} catch (err) { console.error('[EMOJI_ADMIN] Load Error:', err); }
};
const addEmoji = async (e) => {
if (e) e.preventDefault();
const name = document.getElementById('emoji-name').value;
const url = document.getElementById('emoji-url').value;
const fileInput = document.getElementById('emoji-file');
if (!name || (!url && !fileInput.files[0])) return alert('Fill Name and either URL or File');
const btn = document.getElementById('add-emoji');
const oldText = btn.textContent;
btn.disabled = true;
btn.textContent = i18n.uploading || 'Uploading...';
const formData = new FormData();
formData.append('name', name);
formData.append('url', url);
if (fileInput.files[0]) {
formData.append('file', fileInput.files[0]);
}
try {
const headers = { 'X-Requested-With': 'XMLHttpRequest' };
const csrf = '{{ csrf_token }}';
if (csrf) headers['X-CSRF-Token'] = csrf;
const res = await fetch('/api/v2/admin/emojis', {
method: 'POST',
headers: headers,
body: formData
});
const data = await res.json();
if (data.success) {
document.getElementById('emoji-name').value = '';
document.getElementById('emoji-url').value = '';
document.getElementById('emoji-file').value = '';
loadEmojis();
} else {
alert('Failed: ' + (data.message || data.msg || 'Unknown error'));
}
} catch (e) {
console.error('[EMOJI_ADMIN] Add Error:', e);
alert('Error: ' + e.message);
} finally {
btn.disabled = false;
btn.textContent = oldText;
}
};
const deleteEmoji = async (id) => {
if (!confirm('Delete this emoji?')) return;
try {
const res = await fetch('/api/v2/admin/emojis/' + id + '/delete', {
method: 'POST',
headers: { 'X-CSRF-Token': '{{ csrf_token }}' }
});
const data = await res.json();
if (data.success) {
loadEmojis();
} else {
alert('Delete failed');
}
} catch (e) { console.error(e); }
};
// Global scope for onclick
window.emojiAdmin = { deleteEmoji };
const btnAddEmoji = document.getElementById('add-emoji');
if (btnAddEmoji) btnAddEmoji.addEventListener('click', addEmoji);
// Live Update Listener (SSE dispatched via f0ckm.js)
document.addEventListener('f0ck:emojis_updated', loadEmojis);
loadEmojis();
})();
</script>
</div>
</div>
@include(snippets/footer)

322
views/admin/halls.html Normal file
View File

@@ -0,0 +1,322 @@
@include(snippets/header)
<div class="pagewrapper">
<div id="main">
<style>
.hm-btn {
display: inline-flex;
align-items: center;
height: 28px;
padding: 0 12px;
border-radius: 3px;
font-size: 0.85em;
font-family: var(--font);
cursor: pointer;
white-space: nowrap;
text-decoration: none;
box-sizing: border-box;
line-height: 1;
}
</style>
<div class="container" style="padding-top: 20px;">
<h1>{{ t('hall.manager_title') }}</h1>
<p style="color: #aaa; margin-bottom: 20px;">{{ t('hall.manager_desc') }}</p>
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:20px;">
<input type="text" id="new-hall-name" placeholder="{{ t('hall.new_hall_placeholder') }}" style="flex:1;min-width:180px;background:var(--badge-bg);border:1px solid rgba(255,255,255,0.15);color:var(--white);padding:0 10px;height:28px;border-radius:3px;font-family:var(--font);font-size:0.9em;">
<select id="new-hall-rating" style="background:var(--badge-bg);border:1px solid rgba(255,255,255,0.15);color:var(--white);padding:0 10px;height:28px;border-radius:3px;font-family:var(--font);font-size:0.9em;">
<option value="sfw">🟢 SFW</option>
<option value="nsfw">🔴 NSFW</option>
<option value="nsfl">💀 NSFL</option>
</select>
<span id="new-hall-slug-preview" style="font-size:0.78em;color:#555;min-width:80px;"></span>
<button id="btn-create-hall" class="hm-btn" style="background:rgba(255,255,255,0.1);color:var(--white);border:1px solid rgba(255,255,255,0.2);font-weight:bold;">{{ t('hall.create_hall_btn') }}</button>
<span id="create-hall-status" style="font-size:0.8em;color:#888;"></span>
</div>
<hr>
<div id="hall-manager-grid" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 20px; margin-top: 20px;">
@each(hallsList as h)
<div class="hall-manager-card" id="hall-card-{{ h.slug }}" data-slug="{{ h.slug }}">
<div class="hall-manager-image" style="position:relative; height: 140px; overflow:hidden; background:#111; cursor:pointer;" title="{{ t('hall.click_upload_hint') }}">
<img src="/hall_image/{{ h.slug }}" alt="{!! h.name !!}" style="width:100%;height:100%;object-fit:cover;opacity:0.8;" id="hall-img-{{ h.slug }}">
<span style="position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:1.1em;font-weight:700;color:#fff;text-transform:uppercase;letter-spacing:0.05em;text-shadow:0 0 10px rgba(0,0,0,0.9),0 1px 3px rgba(0,0,0,0.8);pointer-events:none;">{!! h.name !!}</span>
@if(h.custom_image)
<button class="btn-del-img" title="Remove custom image" style="position:absolute;top:6px;right:6px;width:24px;height:24px;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.7);color:#fff;border:1px solid rgba(255,255,255,0.3);border-radius:50%;cursor:pointer;font-size:0.75em;line-height:1;z-index:2;padding:0;"></button>
@endif
<input type="file" class="hall-img-upload" accept="image/*" style="display:none;">
</div>
<div style="padding: 12px;">
<div style="margin-bottom: 8px;">
<label style="font-size:0.8em;color:#888;display:block;">{{ t('common.name') }}</label>
<input type="text" class="hall-name-input" value="{!! h.name !!}" style="width:100%;box-sizing:border-box;background:var(--badge-bg);border:1px solid rgba(255,255,255,0.15);color:var(--white);padding:5px 8px;border-radius:3px;font-family:var(--font);">
</div>
<div style="margin-bottom: 8px;">
<label style="font-size:0.8em;color:#888;display:block;">{{ t('hall.slug') }}</label>
<input type="text" class="hall-slug-input" value="{!! h.slug !!}" style="width:100%;box-sizing:border-box;background:var(--badge-bg);border:1px solid rgba(255,255,255,0.15);color:var(--white);padding:5px 8px;border-radius:3px;font-family:var(--font);font-size:0.9em;color:#aaa;">
</div>
<div style="margin-bottom: 8px;">
<label style="font-size:0.8em;color:#888;display:block;">{{ t('common.description') }}</label>
<textarea class="hall-desc-input" style="width:100%;box-sizing:border-box;background:var(--badge-bg);border:1px solid rgba(255,255,255,0.15);color:var(--white);padding:5px 8px;border-radius:3px;font-family:var(--font);resize:vertical;min-height:60px;">{!! h.description || '' !!}</textarea>
</div>
<div style="margin-bottom: 8px;">
<label style="font-size:0.8em;color:#888;display:block;">{{ t('hall.rating') }}</label>
<select class="hall-rating-input" style="width:100%;box-sizing:border-box;background:var(--badge-bg);border:1px solid rgba(255,255,255,0.15);color:var(--white);padding:5px 8px;border-radius:3px;font-family:var(--font);">
<option value="sfw" @if(h.rating === 'sfw' || !h.rating) selected @endif>🟢 SFW</option>
<option value="nsfw" @if(h.rating === 'nsfw') selected @endif>🔴 NSFW</option>
<option value="nsfl" @if(h.rating === 'nsfl') selected @endif>💀 NSFL</option>
</select>
</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;">
<button class="btn-save-hall hm-btn" style="background:var(--accent);color:#000;border:none;font-weight:bold;">{{ t('common.save') }}</button>
<a href="/h/{{ h.slug }}" class="hall-view-link hm-btn" style="background:rgba(255,255,255,0.05);color:#aaa;border:1px solid rgba(255,255,255,0.1);">{{ t('common.view') }} →</a>
<span style="margin-left:4px;font-size:0.8em;color:#666;">{{ t('hall.posts', { count: h.item_count || 0 }) }}</span>
<button class="btn-delete-hall hm-btn" style="margin-left:auto;background:rgba(200,0,0,0.25);color:#f55;border:1px solid rgba(200,0,0,0.4);">🗑 {{ t('common.delete') }}</button>
</div>
<div class="hall-status" style="margin-top:6px;font-size:0.8em;color:#888;min-height:1.2em;"></div>
</div>
</div>
@endeach
</div>
@if(!hallsList || !hallsList.length)
<p style="color:#888;text-align:center;padding:40px 0;">{{ t('hall.no_halls') }}</p>
@endif
</div>
<script>
(function() {
var grid = document.getElementById('hall-manager-grid');
var i18n = window.f0ckI18n || {};
// ── Card wiring ─────────────────────────────────────────────────────────
function wireCard(card) {
var slug = card.dataset.slug;
var status = card.querySelector('.hall-status');
var nameInput = card.querySelector('.hall-name-input');
var slugInput = card.querySelector('.hall-slug-input');
var descInput = card.querySelector('.hall-desc-input');
var ratingInput = card.querySelector('.hall-rating-input');
var imgEl = card.querySelector('.hall-manager-image img');
var imgContainer = card.querySelector('.hall-manager-image');
var fileInput = card.querySelector('.hall-img-upload');
var viewLink = card.querySelector('.hall-view-link');
function setStatus(msg, color) {
status.textContent = msg;
status.style.color = color || '#aaa';
}
// Live name overlay
var nameOverlay = imgContainer.querySelector('span');
nameInput.addEventListener('input', function() {
if (nameOverlay) nameOverlay.textContent = nameInput.value;
});
// Auto-sanitize slug on blur
slugInput.addEventListener('blur', function() {
slugInput.value = slugInput.value.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
});
// Click image → open file picker
imgContainer.addEventListener('click', function(e) {
if (e.target.classList.contains('btn-del-img')) return;
fileInput.click();
});
// Save name + slug + description
card.querySelector('.btn-save-hall').addEventListener('click', async function() {
setStatus(i18n.hall_saving || 'Saving...');
var newSlug = slugInput.value.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
if (!newSlug) { setStatus("✗ " + (i18n.hall_slug_empty_error || 'Slug cannot be empty'), '#f55'); return; }
try {
var r = await fetch('/api/v2/admin/halls/' + encodeURIComponent(slug), {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', 'x-csrf-token': (window.f0ckSession && window.f0ckSession.csrf_token) || '' },
body: JSON.stringify({ name: nameInput.value, slug: newSlug, description: descInput.value, rating: ratingInput.value })
});
var d = await r.json();
if (d.success) {
// If slug changed, update all references in the DOM
if (newSlug !== slug) {
slug = newSlug; // update closure variable — all future fetches use new slug
card.dataset.slug = newSlug;
card.id = 'hall-card-' + newSlug;
imgEl.src = '/hall_image/' + newSlug + '?v=' + Date.now();
if (viewLink) viewLink.href = '/h/' + newSlug;
slugInput.value = newSlug;
}
setStatus("✓ " + (i18n.hall_saved || 'Saved'), 'var(--accent)');
} else {
setStatus('✗ ' + d.msg, '#f55');
}
} catch (e) { setStatus("✗ " + (i18n.hall_error || 'Error'), '#f55'); }
});
// Upload image
fileInput.addEventListener('change', async function(e) {
var file = e.target.files[0];
if (!file) return;
setStatus(i18n.uploading || 'Uploading...');
var fd = new FormData();
fd.append('file', file);
try {
var r = await fetch('/api/v2/admin/halls/' + encodeURIComponent(slug) + '/image', {
method: 'POST',
headers: { 'x-csrf-token': (window.f0ckSession && window.f0ckSession.csrf_token) || '' },
body: fd
});
var d = await r.json();
if (d.success) {
imgEl.src = '/hall_image/' + slug + '?v=' + Date.now();
setStatus("✓ " + (i18n.hall_image_uploaded || 'Image uploaded'), 'var(--accent)');
if (!imgContainer.querySelector('.btn-del-img')) {
var xBtn = document.createElement('button');
xBtn.className = 'btn-del-img';
xBtn.title = i18n.hall_click_upload_hint || 'Click to upload';
xBtn.textContent = '✕';
xBtn.style.cssText = 'position:absolute;top:6px;right:6px;width:24px;height:24px;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.7);color:#fff;border:1px solid rgba(255,255,255,0.3);border-radius:50%;cursor:pointer;font-size:0.75em;line-height:1;z-index:2;padding:0;';
imgContainer.appendChild(xBtn);
wireDelBtn(xBtn);
}
} else {
setStatus('✗ ' + d.msg, '#f55');
}
} catch (e) { setStatus("✗ " + (i18n.hall_error || 'Error'), '#f55'); }
e.target.value = '';
});
function wireDelBtn(btn) {
btn.addEventListener('click', async function(e) {
e.stopPropagation();
setStatus(i18n.hall_removing || 'Removing...');
try {
var r = await fetch('/api/v2/admin/halls/' + encodeURIComponent(slug) + '/image', {
method: 'DELETE',
headers: { 'x-csrf-token': (window.f0ckSession && window.f0ckSession.csrf_token) || '' }
});
var d = await r.json();
if (d.success) {
imgEl.src = '/hall_image/' + slug + '?v=' + Date.now();
btn.remove();
setStatus("✓ " + (i18n.hall_image_removed || 'Image removed'), 'var(--accent)');
} else { setStatus('✗ ' + d.msg, '#f55'); }
} catch (e) { setStatus("✗ " + (i18n.hall_error || 'Error'), '#f55'); }
});
}
var existingDelBtn = imgContainer.querySelector('.btn-del-img');
if (existingDelBtn) wireDelBtn(existingDelBtn);
// Delete hall
card.querySelector('.btn-delete-hall').addEventListener('click', async function() {
var confirmMsg = (i18n.hall_delete_confirm || 'Really delete hall "{slug}"?').replace('{slug}', slug);
if (!confirm(confirmMsg)) return;
setStatus(i18n.hall_deleting || 'Deleting...');
try {
var r = await fetch('/api/v2/admin/halls/' + encodeURIComponent(slug), {
method: 'DELETE',
headers: { 'x-csrf-token': (window.f0ckSession && window.f0ckSession.csrf_token) || '' }
});
var d = await r.json();
if (d.success) {
card.style.transition = 'opacity 0.3s';
card.style.opacity = '0';
setTimeout(function() { card.remove(); }, 300);
} else { setStatus('✗ ' + d.msg, '#f55'); }
} catch (e) { setStatus('✗ Error', '#f55'); }
});
}
// Wire all existing cards on load
document.querySelectorAll('.hall-manager-card').forEach(wireCard);
// ── Create Hall ──────────────────────────────────────────────────────────
var newHallName = document.getElementById('new-hall-name');
var newHallRating = document.getElementById('new-hall-rating');
var slugPreview = document.getElementById('new-hall-slug-preview');
var createBtn = document.getElementById('btn-create-hall');
var createStatus = document.getElementById('create-hall-status');
function toSlug(s) {
return s.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
}
newHallName.addEventListener('input', function() {
var slug = toSlug(newHallName.value);
slugPreview.textContent = slug ? '/' + slug : '';
});
function buildCard(name, slug, rating) {
var ratingOpts = ['sfw','nsfw','nsfl'].map(function(r) {
return '<option value="' + r + '"' + (r === rating ? ' selected' : '') + '>' + (r === 'sfw' ? '🟢 SFW' : r === 'nsfw' ? '🔴 NSFW' : '💀 NSFL') + '</option>';
}).join('');
var div = document.createElement('div');
div.className = 'hall-manager-card';
div.id = 'hall-card-' + slug;
div.dataset.slug = slug;
div.innerHTML =
'<div class="hall-manager-image" style="position:relative;height:140px;overflow:hidden;background:#111;cursor:pointer;" title="Click to upload a custom image">' +
'<img src="/hall_image/' + slug + '" alt="' + name + '" style="width:100%;height:100%;object-fit:cover;opacity:0.8;">' +
'<span style="position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:1.1em;font-weight:700;color:#fff;text-transform:uppercase;letter-spacing:0.05em;text-shadow:0 0 10px rgba(0,0,0,0.9),0 1px 3px rgba(0,0,0,0.8);pointer-events:none;">' + name + '</span>' +
'<input type="file" class="hall-img-upload" accept="image/*" style="display:none;">' +
'</div>' +
'<div style="padding:12px;">' +
'<div style="margin-bottom:8px;"><label style="font-size:0.8em;color:#888;display:block;">' + (i18n.common_name || 'Name') + '</label>' +
'<input type="text" class="hall-name-input" value="' + name + '" style="width:100%;box-sizing:border-box;background:var(--badge-bg);border:1px solid rgba(255,255,255,0.15);color:var(--white);padding:5px 8px;border-radius:3px;font-family:var(--font);"></div>' +
'<div style="margin-bottom:8px;"><label style="font-size:0.8em;color:#888;display:block;">' + (i18n.hall_slug || 'Slug') + '</label>' +
'<input type="text" class="hall-slug-input" value="' + slug + '" style="width:100%;box-sizing:border-box;background:var(--badge-bg);border:1px solid rgba(255,255,255,0.15);color:var(--white);padding:5px 8px;border-radius:3px;font-family:var(--font);font-size:0.9em;color:#aaa;"></div>' +
'<div style="margin-bottom:8px;"><label style="font-size:0.8em;color:#888;display:block;">' + (i18n.common_description || 'Description') + '</label>' +
'<textarea class="hall-desc-input" style="width:100%;box-sizing:border-box;background:var(--badge-bg);border:1px solid rgba(255,255,255,0.15);color:var(--white);padding:5px 8px;border-radius:3px;font-family:var(--font);resize:vertical;min-height:60px;"></textarea></div>' +
'<div style="margin-bottom:8px;"><label style="font-size:0.8em;color:#888;display:block;">' + (i18n.hall_rating || 'Rating') + '</label>' +
'<select class="hall-rating-input" style="width:100%;box-sizing:border-box;background:var(--badge-bg);border:1px solid rgba(255,255,255,0.15);color:var(--white);padding:5px 8px;border-radius:3px;font-family:var(--font);">' + ratingOpts + '</select></div>' +
'<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;">' +
'<button class="btn-save-hall hm-btn" style="background:var(--accent);color:#000;border:none;font-weight:bold;">' + (i18n.common_save || 'Save') + '</button>' +
'<a href="/h/' + slug + '" class="hall-view-link hm-btn" style="background:rgba(255,255,255,0.05);color:#aaa;border:1px solid rgba(255,255,255,0.1);">' + (i18n.common_view || 'View') + ' →</a>' +
'<span style="margin-left:4px;font-size:0.8em;color:#666;">' + (i18n.hall_posts || '{count} posts').replace('{count}', '0') + '</span>' +
'<button class="btn-delete-hall hm-btn" style="margin-left:auto;background:rgba(200,0,0,0.25);color:#f55;border:1px solid rgba(200,0,0,0.4);">🗑 ' + (i18n.common_delete || 'Delete') + '</button>' +
'</div>' +
'<div class="hall-status" style="margin-top:6px;font-size:0.8em;color:#888;min-height:1.2em;"></div>' +
'</div>';
return div;
}
async function createHall() {
var name = newHallName.value.trim();
if (!name) { createStatus.textContent = i18n.hall_enter_name_error || 'Enter a name'; return; }
var slug = toSlug(name);
var rating = newHallRating ? newHallRating.value : 'sfw';
createStatus.textContent = i18n.hall_creating || 'Creating...';
createStatus.style.color = '#888';
try {
var r = await fetch('/api/v2/admin/halls', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-csrf-token': (window.f0ckSession && window.f0ckSession.csrf_token) || '' },
body: JSON.stringify({ name: name, slug: slug, rating: rating })
});
var d = await r.json();
if (d.success) {
createStatus.textContent = "✓ " + (i18n.hall_saved || 'Saved') + "!";
createStatus.style.color = 'var(--accent)';
var card = buildCard(name, slug, rating);
grid.appendChild(card);
wireCard(card);
// Scroll new card into view
card.scrollIntoView({ behavior: 'smooth', block: 'center' });
// Reset form
newHallName.value = '';
slugPreview.textContent = '';
setTimeout(function() { createStatus.textContent = ''; }, 2000);
} else {
createStatus.textContent = '✗ ' + d.msg;
createStatus.style.color = '#f55';
}
} catch (e) { createStatus.textContent = "✗ " + (i18n.hall_error || 'Error'); createStatus.style.color = '#f55'; }
}
createBtn.addEventListener('click', createHall);
newHallName.addEventListener('keydown', function(e) { if (e.key === 'Enter') createHall(); });
})();
</script>
</div>
</div>
@include(snippets/footer)

20
views/admin/log.html Normal file
View File

@@ -0,0 +1,20 @@
@include(snippets/header)
<div class="pagewrapper">
<div id="main">
@if(log)
<h1>last {{ log.length }} entries:</h1>
<div class="logwrap">
@each(log as line)
<p>{{ line }}</p>
@endeach
</div>
<script>
(() => {
const d = document.querySelector("div.logwrap");
d.scrollTop = d.scrollHeight;
})();
</script>
@endif
</div>
</div>
@include(snippets/footer)

159
views/admin/memes.html Normal file
View File

@@ -0,0 +1,159 @@
@include(snippets/header)
<div class="pagewrapper">
<div id="main" class="admin-container">
<div class="container">
<h2>Meme Manager</h2>
<div class="admin-form-container"
style="margin-bottom: 20px; text-align: left; background: var(--dropdown-bg); padding: 15px; border: 1px solid var(--nav-border-color);">
<h4>Add New Meme Template</h4>
<div style="display: flex; flex-direction: column; gap: 10px; max-width: 500px;">
<input type="text" id="meme-id" placeholder="Template ID (e.g. surprised-pikachu)" style="background: var(--bg); border: 1px solid var(--black); padding: 8px; color: var(--white);">
<input type="text" id="meme-name" placeholder="Display Name (e.g. Surprised Pikachu)" style="background: var(--bg); border: 1px solid var(--black); padding: 8px; color: var(--white);">
<input type="text" id="meme-url" placeholder="Image URL (Alternative to upload)" style="background: var(--bg); border: 1px solid var(--black); padding: 8px; color: var(--white);">
<div style="margin: 5px 0; color: #888; font-size: 0.8em; text-align: center;">- OR -</div>
<input type="file" id="meme-file" accept="image/*" style="background: var(--bg); border: 1px solid var(--black); padding: 8px; color: var(--white);">
<input type="text" id="meme-category" placeholder="Category (e.g. Classic, Reaction)" style="background: var(--bg); border: 1px solid var(--black); padding: 8px; color: var(--white);">
<button id="add-meme" class="btn-upload" style="width: auto; padding: 10px 20px; border: 1px solid var(--nav-border-color); background: var(--bg); color: var(--white); cursor: pointer; align-self: flex-start;">Add Template</button>
</div>
</div>
<div class="admin-table-container" style="overflow-x: auto;">
<table style="width: 100%; border-collapse: collapse; color: var(--white);">
<thead>
<tr style="border-bottom: 1px solid var(--nav-border-color); text-align: left;">
<th style="padding: 10px;">Preview</th>
<th style="padding: 10px;">Name</th>
<th style="padding: 10px;">Category</th>
<th style="padding: 10px;">Template ID</th>
<th style="padding: 10px;">Actions</th>
</tr>
</thead>
<tbody id="meme-list">
<!-- Populated by JS -->
</tbody>
</table>
</div>
</div>
<script>
(() => {
var i18n = window.f0ckI18n || {};
console.log('[MEME_ADMIN] Initializing');
const esc = (s) => (s || '').toString().replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#039;');
const loadMemes = async () => {
try {
const res = await fetch('/api/v2/memes');
const data = await res.json();
if (data.success) {
const tbody = document.getElementById('meme-list');
if (!tbody) return;
tbody.innerHTML = data.memes.map(m =>
'<tr style="border-bottom: 1px solid rgba(255,255,255,0.05);">' +
'<td style="padding: 10px;"><img src="' + m.url + '" style="height: 60px; width: 60px; object-fit: contain; border-radius: 4px; background: #000;"></td>' +
'<td style="padding: 10px;">' + esc(m.name) + '</td>' +
'<td style="padding: 10px;"><span style="background: rgba(255,255,255,0.1); padding: 2px 8px; border-radius: 10px; font-size: 0.85em;">' + esc(m.category || 'General') + '</span></td>' +
'<td style="padding: 10px; font-family: monospace; opacity: 0.7;">' + esc(m.template_id) + '</td>' +
'<td style="padding: 10px;">' +
'<button onclick="window.memeAdmin.deleteMeme(' + m.id + ')" class="btn-remove" style="padding: 5px 15px; font-size: 0.8em; background: #c00; color: white; border: none; cursor: pointer; border-radius: 2px;">Delete</button>' +
'</td>' +
'</tr>'
).join('');
}
} catch (err) { console.error('[MEME_ADMIN] Load Error:', err); }
};
const addMeme = async (e) => {
if (e) e.preventDefault();
console.log('[MEME_ADMIN] addMeme triggered');
const template_id = document.getElementById('meme-id').value;
const name = document.getElementById('meme-name').value;
const url = document.getElementById('meme-url').value;
const category = document.getElementById('meme-category').value;
const fileInput = document.getElementById('meme-file');
if (!template_id || !name || (!url && !fileInput.files[0])) {
return alert('Fill all fields (ID, Name, and either URL or File)');
}
const btn = document.getElementById('add-meme');
const oldText = btn.textContent;
btn.disabled = true;
btn.textContent = i18n.uploading || 'Uploading...';
const formData = new FormData();
formData.append('template_id', template_id);
formData.append('name', name);
formData.append('url', url);
formData.append('category', category);
if (fileInput.files[0]) {
formData.append('file', fileInput.files[0]);
}
try {
const headers = {
'X-Requested-With': 'XMLHttpRequest'
};
const csrf = '{{ csrf_token }}';
if (csrf) headers['X-CSRF-Token'] = csrf;
const res = await fetch('/api/v2/admin/memes', {
method: 'POST',
headers: headers,
body: formData
});
const data = await res.json();
if (data.success) {
document.getElementById('meme-id').value = '';
document.getElementById('meme-name').value = '';
document.getElementById('meme-url').value = '';
document.getElementById('meme-category').value = '';
document.getElementById('meme-file').value = '';
loadMemes();
} else {
alert('Server Error: ' + (data.message || data.msg || 'Unknown error'));
}
} catch (e) {
console.error('[MEME_ADMIN] Post Error:', e);
alert('Submission failed: ' + e.message);
} finally {
btn.disabled = false;
btn.textContent = oldText;
}
};
const deleteMeme = async (id) => {
if (!confirm('Delete this template?')) return;
try {
const res = await fetch('/api/v2/admin/memes/' + id + '/delete', {
method: 'POST',
headers: { 'X-CSRF-Token': '{{ csrf_token }}' }
});
const data = await res.json();
if (data.success) {
loadMemes();
} else {
alert('Delete failed');
}
} catch (e) { console.error(e); }
};
// Global scope for onclick handlers
window.memeAdmin = { deleteMeme };
const btnAddMeme = document.getElementById('add-meme');
if (btnAddMeme) {
console.log('[MEME_ADMIN] Registering click listener');
btnAddMeme.addEventListener('click', addMeme);
} else {
console.error('[MEME_ADMIN] Add button not found!');
}
loadMemes();
})();
</script>
</div>
</div>
@include(snippets/footer)

77
views/admin/motd.html Normal file
View File

@@ -0,0 +1,77 @@
@include(snippets/header)
<div class="pagewrapper">
<div id="main" class="admin-container">
<div class="container">
<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>
<div class="admin-motd-form">
<form id="motd-form" action="/admin/motd" method="POST" onsubmit="event.preventDefault(); saveMotd(this);">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div style="margin-bottom: 20px;">
<label for="motd-text" style="display: block; margin-bottom: 8px; color: var(--accent);">MOTD Content (Markdown supported)</label>
<textarea id="motd-text" name="motd" style="width: 100%; min-height: 200px; background: rgba(0,0,0,0.3); border: 1px solid rgba(255,255,255,0.1); color: #fff; padding: 15px; border-radius: 4px; font-family: inherit; font-size: 1.1em; resize: vertical;">{!! motd !!}</textarea>
</div>
<div style="display: flex; gap: 10px; align-items: center;">
<button type="submit" class="btn-primary" style="width: auto; padding: 10px 30px;">Save MOTD</button>
<button type="button" class="btn-remove" style="width: auto; padding: 10px 20px;" onclick="document.getElementById('motd-text').value=''; saveMotd(document.getElementById('motd-form'));">Clear MOTD</button>
<span id="motd-status" style="margin-left: 10px; font-weight: bold; display: none;"></span>
</div>
</form>
</div>
<div style="margin-top: 40px; padding: 20px; background: rgba(0,0,0,0.2); border-left: 4px solid var(--accent); border-radius: 4px;">
<h4 style="color: var(--accent); margin-top: 0;">Preview Tip</h4>
<p style="margin-bottom: 0;">You can use <strong>Markdown</strong> syntax like <code># Header</code>, <code>**bold**</code>, <code>[links](url)</code>, or standard HTML. The message updates instantly site-wide when you click Save.
</p>
</div>
</div>
<script>
async function saveMotd(form) {
const status = document.getElementById('motd-status');
const textarea = document.getElementById('motd-text');
const motd = textarea.value;
status.textContent = 'Saving...';
status.style.color = 'var(--accent)';
status.style.display = 'inline';
try {
const res = await fetch(form.action, {
method: 'POST',
headers: {
'X-Requested-With': 'XMLHttpRequest',
'Content-Type': 'application/x-www-form-urlencoded',
'X-CSRF-Token': window.f0ckSession?.csrf_token
},
body: new URLSearchParams(new FormData(form))
});
const data = await res.json();
if (data.success) {
status.textContent = 'Saved!';
status.style.color = '#28a745';
if (typeof window.updateMotdUI === 'function') {
window['motd_dismissed'] = false; // Force show on save
window.updateMotdUI(motd);
}
setTimeout(() => {
status.style.display = 'none';
}, 2000);
} else {
throw new Error(data.msg || 'Unknown error');
}
} catch (err) {
console.error('MOTD Save Error:', err);
status.textContent = 'Error: ' + err.message;
status.style.color = '#d9534f';
}
}
</script>
</div>
</div>
@include(snippets/footer)

26
views/admin/recover.html Normal file
View File

@@ -0,0 +1,26 @@
@include(snippets/header)
<div id="main">
<table class="table" style="width: 100%">
<thead>
<tr>
<td></td>
<td>ID</td>
<td>f0cker</td>
<td>mime</td>
<td></td>
</tr>
</thead>
<tbody>
@each(posts as post)
<tr>
<td><img src="data:image/webp;base64,{{ post.thumbnail }}" /></td>
<td>{{ post.id }}</td>
<td>{!! post.username !!}</td>
<td>{{ post.mime }}</td>
<td><a href="/admin/recover/?id={{ post.id }}">recover</a></td>
</tr>
@endeach
</tbody>
</table>
</div>
@include(snippets/footer)

68
views/admin/rules.html Normal file
View File

@@ -0,0 +1,68 @@
@include(snippets/header)
<div class="pagewrapper">
<div id="main" class="admin-container">
<div class="container">
<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>
<div class="admin-motd-form">
<form id="rules-form" action="/admin/rules" method="POST" onsubmit="event.preventDefault(); savePage(this, 'rules');">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div style="margin-bottom: 20px;">
<label for="rules-text" style="display: block; margin-bottom: 8px; color: var(--accent);">Rules Page Content (Markdown supported)</label>
<textarea id="rules-text" name="rules_text" style="width: 100%; min-height: 300px; background: rgba(0,0,0,0.3); border: 1px solid rgba(255,255,255,0.1); color: #fff; padding: 15px; border-radius: 4px; font-family: inherit; font-size: 1.1em; resize: vertical;">{!! rules_text !!}</textarea>
</div>
<div style="display: flex; gap: 10px; align-items: center;">
<button type="submit" class="btn-primary" style="width: auto; padding: 10px 30px;">Save</button>
<button type="button" class="btn-remove" style="width: auto; padding: 10px 20px;" onclick="document.getElementById('rules-text').value=''; savePage(document.getElementById('rules-form'), 'rules');">Clear</button>
<span id="rules-status" style="margin-left: 10px; font-weight: bold; display: none;"></span>
</div>
</form>
</div>
<div style="margin-top: 40px; padding: 20px; background: rgba(0,0,0,0.2); border-left: 4px solid var(--accent); border-radius: 4px;">
<h4 style="color: var(--accent); margin-top: 0;">Tips</h4>
<p style="margin-bottom: 0;">Use <strong>Markdown</strong> syntax like <code># Header</code>, <code>**bold**</code>, <code>[links](url)</code>, or plain HTML.<br>
If empty, the default static rules page is shown instead.</p>
</div>
</div>
<script>
async function savePage(form, page) {
const statusId = page + '-status';
const status = document.getElementById(statusId);
status.textContent = 'Saving...';
status.style.color = 'var(--accent)';
status.style.display = 'inline';
try {
const res = await fetch(form.action, {
method: 'POST',
headers: {
'X-Requested-With': 'XMLHttpRequest',
'Content-Type': 'application/x-www-form-urlencoded',
'X-CSRF-Token': window.f0ckSession?.csrf_token
},
body: new URLSearchParams(new FormData(form))
});
const data = await res.json();
if (data.success) {
status.textContent = 'Saved!';
status.style.color = '#28a745';
setTimeout(() => { status.style.display = 'none'; }, 2000);
} else {
throw new Error(data.msg || 'Unknown error');
}
} catch (err) {
console.error('Save Error:', err);
status.textContent = 'Error: ' + err.message;
status.style.color = '#d9534f';
}
}
</script>
</div>
</div>
@include(snippets/footer)

80
views/admin/sessions.html Normal file
View File

@@ -0,0 +1,80 @@
@include(snippets/header)
<div class="pagewrapper">
<div id="main" class="session-grid">
<h2 class="session-page-title">
Sessions
<span class="session-stats">
@if(activeUsers > 0)
({{ activeUsers }} active: {{ activeUserList.join(', ') }})
@else
(0 active)
@endif
</span>
</h2>
@each(sessions as s)
<div class="session-card {{ s.id === session.sess_id ? 'current' : '' }}">
<div class="session-header">
<span class="session-user">{!! s.user !!}</span>
<div class="session-badges">
@if(s.id === session.sess_id)
<span class="badge badge-current">Current</span>
@endif
@if(s.kmsi)
<span class="badge badge-kmsi" title="Keep Me Signed In">KMSI</span>
@endif
<span class="session-id">#{{ s.id }}</span>
@if(s.id !== session.sess_id)
<a href="javascript:void(0)" onclick="deleteSession({{ s.id }}, this)" class="session-delete"
title="Delete Session">&#10006;</a>
@endif
</div>
</div>
<div class="session-body">
<div class="session-info">
<span class="label">Browser:</span>
<span class="value browser-info" title="{{ s.browser }}">{{ s.browser }}</span>
</div>
<div class="session-info">
<span class="label">Created:</span>
<span class="value">{{ new Date(s.created_at * 1e3).toLocaleString("de-DE") }}</span>
</div>
<div class="session-info">
<span class="label">Last Used:</span>
<span class="value">{{ new Date(s.last_used * 1e3).toLocaleString("de-DE") }}</span>
</div>
<div class="session-info">
<span class="label">Last Action:</span>
<span class="value">{{ s.last_action }}</span>
</div>
</div>
</div>
@endeach
<script>
async function deleteSession(id, el) {
if (!confirm('Are you sure you want to delete this session?')) return;
try {
const res = await fetch('/api/v2/admin/sessions/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id })
});
const data = await res.json();
if (data.success) {
// Remove the card
const card = el.closest('.session-card');
card.style.opacity = '0';
setTimeout(() => card.remove(), 200);
} else {
alert(data.msg || 'Failed to delete session');
}
} catch (err) {
console.error(err);
alert('An error occurred');
}
}
</script>
</div>
</div>
@include(snippets/footer)

68
views/admin/terms.html Normal file
View File

@@ -0,0 +1,68 @@
@include(snippets/header)
<div class="pagewrapper">
<div id="main" class="admin-container">
<div class="container">
<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>
<div class="admin-motd-form">
<form id="terms-form" action="/admin/terms" method="POST" onsubmit="event.preventDefault(); savePage(this, 'terms');">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div style="margin-bottom: 20px;">
<label for="terms-text" style="display: block; margin-bottom: 8px; color: var(--accent);">Terms Page Content (Markdown supported)</label>
<textarea id="terms-text" name="terms_text" style="width: 100%; min-height: 300px; background: rgba(0,0,0,0.3); border: 1px solid rgba(255,255,255,0.1); color: #fff; padding: 15px; border-radius: 4px; font-family: inherit; font-size: 1.1em; resize: vertical;">{!! terms_text !!}</textarea>
</div>
<div style="display: flex; gap: 10px; align-items: center;">
<button type="submit" class="btn-primary" style="width: auto; padding: 10px 30px;">Save</button>
<button type="button" class="btn-remove" style="width: auto; padding: 10px 20px;" onclick="document.getElementById('terms-text').value=''; savePage(document.getElementById('terms-form'), 'terms');">Clear</button>
<span id="terms-status" style="margin-left: 10px; font-weight: bold; display: none;"></span>
</div>
</form>
</div>
<div style="margin-top: 40px; padding: 20px; background: rgba(0,0,0,0.2); border-left: 4px solid var(--accent); border-radius: 4px;">
<h4 style="color: var(--accent); margin-top: 0;">Tips</h4>
<p style="margin-bottom: 0;">Use <strong>Markdown</strong> syntax like <code># Header</code>, <code>**bold**</code>, <code>[links](url)</code>, or plain HTML.<br>
If empty, the default static terms page is shown instead.</p>
</div>
</div>
<script>
async function savePage(form, page) {
const statusId = page + '-status';
const status = document.getElementById(statusId);
status.textContent = 'Saving...';
status.style.color = 'var(--accent)';
status.style.display = 'inline';
try {
const res = await fetch(form.action, {
method: 'POST',
headers: {
'X-Requested-With': 'XMLHttpRequest',
'Content-Type': 'application/x-www-form-urlencoded',
'X-CSRF-Token': window.f0ckSession?.csrf_token
},
body: new URLSearchParams(new FormData(form))
});
const data = await res.json();
if (data.success) {
status.textContent = 'Saved!';
status.style.color = '#28a745';
setTimeout(() => { status.style.display = 'none'; }, 2000);
} else {
throw new Error(data.msg || 'Unknown error');
}
} catch (err) {
console.error('Save Error:', err);
status.textContent = 'Error: ' + err.message;
status.style.color = '#d9534f';
}
}
</script>
</div>
</div>
@include(snippets/footer)

101
views/admin/tokens.html Normal file
View File

@@ -0,0 +1,101 @@
@include(snippets/header)
<div class="pagewrapper">
<div id="main" class="admin-container">
<div class="admin-header-flex">
<h2>Invite Tokens</h2>
<button id="generate-token" class="btn-upload" style="width: auto; padding: 10px 20px;">Generate New Token</button>
</div>
<div class="upload-form">
<table class="responsive-table">
<thead>
<tr>
<th>Token</th>
<th>Status</th>
<th>Source</th>
<th>Used By</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="token-list">
<!-- Populated by JS -->
</tbody>
</table>
</div>
<script>
const loadTokens = async () => {
try {
console.log('Loading tokens...');
const res = await fetch('/api/v2/admin/tokens');
const data = await res.json();
console.log('Tokens data:', data);
if (data.success) {
const tbody = document.getElementById('token-list');
if (!tbody) return;
tbody.innerHTML = data.tokens.map(t =>
'<tr>' +
'<td data-label="Token" style="font-family: monospace; font-size: 1.1em; color: var(--accent);">' + t.token + '</td>' +
'<td data-label="Status">' +
(t.is_used ? '<span style="color: #ff6b6b">Used</span>' : '<span style="color: #51cf66">Available</span>') +
'</td>' +
'<td data-label="Source">' +
(t.created_by_matrix ? '<span style="color: #0DBD8B">[Matrix] ' + t.created_by_matrix + '</span>' :
(t.created_by_discord ? '<span style="color: #5865F2"><i class="fab fa-discord"></i> ' + t.created_by_discord + '</span>' :
(t.created_by_name ? 'Web/Admin (' + t.created_by_name + ')' : 'Web/Admin'))) +
'</td>' +
'<td data-label="Used By">' + (t.used_by_name || '-') + '</td>' +
'<td data-label="Created">' + (t.created_at ? new Date(parseInt(t.created_at) * 1000).toLocaleString() : '-') + '</td>' +
'<td data-label="Actions">' +
(!t.is_used ? '<button onclick="deleteToken(' + t.id + ')" class="btn-remove" style="padding: 5px 10px; font-size: 0.8em;">Delete</button>' : '') +
'</td>' +
'</tr>'
).join('');
}
} catch (e) { console.error(e); }
};
const generateToken = async () => {
console.log('Generating...');
try {
const res = await fetch('/api/v2/admin/tokens/create', {
method: 'POST',
headers: { 'X-CSRF-Token': window.f0ckSession?.csrf_token }
});
const data = await res.json();
console.log('Gen result:', data);
if (data.success) {
loadTokens();
} else {
alert('Failed: ' + data.msg);
}
} catch (e) {
console.error(e);
alert('Error: ' + e.message);
}
};
const deleteToken = async (id) => {
if (!confirm('Delete this token?')) return;
const res = await fetch('/api/v2/admin/tokens/delete', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': window.f0ckSession?.csrf_token
},
body: JSON.stringify({ id })
});
const data = await res.json();
if (data.success) {
loadTokens();
}
};
const btnGenToken = document.getElementById('generate-token');
if (btnGenToken) btnGenToken.addEventListener('click', generateToken);
loadTokens();
</script>
</div>
</div>
@include(snippets/footer)

389
views/admin/users.html Normal file
View File

@@ -0,0 +1,389 @@
@include(snippets/header)
<div class="pagewrapper">
<div id="main" class="admin-container">
<style>
.admin-users-table {
width: 100%;
border-collapse: separate;
border-spacing: 0 8px;
color: var(--white);
}
.admin-users-table th {
padding: 15px;
text-align: left;
text-transform: uppercase;
font-size: 0.75rem;
letter-spacing: 1px;
color: #888;
border-bottom: 1px solid rgba(255,255,255,0.05);
}
.admin-users-table tr {
transition: all 0.2s ease;
}
.admin-users-table tbody tr {
background: rgba(255, 255, 255, 0.02);
}
.admin-users-table tbody tr:hover {
background: rgba(255, 255, 255, 0.05);
}
.admin-users-table td {
padding: 15px;
vertical-align: middle;
}
.status-badge {
padding: 4px 10px;
border-radius: 20px;
font-size: 0.75rem;
font-weight: bold;
text-transform: uppercase;
display: inline-block;
}
.status-active { background: linear-gradient(135deg, #28a745, #20c997); color: #fff; }
.status-pending { background: linear-gradient(135deg, #f08c00, #ffc107); color: #000; }
.status-banned { background: linear-gradient(135deg, #e03131, #f03e3e); color: #fff; }
.method-tag {
font-size: 0.7rem;
padding: 2px 8px;
background: rgba(var(--accent-rgb, 0, 150, 255), 0.1);
border: 1px solid rgba(var(--accent-rgb, 0, 150, 255), 0.2);
color: var(--accent);
border-radius: 4px;
}
.stat-box {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 0.85rem;
color: #ccc;
transition: color 0.2s, transform 0.1s;
}
.stat-box:hover {
color: var(--accent);
transform: translateY(-1px);
}
.stat-box svg { opacity: 0.6; }
.btn-modern {
border: 0;
padding: 6px 12px;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
cursor: pointer;
transition: opacity 0.2s;
color: #fff;
white-space: nowrap;
}
.btn-modern:hover { opacity: 0.8; }
.btn-ban { background: #e03131; }
.btn-unban { background: #28a745; }
.btn-files { background: #f08c00; }
.btn-comms { background: #4dabf7; }
.btn-verify { background: #5c7cfa; }
.user-avatar {
width: 40px;
height: 40px;
border-radius: 8px;
object-fit: cover;
border: 1px solid rgba(255,255,255,0.1);
}
.avatar-placeholder {
width: 40px;
height: 40px;
background: linear-gradient(135deg, #333, #111);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
color: var(--accent);
font-size: 1.2rem;
border: 1px solid rgba(255,255,255,0.1);
}
</style>
<div class="container">
<div style="display: flex; justify-content: space-between; align-items: flex-end; margin-bottom: 30px; gap: 20px; flex-wrap: wrap;">
<div>
<h2 style="margin: 0; font-weight: 800; letter-spacing: -0.5px;">User Management</h2>
<p style="color: #888; margin: 5px 0 0 0;">Administration hub for <span id="total-count">{!! total !!}</span> registered members.</p>
</div>
<div style="flex-grow: 1; max-width: 400px; position: relative;">
<input type="text" id="user-search" placeholder="Search by name or email..."
style="width: 100%; padding: 12px 20px; background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1); border-radius: 8px; color: #fff; outline: none; transition: border-color 0.2s;"
value="{{ q }}">
<div id="search-spinner" style="position: absolute; right: 15px; top: 12px; display: none;">
<svg class="spinner" width="20" height="20" viewBox="0 0 50 50" style="animation: rotate 2s linear infinite;">
<circle cx="25" cy="25" r="20" fill="none" stroke="var(--accent)" stroke-width="5" stroke-dasharray="90,150" stroke-dashoffset="0" style="stroke-linecap: round;"></circle>
</svg>
</div>
</div>
</div>
<div class="upload-form">
<table class="admin-users-table responsive-table">
<thead>
<tr>
<th>User & Contact</th>
<th>Activity</th>
<th>Registration</th>
<th>Account Age</th>
<th>Status</th>
<th style="text-align: right;">Actions</th>
</tr>
</thead>
<tbody id="user-table-body">
@include(admin/users_list)
</tbody>
</table>
<div id="loading-trigger" style="height: 20px; margin-top: 10px;"></div>
<div id="no-users-msg" style="display: {{ users.length === 0 ? 'block' : 'none' }}; padding: 40px; text-align: center; color: #666;">
No users matched your search.
</div>
</div>
</div>
<script>
function updateUI(id, data, action) {
var rowId = id ? id : ('ghost-' + data.username);
var statusCell = document.getElementById('status-cell-' + rowId);
var actionsDiv = document.getElementById('actions-' + rowId);
if (!statusCell || !actionsDiv) return console.error('UI elements not found for ID', rowId);
if (action === 'activate') {
statusCell.innerHTML = '<span class="status-badge status-active">Active</span>';
var verifyBtn = actionsDiv.querySelector('.btn-verify');
if (verifyBtn) verifyBtn.remove();
} else if (action === 'ban') {
statusCell.innerHTML = '<span class="status-badge status-banned">Banned</span>';
var banBtn = actionsDiv.querySelector('.btn-ban');
if (banBtn) {
banBtn.textContent = 'Unban';
banBtn.className = 'btn-modern btn-unban';
// We re-bind the onclick via HTML because simple assignment doesn't preserve the 'this' context in the same way with these handlers
}
var verifyBtn = actionsDiv.querySelector('.btn-verify');
if (verifyBtn) verifyBtn.remove();
} else if (action === 'unban') {
statusCell.innerHTML = '<span class="status-badge status-active">Active</span>';
var unbanBtn = actionsDiv.querySelector('.btn-unban');
if (unbanBtn) {
unbanBtn.textContent = 'Ban';
unbanBtn.className = 'btn-modern btn-ban';
}
}
}
async function activateUser(btn) {
var id = btn.dataset.id;
var userName = btn.dataset.name;
ModAction.confirm('Verify User', 'Manually verify account for <strong>' + escHTML(userName) + '</strong>?', async () => {
var res = await fetch('/api/v2/admin/users/activate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user_id: id })
});
var data = await res.json();
if (data.success) {
showFlash(data.msg, 'success');
updateUI(id, data, 'activate');
} else {
throw new Error(data.msg || 'Failed to verify user');
}
}, { hideReason: true });
}
async function banUser(btn) {
var id = btn.dataset.id;
var userName = btn.dataset.name;
ModAction.confirm('Ban User', 'Reason for banning <strong>' + escHTML(userName) + '</strong>?', async (reason) => {
var res = await fetch('/api/v2/admin/ban', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user_id: id, reason: reason, duration: 'permanent' })
});
var data = await res.json();
if (data.success) {
showFlash('User ' + escHTML(userName) + ' banned.', 'success');
updateUI(id, data, 'ban');
} else {
throw new Error(data.msg || 'Failed to ban user');
}
}, { hideReason: false, confirmText: 'Ban User' });
}
async function unbanUser(btn) {
var id = btn.dataset.id;
var userName = btn.dataset.name;
ModAction.confirm('Unban User', 'Unban account for <strong>' + escHTML(userName) + '</strong>?', async () => {
var res = await fetch('/api/v2/admin/unban', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user_id: id })
});
var data = await res.json();
if (data.success) {
showFlash('User ' + escHTML(userName) + ' unbanned.', 'success');
updateUI(id, data, 'unban');
} else {
throw new Error(data.msg || 'Failed to unban user');
}
}, { hideReason: true });
}
async function deleteUploads(btn) {
var id = btn.dataset.id;
var userName = btn.dataset.name;
ModAction.confirm('Delete Uploads', 'Are you SURE you want to delete ALL uploads by <strong>' + escHTML(userName) + '</strong>? This cannot be undone.', async () => {
var res = await fetch('/api/v2/admin/users/bulk-delete-items', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user_id: id, username: btn.dataset.username })
});
var data = await res.json();
if (data.success) {
showFlash(data.msg, 'success');
} else {
throw new Error(data.msg || 'Deletion failed');
}
}, { hideReason: true, confirmText: 'Delete Everything' });
}
async function deleteComments(btn) {
var id = btn.dataset.id;
var userName = btn.dataset.name;
ModAction.confirm('Delete Comments', 'Are you SURE you want to delete ALL comments by <strong>' + escHTML(userName) + '</strong>? This will be permanent.', async () => {
var res = await fetch('/api/v2/admin/users/bulk-delete-comments', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user_id: id, username: btn.dataset.username })
});
var data = await res.json();
if (data.success) {
showFlash(data.msg, 'success');
} else {
throw new Error(data.msg || 'Deletion failed');
}
}, { hideReason: true, confirmText: 'Delete All Comments' });
}
async function adminSetDisplayName(btn) {
var id = btn.dataset.id;
var userName = btn.dataset.name;
var currentDisplay = btn.dataset.display || '';
var hint = currentDisplay
? 'Current nick: <strong style="color: var(--accent);">' + escHTML(currentDisplay) + '</strong><br>Enter a new stylized name, or leave empty to clear it.'
: 'Enter a stylized display name for <strong>' + escHTML(userName) + '</strong> (e.g. <code>F.O.O</code>). Leave empty to clear.';
ModAction.confirm('Set Display Name', hint, async (newName) => {
var res = await fetch('/api/v2/admin/users/set-display-name', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user_id: id, display_name: newName || '' })
});
var data = await res.json();
if (data.success) {
showFlash(data.msg || 'Display name updated.', 'success');
// Update button data attribute for next edit
btn.dataset.display = data.display_name || '';
// Refresh the row name link in the table
var rowId = 'user-row-' + id;
var row = document.getElementById(rowId);
if (row) {
var link = row.querySelector('.user-info-cell a');
if (link && data.display_name) {
link.innerHTML = '<span style="color: var(--accent);">' + escHTML(data.display_name) + '</span> <span style="font-size: 0.75em; color: #666;">(' + escHTML(userName) + ')</span>';
} else if (link) {
link.textContent = userName;
}
}
} else {
throw new Error(data.msg || 'Failed to set display name');
}
}, { hideReason: false, allowEmpty: true, confirmText: 'Set Nick', placeholder: currentDisplay || 'e.g. F.O.O' });
}
var currentPage = {!! page !!};
var hasMore = {!! hasMore ? 'true' : 'false' !!};
var isLoading = false;
var searchQuery = '{{ q }}';
var searchInput = document.getElementById('user-search');
var tableBody = document.getElementById('user-table-body');
var loadingTrigger = document.getElementById('loading-trigger');
var noUsersMsg = document.getElementById('no-users-msg');
var spinner = document.getElementById('search-spinner');
var countSpan = document.getElementById('total-count');
async function fetchUsers(page, q, append) {
if (isLoading) return;
isLoading = true;
if (!append) spinner.style.display = 'block';
try {
var url = '/admin/users?page=' + page + '&q=' + encodeURIComponent(q);
var res = await fetch(url, { headers: { 'X-Requested-With': 'XMLHttpRequest' } });
// Update state from headers
var total = res.headers.get('X-Total-Count');
var hasMoreHeader = res.headers.get('X-Has-More');
if (total !== null) countSpan.textContent = total;
if (hasMoreHeader !== null) hasMore = (hasMoreHeader === 'true');
var html = await res.text();
if (append) {
tableBody.insertAdjacentHTML('beforeend', html);
} else {
tableBody.innerHTML = html;
}
noUsersMsg.style.display = (tableBody.children.length === 0) ? 'block' : 'none';
} catch (e) {
console.error('Fetch failed', e);
} finally {
isLoading = false;
spinner.style.display = 'none';
}
}
// Search Input Handling (Debounced)
var searchTimeout;
searchInput.addEventListener('input', function() {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(function() {
searchQuery = searchInput.value;
currentPage = 1;
fetchUsers(1, searchQuery, false);
}, 300);
});
// Infinite Scroll
var observer = new IntersectionObserver(function(entries) {
if (entries[0].isIntersecting && hasMore && !isLoading) {
currentPage++;
fetchUsers(currentPage, searchQuery, true);
}
}, { threshold: 0.1 });
observer.observe(loadingTrigger);
// Styling for spinner
var style = document.createElement('style');
style.innerHTML = '@keyframes rotate { 100% { transform: rotate(360deg); } }';
document.head.appendChild(style);
</script>
</div>
</div>
@include(snippets/footer)

View File

@@ -0,0 +1,91 @@
@for(let u of users)
<tr id="user-row-{{ u.id || 'ghost-' + u.login }}">
<td data-label="User & Contact">
<div class="user-info-cell" style="display: flex; align-items: center; gap: 15px;">
@if(u.avatar_file)
<img src="/a/{{ u.avatar_file }}" class="user-avatar" alt="Avatar" style="width: 45px; height: 45px; border-radius: 10px; object-fit: cover; border: 2px solid rgba(255,255,255,0.1);">
@else
<div class="avatar-placeholder" style="width: 45px; height: 45px; border-radius: 10px; display: flex; align-items: center; justify-content: center; background: rgba(255,255,255,0.05); color: var(--accent); font-weight: bold; border: 2px solid rgba(255,255,255,0.1); font-size: 1.2rem; {{ !u.id ? 'background: #333; color: #666;' : '' }}">{{ !u.id ? '?' : u.user.charAt(0).toUpperCase() }}</div>
@endif
<div>
<a href="/user/{{ u.login }}" target="_blank" style="color: #fff; font-weight: 800; font-size: 1.1rem; text-decoration: none; display: block; margin-bottom: 2px;">@if(u.display_name)<span style="color: var(--accent);">{!! u.display_name !!}</span> <span style="font-size: 0.75em; color: #666;">({!! u.user !!})</span>@else{!! u.user !!}@endif</a>
<div style="font-size: 0.8rem; color: #888; letter-spacing: 0.2px;">{{ !u.id ? 'Ghost User / Legacy' : (u.email || 'no email') }}</div>
</div>
</div>
</td>
<td data-label="Activity">
<div style="display: flex; align-items: center; gap: 15px; white-space: nowrap;">
<a href="/user/{{ u.login }}" target="_blank" class="stat-box" title="Uploads" style="text-decoration: none;">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M17 8l-5-5-5 5M12 3v12"/></svg>
<strong>{{ u.upload_count }}</strong>
</a>
<a href="/user/{{ u.login }}/comments" target="_blank" class="stat-box" title="Comments" style="text-decoration: none;">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/></svg>
<strong>{{ u.comment_count }}</strong>
</a>
</div>
</td>
<td data-label="Registration">
<div style="font-size: 0.85rem; color: #eee; font-weight: 600; cursor: help;" tooltip="Method: {{ u.reg_method }}">{{ new Date(u.created_at).toLocaleDateString() }}</div>
</td>
<td data-label="Account Age">
<div style="font-size: 0.85rem; font-weight: 600; color: #aaa;">{{ Math.floor(u.age_days) }} Days</div>
</td>
<td data-label="Status" id="status-cell-{{ u.id || 'ghost-' + u.login }}">
@if(!u.id)
<span class="status-badge" style="background: rgba(255,255,255,0.05); color: #888; border: 1px dashed rgba(255,255,255,0.1);">Legacy</span>
@else
@if(u.banned)
<span class="status-badge status-banned">Banned</span>
@else
@if(u.activated)
<span class="status-badge status-active">Active</span>
@else
<span class="status-badge status-pending">Pending</span>
@endif
@endif
@if(u.failed_attempts > 0)
@if(u.failed_attempts >= 5)
<span class="status-badge" style="background: rgba(255, 0, 0, 0.1); color: #ff4d4d; border: 1px solid rgba(255, 0, 0, 0.2); font-weight: 700;">IP LOCKED</span>
@else
<span class="status-badge" style="background: rgba(255, 255, 0, 0.1); color: #ffcc00; border: 1px solid rgba(255, 255, 0, 0.2);">{{ u.failed_attempts }} Tries</span>
@endif
@endif
@endif
</td>
<td data-label="Actions">
<div id="actions-{{ u.id || 'ghost-' + u.login }}" class="user-actions-row" style="display: flex; gap: 8px; justify-content: flex-end; flex-wrap: wrap;">
@if(u.id && u.login !== 'deleted_user')
@if(!u.activated && !u.banned)
<button data-id="{{ u.id }}" data-name="{!! u.user !!}" data-username="{{ u.login }}" onclick="activateUser(this)" class="btn-modern btn-verify">Verify</button>
@endif
@if(u.banned)
<button data-id="{{ u.id }}" data-name="{!! u.user !!}" data-username="{{ u.login }}" onclick="unbanUser(this)" class="btn-modern btn-unban">Unban</button>
@else
<button data-id="{{ u.id }}" data-name="{!! u.user !!}" data-username="{{ u.login }}" onclick="banUser(this)" class="btn-modern btn-ban">Ban</button>
@endif
@endif
@if(u.id)
<button data-id="{{ u.id }}" data-name="{!! u.user !!}" data-username="{{ u.login }}" onclick="deleteUploads(this)" class="btn-modern btn-files">Del Files</button>
<button data-id="{{ u.id }}" data-name="{!! u.user !!}" data-username="{{ u.login }}" onclick="deleteComments(this)" class="btn-modern btn-comms">Del Comms</button>
<button data-id="{{ u.id }}" data-name="{!! u.user !!}" data-username="{{ u.login }}" onclick="adminBulkDeleteHalls(this)" class="btn-modern btn-comms" title="Delete all user halls" style="background: rgba(255, 0, 255, 0.1); color: #ff00ff; border: 1px solid rgba(255, 0, 255, 0.2);"><i class="fa fa-folder-open"></i> Del Halls</button>
@if(u.failed_attempts > 0)
<button data-username="{{ u.login }}" onclick="adminResetLoginAttempts(this)" class="btn-modern btn-pw" title="Reset Login Attempts" style="background: rgba(255, 204, 0, 0.1); color: #ffcc00; border: 1px solid rgba(255, 204, 0, 0.2);"><i class="fa fa-unlock"></i> Reset IP</button>
@endif
@else
<button data-id="" data-name="{{ u.user }}" data-username="{{ u.login }}" onclick="deleteUploads(this)" class="btn-modern btn-files">Del Legacy Files</button>
@endif
@if(u.id && u.login !== 'deleted_user')
<button data-id="{{ u.id }}" data-name="{!! u.user !!}" data-username="{{ u.login }}" data-display="{!! u.display_name || '' !!}" onclick="adminSetDisplayName(this)" class="btn-modern btn-nick" style="background: rgba(100, 200, 100, 0.1); color: #64c864; border: 1px solid rgba(100, 200, 100, 0.2);" title="Set stylized display name">✏ Nick</button>
<button data-id="{{ u.id }}" data-name="{!! u.user !!}" data-username="{{ u.login }}" onclick="adminSetPassword(this)" class="btn-modern btn-pw" style="background: rgba(var(--accent-rgb, 0, 150, 255), 0.1); color: var(--accent, #0096ff); border: 1px solid rgba(var(--accent-rgb, 0, 150, 255), 0.2);">Set PW</button>
<button data-id="{{ u.id }}" data-name="{!! u.user !!}" data-username="{{ u.login }}" onclick="adminDeleteUser(this)" class="btn-modern btn-delete" style="background: rgba(217, 83, 79, 0.1); color: #d9534f; border: 1px solid rgba(217, 83, 79, 0.2);">Delete</button>
@elseif(u.login === 'deleted_user')
<span style="font-size: 0.8rem; color: #666; font-style: italic; padding: 5px 10px;">Protected System Account</span>
@endif
</div>
</td>
</tr>
@endfor