init f0ckm
This commit is contained in:
68
views/admin/about.html
Normal file
68
views/admin/about.html
Normal 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
244
views/admin/approve.html
Normal 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">« Prev</a>
|
||||
@endif
|
||||
<span>Page {{ page }} of {{ pages }}</span>
|
||||
@if(page < pages) <a href="/admin/approve?page={{ page + 1 }}" class="badge badge-secondary">Next »</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
135
views/admin/emojis.html
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
||||
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
322
views/admin/halls.html
Normal 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
20
views/admin/log.html
Normal 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
159
views/admin/memes.html
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
||||
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
77
views/admin/motd.html
Normal 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
26
views/admin/recover.html
Normal 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
68
views/admin/rules.html
Normal 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
80
views/admin/sessions.html
Normal 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">✖</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
68
views/admin/terms.html
Normal 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
101
views/admin/tokens.html
Normal 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
389
views/admin/users.html
Normal 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)
|
||||
91
views/admin/users_list.html
Normal file
91
views/admin/users_list.html
Normal 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
|
||||
Reference in New Issue
Block a user