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

31
views/about.html Normal file
View File

@@ -0,0 +1,31 @@
@include(snippets/header)
<div class="pagewrapper">
<div id="main">
<div class="rules">
@if(about_text)
<div class="dynamic-page-content" id="about-dynamic-content"></div>
<textarea id="about-raw-data" hidden>{!! about_text !!}</textarea>
<script>
(function() {
var raw = document.getElementById('about-raw-data');
var el = document.getElementById('about-dynamic-content');
function render() {
if (raw && el && typeof marked !== 'undefined') {
el.innerHTML = marked.parse(raw.value || '', { gfm: true, breaks: true });
raw.remove();
}
}
if (typeof marked !== 'undefined') {
render();
} else {
document.addEventListener('DOMContentLoaded', render);
}
})();
</script>
@else
<h1>About</h1>
@endif
</div>
</div>
</div>
@include(snippets/footer)

177
views/admin.html Normal file
View File

@@ -0,0 +1,177 @@
@include(snippets/header)
<div class="pagewrapper">
<div id="main">
<div class="container">
<h1>ADMINBEREICH</h1>
<h5>Hallo, {{ session.user }}</h5>
<span>Hier entsteht eine Internetpräsenz!</span><br>
<hr>
<p>f0ck stats: @if(typeof totals !== "undefined")total: {{ totals.total }} | tagged: {{ totals.tagged }} | untagged: {{ totals.untagged }} | sfw: {{ totals.sfw }} | nsfw: {{ totals.nsfw }}@endif</p>
<hr>
<div class="admintools">
<p>Adminwerkzeuge</p>
<ul>
<li><a href="/mod/audit">Audit Log</a></li>
<li><a href="/admin/approve">Approval Queue</a></li>
<li><a href="/admin/sessions">Sessions</a></li>
<li><a href="/admin/tokens">Invite Tokens</a></li>
<li><a href="/admin/users">User Manager</a></li>
<li><a href="/admin/emojis">Emoji Manager</a></li>
<li><a href="/admin/memes">Meme Manager</a></li>
<li><a href="/admin/halls">Hall Manager</a></li>
<li><a href="/admin/motd">MOTD Manager</a></li>
<li><a href="/admin/about">About Page</a></li>
<li><a href="/admin/rules">Rules Page</a></li>
</ul>
<hr style="margin: 20px 0; border: 0; border-top: 1px solid rgba(255,255,255,0.1);">
<div class="settings-toggle" style="background: rgba(0,0,0,0.2); padding: 15px; border-radius: 4px; display: flex; align-items: center; justify-content: space-between;">
<div>
<label style="display: block; font-weight: bold; color: var(--accent);">Manual Upload Approval</label>
<p style="margin: 2px 0 0 0; font-size: 0.8em; color: #aaa;">If enabled, mods must approve every upload.</p>
</div>
<label class="switch">
<input type="checkbox" id="manual_approval_toggle" {{ manual_approval ? 'checked' : '' }} onchange="saveAdminSettings()">
<span class="slider round"></span>
</label>
</div>
@if(registration_web_toggle_enabled)
<div class="settings-toggle" style="background: rgba(0,0,0,0.2); padding: 15px; border-radius: 4px; display: flex; align-items: center; justify-content: space-between; margin-top: 10px;">
<div>
<label style="display: block; font-weight: bold; color: var(--accent);">Open Registration</label>
<p style="margin: 2px 0 0 0; font-size: 0.8em; color: #aaa;">Allow registration without invite tokens. Requires email activation.</p>
</div>
<label class="switch">
<input type="checkbox" id="registration_open_toggle" {{ registration_open ? 'checked' : '' }} onchange="saveAdminSettings()">
<span class="slider round"></span>
</label>
</div>
@endif
<div class="settings-item" style="background: rgba(0,0,0,0.2); padding: 15px; border-radius: 4px; display: flex; align-items: center; justify-content: space-between; margin-top: 10px;">
<div>
<label style="display: block; font-weight: bold; color: var(--accent);">Minimum Tags</label>
<p style="margin: 2px 0 0 0; font-size: 0.8em; color: #aaa;">Minimum number of tags required per upload.</p>
</div>
<input type="number" id="min_tags_input" value="{{ min_tags }}" min="1" max="20" style="width: 60px; background: #333; border: 1px solid #444; color: #fff; padding: 5px; border-radius: 4px; text-align: center;" onchange="saveAdminSettings()">
</div>
<div class="settings-item" style="background: rgba(0,0,0,0.2); padding: 15px; border-radius: 4px; display: flex; align-items: center; justify-content: space-between; margin-top: 10px;">
<div>
<label style="display: block; font-weight: bold; color: var(--accent);">Trusted Upload Threshold</label>
<p style="margin: 2px 0 0 0; font-size: 0.8em; color: #aaa;">New users with fewer than this many approved uploads must still go through manual approval, even if the global toggle is off. Set to 0 to disable.</p>
</div>
<input type="number" id="trusted_uploads_input" value="{{ trusted_uploads }}" min="0" max="999" style="width: 60px; background: #333; border: 1px solid #444; color: #fff; padding: 5px; border-radius: 4px; text-align: center;" onchange="saveAdminSettings()">
</div>
<div class="settings-item" style="background: rgba(0,0,0,0.2); padding: 15px; border-radius: 4px; display: flex; align-items: center; justify-content: space-between; margin-top: 10px;">
<div>
<label style="display: block; font-weight: bold; color: var(--accent);">Tag Image Cache</label>
<p style="margin: 2px 0 0 0; font-size: 0.8em; color: #aaa;">Regenerate thumbnails for the /tags page. This may take a while.</p>
</div>
<button onclick="regenerateTagImages()" id="regen_tag_btn" style="background: #007bff; border: 0; color: #fff; padding: 8px 16px; border-radius: 4px; cursor: pointer; font-size: 0.8em; font-weight: bold;">Regenerate All</button>
</div>
<span id="settings-status" style="display: block; margin-top: 10px; font-size: 0.8em; font-weight: bold; text-align: right;"></span>
</div>
</div>
<style>
.switch { position: relative; display: inline-block; width: 50px; height: 26px; }
.switch input { opacity: 0; width: 0; height: 0; }
.slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: #333; transition: .4s; border-radius: 34px; }
.slider:before { position: absolute; content: ""; height: 18px; width: 18px; left: 4px; bottom: 4px; background-color: white; transition: .4s; border-radius: 50%; }
input:checked + .slider { background-color: var(--accent); }
input:checked + .slider:before { transform: translateX(24px); background-color: #000; }
</style>
<script>
async function saveAdminSettings() {
const status = document.getElementById('settings-status');
const approvalToggle = document.getElementById('manual_approval_toggle');
const registrationToggle = document.getElementById('registration_open_toggle');
const minTagsInput = document.getElementById('min_tags_input');
const trustedUploadsInput = document.getElementById('trusted_uploads_input');
status.textContent = 'Saving...';
status.style.color = 'var(--accent)';
try {
const res = await fetch('/admin/settings', {
method: 'POST',
headers: {
'X-Requested-With': 'XMLHttpRequest',
'Content-Type': 'application/x-www-form-urlencoded'
},
body: new URLSearchParams({
manual_approval: approvalToggle.checked ? 'on' : 'off',
...(registrationToggle ? { registration_open: registrationToggle.checked ? 'on' : 'off' } : {}),
min_tags: minTagsInput.value,
trusted_uploads: trustedUploadsInput.value,
csrf_token: '{{ csrf_token }}'
}).toString()
});
if (!res.ok) throw new Error('Server returned ' + res.status);
const data = await res.json();
if (data.success) {
status.textContent = 'Saved!';
status.style.color = '#28a745';
setTimeout(() => { status.textContent = ''; }, 2000);
} else {
throw new Error(data.msg || 'Save failed');
}
} catch (err) {
console.error('Settings save error:', err);
status.textContent = 'Error: ' + err.message;
status.style.color = '#d9534f';
}
}
async function regenerateTagImages() {
const btn = document.getElementById('regen_tag_btn');
const status = document.getElementById('settings-status');
if (!confirm('Are you sure you want to trigger a full regeneration of all tag images? This will run in the background.')) return;
btn.disabled = true;
btn.textContent = 'Processing...';
status.textContent = 'Triggering regeneration...';
status.style.color = 'var(--accent)';
try {
const res = await fetch('/admin/tag_image/regenerate_all', {
headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
const data = await res.json();
if (data.success) {
status.textContent = data.message;
status.style.color = '#28a745';
btn.textContent = 'Started!';
setTimeout(() => {
btn.disabled = false;
btn.textContent = 'Regenerate All';
status.textContent = '';
}, 5000);
} else {
throw new Error(data.msg || 'Action failed');
}
} catch (err) {
console.error('Regeneration error:', err);
status.textContent = 'Error: ' + err.message;
status.style.color = '#d9534f';
btn.disabled = false;
btn.textContent = 'Regenerate All';
}
}
</script>
</div>
</div>
@include(snippets/footer)

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

1
views/ajax-item.html Normal file
View File

@@ -0,0 +1 @@
@include(item-partial)

13
views/banned.html Normal file
View File

@@ -0,0 +1,13 @@
@include(snippets/header)
<div id="main" style="display: flex; justify-content: center; align-items: center; min-height: 80vh;">
<div class="banned-container" style="background: rgba(0,0,0,0.85); padding: 40px; margin: 25px; border: 2px solid var(--accent); text-align: center; position: relative; z-index: 10002;">
<h1 style="color: var(--accent); margin-bottom: 20px;">YOU ARE BANNED!</h1>
<video style="max-width: 100%;" src="{{ ban_video }}" autoplay loop controls></video>
<p style="font-size: 1.2em; margin-bottom: 20px;">Reason: <strong>{{ reason }}</strong></p>
<p style="font-size: 1.1em; color: rgba(255,255,255,0.7);">Ban expires: <strong>{{ expires }}</strong></p>
<div style="margin-top: 30px;">
<a href="/logout" class="btn btn-outline-danger" style="padding: 10px 20px; border: 1px solid var(--accent); color: var(--accent); text-decoration: none; border-radius: 4px;">Leave</a>
</div>
</div>
</div>
@include(snippets/footer)

View File

@@ -0,0 +1,28 @@
<div class="profile_head">
@if(user.avatar_file)
<div class="profile_head_avatar">
<img src="/a/{{ user.avatar_file }}" style="display: grid;width: 55px" />
</div>
@elseif(user.avatar && user.avatar > 0)
<div class="profile_head_avatar">
<img src="/t/{{ user.avatar }}.webp" style="display: grid;width: 55px" />
</div>
@endif
<div class="layersoffear">
<div class="profile_head_username">
<span @if(user.username_color) style="color: {{ user.username_color }}" @endif>{{ t('profile.comments_title').replace('{user}', user.user) }}</span>
</div>
<div class="profile_head_user_stats">
<a href="/user/{!! user.user !!}">{{ t('profile.back_to_profile') }}</a>
</div>
</div>
</div>
<div class="user_content_wrapper" style="display: block;">
<div class="comments-list-page" style="max-width: 800px; margin: 0 auto;">
<!-- Container for CSR comments -->
<div id="user-comments-container" data-user="{!! user.user !!}"></div>
</div>
</div>
<!-- Include local script for this page -->
<script src="/s/js/user_comments.js?v=1"></script>

7
views/comments_user.html Normal file
View File

@@ -0,0 +1,7 @@
@include(snippets/header)
<div id="main">
@include(comments_user-partial)
</div>
@include(snippets/footer)

20
views/error-partial.html Normal file
View File

@@ -0,0 +1,20 @@
<div id="main">
<div class="container">
<div class="_error_wrapper">
<div class="err">
<div class="_error_topbar">
<span>(Ͼ˳Ͽ)..!!!</span>
</div>
<div class="_error_content">
<a href="/random">
<img src="/s/img/404.gif" alt="ZOMG">
</a>
<div class="_error_message">
<span>Error</span>
<code>{{ message }}</code>
</div>
</div>
</div>
</div>
</div>
</div>

22
views/error.html Normal file
View File

@@ -0,0 +1,22 @@
@include(snippets/header)
<div id="main">
<div class="container">
<div class="_error_wrapper">
<div class="err">
<div class="_error_topbar">
<span>(Ͼ˳Ͽ)..!!!</span>
</div>
<div class="_error_content">
<a href="/random">
<img src="/s/img/404.gif" alt="ZOMG">
</a>
<div class="_error_message">
<span>{{ t('error.label') }}</span>
<code>{{ message }}</code>
</div>
</div>
</div>
</div>
</div>
</div>
@include(snippets/footer)

22
views/hall-cards.html Normal file
View File

@@ -0,0 +1,22 @@
@each(hallsList as hall)
<a href="/h/{{ hall.slug }}" class="tag-card">
<div class="tag-card-image">
<img src="/hall_image/{{ hall.slug }}?m={{ session.mode }}" loading="lazy" alt="{!! hall.name !!}">
<span class="tag-card-image-title">{!! hall.name !!}</span>
@if(hall.rating === 'nsfw')
<span class="hall-rating-badge hall-rating-nsfw">NSFW</span>
@elseif(hall.rating === 'nsfl')
<span class="hall-rating-badge hall-rating-nsfl">NSFL</span>
@else
<span class="hall-rating-badge hall-rating-sfw">SFW</span>
@endif
</div>
<div class="tag-card-content">
<span class="tag-name">{!! hall.name !!}</span>
@if(hall.description)
<span class="tag-description">{!! hall.description !!}</span>
@endif
<span class="tag-count">{{ t('hall.posts', { count: hall.total_items }) }}</span>
</div>
</a>
@endeach

12
views/halls-partial.html Normal file
View File

@@ -0,0 +1,12 @@
<div class="container" style="padding-top: 20px;">
<h3 style="text-align: center;">{{ t('nav.halls') }}</h3>
<div class="tags-grid no-infinite-scroll" id="halls-container">
@include(hall-cards)
</div>
</div>
<div class="pagination-container-fluid" @if(typeof hidePagination !=='undefined' && hidePagination) style="display: none;" @endif>
<div class="pagination-wrapper bottom-pagination fixed-pagination">
@include(snippets/pagination)
</div>
</div>

5
views/halls.html Normal file
View File

@@ -0,0 +1,5 @@
@include(snippets/header)
<div id="main">
@include(halls-partial)
</div>
@include(snippets/footer)

33
views/index-partial.html Normal file
View File

@@ -0,0 +1,33 @@
<div class="index-layout-wrapper">
<div class="index-container">
@include(snippets/page-title)
<div class="posts" data-current-page="{{ pagination.current }}" data-has-more="{{ pagination.next ? 'true' : 'false' }}">
@each(items as item)
<a href="{{ link.main }}{{ item.id }}" class="{{ item.is_pinned ? 'anim-boxshadow ' : '' }}thumb lazy-thumb {{ item.has_notification ? 'has-notif' : '' }} {{ item.is_pinned ? 'is-pinned' : '' }}" data-file="{{ item.dest }}" data-mime="{{ item.mime }}" data-user="{!! item.display_name || item.username !!}" data-ext="{{ item.mime.split('/')[1].replace('youtube', 'yt').replace('x-shockwave-flash', 'flash').replace('vnd.adobe.flash.movie', 'flash').toUpperCase() }}" data-mode="{{ item.tag_id == nsfl_tag_id ? 'nsfl' : (item.tag_id == 2 ? 'nsfw' : (item.tag_id == 1 ? 'sfw' : 'null')) }}" data-bg="/t/{{ item.id }}.webp">
<div class="thumb-indicators">
@if(item.is_pinned)
<i class="fa-solid fa-thumbtack pin-indicator anim"></i>
@endif
@if(item.is_oc)
<span class="oc-indicator anim">OC</span>
@endif
@if(enable_xd_score && item.xd_score > 0)
<span class="thumb-xd-indicator xd-tier-{{ item.xd_score >= 60 ? 5 : (item.xd_score >= 30 ? 4 : (item.xd_score >= 15 ? 3 : (item.xd_score >= 5 ? 2 : 1))) }}" title="xD Score: {{ item.xd_score }}">xD</span>
@endif
</div>
<p></p>
</a>
@endeach
</div>
<div id="footbar">
&#9660;
</div>
</div>
<div class="pagination-container-fluid" @if(typeof hidePagination !=='undefined' && hidePagination) style="display: none;" @endif>
<div class="pagination-wrapper bottom-pagination fixed-pagination">
@include(snippets/pagination)
</div>
</div>
</div>

139
views/index.html Normal file
View File

@@ -0,0 +1,139 @@
@if(private_society && !session)
<html>
<head><title>502 Bad Gateway</title></head>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<body bgcolor="white">
<div id="nginx-502-gate">
<center><h1>502 Bad Gatew<span id="secret-letter">a</span>y</h1></center>
<hr><center><span style="cursor: text;top: -7px; position: relative;">nginx</span></center>
</div>
<div id="hot-corner" style="position:fixed; bottom:0; left:0; width:20px; height:20px; z-index:9999;"></div>
<style>
#nginx-502-gate {
background: white !important;
color: black !important;
font-family: serif !important;
font-size: 16px !important;
margin: 1px !important;
}
#nginx-502-gate center { display: block !important; text-align: center !important; margin: 0 !important; padding: 0 !important;}
#nginx-502-gate h1 { font-family: serif !important; font-size: 2em !important; font-weight: bold !important; margin: .67em 0 !important; display: block !important; color: black !important; cursor: text !important;}
#nginx-502-gate hr { margin: 15px 0 !important; display: block !important; border: 0 !important; border-top: 2px solid gray !important; }
#secret-letter {
cursor: text;
}
#secret-letter { cursor: text; }
#login-modal, #register-modal {
background: rgba(0,0,0,0.1) !important;
z-index: 10000 !important;
}
#login-modal .login-modal-content, #register-modal .login-modal-content {
background: #f0f0f0 !important;
border: 1px solid #777 !important;
color: black !important;
border-radius: 0 !important;
box-shadow: 4px 4px 0 rgba(0,0,0,0.2) !important;
font-family: sans-serif !important;
}
.login-modal-content h2 {
color: black !important;
margin-bottom: 15px !important;
font-size: 1.3em !important;
}
.login-form input[type="text"],
.login-form input[type="password"],
.login-form input[type="email"] {
background: white !important;
color: black !important;
border: 1px solid #999 !important;
border-radius: 0 !important;
padding: 6px !important;
width: 100% !important;
box-sizing: border-box !important;
margin-bottom: 10px !important;
}
.login-form button[type="submit"] {
background: #e1e1e1 !important;
color: black !important;
border: 1px solid #777 !important;
border-radius: 0 !important;
padding: 8px !important;
font-weight: bold !important;
cursor: pointer !important;
opacity: 1 !important;
}
.login-form button[type="submit"]:hover {
background: #d1d1d1 !important;
}
.login-form label, .login-form p, .login-form a {
color: #444 !important;
font-size: 0.9em !important;
}
.login-form a {
text-decoration: underline !important;
}
#login-modal-close, #register-modal-close {
color: #444 !important;
font-size: 24px !important;
}
</style>
<div id="gate-container" style="display:none;">
@include(snippets/navbar)
@include(snippets/footer)
</div>
<script>
function openLoginGate() {
const container = document.getElementById('gate-container');
container.style.display = 'block';
if (!document.getElementById('injected-gate-styles')) {
const link = document.createElement('link');
link.id = 'injected-gate-styles';
link.rel = 'stylesheet';
link.href = '/s/css/f0ckm.css?v={{ ts }}';
document.head.appendChild(link);
}
const modal = document.getElementById('login-modal');
if (modal) {
modal.style.display = 'flex';
} else {
const checkModal = setInterval(() => {
const m = document.getElementById('login-modal');
if (m) {
m.style.display = 'flex';
clearInterval(checkModal);
}
}, 50);
}
}
document.getElementById('secret-letter').onclick = openLoginGate;
document.getElementById('hot-corner').onclick = openLoginGate;
let secretBuffer = '';
document.addEventListener('keydown', (e) => {
secretBuffer += e.key.toLowerCase();
if (secretBuffer.endsWith('premiumhumor')) {
openLoginGate();
secretBuffer = '';
}
if (secretBuffer.length > 10) secretBuffer = secretBuffer.substring(1);
});
</script>
</body>
</html>
@else
@include(snippets/header)
<div class="pagewrapper">
<div id="main">
@include(index-partial)
</div>
</div>
@include(snippets/footer)
@endif

View File

@@ -0,0 +1,192 @@
{{-- LEGACY LAYOUT — 2-column (main + fixed right sidebar) --}}
{{-- Used by: members with use_new_layout = false --}}
<div class="item-layout-container">
{{-- MAIN CONTENT: media + navigation + metadata + comments --}}
<div class="item-main-content">
<div class="_204863">
<div class="location">{{ link.main }}{{ item.id }}{{ link.suffix }}</div>
<div class="gapLeft"></div>
</div>
<div class="content">
<div class="previous-post">
@if(pagination.next)
<div class="arrow-prev">
<a id="prev" href="{{ link.main }}{{ pagination.next }}{{ link.suffix }}"></a>
</div>
@else
<div class="arrow-prev">
<a id="prev" href="#" style="color: #ccc !important;"></a>
</div>
@endif
</div>
<div class="media-object">
@include(snippets/item-media)
</div>
<div class="next-post">
@if(pagination.prev)
<div class="arrow-next">
<a id="next" href="{{ link.main }}{{ pagination.prev }}{{ link.suffix }}"></a>
</div>
@else
<div class="arrow-next">
<a id="next" href="#" style="color: #ccc !important;"></a>
</div>
@endif
</div>
</div>
<div class="metadata">
<div class="kontrollelement">
<div class="einheit">
@if(typeof pagination !== "undefined")
<nav class="steuerung">
@if(pagination.next)
<a class="nav-prev" href="{{ link.main }}{{ pagination.next }}{{ link.suffix }}">← {{ t('nav.prev') }}</a>
@else
<a class="nav-prev" href="#" style="visibility: hidden">← {{ t('nav.prev') }}</a>
@endif
<span>|</span>
<a id="random" class="" href="/random">
<span>{{ t('nav.random_nav') }}</span>
</a>
<span>|</span>
@if(pagination.prev)
<a class="nav-next" href="{{ link.main }}{{ pagination.prev }}{{ link.suffix }}">{{ t('nav.next') }} →</a>
@else
<a class="nav-next" href="#" style="visibility: hidden">{{ t('nav.next') }} →</a>
@endif
</nav>
@endif
</div>
</div>
<div class="blahlol">
@if(use_ententeich)
<div class="ententeich-block" style="--author-accent: @if(item.author_color){{ item.author_color }}@else var(--acent) @endif; --author-border: @if(item.author_color){{ item.author_color }}@else var(--accent) @endif;">
<div class="enten-avatar">
<a href="/user/{{ (item.username || '').toLowerCase() }}">
@if(item.author_avatar_file)
<img src="/a/{{ item.author_avatar_file }}" />
@elseif(item.author_avatar && item.author_avatar > 0)
<img src="/t/{{ item.author_avatar }}.webp" />
@else
<img src="/a/default.png" style="border-color: #444;" />
@endif
</a>
</div>
<div class="enten-info">
<div class="enten-header">
<div class="enten-username-container">
<a href="/user/{{ (item.username || '').toLowerCase() }}" tooltip="ID: {{ item.author_id }}" class="enten-username">{!! item.author_display_name || item.username !!}</a>
</div>
<span class="enten-timestamp"><time class="timeago" tooltip="{{ item.timestamp.timefull }}">{{item.timestamp.timeago }}</time></span>
</div>
<div class="enten-description">
{!! item.author_description || '' !!}
</div>
</div>
</div>
@endif
<span class="badge badge-dark">
<a href="/{{ item.id }}" class="id-link" @if(use_ententeich)style="display:none"@endif>{{ item.id }}</a>
@if(item.src.short)@if(!use_ententeich) — @endif<a href="{{ item.src.long }}" target="_blank">{{ item.src.short }}</a>@endif
@if(session && !use_ententeich) — [<a id="a_username" data-username="{!! item.username || '' !!}" href="/user/{{ (item.username || '').toLowerCase() }}" @if(item.author_id) tooltip="ID: {{ item.author_id }}" @endif @if(item.author_color) style="color: {{ item.author_color }}" @endif>{!! item.author_display_name || item.username || 'unknown' !!}</a>] @endif
@if(item.is_oc)@if(!use_ententeich || item.src.short) — @endif<span class="oc-badge" tooltip="Original Content">OC</span>@endif
</span>
@if(!use_ententeich) — <span class="badge badge-dark"><time class="timeago" tooltip="{{ item.timestamp.timefull }}">{{item.timestamp.timeago }}</time></span> — @endif
@if(item.primaryHall)
<span class="badge hall-badge-wrap">
<a href="/h/{{ item.primaryHall.slug }}" class="hall-badge-primary">{{ item.primaryHall.name }}</a>@if(is_mod_or_admin)&nbsp;<a class="remove-from-hall" href="#" data-hall="{{ item.primaryHall.slug }}" title="Remove from hall"><i class="fa-solid fa-xmark"></i></a>@endif@if(item.otherHalls && item.otherHalls.length)<span class="hall-overflow-pill">+{{ item.otherHalls.length }}<span class="hall-overflow-tooltip">@each(item.otherHalls as oh)<a href="/h/{{ oh.slug }}">{{ oh.name }}</a>@endeach</span></span>@endif
</span>@if(!use_ententeich) —@endif
@endif
@if(session)
<div class="gapRight">
@if(user_has_favorited)
<i class="iconset fa-solid fa-heart" id="a_favo" title="Favorite"></i>
@else
<i class="iconset fa-regular fa-heart" id="a_favo" title="Favorite"></i>
@endif
<i class="iconset {{ isSubscribed ? 'fa-solid' : 'fa-regular' }} fa-bell" id="subscribe-btn" data-item-id="{{ item.id }}" title="{{ isSubscribed ? 'Subscribed' : 'Subscribe' }}"></i>
<i class="iconset fa-solid fa-triangle-exclamation report-item-btn" data-item-id="{{ item.id }}" title="Report this post"></i>
<i class="iconset fa-solid fa-building-columns" id="a_hall" data-item-id="{{ item.id }}" data-halls="{{ halls_slugs }}" data-user-halls="{{ user_halls_slugs }}" data-current-hall="{{ (tmp.hall && typeof tmp.hall === 'object') ? tmp.hall.slug : (tmp.hall || '') }}" data-current-user-hall="{{ (tmp.userHall && typeof tmp.userHall === 'object') ? tmp.userHall.slug : (tmp.userHall || '') }}" data-current-user-hall-owner="{{ tmp.userHallOwner || '' }}" title="Add to Hall"></i>
@if(can_manage_item)
<i class="iconset {{ item.is_oc ? 'fa-solid' : 'fa-regular' }} fa-star" id="a_oc" data-item-id="{{ item.id }}" data-is-oc="{{ item.is_oc }}" title="{{ item.is_oc ? 'Remove OC status' : 'Mark as OC' }}"></i>
@if(can_extract_meta)
<i class="iconset fa-solid fa-magic" id="a_metadata" data-item-id="{{ item.id }}" title="Extract Metadata"></i>
@endif
@if(item.mime === 'application/x-shockwave-flash' || item.mime === 'application/vnd.adobe.flash.movie')
<i class="iconset fa-solid fa-image" id="a_rethumb" data-item-id="{{ item.id }}" title="Re-upload Thumbnail"></i>
@endif
@endif
@if(is_mod_or_admin)
<i class="iconset fa-solid fa-thumbtack{{ item.is_pinned ? ' active' : '' }}" id="a_pin" data-pinned="{{ item.is_pinned }}" title="{{ item.is_pinned ? 'Unpin from main' : 'Pin to main' }}"></i>
<i class="iconset fa-solid fa-xmark" id="a_delete" title="Delete"></i>
@endif
</div>
@endif
<span class="badge badge-dark" id="tags">
<span class="tags-inner">
@if(typeof item.tags !== "undefined")
@each(item.tags as tag)
<span tooltip="{!! tag.display_name || tag.user !!}" class="badge {{ tag.badge }} mr-2">
<a href="/tag/{{ tag.normalized }}">{!! tag.tag !!}</a>@if(is_mod_or_admin)&nbsp;<a class="removetag" href="#"><i class="fa-solid fa-xmark"></i></a>@endif
</span>
@endeach
@endif
</span>
@if(item.tags && item.tags.length > 10)
<a href="#" class="show-tags-toggle" data-count="{{ item.tags.length - 10 }}">show {{ item.tags.length - 10 }} more</a>
@endif
@if(session)
<div class="tag-controls">
<a href="#" id="a_addtag" class="tag-btn" flow="up-left">
<i class="fa-solid fa-plus"></i>
</a>
@if(can_manage_item)
<button class="rating-btn {{ item.is_nsfl ? 'is-nsfl' : (item.is_nsfw ? 'is-nsfw' : (item.is_sfw ? 'is-sfw' : 'is-untagged')) }}" id="a_toggle" title="Toggle Rating">{{ item.is_nsfl ? 'NSFL' : (item.is_nsfw ? 'NSFW' : (item.is_sfw ? 'SFW' : '?')) }}</button>
@endif
</div>
@endif
</span>
<span class="badge" id="favs" @if(!item.favorites.length) hidden@endif>
@each(item.favorites as fav)
<a href="/user/{{ fav.user.toLowerCase() }}" tooltip="{!! fav.display_name || fav.user !!}" flow="up"><img src="@if(fav.avatar_file)/a/{{ fav.avatar_file }}@elseif(fav.avatar)/t/{{ fav.avatar }}.webp@else/a/default.png@endif" style="height: 32px; width: 32px@if(fav.username_color); border-color: {{ fav.username_color }}@endif" loading="lazy" /></a>
@endeach
</span>
@if(enable_xd_score && item.xd_score > 0)
<div class="xd-score-wrapper">
<span class="xd-score-badge xd-tier-{{ item.xd_tier }}" tooltip="xD Score: {{ item.xd_score }} pts" flow="up">
{{ item.xd_label }} <span class="xd-score-num">{{ item.xd_score }}</span>
</span>
</div>
@endif
</div>
</div>
<div id="comments-container"
data-item-id="{{ item.id }}"
@if(session) data-user="{{ session.user }}" @endif
@if(session && (session.admin || session.is_moderator)) data-is-admin="true" @endif
@if(item.is_comments_locked) data-is-locked="true" @endif>
@if(!item.is_comments_locked)
<div class="comment-input main-input">
<textarea disabled></textarea>
</div>
@endif
</div>
<script id="initial-subscription" type="application/json">{{ isSubscribed }}</script>
</div>
{{-- RIGHT SIDEBAR: recent activity (fixed to viewport) --}}
</div>

View File

@@ -0,0 +1,163 @@
{{-- MODERN LAYOUT — 3-column grid --}}
{{-- Used by: guests (no session) + members with use_new_layout = true --}}
<div class="item-layout-container">
{{-- LEFT SIDEBAR: comments + tags --}}
<div class="item-sidebar-left">
@if(session || !hide_comments_from_public)
<div id="comments-container"
data-item-id="{{ item.id }}"
@if(session) data-user="{{ session.user }}" @endif
@if(session && is_mod_or_admin) data-is-admin="true" @endif
@if(item.is_comments_locked) data-is-locked="true" @endif>
@if(session && !item.is_comments_locked)
<div class="comment-input main-input">
<textarea disabled></textarea>
</div>
@endif
</div>
@endif
@if(session)
<div class="tag-controls">
<a href="#" id="a_addtag" class="tag-btn" flow="up-left">
<i class="fa-solid fa-plus"></i>
</a>
@if(can_manage_item)
<button class="rating-btn {{ item_rating_class }}" id="a_toggle" title="Toggle Rating">{{ item_rating_label }}</button>
@endif
</div>
@endif
<div class="sidebar-tags-container">
<div style="margin-bottom: 8px; font-weight: bold; color: var(--white); font-size: 0.9em; text-transform: uppercase;"></div>
<span class="badge badge-dark" id="tags" style="display: flex; flex-wrap: wrap; gap: 5px; background: transparent; padding: 0; text-align: left; white-space: normal;">
@if(typeof item.tags !== "undefined")
@each(item.tags as tag)
<span @if(session) tooltip="{!! tag.display_name || tag.user !!}" @endif class="badge {{ tag.badge }}">
<a href="/tag/{{ tag.normalized }}">{!! tag.tag !!}</a>@if(is_mod_or_admin)&nbsp;<a class="removetag" href="#"><i class="fa-solid fa-xmark"></i></a>@endif
</span>
@endeach
@endif
</span>
</div>
</div>
{{-- MAIN CONTENT: media + navigation + metadata --}}
<div class="item-main-content">
<div class="_204863">
<div class="location">{{ link.main }}{{ item.id }}{{ link.suffix }}</div>
<div class="gapLeft"></div>
</div>
<div class="content">
<div class="previous-post">
@if(pagination.next)
<div class="arrow-prev">
<a id="prev" href="{{ link.main }}{{ pagination.next }}{{ link.suffix }}"></a>
</div>
@else
<div class="arrow-prev">
<a id="prev" href="#" style="color: #ccc !important;"></a>
</div>
@endif
</div>
<div class="media-object">
@include(snippets/item-media)
</div>
<div class="next-post">
@if(pagination.prev)
<div class="arrow-next">
<a id="next" href="{{ link.main }}{{ pagination.prev }}{{ link.suffix }}"></a>
</div>
@else
<div class="arrow-next">
<a id="next" href="#" style="color: #ccc !important;"></a>
</div>
@endif
</div>
</div>
<div class="metadata">
<div class="kontrollelement">
<div class="einheit">
@if(typeof pagination !== "undefined")
<nav class="steuerung">
@if(pagination.next)
<a class="nav-prev" href="{{ link.main }}{{ pagination.next }}{{ link.suffix }}">← {{ t('nav.prev') }}</a>
@else
<a class="nav-prev" href="#" style="visibility: hidden">← {{ t('nav.prev') }}</a>
@endif
<span>|</span>
<a id="random" class="" href="/random">
<span>{{ t('nav.random_nav') }}</span>
</a>
<span>|</span>
@if(pagination.prev)
<a class="nav-next" href="{{ link.main }}{{ pagination.prev }}{{ link.suffix }}">{{ t('nav.next') }} →</a>
@else
<a class="nav-next" href="#" style="visibility: hidden">{{ t('nav.next') }} →</a>
@endif
</nav>
@endif
</div>
</div>
<div class="blahlol">
<span class="badge badge-dark">
<a href="/{{ item.id }}" class="id-link">{{ item.id }}</a>@if(item.src.short) — <a href="{{ item.src.long }}" target="_blank">{{ item.src.short }}</a>@endif @if(session) — [<a id="a_username" data-username="{!! item.username || '' !!}" href="/user/{{ item_username_lower }}" @if(item.author_id) tooltip="ID: {{ item.author_id }}" @endif @if(item.author_color) style="color: {{ item.author_color }}" @endif>{!! item.author_display_name || item.username || 'unknown' !!}</a>] @endif @if(item.is_oc) — <span class="oc-badge" tooltip="Original Content">OC</span>@endif
</span>
@if(item.primaryHall)
<span class="badge hall-badge-wrap">
<a href="/h/{{ item.primaryHall.slug }}" class="hall-badge-primary">{{ item.primaryHall.name }}</a>@if(is_mod_or_admin)&nbsp;<a class="remove-from-hall" href="#" data-hall="{{ item.primaryHall.slug }}" title="Remove from hall"><i class="fa-solid fa-xmark"></i></a>@endif@if(item.otherHalls && item.otherHalls.length)<span class="hall-overflow-pill">+{{ item.otherHalls.length }}<span class="hall-overflow-tooltip">@each(item.otherHalls as oh)<a href="/h/{{ oh.slug }}">{{ oh.name }}</a>@endeach</span></span>@endif
</span>
@endif
<span class="badge badge-dark"><time class="timeago" tooltip="{{ item.timestamp.timefull }}">{{item.timestamp.timeago }}</time></span>
@if(session)
<div class="gapRight">
@if(user_has_favorited)
<i class="iconset fa-solid fa-heart" id="a_favo" title="Favorite"></i>
@else
<i class="iconset fa-regular fa-heart" id="a_favo" title="Favorite"></i>
@endif
<i class="iconset {{ isSubscribed ? 'fa-solid' : 'fa-regular' }} fa-bell" id="subscribe-btn" data-item-id="{{ item.id }}" title="{{ isSubscribed ? 'Subscribed' : 'Subscribe' }}"></i>
<i class="iconset fa-solid fa-triangle-exclamation report-item-btn" data-item-id="{{ item.id }}" title="Report this post"></i>
@if(can_manage_item)
<i class="iconset {{ item.is_oc ? 'fa-solid' : 'fa-regular' }} fa-star" id="a_oc" data-item-id="{{ item.id }}" data-is-oc="{{ item.is_oc }}" title="{{ item.is_oc ? 'Remove OC status' : 'Mark as OC' }}"></i>
<i class="iconset fa-solid fa-magic" id="a_metadata" data-item-id="{{ item.id }}" title="Extract Metadata"></i>
@if(is_flash_item)
<i class="iconset fa-solid fa-image" id="a_rethumb" data-item-id="{{ item.id }}" title="Re-upload Thumbnail"></i>
@endif
@endif
<i class="iconset fa-solid fa-building-columns" id="a_hall" data-item-id="{{ item.id }}" data-halls="{{ halls_slugs }}" data-user-halls="{{ user_halls_slugs }}" data-current-hall="{{ current_hall_slug }}" data-current-user-hall="{{ current_user_hall_slug }}" data-current-user-hall-owner="{{ current_user_hall_owner }}" title="Add to Hall"></i>
@if(is_mod_or_admin)
<i class="iconset fa-solid fa-thumbtack{{ item.is_pinned ? ' active' : '' }}" id="a_pin" data-pinned="{{ item.is_pinned }}" title="{{ item.is_pinned ? 'Unpin from main' : 'Pin to main' }}"></i>
<i class="iconset fa-solid fa-xmark" id="a_delete" title="Delete"></i>
@endif
</div>
@endif
<span id="favs" @if(!item.favorites.length) hidden@endif style="margin-top: 5px; display: block;">
@each(item.favorites as fav)
<a href="/user/{{ fav.user.toLowerCase() }}" tooltip="{!! fav.display_name || fav.user !!}" flow="up"><img src="@if(fav.avatar_file)/a/{{ fav.avatar_file }}@elseif(fav.avatar)/t/{{ fav.avatar }}.webp@else/a/default.png@endif" style="height: 32px; width: 32px@if(fav.username_color); border-color: {{ fav.username_color }}@endif" loading="lazy" /></a>
@endeach
</span>
@if(enable_xd_score && item.xd_score > 0)
<div class="xd-score-wrapper">
<span class="xd-score-badge xd-tier-{{ item.xd_tier }}" tooltip="xD Score: {{ item.xd_score }} pts" flow="up">
{{ item.xd_label }} <span class="xd-score-num">{{ item.xd_score }}</span>
</span>
</div>
@endif
</div>
</div>
<script id="initial-subscription" type="application/json">{{ isSubscribed }}</script>
</div>
{{-- RIGHT SIDEBAR: recent activity --}}
</div>

13
views/item-partial.html Normal file
View File

@@ -0,0 +1,13 @@
@if(session)
@if(session.use_new_layout)
@include(item-partial-modern)
@else
@include(item-partial-legacy)
@endif
@else
@if(default_layout === 'legacy')
@include(item-partial-legacy)
@else
@include(item-partial-modern)
@endif
@endif

19
views/item.html Normal file
View File

@@ -0,0 +1,19 @@
@include(snippets/header)
<div class="pagewrapper">
<div id="main" class="item-view">
@if(session)
@if(session.use_new_layout)
@include(item-partial-modern)
@else
@include(item-partial-legacy)
@endif
@else
@if(default_layout === 'legacy')
@include(item-partial-legacy)
@else
@include(item-partial-modern)
@endif
@endif
</div>
</div>
@include(snippets/footer)

27
views/login.html Normal file
View File

@@ -0,0 +1,27 @@
<!doctype f0ck>
<html theme="@if(typeof theme !== 'undefined'){{ theme }}@endif">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<title>login</title>
<link rel="icon" @if(custom_favicon && custom_favicon.length > 0)href="{{ custom_favicon }}"@else type="image/gif" href="/s/img/favicon.gif"@endif />
<link href="/s/css/f0ckm.css" rel="stylesheet" />
</head>
<body class="login-page">
<form class="login-form" method="post" action="/login" novalidate>
<h2>{{ t('auth.login_title') }}</h2>
@if(error)
<div class="flash-error" style="margin-bottom: 15px;">{{ error }}</div>
@endif
<input type="text" name="username" placeholder="{{ t('auth.username_placeholder') }}" autocomplete="off" required />
<input type="password" name="password" placeholder="{{ t('auth.password_placeholder') }}" autocomplete="off" required minlength="20" />
<p><input type="checkbox" id="kmsi" name="kmsi" /> <label for="kmsi">{{ t('auth.stay_signed_in_label') }}</label></p>
<button type="submit">{{ t('auth.login_title') }}</button>
@if(smtp_enabled)
<div style="margin-top: 15px; text-align: center;">
<a href="/forgot-password" style="font-size: 0.9em; color: var(--text-muted); text-decoration: none;">{{ t('auth.forgot_password') }}</a>
</div>
@endif
</form>
</body>
</html>

8
views/matrix.html Normal file
View File

@@ -0,0 +1,8 @@
@include(snippets/header)
<div id="main">
<div class="matrix-page">
<h1>Matrix</h1>
<p>#w0bm:w0bm.com</p>
</div>
</div>
@include(snippets/footer)

68
views/meme-creator.html Normal file
View File

@@ -0,0 +1,68 @@
@include(snippets/header)
<div class="pagewrapper">
<div id="main">
<div class="meme-layout-wrapper">
<link rel="stylesheet" href="/s/css/meme-creator.css">
<link rel="preload" href="/s/impact.woff" as="font" type="font/woff" crossorigin>
<style>
.meme-title { font-family: 'Impact', 'VCR', sans-serif; }
/* NUCLEAR VISIBILITY RESET: Override any and all parent constraints for this page */
@media (max-width: 950px) {
/* Target all possible shell triggers */
html, body, .pagewrapper, #main, .layout-modern react-wrapper, .layout-legacy react-wrapper {
height: auto !important;
overflow: visible !important;
min-height: 100vh !important;
display: block !important;
flex: none !important;
}
#main {
padding-bottom: 50px; /* Space for footer/controls */
}
}
</style>
<div class="meme-creator-container">
<div class="meme-header">
<h1 class="meme-title">{{ t('meme.create_meme') }} {!! template.name !!}</h1> </div>
<div class="meme-editor-layout">
<div class="canvas-wrapper">
<canvas id="memeCanvas"></canvas>
</div>
<div class="meme-controls">
<div id="textLayersContainer">
<!-- Dynamic inputs injected by JS -->
</div>
<button id="addText" class="btn btn-secondary btn-block" style="margin-bottom: 20px;">
<i class="fa fa-plus"></i> {{ t('meme.add_text_layer') }}
</button>
<div class="form-group" style="display: none;">
<label for="tags">{{ t('meme.tags_label') }}</label>
<input type="text" id="tags" value="meme, {!! template.category || 'General' !!}" placeholder="meme, funny, ...">
</div>
<button id="uploadMeme" class="btn btn-primary btn-block">
<i class="fa fa-upload"></i> {{ t('meme.upload_btn') }}
</button>
<a href="/meme" class="btn btn-secondary btn-block">{{ t('meme.back_btn') }}</a>
</div>
</div>
</div>
</div>
<script>
window.memeTemplate = {
url: "{!! template.url !!}",
name: "{!! template.name !!}",
category: "{!! template.category || 'General' !!}",
sub_category: "{!! template.sub_category || '' !!}"
};
window.csrf_token = "{{ csrf_token }}";
</script>
<script src="/s/js/meme-creator.js?v={{ ts }}"></script>
</div>
</div>
@include(snippets/footer)

54
views/meme-select.html Normal file
View File

@@ -0,0 +1,54 @@
@include(snippets/header)
<div class="pagewrapper">
<div id="main">
<div class="meme-layout-wrapper">
<link rel="stylesheet" href="/s/css/meme-creator.css">
<div class="meme-select-container">
<h1 class="meme-title">{{ t('meme.select_title') }}</h1>
<p class="meme-subtitle">{{ t('meme.select_subtitle') }}</p>
<div class="category-filter-bar">
@each(categories as cat)
<button class="category-chip @if(cat == 'All') active @endif" data-category="{!! cat !!}">{!! cat !!}</button>
@endeach
</div>
<div class="template-grid">
@each(templates as template)
<a href="/meme/{{ template.id }}" class="template-item" data-category="{!! template.category || 'General' !!}">
<div class="template-image-wrapper">
<img src="{{ template.url }}" alt="{!! template.name !!}" loading="lazy">
</div>
<div class="template-info">
<span class="template-name">{!! template.name !!}</span>
<span class="template-category-tag">{!! template.category || 'General' !!}</span>
</div>
</a>
@endeach
</div>
</div>
</div>
<script>
document.querySelectorAll('.category-chip').forEach(chip => {
chip.addEventListener('click', () => {
const category = chip.getAttribute('data-category');
// Update active chip
document.querySelectorAll('.category-chip').forEach(c => c.classList.remove('active'));
chip.classList.add('active');
// Filter grid
document.querySelectorAll('.template-item').forEach(item => {
if (category === 'All' || item.getAttribute('data-category') === category) {
item.style.display = 'flex';
} else {
item.style.display = 'none';
}
});
});
});
</script>
</div>
</div>
@include(snippets/footer)

View File

@@ -0,0 +1,48 @@
@include(snippets/header)
<div class="pagewrapper">
<div id="main" class="messages-page messages-convo-page">
<!-- Back + header -->
<div class="messages-header">
<a href="/messages" class="dm-back-btn" title="Back to inbox"></a>
<div class="dm-convo-header-info">
@if(other.avatar_file)
<img class="dm-header-avatar" src="/a/{{ other.avatar_file }}" alt="" onerror="this.src='/a/default.png'">
@elseif(other.avatar && other.avatar > 0)
<img class="dm-header-avatar" src="/t/{{ other.avatar }}.webp" alt="" onerror="this.src='/a/default.png'">
@else
<img class="dm-header-avatar" src="/a/default.png" alt="">
@endif
<a href="/user/{{ other.user.toLowerCase() }}" class="dm-header-username" @if(other.username_color) style="color:{{ other.username_color }}" @endif>{!! other.display_name || other.user !!}</a>
</div>
<button class="dm-manage-keys-btn btn-small" title="Manage encryption key">🔑 Keys</button>
</div>
<!-- Key notice -->
<div id="dm-key-notice" class="dm-key-notice" style="display:none;"></div>
<!-- Thread -->
<div id="dm-thread"
class="dm-thread"
data-other-id="{{ other.id }}"
data-other-name="{{ other.user }}"
data-my-id="{{ session.id }}">
<div class="dm-loading">{{ t('messages.decrypting') }}</div>
</div>
<!-- Send form -->
<form id="dm-send-form" class="dm-send-form comment-input" autocomplete="off">
<textarea id="dm-input"
class="dm-input"
placeholder="{{ t('messages.input_placeholder') }}"
rows="2"
maxlength="4000"
required></textarea>
<div class="input-actions">
<button type="submit" id="dm-send-btn" class="submit-comment dm-send-btn">{{ t('messages.send') }}</button>
</div>
</form>
</div>
</div>
@include(snippets/footer)

19
views/messages.html Normal file
View File

@@ -0,0 +1,19 @@
@include(snippets/header)
<div class="pagewrapper">
<div id="main" class="messages-page">
<div class="messages-header">
<h2>{{ t('messages.page_title') }}</h2>
<div class="messages-header-actions">
<button class="dm-manage-keys-btn btn-small" title="Manage your encryption key for multi-device use">{{ t('messages.manage_keys') }}</button>
</div>
</div>
<!-- Key notice banner (shown when a fresh key is generated) -->
<div id="dm-key-notice" class="dm-key-notice" style="display:none;"></div>
<div id="dm-inbox-list" class="dm-inbox-list">
<div class="dm-loading">{{ t('messages.loading') }}</div>
</div>
</div>
</div>
@include(snippets/footer)

29
views/mod.html Normal file
View File

@@ -0,0 +1,29 @@
@include(snippets/header)
<div class="pagewrapper">
<div id="main">
<div class="container">
<h1>MODERATOR DASHBOARD</h1>
<h5>Hello, {!! session.user !!}</h5>
<hr>
@if(manualApproval)
<p>Stats: Pending Uploads: {!! pendingCount !!}</p>
<hr>
@endif
<div class="admintools">
<p>Tools</p>
<ul>
@if(manualApproval)
<li><a href="/mod/approve">Approval Queue @if(pendingCount > 0) <span class="badge badge-danger">{!! pendingCount !!}</span> @endif</a></li>
@endif
@if(session.admin || session.is_moderator)
<li><a href="/mod/audit">Audit Log</a></li>
<li><a href="/mod/reports">User Reports</a></li>
<li><a href="/mod/motd">MOTD</a></li>
<li><a href="/mod/halls">Hall Manager</a></li>
@endif
</ul>
</div>
</div>
</div>
</div>
@include(snippets/footer)

321
views/mod/approve.html Normal file
View File

@@ -0,0 +1,321 @@
@include(snippets/header)
<div class="pagewrapper">
<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>
<div class="approval-grid">
@each(pending as post)
<div class="approval-card">
<div class="approval-card-media">
@if(post.mime.startsWith('video'))
<video controls loop muted preload="metadata">
<source src="/mod/pending/b/{!! post.dest !!}" type="{!! post.mime !!}">
Your browser does not support the video tag.
</video>
@else
<img src="/mod/pending/t/{!! post.id !!}.webp" alt="Preview">
@endif
</div>
<div class="approval-card-body">
<div class="approval-card-info">
<div><strong>ID:</strong> {!! post.id !!}</div>
<div><strong>User:</strong> {!! post.username !!}</div>
<div><strong>Type:</strong> {!! post.mime !!}</div>
</div>
<div class="approval-card-tags">
@each(post.tags as tag)
<span class="badge {!! tag.badge !!}">{!! tag.tag !!}</span>
@endeach
</div>
<div class="approval-card-actions" style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px;">
<a href="/mod/approve/?id={!! post.id !!}" class="badge badge-success btn-approve-async" style="margin: 0; text-align: center;">Approve</a>
<a href="/mod/deny/?id={!! post.id !!}" class="badge badge-danger btn-deny-async" style="margin: 0; text-align: center;">Deny / Delete</a>
<a href="/api/v2/tags/{!! post.id !!}/toggle" class="badge btn-rating-toggle-async" style="grid-column: span 2; background: #444; color: #ccc; margin: 0; text-align: center;">Rating</a>
</div>
</div>
</div>
@endeach
</div>
@endif
<div style="display: flex; justify-content: space-between; align-items: center; margin-top: 40px; margin-bottom: 20px;">
<h2 style="color: #ff6b6b; margin: 0;">Soft Deleted</h2>
@if(trash.length > 0 && session.admin)
<button id="btn-purge-trash" class="badge badge-danger" style="border: none; padding: 10px 15px; cursor: pointer; font-size: 14px;">Purge All Soft-Deleted</button>
@endif
</div>
<p class="text-muted">These items are in the deleted folder but not purged from DB. Approving them will restore them.</p>
@if(trash.length > 0)
<div class="approval-grid" style="opacity: 0.8;">
@each(trash as post)
<div class="approval-card">
<div class="approval-card-media">
@if(post.mime.startsWith('video'))
<video controls loop muted preload="metadata">
<source src="/mod/deleted/b/{!! post.dest !!}" type="{!! post.mime !!}">
Your browser does not support the video tag.
</video>
@else
<img src="/mod/deleted/t/{!! post.id !!}.webp" style="filter: grayscale(50%);" alt="Preview">
@endif
</div>
<div class="approval-card-body">
<div class="approval-card-info">
<div><strong>ID:</strong> {!! post.id !!}</div>
<div><strong>User:</strong> {!! post.username !!}</div>
<div><strong>Type:</strong> {!! post.mime !!}</div>
@if(post.delete_reason)
<div style="color: #ffb8b8;"><strong>Reason:</strong> {!! post.delete_reason !!}</div>
@endif
</div>
<div class="approval-card-tags">
@each(post.tags as tag)
<span class="badge {!! tag.badge !!}">{!! tag.tag !!}</span>
@endeach
</div>
<div class="approval-card-actions" style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px;">
<a href="/mod/approve/?id={!! post.id !!}" class="badge badge-warning btn-approve-async" style="margin: 0; text-align: center;">Restore</a>
@if(session.admin)
<a href="/mod/deny/?id={!! post.id !!}" class="badge badge-danger btn-deny-async" style="margin: 0; text-align: center;">Purge</a>
@else
<span></span>
@endif
<a href="/api/v2/tags/{!! post.id !!}/toggle" class="badge btn-rating-toggle-async" style="grid-column: span 2; background: #444; color: #ccc; margin: 0; text-align: center;">Rating</a>
</div>
</div>
</div>
@endeach
</div>
@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="/mod/approve?page={!! page - 1 !!}" class="badge badge-secondary">&laquo; Prev</a>
@endif
<span>Page {!! page !!} of {!! pages !!}</span>
@if(page < pages) <a href="/mod/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 id="modal-reason-container" style="display: none; margin-bottom: 15px; text-align: left;">
<label style="display: block; margin-bottom: 5px; font-size: 0.9em; color: #aaa;">Reason (required):</label>
<textarea id="modal-reason" style="width: 100%; background: #333; color: #fff; border: 1px solid #444; border-radius: 4px; padding: 8px; resize: none;" rows="3" placeholder="Enter reason for denial..."></textarea>
</div>
<div style="display: flex; justify-content: space-around; margin-top: 20px;">
<button id="modal-cancel" class="badge badge-secondary" style="border: none; padding: 10px 20px; cursor: pointer;">Cancel</button>
<button id="modal-confirm" class="badge badge-danger" style="border: none; padding: 10px 20px; cursor: pointer;">Confirm</button>
</div>
</div>
</div>
<script>
(() => {
const modal = document.getElementById('custom-modal');
const modalTitle = document.getElementById('modal-title');
const modalText = document.getElementById('modal-text');
const modalReasonContainer = document.getElementById('modal-reason-container');
const modalReasonInput = document.getElementById('modal-reason');
const btnConfirm = document.getElementById('modal-confirm');
const btnCancel = document.getElementById('modal-cancel');
let pendingAction = null;
const showModal = (title, text, action, showReason = false) => {
modalTitle.innerText = title;
modalText.innerText = text;
pendingAction = action;
if (showReason) {
modalReasonContainer.style.display = 'block';
modalReasonInput.value = '';
modalReasonInput.focus();
} else {
modalReasonContainer.style.display = 'none';
}
modal.style.display = 'flex';
btnConfirm.onclick = async () => {
if (!pendingAction) return;
btnConfirm.disabled = true;
btnConfirm.innerText = 'Processing...';
try {
let reason = null;
if (showReason) {
reason = modalReasonInput.value.trim();
if (!reason) {
modalReasonInput.style.borderColor = '#ff4444';
alert('Please provide a reason for denial');
return;
}
modalReasonInput.style.borderColor = '#444';
}
await pendingAction(reason);
closeModal();
} catch (e) {
alert('Error: ' + e.message);
} finally {
btnConfirm.disabled = false;
btnConfirm.innerText = 'Confirm';
}
};
};
const closeModal = () => {
modal.style.display = 'none';
pendingAction = null;
};
if (btnCancel) btnCancel.onclick = closeModal;
// Single Deny
document.querySelectorAll('.btn-deny-async').forEach(btn => {
btn.addEventListener('click', e => {
e.preventDefault();
const url = btn.getAttribute('href');
const card = btn.closest('.approval-card');
showModal('{!! t('mod.confirm_action') !!}', '{!! t('mod.confirm_action') !!}?', async (reason) => {
const res = await fetch(url + (url.indexOf('?') > -1 ? '&' : '?') + 'reason=' + encodeURIComponent(reason), {
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');
card.style.opacity = '0';
setTimeout(() => card.remove(), 300);
} else {
if (window.flashMessage) window.flashMessage(data.msg || '{!! t('toast.report_error') !!}', 3000, 'error');
throw new Error(data.msg || 'Request failed');
}
}, true);
});
});
// Single Approve / Restore
document.querySelectorAll('.btn-approve-async').forEach(btn => {
btn.addEventListener('click', async e => {
e.preventDefault();
const url = btn.getAttribute('href');
const card = btn.closest('.approval-card'); // Updated selector
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');
card.style.opacity = '0';
setTimeout(() => card.remove(), 300);
} else {
if (window.flashMessage) window.flashMessage(data.msg || '{!! t('toast.approve_error') !!}', 3000, 'error');
}
} catch (err) {
console.error(err);
if (window.flashMessage) window.flashMessage('{!! t('toast.network_error') !!}', 3000, 'error');
}
});
});
// Rating Toggle
document.querySelectorAll('.btn-rating-toggle-async').forEach(btn => {
btn.addEventListener('click', async e => {
e.preventDefault();
const url = btn.getAttribute('href');
const card = btn.closest('.approval-card');
const tagsContainer = card.querySelector('.approval-card-tags');
btn.style.opacity = '0.5';
btn.style.pointerEvents = 'none';
try {
const res = await fetch(url, {
method: 'PUT',
headers: {
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'application/json'
}
});
const data = await res.json();
if (data.success && data.tags) {
// Update tags UI
tagsContainer.innerHTML = '';
data.tags.forEach(tag => {
const span = document.createElement('span');
let badgeClass = 'badge-light';
if (tag.normalized === 'sfw') badgeClass = 'badge-success';
else if (tag.normalized === 'nsfw') badgeClass = 'badge-danger';
else if (tag.tag.startsWith('>')) badgeClass = 'badge-greentext badge-light';
else if (tag.normalized === 'ukraine') badgeClass = 'badge-ukraine badge-light';
else if (/[а-яё]/.test(tag.normalized) || tag.normalized === 'russia') badgeClass = 'badge-russia badge-light';
else if (tag.normalized === 'german') badgeClass = 'badge-german badge-light';
else if (tag.normalized === 'dutch') badgeClass = 'badge-dutch badge-light';
span.className = 'badge ' + badgeClass;
span.innerText = tag.tag;
tagsContainer.appendChild(span);
});
} else {
alert('Error: ' + (data.msg || 'Unknown error'));
}
} catch (err) {
console.error(err);
alert('Network error');
} finally {
btn.style.opacity = '1';
btn.style.pointerEvents = 'auto';
}
});
});
// Purge All Trash
const btnPurgeTrash = document.getElementById('btn-purge-trash');
if (btnPurgeTrash) {
btnPurgeTrash.addEventListener('click', () => {
showModal('Purge Trash', 'Permanently delete ALL items in the trash? This cannot be undone.', async () => {
const res = await fetch('/mod/purge-trash-all', { method: 'POST' });
const data = await res.json();
if (data.success) {
location.reload();
} else {
throw new Error(data.msg || 'Purge failed');
}
});
});
}
})();
</script>
</div>
</div>
</div>
@include(snippets/footer)

236
views/mod/audit.html Normal file
View File

@@ -0,0 +1,236 @@
@include(snippets/header)
<div id="main">
<div class="container">
<h1>AUDIT LOG</h1>
<p>Actions performed by moderators and admins.</p>
<hr>
<div class="audit-grid" id="audit-grid">
@each(logs as entry)
<div class="audit-card">
<div class="audit-card-header">
<div class="audit-card-user">
<a href="/user/{!! entry.username !!}">{!! entry.username !!}</a>
</div>
<div class="audit-card-time">{!! entry.created_at_fmt !!}</div>
</div>
<div class="audit-card-body">
<div class="audit-card-row">
<span class="audit-label">Action:</span>
<span class="badge badge-secondary badge-action">{!! entry.action !!}</span>
</div>
<div class="audit-card-row">
<span class="audit-label">Target:</span>
<span class="audit-value">
<strong>{!! entry.target_type !!}</strong>
@if(entry.target_type === 'comment')
@if(entry.item_id)
<a href="/{!! entry.item_id !!}#c{!! entry.target_id !!}">#c{!! entry.target_id !!}</a>
@else
#c{!! entry.target_id !!}
@endif
@else
@if(entry.target_type === 'item')
<a href="/{!! entry.target_id !!}">/{!! entry.target_id !!}</a>
@else
{!! entry.target_id !!}
@endif
@endif
</span>
</div>
@if(entry.reason)
<div class="audit-card-row">
<span class="audit-label">Reason:</span>
<span class="audit-value">{!! entry.reason !!}</span>
</div>
@endif
@if(entry.uploader_info)
<div class="audit-card-row">
<span class="audit-label">Info:</span>
<span class="audit-value" style="color: #ffb8b8;">{!! entry.uploader_info !!}</span>
</div>
@endif
@if(entry.old_content !== null || entry.new_content !== null || entry.details_json)
<div class="audit-card-row" style="margin-top: 5px; flex-direction: column; align-items: stretch;">
<span class="audit-label">Changes:</span>
<div class="audit-diff">
@if(entry.old_content)
<div class="diff-removed">- {!! entry.old_content !!}</div>
@endif
@if(entry.new_content)
<div class="diff-added">+ {!! entry.new_content !!}</div>
@endif
@if(entry.details_json)
<div class="audit-details-json"
style="font-size: 0.85em; color: #aaa; margin-top: 5px; border-top: 1px solid rgba(255,255,255,0.05); padding-top: 5px;">
{!! entry.details_json !!}
</div>
@endif
</div>
</div>
@endif
</div>
</div>
@endeach
</div>
<div id="audit-loading" style="text-align: center; padding: 20px; display: none;">
<span class="loading-spinner">Loading more logs...</span>
</div>
<br>
@if(typeof pages !== 'undefined' && pages > 1)
<div class="pagination-container" id="audit-pagination"
style="display: flex; gap: 10px; align-items: center; justify-content: center;">
@if(page > 1)
<a href="/mod/audit?page={!! page - 1 !!}" class="badge badge-secondary">&laquo; Prev</a>
@endif
<span>Page <span id="current-page-display">{!! page !!}</span> of {!! pages !!}</span>
@if(page < pages) <a href="/mod/audit?page={!! page + 1 !!}" id="next-page-link" class="badge badge-secondary">Next &raquo;</a>@endif
</div>
<br>
@endif
<script>
(function () {
var grid = document.getElementById('audit-grid');
var loader = document.getElementById('audit-loading');
var pagination = document.getElementById('audit-pagination');
var escapeHtml = function (unsafe) {
return (unsafe || '')
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
};
var renderDiff = function (oldText, newText) {
var isEmpty = function (t) { return !t || t === 'null' || t === 'undefined'; };
if (isEmpty(oldText) && isEmpty(newText)) return '';
var html = '<div class="audit-diff">';
if (!isEmpty(oldText)) html += '<div class="diff-removed">- ' + escapeHtml(oldText) + '</div>';
if (!isEmpty(newText)) html += '<div class="diff-added">+ ' + escapeHtml(newText) + '</div>';
html += '</div>';
return html;
};
var currentPage = Number('{{ page }}') || 1;
var totalPages = Number('{{ pages }}') || 1;
var loading = false;
var hasMore = currentPage < totalPages;
if (pagination) pagination.style.display = 'none';
window.addEventListener('scroll', function () {
if (loading || !hasMore) return;
var scrollPosition = window.innerHeight + window.scrollY;
var threshold = document.documentElement.scrollHeight - 500;
if (scrollPosition > threshold) {
loadMore();
}
});
async function loadMore() {
if (loading || !hasMore) return;
loading = true;
if (loader) loader.style.display = 'block';
try {
var next = currentPage + 1;
var res = await fetch('/mod/audit?page=' + next, {
headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
var data = await res.json();
if (data.success && data.logs && data.logs.length) {
data.logs.forEach(function (log) {
var card = document.createElement('div');
card.className = 'audit-card';
var reason = log.reason;
var html = '<div class="audit-card-header">' +
'<div class="audit-card-user">' +
'<a href="/user/' + encodeURIComponent(log.username) + '">' + log.username + '</a>' +
'</div>' +
'<div class="audit-card-time">' + (log.created_at || '') + '</div>' +
'</div>' +
'<div class="audit-card-body">' +
'<div class="audit-card-row">' +
'<span class="audit-label">Action:</span>' +
'<span class="badge badge-secondary badge-action">' + (log.action || '') + '</span>' +
'</div>' +
'<div class="audit-card-row">' +
'<span class="audit-label">Target:</span>' +
'<span class="audit-value">' +
'<strong>' + (log.target_type || '') + '</strong> ';
if (log.target_type === 'comment') {
if (log.item_id) {
html += '<a href="/' + log.item_id + '#c' + log.target_id + '">#c' + log.target_id + '</a>';
} else {
html += '#c' + (log.target_id || '');
}
} else if (log.target_type === 'item') {
html += '<a href="/' + log.target_id + '">/' + log.target_id + '</a>';
} else {
html += (log.target_id || '');
}
html += '</span></div>';
if (reason) {
html += '<div class="audit-card-row">' +
'<span class="audit-label">Reason:</span>' +
'<span class="audit-value">' + escapeHtml(reason) + '</span>' +
'</div>';
}
if (log.uploader_info) {
html += '<div class="audit-card-row">' +
'<span class="audit-label">Info:</span>' +
'<span class="audit-value" style="color: #ffb8b8;">' + escapeHtml(log.uploader_info) + '</span>' +
'</div>';
}
if (log.old_content || log.new_content || log.details_json) {
html += '<div class="audit-card-row" style="margin-top: 5px; flex-direction: column; align-items: stretch;">' +
'<span class="audit-label">Changes:</span>' +
'<div class="audit-diff">' +
(log.old_content ? '<div class="diff-removed">- ' + escapeHtml(log.old_content) + '</div>' : '') +
(log.new_content ? '<div class="diff-added">+ ' + escapeHtml(log.new_content) + '</div>' : '') +
(log.details_json ? '<div class="audit-details-json" style="font-size: 0.85em; color: #aaa; margin-top: 5px; border-top: 1px solid rgba(255,255,255,0.05); padding-top: 5px;">' + escapeHtml(log.details_json) + '</div>' : '') +
'</div>' +
'</div>';
}
html += '</div>';
card.innerHTML = html;
grid.appendChild(card);
});
currentPage = data.page;
hasMore = data.hasMore;
if (document.getElementById('current-page-display')) {
document.getElementById('current-page-display').innerText = currentPage;
}
} else {
hasMore = false;
}
} catch (err) {
console.error('Audit infinite scroll error:', err);
hasMore = false;
} finally {
loading = false;
if (loader) loader.style.display = 'none';
}
}
})();
</script>
</div>
</div>
@include(snippets/footer)

81
views/mod/motd.html Normal file
View File

@@ -0,0 +1,81 @@
@include(snippets/header)
<div class="pagewrapper">
<div id="main">
<div class="container">
<h2>Moderator Message of the Day (MOTD)</h2>
<p style="color: #ccc; margin-bottom: 20px;">This message is displayed site-wide. It will automatically be tagged with your name (<code>t. {!! session.display_name || session.user !!}</code>).</p>
<div class="admin-motd-form">
<form id="motd-form" action="/mod/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. Your username will be appended as a signature.
</p>
</div>
</div>
<script>
async function saveMotd(form) {
const status = document.getElementById('motd-status');
const textarea = document.getElementById('motd-text');
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';
// Update textarea with the actual saved MOTD (which includes the tag)
if (data.motd) {
textarea.value = data.motd;
}
if (typeof window.updateMotdUI === 'function') {
window['motd_dismissed'] = false; // Force show on save
window.updateMotdUI(data.motd || textarea.value);
}
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)

352
views/mod_reports.html Normal file
View File

@@ -0,0 +1,352 @@
@include(snippets/header)
<div class="pagewrapper">
<div id="main">
<style>
.mod-reports-table .btn, .mod-reports-table button {
border-radius: 0 !important;
}
</style>
<div class="container mod-reports-page">
<h1>User Reports</h1>
<hr>
<div class="row" style="margin-bottom: 20px;">
<div class="col-md-12">
<select id="report-status-filter" class="form-control" style="width: 200px; display: inline-block;">
<option value="pending">Pending</option>
<option value="resolved">Resolved</option>
<option value="rejected">Rejected</option>
</select>
<button class="btn btn-secondary" onclick="loadReports(1)">Refresh</button>
</div>
</div>
<div class="table-responsive">
<table class="table table-striped table-dark">
<thead>
<tr>
<th>ID</th>
<th>Reporter</th>
<th>Target</th>
<th>Reason</th>
<th>Date</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="reports-table-body">
<tr><td colspan="6" class="text-center">Loading reports...</td></tr>
</tbody>
</table>
</div>
<div id="reports-pagination" style="text-align: center; margin-top: 15px;"></div>
</div>
<script>
window.currentPage = window.currentPage || 1;
window.loadReports = async function(page = 1) {
window.currentPage = page;
const status = document.getElementById('report-status-filter').value;
const tbody = document.getElementById('reports-table-body');
const pag = document.getElementById('reports-pagination');
tbody.innerHTML = '<tr><td colspan="6" class="text-center">Loading reports...</td></tr>';
try {
const res = await fetch('/api/v2/mod/reports?status=' + status + '&page=' + page);
const data = await res.json();
if (data.success) {
window.currentReports = data.reports;
window.emojiMap = new Map();
if (data.emojis) {
data.emojis.forEach(emojiObj => window.emojiMap.set(emojiObj.name.toLowerCase(), emojiObj.url));
}
tbody.innerHTML = '';
if (data.reports.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" class="text-center">No reports found.</td></tr>';
return;
}
data.reports.forEach(r => {
let targetHtml = '';
if (r.comment_id) {
targetHtml += 'Comment: <a href="#" onclick="window.expandItem(event, ' + r.id + ')">#' + r.comment_id + '</a> (Click to expand)';
} else if (r.resolved_item_id) {
targetHtml += 'Item: <a href="#" onclick="window.expandItem(event, ' + r.id + ')">#' + r.resolved_item_id + '</a> (Click to expand)';
} else if (r.reported_user_name) {
targetHtml += 'User: <a href="/user/' + r.reported_user_name + '">' + r.reported_user_name + '</a>';
}
let actionHtml = '';
if (status === 'pending') {
actionHtml =
'<button class="btn btn-sm btn-success" onclick="window.resolveReport(' + r.id + ', &quot;resolved&quot;)">Resolve</button> ' +
'<button class="btn btn-sm btn-danger" onclick="window.resolveReport(' + r.id + ', &quot;rejected&quot;)">Reject</button>';
}
const tr = document.createElement('tr');
tr.innerHTML =
'<td>' + r.id + '</td>' +
'<td><a href="/user/' + r.reporter_name + '">' + r.reporter_name + '</a></td>' +
'<td>' + targetHtml + '</td>' +
'<td>' + r.reason + '</td>' +
'<td>' + new Date(r.created_at).toLocaleString() + '</td>' +
'<td>' + actionHtml + '</td>';
tbody.appendChild(tr);
});
// Pagination
pag.innerHTML = '';
if (data.pages > 1) {
if (data.page > 1) {
pag.innerHTML += '<button class="btn btn-sm btn-secondary" onclick="window.loadReports(' + (data.page - 1) + ')">Prev</button> ';
}
pag.innerHTML += 'Page ' + data.page + ' of ' + data.pages + ' ';
if (data.page < data.pages) {
pag.innerHTML += '<button class="btn btn-sm btn-secondary" onclick="window.loadReports(' + (data.page + 1) + ')">Next</button>';
}
}
} else {
tbody.innerHTML = '<tr><td colspan="6" class="text-center text-danger">Error: ' + data.msg + '</td></tr>';
}
} catch (e) {
tbody.innerHTML = '<tr><td colspan="6" class="text-center text-danger">Network Error</td></tr>';
}
};
window.resolveReport = async function(id, action) {
if (!confirm('Mark report #' + id + ' as ' + action + '?')) return;
try {
const params = new URLSearchParams();
params.append('action', action);
const res = await fetch('/api/v2/mod/reports/' + id + '/resolve', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params
});
const data = await res.json();
if (data.success) {
window.loadReports(window.currentPage);
} else {
alert('Error: ' + data.msg);
}
} catch (e) {
alert('Network error');
}
};
window.expandItem = function(e, id) {
e.preventDefault();
const tr = e.target.closest('tr');
// Toggle logic: If the next row is an expanded row, remove it and return.
if (tr.nextElementSibling && tr.nextElementSibling.classList.contains('expanded-report')) {
tr.nextElementSibling.remove();
return;
}
// Lookup the report locally
const r = window.currentReports.find(x => x.id === id);
if (!r) return;
// Checking if the moderator is also a superadmin for ban abilities
const isAdmin = window.f0ckSession && window.f0ckSession.admin;
// Build the Expansion Row
const expTr = document.createElement('tr');
expTr.className = 'expanded-report';
const isComment = !!r.comment_id;
const isItem = !!r.resolved_item_id && r.resolved_item_dest;
let previewHtml = '';
// Only show media preview for direct Item reports
if (isItem && !isComment) {
const mime = r.resolved_item_mime || '';
const src = '/b/' + r.resolved_item_dest;
const baseStyle = 'max-height: 250px; border: 1px solid #333; border-radius: 4px;';
if (mime.startsWith('image/')) {
previewHtml = '<div><img src="' + src + '" style="' + baseStyle + ' background: #000;"></div>';
} else if (mime.startsWith('audio/')) {
previewHtml = '<div><audio src="' + src + '" controls style="' + baseStyle + '"></audio></div>';
} else {
previewHtml = '<div><video src="' + src + '" controls loop style="' + baseStyle + ' background: #000;"></video></div>';
}
}
if (isComment) {
let escapedContent = (r.comment_body || '[Deleted or Empty]')
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
// Handle Emojis
if (window.emojiMap) {
escapedContent = escapedContent.replace(/:([a-z0-9_]+):/g, function(match, code) {
var url = window.emojiMap.get(code.toLowerCase());
if (url) {
return '<img src="' + url + '" style="height:24px;vertical-align:middle;" alt="' + code + '" title=":' + code + ':">';
}
return match;
});
}
previewHtml += '<div style="background: rgba(0,0,0,0.5); padding: 15px; border: 1px solid #444; color: #eee; font-family: monospace; max-height: 250px; overflow-y: auto; white-space: pre-wrap; font-size: 0.9rem;">' +
'<strong>Reported Comment:</strong><br><br>' + escapedContent +
'</div>';
}
let buttonsHtml = '';
// Delete Video button only for direct Video reports
if (isItem && !isComment) {
buttonsHtml += '<button class="btn btn-sm btn-danger" style="padding: 6px 15px;" onclick="window.adminDeleteItem(' + r.resolved_item_id + ')">Delete Item</button>';
}
if (isComment) {
buttonsHtml += '<button class="btn btn-sm btn-danger" style="padding: 6px 15px;" onclick="window.adminDeleteComment(' + r.comment_id + ')">Delete Comment</button>';
if (r.resolved_item_id) {
buttonsHtml += '<a href="/' + r.resolved_item_id + '" class="btn btn-sm btn-info" style="padding: 6px 15px; text-decoration: none; color: white; border: 1px solid #0dcaf0;" target="_blank">View Video</a>';
}
}
// Punitive actions target the reported party
if (r.reported_user_id) {
// Only show punitive actions if viewer is admin OR reported user is NOT an admin
if (isAdmin || !r.reported_user_is_admin) {
const warnLabel = isItem ? 'Warn Uploader' : (isComment ? 'Warn Commenter' : 'Warn User');
buttonsHtml += '<button class="btn btn-sm btn-warning" style="padding: 6px 15px;" onclick="window.modWarnUser(' + r.reported_user_id + ')">' + warnLabel + ' (' + r.reported_user_name + ')</button>';
const banLabel = isItem ? 'Ban Uploader' : (isComment ? 'Ban Commenter' : 'Ban User');
buttonsHtml += '<button class="btn btn-sm btn-danger" style="padding: 6px 15px;" onclick="window.adminBanUser(' + r.reported_user_id + ')">' + banLabel + ' (' + r.reported_user_name + ')</button>';
} else {
buttonsHtml += '<span style="color:var(--gray); font-style: italic; opacity:0.8; margin-left: 10px;">(Admin Protection Active)</span>';
}
} else {
buttonsHtml += '<span style="color:var(--gray); opacity:0.6;">(Anonymous/Unknown Source)</span>';
}
expTr.innerHTML =
'<td colspan="6" style="background: rgba(0,0,0,0.3); border-left: 3px solid var(--accent); padding: 20px;">' +
'<div style="display: flex; gap: 40px; align-items: center;">' +
previewHtml +
'<div style="flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 15px;">' +
'<div style="font-weight: bold; opacity: 1; text-transform: uppercase; font-size: 0.8rem; letter-spacing: 1.5px; margin-bottom: 5px;">Moderation Action:</div>' +
'<div style="display: flex; gap: 12px; flex-wrap: wrap; justify-content: center;">' +
buttonsHtml +
'<button class="btn btn-sm btn-secondary" style="border: 1px solid #555; padding: 6px 15px;" onclick="window.modWarnUser(' + r.reporter_id + ')">Warn Reporter (' + r.reporter_name + ')</button>' +
'</div>' +
'</div>' +
'</div>' +
'</td>';
tr.insertAdjacentElement('afterend', expTr);
};
window.adminDeleteComment = function(id) {
window.ModAction.confirm('Delete Comment #' + id, 'Are you sure you want to delete this comment? This action is permanent.', async (reason) => {
const params = new URLSearchParams();
params.append('reason', reason);
const res = await fetch('/api/comments/' + id + '/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params
});
const data = await res.json();
if (data.success) {
if (window.showFlash) window.showFlash('comment deleted', 'success');
} else {
throw new Error(data.msg || 'Unknown error');
}
});
};
window.adminDeleteItem = function(id) {
window.ModAction.confirm('Delete Item #' + id, 'Are you sure you want to delete this item? This action is permanent.', async (reason) => {
const params = new URLSearchParams();
params.append('postid', id);
params.append('reason', reason);
const res = await fetch('/api/v2/admin/deletepost', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params
});
const data = await res.json();
if (data.success) {
if (window.showFlash) window.showFlash('item deleted', 'success');
} else {
throw new Error(data.msg || 'Unknown error');
}
});
};
window.modWarnUser = function(userId) {
window.ModAction.confirm('Warn User ID ' + userId, 'A live notification will be sent to the user via SSE.', async (reason) => {
const params = new URLSearchParams();
params.append('user_id', userId);
params.append('reason', reason);
const res = await fetch('/api/v2/mod/warnings/issue', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params
});
const data = await res.json();
if (data.success) {
if (window.showFlash) window.showFlash('user has been warned', 'success');
} else {
throw new Error(data.msg || 'Unknown error');
}
});
};
window.adminBanUser = function(userId) {
const isAdmin = window.f0ckSession && window.f0ckSession.admin;
const promptHtml =
'<p>This will restrict the user from accessing their account and performing most actions.</p>' +
'<div style="margin-top:10px;">' +
'<label>Ban Duration:</label>' +
'<select id="ban-duration-select" class="form-control" style="margin-top:5px;">' +
(isAdmin ? '<option value="permanent">Permanent</option>' : '') +
'<option value="1">1 Hour</option>' +
'<option value="6">6 Hours</option>' +
'<option value="24">24 Hours (1 Day)</option>' +
(!isAdmin ? '<option value="48">48 Hours (2 Days)</option>' : '') +
(isAdmin ? '<option value="168">168 Hours (1 Week)</option>' : '') +
(isAdmin ? '<option value="720">720 Hours (1 Month)</option>' : '') +
'</select>' +
'</div>';
window.ModAction.confirm('Ban User ID ' + userId, promptHtml, async (reason) => {
const duration = document.getElementById('ban-duration-select').value;
const params = new URLSearchParams();
params.append('user_id', userId);
params.append('reason', reason);
params.append('duration', duration);
const res = await fetch('/api/v2/admin/ban', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params
});
const data = await res.json();
if (data.success) {
if (window.showFlash) window.showFlash('User banned cleanly.', 'success');
} else {
throw new Error(data.msg || 'Unknown error');
}
});
};
(function() {
const filter = document.getElementById('report-status-filter');
if (filter) {
// Prevent stacking, although safe here
filter.onchange = () => window.loadReports(1);
}
window.loadReports(1);
})();
</script>
</div>
</div>
</div>
@include(snippets/footer)

38
views/notifications.html Normal file
View File

@@ -0,0 +1,38 @@
@include(snippets/header)
<div class="pagewrapper">
<div id="main" class="notifications-page">
<div class="notif-history-container">
<div class="notif-page-header">
<h2>{{ t('notifications.page_title') }}</h2>
<button id="mark-all-read-page" class="btn-small">{{ t('notifications.mark_all_read') }}</button>
</div>
<div id="notifications-container" class="posts notifications-list-full" data-page="{{ pagination.page }}">
@include(snippets/notifications-list)
</div>
@if(pagination.next)
<div id="footbar" data-end-msg="You reached the end" style="text-align: center; padding: 20px;">
<span class="loading-spinner" style="color: #888;">{{ t('notifications.scroll_for_more') }}</span>
</div>
@endif
<script>
// Initialize mark all read for the page
(function () {
const btn = document.getElementById('mark-all-read-page');
if (btn) {
btn.onclick = async () => {
const res = await fetch('/api/notifications/read', { method: 'POST' });
const data = await res.json();
if (data.success) {
document.querySelectorAll('.notif-item.unread').forEach(el => el.classList.remove('unread'));
if (window.NotificationSystemInstance) {
window.NotificationSystemInstance.markAllReadUI();
}
}
};
}
})();
</script>
</div>
</div>
</div>
@include(snippets/footer)

121
views/ranking.html Normal file
View File

@@ -0,0 +1,121 @@
@include(snippets/header)
<div class="pagewrapper">
<div id="main" class="ranking-page">
<div class="topf0ckers">
<h3>{{ t('ranking.title') }}</h3>
</div>
<div class="ranking-wrapper">
<div class="section-big">
<h3>{{ t('ranking.top_contributors') }}</h3>
@if(list.length >= 3)
<div class="ranking-podium">
@for(let i = 0; i < 3; i++)
<div class="podium-item rank-{{ i + 1 }}">
@if(i === 0)<div class="podium-crown">👑</div>@endif
<div class="podium-avatar-wrapper">
@if(list[i].avatar_file)
<a href="/user/{{ list[i].user }}"><img class="podium-avatar" src="/a/{{ list[i].avatar_file }}" alt="{{ list[i].user }}"></a>
@elseif(list[i].avatar)
<a href="/user/{{ list[i].user }}"><img class="podium-avatar" src="/t/{{ list[i].avatar }}.webp" alt="{{ list[i].user }}"></a>
@else
<a href="/user/{{ list[i].user }}"><img class="podium-avatar" src="/a/default.png" alt="{{ list[i].user }}"></a>
@endif
<div class="podium-rank-label">#{{ i + 1 }}</div>
</div>
<div class="podium-info">
<a href="/user/{{ list[i].user }}" class="podium-name">{!! list[i].display_name || list[i].user !!}</a>
<span class="podium-count">{{ list[i].count }} Tags</span>
</div>
</div>
@endfor
</div>
@endif
<table class="ranking-table clean-table" id="ranking-list">
<thead>
<tr>
<th class="rank-col">{{ t('ranking.col_rank') }}</th>
<th class="avatar-col">{{ t('ranking.col_avatar') }}</th>
<th class="user-col">{{ t('ranking.col_username') }}</th>
<th class="count-col">{{ t('ranking.col_tagged') }}</th>
</tr>
</thead>
<tbody>
@for(let i = (list.length >= 3 ? 3 : 0); i < list.length; i++)
<tr>
<td class="rank-cell">#{{ i + 1 }}</td>
<td class="avatar-cell">
@if(list[i].avatar_file)
<a href="/user/{{ list[i].user }}"><img class="rank-avatar" src="/a/{{ list[i].avatar_file }}" alt="{{ list[i].user }}"></a>
@elseif(list[i].avatar)
<a href="/user/{{ list[i].user }}"><img class="rank-avatar" src="/t/{{ list[i].avatar }}.webp" alt="{{ list[i].user }}"></a>
@else
<a href="/user/{{ list[i].user }}"><img class="rank-avatar" src="/a/default.png" alt="{{ list[i].user }}"></a>
@endif
</td>
<td class="user-cell">
@if(list[i].admin)<span class="admin-icon" title="Administrator">&#9889;</span>@endif
<a href="/user/{{ list[i].user }}">{!! list[i].display_name || list[i].user !!}</a>
</td>
<td class="count-cell">{{ list[i].count }}</td>
</tr>
@endfor
</tbody>
</table>
</div>
<div class="section-small">
<div class="stats-box-simple" id="tag-statistics">
<h3>{{ t('ranking.tag_stats') }}</h3>
<table class="stats-table clean-table">
<tbody>
<tr><td>{{ t('ranking.stat_total') }}</td><td>{{ stats.total }}</td></tr>
<tr><td>{{ t('ranking.stat_tagged') }}</td><td>{{ stats.tagged }}</td></tr>
<tr><td>{{ t('ranking.stat_untagged') }}</td><td>{{ stats.untagged }}</td></tr>
<tr><td>{{ t('ranking.stat_sfw') }}</td><td>{{ stats.sfw }}</td></tr>
<tr><td>{{ t('ranking.stat_nsfw') }}</td><td>{{ stats.nsfw }}</td></tr>
<tr><td>{{ t('ranking.stat_deleted') }}</td><td>{{ stats.deleted }}</td></tr>
</tbody>
</table>
</div>
<div class="stats-box-simple" id="top-f0cks">
<h3>{{ t('ranking.most_favorited') }}</h3>
<table class="f0cks-table clean-table">
<tbody>
@each(favotop as favo)
<tr>
<td><a href="/{{ favo.item_id }}">#{{ favo.item_id }}</a></td>
<td>{{ favo.favs }} <span style="opacity: 0.5; font-size: 0.8em;">{{ t('ranking.favs') }}</span></td>
</tr>
@endeach
</tbody>
</table>
</div>
@if(enable_xd_score && xdtop.length > 0)
<div class="stats-box-simple" id="top-xd-scores">
<h3>{{ t('ranking.top_xd') }}</h3>
<table class="xd-scores-table clean-table">
<tbody>
@each(xdtop as item)
<tr>
<td><a href="/{{ item.id }}">#{{ item.id }}</a></td>
<td>
<span class="xd-score-badge xd-tier-{{ item.xd_tier }}" tooltip="xD Score: {{ item.xd_score }} pts" flow="up">
{{ item.xd_label }} <span class="xd-score-num">{{ item.xd_score }}</span>
</span>
</td>
</tr>
@endeach
</tbody>
</table>
</div>
@endif
</div>
</div>
</div>
</div>
@include(snippets/footer)

44
views/register.html Normal file
View File

@@ -0,0 +1,44 @@
<!doctype f0ck>
<html theme="f0ck">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<title>register</title>
<link rel="icon" @if(custom_favicon && custom_favicon.length > 0)href="{{ custom_favicon }}"@else type="image/gif" href="/s/img/favicon.gif"@endif />
<link href="/s/css/f0ckm.css" rel="stylesheet" />
</head>
<body type="login">
<form class="login-form" method="post" action="/register" novalidate>
@if(!success && typeof error === 'undefined')
<h2 style="text-align: center; margin-bottom: 20px;">{{ t('auth.register_title') }}</h2>
@endif
@if(typeof error !== 'undefined')
<div class="flash-error">{{ error }}</div>
@endif
@if(success)
<div class="flash-success">{{ success }}</div>
@endif
@if(!success && typeof error === 'undefined')
<input type="text" name="username" placeholder="{{ t('auth.username_placeholder') }}" autocomplete="off" required />
<input type="password" name="password" placeholder="{{ t('auth.password_placeholder') }}" autocomplete="off" required minlength="20" title="{{ t('auth.password_min_hint') }}" />
<input type="password" name="password_confirm" placeholder="{{ t('auth.confirm_password') }}" autocomplete="off" required minlength="20" /><br>
@if(registration_open)
<input type="email" name="email" placeholder="{{ t('auth.email_placeholder') }}" autocomplete="off" required />
@else
<input type="text" name="token" placeholder="{{ t('auth.invite_token') }}" autocomplete="off" value="{{ typeof token !== 'undefined' ? token : '' }}" required />
@endif
<input type="text" name="email_confirm_field" style="display: none !important;" tabindex="-1" autocomplete="off" />
<p style="text-align: left; font-size: 0.9em; margin: 10px 0; color: #fff;">
<input type="checkbox" id="tos-page" name="tos" required />
<label for="tos-page">@if(private_society){{ t('auth.tos_private_simple') }}@else{{ t('auth.tos_public') }} <a href="/terms" target="_blank" style="color: var(--accent); text-decoration: underline;">{{ t('auth.tos_terms') }}</a>, <a href="/rules" target="_blank" style="color: var(--accent); text-decoration: underline;">{{ t('auth.tos_rules') }}</a> {{ t('auth.tos_age') }}@endif</label>
</p>
<input type="submit" value="{{ t('auth.register_title') }}" />
@endif
<div style="margin-top: 15px; text-align: center;">
<a href="/login" class="login-trigger-btn" style="color: var(--accent); text-decoration: none;">{{ t('auth.back_to_login') }}</a>
</div>
</form>
</body>
</html>

31
views/rules.html Normal file
View File

@@ -0,0 +1,31 @@
@include(snippets/header)
<div class="pagewrapper">
<div id="main">
<div class="rules">
@if(rules_text)
<div class="dynamic-page-content" id="rules-dynamic-content"></div>
<textarea id="rules-raw-data" hidden>{!! rules_text !!}</textarea>
<script>
(function() {
var raw = document.getElementById('rules-raw-data');
var el = document.getElementById('rules-dynamic-content');
function render() {
if (raw && el && typeof marked !== 'undefined') {
el.innerHTML = marked.parse(raw.value || '', { gfm: true, breaks: true });
raw.remove();
}
}
if (typeof marked !== 'undefined') {
render();
} else {
document.addEventListener('DOMContentLoaded', render);
}
})();
</script>
@else
<h1>Rules</h1>
@endif
</div>
</div>
</div>
@include(snippets/footer)

955
views/scroller.html Normal file
View File

@@ -0,0 +1,955 @@
<!doctype html>
<html lang="{{ lang || 'en' }}" theme="@if(typeof theme !== 'undefined'){{ theme }}@endif">
<head>
<title>{{ domain }} — doomscroll</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#000" />
<link rel="manifest" href="/manifest.json">
<link rel="icon" @if(custom_favicon && custom_favicon.length > 0)href="{{ custom_favicon }}"@else type="image/gif" href="/s/img/favicon.gif"@endif />
<link rel="stylesheet" href="/s/fa/all.min.css">
<link rel="preload" href="/s/vcr.ttf" as="font" type="font/ttf" crossorigin>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
/* Kill all mobile tap highlights globally */
* { -webkit-tap-highlight-color: transparent; }
:root {
--accent: #fff;
--overlay-bg: linear-gradient(to top, rgba(0,0,0,.9) 0%, rgba(0,0,0,.35) 15%, transparent 60%);
--badge-sfw: #4caf50;
--badge-nsfw: #e91e8c;
--badge-nsfl: #c62828;
--panel-bg: rgba(10,10,12,.97);
--panel-border: rgba(255,255,255,.07);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
html[theme='f0ck'] { --accent: #9f0; }
html[theme='p1nk'] { --accent: #ff00d0; }
html[theme='orange'] { --accent: #ff6f00; }
html[theme='atmos'] { --accent: #1fb2b0; }
html[theme='iced'] { --accent: #0084ff; }
html[theme='amoled'] { --accent: #fff; }
html, body {
width: 100%; height: 100%; overflow: hidden; background: #000; color: #fff;
touch-action: pan-y;
/* Prevent text/image selection on long-press mobile */
-webkit-user-select: none; user-select: none;
}
/* When loaded via PJAX: hide the main-site navbar and pagewrapper since
the scroller has its own full-screen topbar (#scroller-topbar). */
body.scroller-active .navbar,
body.scroller-active .pagewrapper > :not(#main),
body.scroller-active .global-sidebar-right { display: none !important; }
/* #main wrapper needed for PJAX content swap — must fill full viewport
so #scroller-feed { height: 100% } has a sized parent to stretch into. */
#main { width: 100%; height: 100%; }
/* ── TOP BAR ──────────────────────────────────── */
#scroller-topbar {
position: fixed; top: 0; left: 0; right: 0;
z-index: 500;
display: flex; align-items: center; justify-content: space-between;
padding: 10px 12px; pointer-events: none;
}
.topbar-left, .topbar-right {
display: flex; align-items: center; gap: 8px; pointer-events: all;
}
.topbar-icon-btn {
background: rgba(0,0,0,.6);
backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255,255,255,.11); border-radius: 50%;
width: 40px; height: 40px;
display: flex; align-items: center; justify-content: center;
color: #fff; font-size: .92rem;
text-decoration: none; cursor: pointer;
transition: background .17s, transform .12s, border-color .17s; flex-shrink: 0;
}
.topbar-icon-btn:hover { background: rgba(255,255,255,.18); transform: scale(1.07); }
.topbar-icon-btn.has-filter { border-color: var(--accent); color: var(--accent); }
#filter-active-summary {
display: none;
background: rgba(0,0,0,.6); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
border: 1px solid var(--accent); border-radius: 50px;
padding: 6px 12px; font-size: .7rem; font-weight: 700; letter-spacing: .03em;
color: var(--accent); max-width: 190px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
#filter-active-summary.show { display: block; }
/* ── VOLUME POPUP ─────────────────────────────── */
#volume-popup {
position: fixed; top: 60px; right: 12px;
z-index: 510;
background: rgba(10,10,12,.95);
backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px);
border: 1px solid rgba(255,255,255,.1); border-radius: 14px;
padding: 14px 12px;
display: flex; flex-direction: column; align-items: center; gap: 10px;
opacity: 0; pointer-events: none;
transform: translateY(-8px) scale(.96);
transition: opacity .2s, transform .2s;
}
#volume-popup.open { opacity: 1; pointer-events: all; transform: translateY(0) scale(1); }
#volume-slider {
-webkit-appearance: none; appearance: none;
writing-mode: vertical-lr; direction: rtl;
width: 5px; height: 100px;
background: rgba(255,255,255,.15); border-radius: 3px; outline: none; cursor: pointer;
}
#volume-slider::-webkit-slider-thumb {
-webkit-appearance: none; appearance: none;
width: 16px; height: 16px; border-radius: 50%;
background: var(--accent); cursor: pointer;
box-shadow: 0 0 6px rgba(0,0,0,.4);
}
#volume-slider::-moz-range-thumb {
width: 16px; height: 16px; border-radius: 50%;
background: var(--accent); border: none; cursor: pointer;
}
.volume-label { font-size: .65rem; font-weight: 700; color: rgba(255,255,255,.5); }
/* ── FEED ─────────────────────────────────────── */
#scroller-feed {
width: 100%; height: 100%;
overflow-y: scroll; scroll-snap-type: y mandatory;
-webkit-overflow-scrolling: touch; overscroll-behavior: contain;
}
#scroller-feed::-webkit-scrollbar { display: none; }
#scroller-feed { scrollbar-width: none; }
/* ── SLIDE ────────────────────────────────────── */
.scroll-slide {
position: relative; width: 100%;
height: 100dvh; height: 100vh;
scroll-snap-align: start; scroll-snap-stop: always;
display: flex; align-items: center; justify-content: center;
background: #000; overflow: hidden;
-webkit-touch-callout: none; -webkit-user-select: none; user-select: none;
}
.scroll-bg-blur {
position: absolute; inset: 0; z-index: 0;
filter: blur(28px) brightness(.38) saturate(1.3); transform: scale(1.15);
}
/* canvas bg: stretch to fill the container */
.scroll-bg-blur canvas, canvas.scroll-bg-blur {
width: 100%; height: 100%; display: block; object-fit: cover;
}
.scroll-media {
position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
overflow: hidden; z-index: 1;
}
.scroll-media video, .scroll-media img { max-width: 100%; max-height: 100%; width: auto; height: auto; object-fit: contain; display: block; }
.scroll-media video { width: 100%; height: 100%; background: transparent; opacity: 0; }
.scroll-media video.ready { opacity: 1; }
.audio-cover {
width: 200px; height: 200px; border-radius: 50%;
background: rgba(255,255,255,.12) center/cover;
backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px);
border: 3px solid rgba(255,255,255,.2);
box-shadow: 0 0 60px rgba(0,0,0,.6), inset 0 0 0 1px rgba(255,255,255,.08);
animation: vinylSpin 12s linear infinite; animation-play-state: paused;
display: flex; align-items: center; justify-content: center;
position: relative;
}
.audio-cover::after {
content: ''; width: 44px; height: 44px; border-radius: 50%;
background: rgba(0,0,0,.65); border: 3px solid rgba(255,255,255,.18);
position: absolute; /* stays centred on top of cover art */
}
.audio-cover.playing { animation-play-state: running; }
@keyframes vinylSpin { to { transform: rotate(360deg); } }
.scroll-overlay { position: absolute; inset: 0; background: var(--overlay-bg); z-index: 2; pointer-events: none; }
/* Long-press peek: hide all UI so user can view the media cleanly */
.ui-peek .scroll-overlay,
.ui-peek .scroll-meta,
.ui-peek .scroll-actions,
.ui-peek .scroll-progress-bar { opacity: 0; pointer-events: none; transition: opacity .15s; }
/* tap layer z:3 — actions/meta at z:10 win */
.tap-overlay { position: absolute; inset: 0; z-index: 3; cursor: pointer; }
.tap-pause-icon {
position: absolute; top: 50%; left: 50%;
transform: translate(-50%, -50%) scale(0);
font-size: 4rem; color: rgba(255,255,255,.88); pointer-events: none;
transition: opacity .28s, transform .22s cubic-bezier(.34,1.56,.64,1); opacity: 0;
}
.tap-pause-icon.show { opacity: 1; transform: translate(-50%, -50%) scale(1); }
.tap-pause-icon.hide { opacity: 0; transform: translate(-50%, -50%) scale(.55); }
/* ── DOUBLE-TAP FAV FLASH ─────────────────────── */
.fav-flash {
position: absolute; top: 50%; left: 50%;
font-size: 5rem; color: #ff4081; pointer-events: none;
transform: translate(-50%, -50%) scale(0); opacity: 0; z-index: 15;
text-shadow: 0 2px 20px rgba(0,0,0,.5);
transition: transform .25s cubic-bezier(.34,1.56,.64,1), opacity .35s;
}
.fav-flash.show { transform: translate(-50%, -50%) scale(1); opacity: 1; }
.fav-flash.hide { transform: translate(-50%, -50%) scale(.6); opacity: 0; }
/* ── Floating heart (double-tap) ───────────────── */
@keyframes tapHeartFloat {
0% { transform: translate(-50%, -50%) scale(0); opacity: 0; }
15% { transform: translate(-50%, -60%) scale(1.2); opacity: 1; }
35% { transform: translate(calc(-50% + var(--drift, 0px)), -80%) scale(1); opacity: 1; }
100% { transform: translate(calc(-50% + var(--drift, 0px)), -200%) scale(.7); opacity: 0; }
}
.tap-heart {
position: absolute; pointer-events: none; z-index: 20;
transform: translate(-50%, -50%) scale(0);
animation: tapHeartFloat .52s cubic-bezier(.22,1,.36,1) forwards;
filter: drop-shadow(0 2px 8px rgba(0,0,0,.4));
line-height: 1;
}
/* ── META z:10 ────────────────────────────────── */
.scroll-meta { position: absolute; bottom: 0; left: 0; right: 72px; z-index: 10; padding: 16px 16px 44px; pointer-events: none; }
.scroll-meta-inner { display: flex; flex-direction: column; gap: 7px; }
.scroll-meta-top { display: flex; align-items: center; gap: 10px; }
.scroll-avatar { width: 38px; height: 38px; border-radius: 50%; object-fit: cover; border: 2px solid rgba(255,255,255,.25); flex-shrink: 0; }
.scroll-username { font-weight: 700; font-size: .9rem; text-shadow: 0 1px 6px rgba(0,0,0,.9); }
.scroll-timeago { font-size: .7rem; color: rgba(255,255,255,.6); margin-top: 1px; }
.scroll-tags { font-size: .74rem; color: rgba(255,255,255,.7); display: flex; flex-wrap: wrap; align-items: center; gap: 4px; }
.scroll-tag-pill {
display: inline-block; padding: 2px 8px; border-radius: 50px;
background: rgba(255,255,255,.12); border: 1px solid rgba(255,255,255,.18);
font-size: .68rem; font-weight: 600; color: rgba(255,255,255,.85);
cursor: pointer; pointer-events: all; position: relative; z-index: 11;
transition: background .13s, border-color .13s;
}
.scroll-tag-pill:hover { background: var(--accent); border-color: var(--accent); color: #000; }
.scroll-tag-pill-del {
display: none; align-items: center; justify-content: center;
margin-left: 2px; font-size: .6rem; opacity: .6; cursor: pointer;
background: none; border: none; color: inherit; padding: 0 2px;
}
.scroll-tag-pill:hover .scroll-tag-pill-del { display: inline-flex; }
/* Notification bell in scroller topbar */
#scroller-notif-btn { position: relative; }
#scroller-notif-badge {
display: none; position: absolute; top: 2px; right: 2px;
min-width: 16px; height: 16px; padding: 0 4px;
background: #e91e8c; color: #fff; border-radius: 50px;
font-size: .6rem; font-weight: 700; line-height: 16px;
text-align: center; pointer-events: none;
}
.scroll-id-link {
display: inline-flex; align-items: center; gap: 5px; font-size: .76rem; color: var(--accent); font-weight: 700;
text-decoration: none; margin-top: 2px; pointer-events: all; position: relative; z-index: 11; width: fit-content;
}
.scroll-id-link:hover { text-decoration: underline; }
.scroll-user-link { pointer-events: all; position: relative; z-index: 11; text-decoration: none; color: inherit; }
.scroll-badges { display: flex; gap: 6px; align-items: center; margin-bottom: 4px; pointer-events: all; position: relative; z-index: 11; }
.scroll-rating { display: inline-flex; align-items: center; padding: 2px 8px; border-radius: 4px; font-size: .67rem; font-weight: 700; text-transform: uppercase; letter-spacing: .04em; }
.scroll-rating.sfw { background: var(--badge-sfw); color: #fff; }
.scroll-rating.nsfw { background: var(--badge-nsfw); color: #fff; }
.scroll-rating.nsfl { background: var(--badge-nsfl); color: #fff; }
.scroll-rating.untagged { background: transparent; color: rgba(255,255,255,.5); border: 1px dashed rgba(255,255,255,.3); }
.scroll-rating.can-cycle { cursor: pointer; pointer-events: all; position: relative; z-index: 11; }
.scroll-rating.can-cycle:hover { filter: brightness(1.2); }
.scroll-oc { display: inline-flex; align-items: center; gap: 4px; padding: 2px 8px; border-radius: 4px; font-size: .67rem; font-weight: 700; background: var(--accent); color: #000; }
/* ── SIDE ACTIONS z:10 ────────────────────────── */
.scroll-actions { position: absolute; right: 10px; bottom: 40px; z-index: 10; display: flex; flex-direction: column; align-items: center; gap: 18px; }
.scroll-btn {
display: flex; flex-direction: column; align-items: center; gap: 3px;
cursor: pointer; text-decoration: none; color: #fff; background: none; border: none; padding: 0;
transition: transform .12s;
}
.scroll-btn:hover { transform: scale(1.14); }
.scroll-btn-icon {
position: relative;
width: 46px; height: 46px; border-radius: 50%;
background: rgba(255,255,255,.1); backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
border: 1px solid rgba(255,255,255,.14); display: flex; align-items: center; justify-content: center;
font-size: 1.05rem; transition: background .17s;
}
.scroll-btn:hover .scroll-btn-icon { background: rgba(255,255,255,.22); }
.scroll-btn-label { font-size: .6rem; font-weight: 600; letter-spacing: .03em; color: rgba(255,255,255,.75); }
.scroll-btn-count {
font-size: .62rem; font-weight: 800; color: rgba(255,255,255,.55); line-height: 1;
min-width: 20px; text-align: center;
}
.scroll-btn.faved .scroll-btn-icon { background: rgba(255,64,129,.25); border-color: #ff4081; }
.scroll-btn.faved .scroll-btn-icon i { color: #ff4081; }
.scroll-btn.faved .scroll-btn-count { color: #ff4081; }
/* ── PROGRESS BAR ─────────────────────────────── */
.scroll-progress-bar {
position: absolute; bottom: 0; left: 0; right: 0; height: 4px;
background: rgba(255,255,255,.15); z-index: 10; cursor: pointer; transition: height .15s;
}
.scroll-progress-bar:hover { height: 6px; }
.scroll-progress-fill { height: 100%; background: var(--accent); width: 0%; pointer-events: none; }
.scroll-progress-thumb {
position: absolute; top: 50%; right: 0; width: 12px; height: 12px; border-radius: 50%;
background: var(--accent); transform: translate(50%, -50%) scale(0);
transition: transform .15s; pointer-events: none;
}
.scroll-progress-bar:hover .scroll-progress-thumb,
.scroll-progress-bar.dragging .scroll-progress-thumb { transform: translate(50%, -50%) scale(1); }
/* ── EMPTY / SENTINEL ────────────────────────── */
#scroller-sentinel { width: 100%; height: 10px; }
#scroller-empty {
width: 100%; height: 100dvh; height: 100vh; display: none;
flex-direction: column; align-items: center; justify-content: center;
gap: 14px; scroll-snap-align: start; color: rgba(255,255,255,.35);
}
#scroller-empty.show { display: flex; }
#scroller-empty i { font-size: 2.8rem; }
/* ── LOADER ─────────────────────────────────── */
#scroller-loader {
position: fixed; inset: 0; background: #000;
display: flex; flex-direction: column; align-items: center; justify-content: center;
z-index: 1000; gap: 16px; transition: opacity .3s;
}
#scroller-loader.hidden { opacity: 0; pointer-events: none; }
.loader-logo { font-size: 2.2rem; font-weight: 800; letter-spacing: -.04em; color: var(--accent, #fff); font-family: 'VCR', 'Courier New', monospace; }
.loader-sub { font-size: .82rem; color: rgba(255,255,255,.4); letter-spacing: .1em; font-weight: 500; }
.loader-spinner { width: 28px; height: 28px; border: 2.5px solid rgba(255,255,255,.1); border-top-color: var(--accent, #fff); border-radius: 50%; animation: spin .65s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
/* ── YT EMBED ────────────────────────────────── */
.yt-container { position: relative; width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; }
.yt-container iframe { width: 100%; height: 56.25vw; max-height: 100%; max-width: 177.78vh; border: 0; }
/* ══════════════════════════════════════════════
SHARED PANEL SYSTEM
══════════════════════════════════════════════ */
.scroller-backdrop {
position: fixed; inset: 0; background: rgba(0,0,0,.5);
backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px);
z-index: 800; opacity: 0; pointer-events: none; transition: opacity .28s;
}
.scroller-backdrop.open { opacity: 1; pointer-events: all; }
.scroller-panel {
position: fixed; bottom: 0; left: 0; right: 0; z-index: 801;
background: var(--panel-bg); border-top: 1px solid var(--panel-border);
border-radius: 22px 22px 0 0; transform: translateY(105%);
transition: transform .32s cubic-bezier(.32,.72,0,1);
max-height: 92dvh; max-height: 92vh; overflow: hidden; display: flex; flex-direction: column;
}
.scroller-panel.open { transform: translateY(0); }
.scroller-panel::-webkit-scrollbar { display: none; }
.panel-handle { display: flex; justify-content: center; padding: 14px 0 4px; flex-shrink: 0; }
.panel-handle-bar { width: 40px; height: 4px; background: rgba(255,255,255,.18); border-radius: 2px; }
.panel-header {
display: flex; align-items: center; justify-content: space-between;
padding: 4px 20px 14px; flex-shrink: 0; border-bottom: 1px solid rgba(255,255,255,.06);
}
.panel-title { font-size: 1rem; font-weight: 800; letter-spacing: -.01em; }
/* ── FILTER PANEL ─────────────────────────────── */
#filter-panel { z-index: 802; }
.filter-reset-btn { font-size: .74rem; color: var(--accent); font-weight: 700; background: none; border: none; cursor: pointer; padding: 4px 8px; border-radius: 6px; transition: background .15s; }
.filter-reset-btn:hover { background: rgba(255,255,255,.08); }
.filter-scroll-area { overflow-y: auto; flex: 1; }
.filter-scroll-area::-webkit-scrollbar { display: none; }
.filter-section { padding: 16px 20px; border-bottom: 1px solid rgba(255,255,255,.05); }
.filter-section:last-of-type { border-bottom: none; }
.filter-section-label { font-size: .68rem; font-weight: 700; letter-spacing: .09em; text-transform: uppercase; color: rgba(255,255,255,.42); margin-bottom: 12px; }
.pill-group { display: flex; flex-wrap: wrap; gap: 8px; }
.filter-pill { padding: 7px 16px; border-radius: 50px; border: 1px solid rgba(255,255,255,.13); background: rgba(255,255,255,.07); color: #fff; font-size: .81rem; font-weight: 600; cursor: pointer; white-space: nowrap; user-select: none; transition: background .14s, border-color .14s, color .14s; }
.filter-pill:hover { background: rgba(255,255,255,.13); }
.filter-pill.active { background: var(--accent); border-color: var(--accent); color: #000; }
.filter-pill i { margin-right: 5px; font-size: .72rem; }
.tag-search-wrap { position: relative; }
#filter-tag-input { width: 100%; padding: 10px 38px 10px 14px; background: rgba(255,255,255,.06); border: 1px solid rgba(255,255,255,.12); border-radius: 10px; color: #fff; font-size: .86rem; outline: none; transition: border-color .17s; }
#filter-tag-input:focus { border-color: var(--accent); }
#filter-tag-input::placeholder { color: rgba(255,255,255,.32); }
.tag-search-icon { position: absolute; right: 12px; top: 50%; transform: translateY(-50%); color: rgba(255,255,255,.34); font-size: .82rem; pointer-events: none; }
#filter-tag-clear { position: absolute; right: 12px; top: 50%; transform: translateY(-50%); color: rgba(255,255,255,.5); cursor: pointer; display: none; background: none; border: none; padding: 4px; }
#filter-tag-clear.show { display: block; }
#tag-suggestions { margin-top: 8px; display: flex; flex-wrap: wrap; gap: 6px; }
.tag-suggestion { padding: 5px 12px; border-radius: 50px; background: rgba(255,255,255,.07); border: 1px solid rgba(255,255,255,.1); font-size: .76rem; color: rgba(255,255,255,.82); cursor: pointer; transition: background .12s; }
.tag-suggestion:hover { background: rgba(255,255,255,.13); }
.tag-suggestion .uses { color: rgba(255,255,255,.36); font-size: .67rem; margin-left: 4px; }
.tag-suggestion.active { background: var(--accent); border-color: var(--accent); color: #000; }
.tag-suggestion.active .uses { color: rgba(0,0,0,.45); }
.tag-suggestion.keyboard-focus { background: rgba(255,255,255,.18); border-color: rgba(255,255,255,.35); outline: 2px solid var(--accent); outline-offset: 1px; }
.tag-suggestion.active.keyboard-focus { outline: 2px solid rgba(0,0,0,.5); outline-offset: 1px; }
#filter-active-tags { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
.active-tag-pill { display: inline-flex; align-items: center; gap: 6px; padding: 5px 10px; border-radius: 50px; background: var(--accent); color: #000; font-size: .76rem; font-weight: 700; cursor: pointer; }
.active-tag-pill i { font-size: .62rem; }
.filter-apply-btn { display: block; margin: 12px 20px 20px; padding: 15px; background: var(--accent); color: #000; font-size: .9rem; font-weight: 800; border: none; border-radius: 14px; cursor: pointer; flex-shrink: 0; transition: opacity .14s, transform .12s; }
.filter-apply-btn:hover { opacity: .88; }
.filter-apply-btn:active { transform: scale(.98); }
/* ── SETTINGS PANEL ────────────────────────────── */
#settings-panel { z-index: 802; }
.settings-toggle-row { display: flex; align-items: center; gap: 14px; padding: 10px 0; }
.settings-toggle-info { flex: 1; min-width: 0; }
.settings-toggle-name { display: block; font-size: .88rem; font-weight: 700; color: #fff; }
.settings-toggle-desc { display: block; font-size: .74rem; color: rgba(255,255,255,.45); margin-top: 2px; line-height: 1.35; }
.settings-toggle-switch {
width: 44px; height: 24px; border-radius: 50px; flex-shrink: 0;
background: rgba(255,255,255,.14); border: 1px solid rgba(255,255,255,.18);
position: relative; cursor: pointer; transition: background .2s, border-color .2s;
}
.settings-toggle-switch.on { background: var(--accent); border-color: var(--accent); }
.settings-toggle-knob {
position: absolute; top: 2px; left: 2px;
width: 18px; height: 18px; border-radius: 50%;
background: rgba(255,255,255,.6); transition: transform .2s, background .2s;
}
.settings-toggle-switch.on .settings-toggle-knob { transform: translateX(20px); background: #000; }
/* Save preset button */
.settings-save-preset-btn {
width: 100%; padding: 11px 16px; margin-bottom: 10px;
background: rgba(255,255,255,.07); border: 1px solid rgba(255,255,255,.13);
border-radius: 12px; color: rgba(255,255,255,.85); font-size: .84rem; font-weight: 600;
cursor: pointer; text-align: left; transition: background .14s;
}
.settings-save-preset-btn:hover { background: rgba(255,255,255,.12); }
.settings-save-preset-btn i { margin-right: 6px; }
/* Preset rows */
.preset-row {
display: flex; align-items: center; gap: 8px; padding: 9px 12px; border-radius: 12px;
background: rgba(255,255,255,.05); border: 1px solid rgba(255,255,255,.08); margin-bottom: 7px;
cursor: pointer; transition: background .14s;
}
.preset-row:hover { background: rgba(255,255,255,.1); }
.preset-row-info { flex: 1; min-width: 0; }
.preset-row-name { font-size: .86rem; font-weight: 700; color: #fff; }
.preset-row-meta { font-size: .72rem; color: rgba(255,255,255,.42); margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.preset-row-del { color: rgba(255,255,255,.35); background: none; border: none; cursor: pointer; padding: 4px 6px; border-radius: 6px; font-size: .8rem; flex-shrink: 0; transition: color .14s, background .14s; }
.preset-row-del:hover { color: #e55; background: rgba(255,60,60,.12); }
.preset-row-edit { color: rgba(255,255,255,.35); background: none; border: none; cursor: pointer; padding: 4px 6px; border-radius: 6px; font-size: .8rem; flex-shrink: 0; transition: color .14s, background .14s; }
.preset-row-edit:hover { color: var(--accent); background: rgba(255,255,255,.08); }
.preset-row-update { color: rgba(255,255,255,.35); background: none; border: none; cursor: pointer; padding: 4px 6px; border-radius: 6px; font-size: .8rem; flex-shrink: 0; transition: color .14s, background .14s; }
.preset-row-update:hover { color: #4caf50; background: rgba(76,175,80,.12); }
/* Preset inline editor */
.preset-editor { display: none; padding: 8px 12px 10px; border-top: 1px solid rgba(255,255,255,.07); margin-top: 4px; }
.preset-editor.open { display: block; }
.preset-editor-tags { display: flex; flex-wrap: wrap; gap: 5px; margin-bottom: 8px; min-height: 22px; }
.preset-editor-tag {
display: inline-flex; align-items: center; gap: 4px; padding: 2px 8px 2px 9px; border-radius: 50px;
background: rgba(255,255,255,.12); border: 1px solid rgba(255,255,255,.18);
font-size: .7rem; font-weight: 600; color: rgba(255,255,255,.85);
}
.preset-editor-tag-del { background: none; border: none; cursor: pointer; color: inherit; opacity: .55; padding: 0; font-size: .68rem; line-height: 1; }
.preset-editor-tag-del:hover { opacity: 1; color: #e55; }
.preset-editor-add { display: flex; gap: 6px; margin-bottom: 8px; }
.preset-editor-add input {
flex: 1; background: rgba(255,255,255,.07); border: 1px solid rgba(255,255,255,.15);
border-radius: 8px; color: #fff; font-size: .82rem; padding: 5px 10px; outline: none;
}
.preset-editor-add input::placeholder { color: rgba(255,255,255,.3); }
.preset-editor-add input:focus { border-color: var(--accent); }
.preset-editor-add button {
padding: 5px 12px; border-radius: 8px; background: rgba(255,255,255,.1);
border: 1px solid rgba(255,255,255,.15); color: #fff; font-size: .8rem; cursor: pointer;
}
.preset-editor-add button:hover { background: var(--accent); border-color: var(--accent); color: #000; }
.preset-editor-actions { display: flex; flex-direction: column; gap: 6px; }
.preset-editor-save {
width: 100%; padding: 8px 14px; border-radius: 8px; background: var(--accent); border: none;
color: #000; font-size: .82rem; font-weight: 700; cursor: pointer; text-align: left;
}
.preset-editor-save:hover { filter: brightness(1.12); }
.preset-editor-overwrite {
width: 100%; padding: 8px 14px; border-radius: 8px; background: rgba(255,255,255,.08);
border: 1px solid rgba(255,255,255,.15); color: #fff; font-size: .82rem; cursor: pointer; text-align: left;
}
.preset-editor-overwrite:hover { background: rgba(255,255,255,.15); }
/* When a preset row is open, don't highlight it on hover */
.preset-row.editing { cursor: default; }
.preset-row.editing:hover { background: rgba(255,255,255,.05); }
.preset-row.editing { flex-wrap: wrap; }
/* ui-hidden: hides topbar content + action buttons, but keeps settings button accessible */
.ui-hidden .topbar-left { opacity: 0; pointer-events: none; transition: opacity .3s; }
.ui-hidden #filter-open-btn,
.ui-hidden #scroller-mute-btn {
opacity: 0; pointer-events: none;
width: 0; min-width: 0; padding: 0; margin: 0; border-width: 0; overflow: hidden;
transition: opacity .3s, width .3s, padding .3s, margin .3s, border-width .3s;
}
/* When not hidden, make sure the buttons transition back smoothly */
#filter-open-btn, #scroller-mute-btn {
transition: background .17s, transform .12s, border-color .17s,
opacity .3s, width .3s, padding .3s, margin .3s, border-width .3s;
}
.ui-hidden #settings-open-btn { opacity: .55; transition: opacity .3s; } /* always reachable */
.ui-hidden #settings-open-btn:hover { opacity: 1; }
.ui-hidden .scroll-actions { opacity: 0; pointer-events: none; transition: opacity .3s; }
.ui-hidden .scroll-meta { opacity: 0; pointer-events: none; transition: opacity .3s; }
/* ── COMMENTS PANEL ───────────────────────────── */
#comments-panel { z-index: 802; }
#comments-list { flex: 1; overflow-y: auto; padding: 0 16px 8px; }
#comments-list::-webkit-scrollbar { width: 4px; }
#comments-list::-webkit-scrollbar-thumb { background: rgba(255,255,255,.12); border-radius: 2px; }
.comment-item { display: flex; gap: 10px; padding: 12px 0; border-bottom: 1px solid rgba(255,255,255,.05); }
.comment-item:last-child { border-bottom: none; }
.comment-avatar { width: 32px; height: 32px; border-radius: 50%; object-fit: cover; flex-shrink: 0; border: 1px solid rgba(255,255,255,.12); }
.comment-body { flex: 1; min-width: 0; }
.comment-username { font-size: .78rem; font-weight: 700; color: rgba(255,255,255,.9); margin-bottom: 3px; }
.comment-content { font-size: .82rem; color: rgba(255,255,255,.78); line-height: 1.45; word-break: break-word; }
.scroller-greentext { color: #789922; display: block; }
.scroller-spoiler {
background: #111; color: #111; border-radius: 3px; cursor: pointer;
padding: 0 3px; transition: color .15s, background .15s; user-select: none;
}
.scroller-spoiler.revealed, .scroller-spoiler:hover { color: inherit; background: rgba(255,255,255,.1); }
.scroller-blur { filter: blur(5px); cursor: pointer; transition: filter .2s; display: inline-block; }
.scroller-blur.revealed, .scroller-blur:hover { filter: none; }
.comment-time { font-size: .67rem; color: rgba(255,255,255,.38); margin-top: 4px; }
#comments-loading, #comments-empty { text-align: center; padding: 40px 20px; color: rgba(255,255,255,.35); font-size: .85rem; }
#comments-loading .loader-spinner { margin: 0 auto 10px; }
/* comment input */
#comments-input-area {
display: flex; flex-direction: column;
padding: 10px 14px 16px;
border-top: 1px solid rgba(255,255,255,.07); flex-shrink: 0;
padding-bottom: calc(16px + env(safe-area-inset-bottom, 0px));
gap: 8px;
}
#comments-input-area.hidden { display: none; }
/* mention dropdown */
#mention-dropdown {
background: rgba(14,14,18,.98); border: 1px solid rgba(255,255,255,.1);
border-radius: 10px; max-height: 160px; overflow-y: auto;
display: none;
}
#mention-dropdown.show { display: block; }
.mention-item {
display: flex; align-items: center; gap: 8px;
padding: 8px 12px; cursor: pointer; font-size: .82rem;
transition: background .12s;
}
.mention-item:hover, .mention-item.selected { background: rgba(255,255,255,.08); }
.mention-avatar { width: 24px; height: 24px; border-radius: 50%; object-fit: cover; }
/* sitewide emoji autocomplete portal (matches comments.js .emoji-autocomplete) */
.emoji-autocomplete {
position: fixed; z-index: 9999;
background: rgba(10,10,14,.97); border: 1px solid rgba(255,255,255,.12);
border-radius: 10px; display: flex; flex-wrap: wrap; gap: 2px; padding: 6px;
max-height: 200px; overflow-y: auto; box-shadow: 0 8px 32px rgba(0,0,0,.6);
}
.emoji-ac-item {
display: flex; flex-direction: column; align-items: center; gap: 3px;
padding: 6px 8px; border-radius: 8px; cursor: pointer;
transition: background .12s; min-width: 54px;
}
.emoji-ac-item:hover, .emoji-ac-item.active { background: rgba(255,255,255,.1); }
.emoji-ac-item img { width: 32px; height: 32px; object-fit: contain; }
.emoji-ac-item span { font-size: .58rem; color: rgba(255,255,255,.5); text-align: center; }
/* emoji trigger (☺ button) */
.emoji-trigger {
background: none; border: none; font-size: 2.2rem; cursor: pointer;
padding: 4px 6px; border-radius: 8px; color: rgba(255,255,255,.6);
transition: background .12s, color .12s; line-height: 1; flex-shrink: 0;
}
.emoji-trigger:hover { background: rgba(255,255,255,.1); color: #fff; }
.comment-input-row { display: flex; align-items: flex-end; gap: 8px; }
#comment-input {
flex: 1; padding: 10px 14px; background: rgba(255,255,255,.08);
border: 1px solid rgba(255,255,255,.12); border-radius: 22px;
color: #fff; font-size: .84rem; outline: none; transition: border-color .17s;
resize: none; line-height: 1.4;
}
#comment-input:focus { border-color: var(--accent); }
#comment-input::placeholder { color: rgba(255,255,255,.32); }
#comment-send-btn {
width: 38px; height: 38px; border-radius: 50%; background: var(--accent); border: none; cursor: pointer;
display: flex; align-items: center; justify-content: center; color: #000; font-size: .88rem;
flex-shrink: 0; transition: opacity .15s, transform .12s;
}
#comment-send-btn:hover { opacity: .85; }
#comment-send-btn:active { transform: scale(.93); }
#comment-send-btn:disabled { opacity: .4; cursor: default; }
.comments-login-note { text-align: center; padding: 12px 16px 20px; font-size: .78rem; color: rgba(255,255,255,.38); }
.comments-login-note a { color: var(--accent); }
/* ── Tag bar ── slide-up panel, opened by Tag action button */
#tag-bar {
position: fixed; bottom: 0; left: 0; right: 0; z-index: 1200;
background: rgba(14,14,18,.97); border-top: 1px solid rgba(255,255,255,.1);
padding: 12px 14px calc(12px + env(safe-area-inset-bottom, 0px));
transform: translateY(100%); transition: transform .22s cubic-bezier(.32,0,.67,0);
backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px);
}
#tag-bar.open { transform: translateY(0); }
#tag-bar-inner { display: flex; align-items: center; gap: 8px; }
#scroll-tag-input {
flex: 1; padding: 10px 14px; background: rgba(255,255,255,.08);
border: 1px solid rgba(255,255,255,.12); border-radius: 22px;
color: #fff; font-size: .84rem; outline: none; transition: border-color .17s;
}
#scroll-tag-input:focus { border-color: var(--accent); }
#scroll-tag-input::placeholder { color: rgba(255,255,255,.32); }
#scroll-tag-send-btn {
width: 38px; height: 38px; border-radius: 50%; background: var(--accent);
border: none; cursor: pointer; display: flex; align-items: center; justify-content: center;
color: #000; font-size: .88rem; flex-shrink: 0; transition: opacity .15s, transform .12s;
}
#scroll-tag-send-btn:hover { opacity: .85; }
#scroll-tag-send-btn:active { transform: scale(.93); }
#tag-bar-close-btn {
width: 34px; height: 34px; border-radius: 50%; background: rgba(255,255,255,.08);
border: 1px solid rgba(255,255,255,.15); cursor: pointer;
display: flex; align-items: center; justify-content: center;
color: rgba(255,255,255,.6); font-size: .8rem; flex-shrink: 0;
transition: background .12s;
}
#tag-bar-close-btn:hover { background: rgba(255,255,255,.15); color: #fff; }
/* tag autocomplete dropdown — opens upward */
#scroll-tag-suggestions {
position: absolute; bottom: calc(100% + 6px); left: 0; right: 0;
background: rgba(14,14,18,.98); border: 1px solid rgba(255,255,255,.1);
border-radius: 12px; max-height: 180px; overflow-y: auto;
display: none; z-index: 1300; box-shadow: 0 -4px 24px rgba(0,0,0,.5);
}
#scroll-tag-suggestions.show { display: block; }
/* ── Share panel ────────────────────────────────── */
#share-backdrop {
display: none; position: fixed; inset: 0; z-index: 1290;
background: rgba(0,0,0,.55); backdrop-filter: blur(3px); -webkit-backdrop-filter: blur(3px);
}
#share-backdrop.show { display: block; }
#share-panel {
position: fixed; bottom: 0; left: 0; right: 0; z-index: 1300;
background: rgba(14,14,18,.98); border-top: 1px solid rgba(255,255,255,.1);
padding: 18px 16px calc(18px + env(safe-area-inset-bottom, 0px));
transform: translateY(100%); transition: transform .22s cubic-bezier(.32,0,.67,0);
backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px);
border-radius: 20px 20px 0 0;
}
#share-panel.open { transform: translateY(0); }
#share-panel-handle {
width: 36px; height: 4px; border-radius: 2px; background: rgba(255,255,255,.2);
margin: 0 auto 16px;
}
#share-panel-title {
font-size: .72rem; font-weight: 700; letter-spacing: .08em;
color: rgba(255,255,255,.4); text-transform: uppercase; margin-bottom: 14px;
}
.share-row {
display: flex; align-items: center; gap: 14px; padding: 13px 14px;
border-radius: 12px; cursor: pointer; transition: background .14s;
background: rgba(255,255,255,.05); border: 1px solid rgba(255,255,255,.07); margin-bottom: 8px;
}
.share-row:last-child { margin-bottom: 0; }
.share-row:hover { background: rgba(255,255,255,.1); }
.share-row-icon {
width: 40px; height: 40px; border-radius: 50%; flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
font-size: 1rem;
}
.share-row-icon.copy-icon { background: rgba(99,179,237,.18); color: #63b3ed; }
.share-row-icon.dm-icon { background: rgba(154,117,255,.18); color: #9a75ff; }
.share-row-text { flex: 1; min-width: 0; }
.share-row-title { font-size: .88rem; font-weight: 600; color: #fff; }
.share-row-sub { font-size: .72rem; color: rgba(255,255,255,.45); margin-top: 2px; }
/* DM user search */
#share-dm-search {
margin-top: 14px; display: none;
}
#share-dm-search.show { display: block; }
#share-user-input {
width: 100%; padding: 10px 14px; background: rgba(255,255,255,.08);
border: 1px solid rgba(255,255,255,.12); border-radius: 22px;
color: #fff; font-size: .84rem; outline: none; transition: border-color .17s; box-sizing: border-box;
}
#share-user-input:focus { border-color: #9a75ff; }
#share-user-input::placeholder { color: rgba(255,255,255,.32); }
#share-user-results {
margin-top: 8px; max-height: 200px; overflow-y: auto;
border-radius: 12px; border: 1px solid rgba(255,255,255,.08);
background: rgba(14,14,18,.98); display: none;
}
#share-user-results.show { display: block; }
.share-user-row {
display: flex; align-items: center; gap: 10px; padding: 10px 12px;
cursor: pointer; transition: background .12s;
}
.share-user-row:hover { background: rgba(255,255,255,.08); }
.share-user-row img {
width: 32px; height: 32px; border-radius: 50%; object-fit: cover; flex-shrink: 0;
}
.share-user-row-name { font-size: .84rem; font-weight: 600; color: #fff; flex: 1; min-width: 0; }
.share-user-row-label { font-size: .7rem; color: rgba(255,255,255,.4); margin-left: auto; flex-shrink: 0; }
.share-recents-label {
padding: 6px 12px 4px; font-size: .7rem; font-weight: 700; letter-spacing: .06em;
color: rgba(255,255,255,.35); text-transform: uppercase;
}
.scroll-tag-sugg-item {
padding: 9px 14px; cursor: pointer; font-size: .84rem; color: rgba(255,255,255,.85);
transition: background .1s; display: flex; align-items: center; gap: 8px;
}
.scroll-tag-sugg-item:hover, .scroll-tag-sugg-item.selected { background: rgba(255,255,255,.08); }
.scroll-tag-sugg-count { font-size: .72rem; color: rgba(255,255,255,.35); margin-left: auto; }
</style>
</head>
<body class="scroller-active">
<div id="main">
<script>
window.f0ckThemes = {{ themes_json }};
window.f0ckDomain = "{{ domain }}";
window.scrollerMode = @if(typeof session !== 'undefined' && session){{ session.mode || 0 }}@else 0@endif;
window.scrollerLoggedIn = @if(typeof session !== 'undefined' && session)true@else false@endif;
window.scrollerIsMod = @if(typeof session !== 'undefined' && session && (session.admin || session.is_moderator))true@else false@endif;
window.scrollerCsrf = "@if(typeof session !== 'undefined' && session){{ session.csrf_token || '' }}@else@endif";
window.scrollerEnableNsfl = {{ enable_nsfl ? 'true' : 'false' }};
window.scrollerEnableSwf = {{ enable_swf ? 'true' : 'false' }};
window.scrollerRuffleVolume = @if(typeof session !== 'undefined' && session && session.ruffle_volume !== undefined && session.ruffle_volume !== null){{ session.ruffle_volume }}@else 0.5@endif;
window.f0ckAllowedImages = {{ allowed_comment_images_json }};
window.scrollerPublic = {{ private_society ? 'false' : 'true' }};
@if(typeof session !== 'undefined' && session)
window.scrollerUsername = "{{ session.user || '' }}";
window.scrollerDisplayName = "{{ session.display_name || session.user || '' }}";
window.scrollerUserAvatar = "{{ session.avatar_file ? '/a/' + session.avatar_file : (session.avatar ? '/t/' + session.avatar + '.webp' : '/a/default.png') }}";
@endif
</script>
<!-- Loader -->
<div id="scroller-loader">
<div class="loader-logo">{{ domain }}</div>
<div class="loader-sub">doomscroll</div>
<div class="loader-spinner"></div>
</div>
<!-- Top bar -->
<div id="scroller-topbar">
<div class="topbar-left">
<a id="scroller-back" class="topbar-icon-btn" href="/" title="Back"><i class="fa-solid fa-arrow-left"></i></a>
<div id="filter-active-summary"></div>
</div>
<div class="topbar-right">
<button id="settings-open-btn" class="topbar-icon-btn" title="Settings"><i class="fa-solid fa-gear"></i></button>
<button id="filter-open-btn" class="topbar-icon-btn" title="Filters (F)"><i class="fa-solid fa-sliders"></i></button>
@if(typeof session !== 'undefined' && session)
<a id="scroller-notif-btn" class="topbar-icon-btn" href="/notifications" title="Notifications">
<i class="fa-solid fa-bell"></i>
<span id="scroller-notif-badge"></span>
</a>
@endif
<button id="scroller-mute-btn" class="topbar-icon-btn" title="Volume (M)"><i class="fa-solid fa-volume-xmark"></i></button>
</div>
</div>
<!-- Volume popup -->
<div id="volume-popup">
<span class="volume-label" id="volume-pct">0%</span>
<input type="range" id="volume-slider" min="0" max="1" step="0.02" value="1" orient="vertical">
<span class="volume-label"><i class="fa-solid fa-volume-low"></i></span>
</div>
<!-- Feed -->
<div id="scroller-feed" role="feed" aria-label="Doomscroll feed">
<div id="scroller-sentinel"></div>
<div id="scroller-empty">
<i class="fa-solid fa-binoculars"></i>
<p>Nothing found with current filters</p>
<button onclick="document.getElementById('filter-open-btn').click()" style="margin-top:8px;padding:8px 20px;border-radius:50px;background:rgba(255,255,255,.08);border:1px solid rgba(255,255,255,.15);color:#fff;cursor:pointer;font-size:.8rem;">Adjust filters</button>
</div>
</div>
<!-- FILTER PANEL -->
<div id="filter-backdrop" class="scroller-backdrop"></div>
<div id="filter-panel" class="scroller-panel" role="dialog" aria-label="Feed filters">
<div class="panel-handle"><div class="panel-handle-bar"></div></div>
<div class="panel-header">
<span class="panel-title">Filters</span>
<button class="filter-reset-btn" id="filter-reset-btn">Reset all</button>
</div>
<div class="filter-scroll-area">
<div class="filter-section">
<div class="filter-section-label">Rating</div>
<div class="pill-group" id="mode-pills">
<button class="filter-pill active" data-mode="0"><i class="fa-solid fa-shield-halved"></i>SFW</button>
@if(session)
<button class="filter-pill" data-mode="1"><i class="fa-solid fa-fire"></i>NSFW</button>
@endif
@if(session && enable_nsfl)
<button class="filter-pill" data-mode="4"><i class="fa-solid fa-skull"></i>NSFL</button>
@endif
@if(session)
<button class="filter-pill" data-mode="3">All</button>
@endif
@if(session && (session.admin || session.is_moderator))
<button class="filter-pill" data-mode="2">Untagged</button>
@endif
</div>
</div>
<div class="filter-section">
<div class="filter-section-label">Media type</div>
<div class="pill-group" id="mime-pills">
<button class="filter-pill active" data-mime=""><i class="fa-solid fa-layer-group"></i>All</button>
@if(scroller_mime_cats.includes('video'))
<button class="filter-pill" data-mime="video"><i class="fa-solid fa-film"></i>Video</button>
@endif
@if(scroller_mime_cats.includes('image'))
<button class="filter-pill" data-mime="image"><i class="fa-solid fa-image"></i>Image</button>
@endif
@if(scroller_mime_cats.includes('audio'))
<button class="filter-pill" data-mime="audio"><i class="fa-solid fa-music"></i>Audio</button>
@endif
</div>
</div>
<div class="filter-section">
<div class="filter-section-label">Order</div>
<div class="pill-group" id="order-pills">
<button class="filter-pill active" data-order="random"><i class="fa-solid fa-shuffle"></i>Random</button>
<button class="filter-pill" data-order="newest"><i class="fa-solid fa-clock-rotate-left"></i>Newest</button>
<button class="filter-pill" data-order="oldest"><i class="fa-solid fa-hourglass-start"></i>Oldest</button>
</div>
</div>
<div class="filter-section">
<div class="filter-section-label">Tags</div>
<div class="tag-search-wrap">
<input type="text" id="filter-tag-input" placeholder="Search tags…" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" />
<span class="tag-search-icon"><i class="fa-solid fa-tag"></i></span>
<button id="filter-tag-clear"><i class="fa-solid fa-xmark"></i></button>
</div>
<div id="tag-suggestions"></div>
<div id="filter-active-tags"></div>
</div>
<div class="filter-section" style="margin-top:8px">
<div class="filter-section-label">Saved presets</div>
<button class="settings-save-preset-btn" id="settings-save-preset-btn"><i class="fa-solid fa-floppy-disk"></i> Save current filters as preset</button>
<div id="settings-presets-list"></div>
</div>
</div>
<button class="filter-apply-btn" id="filter-apply-btn"><i class="fa-solid fa-check" style="margin-right:8px"></i>Apply &amp; Reload</button>
</div>
<!-- SETTINGS PANEL -->
<div id="settings-backdrop" class="scroller-backdrop"></div>
<div id="settings-panel" class="scroller-panel" role="dialog" aria-label="Scroller settings">
<div class="panel-handle"><div class="panel-handle-bar"></div></div>
<div class="panel-header">
<span class="panel-title">Settings</span>
</div>
<div class="filter-scroll-area">
<div class="filter-section">
<div class="filter-section-label">Appearance</div>
<div class="settings-toggle-row">
<div class="settings-toggle-info">
<span class="settings-toggle-name">Hide UI</span>
<span class="settings-toggle-desc">Hides the top bar and action buttons for full immersion</span>
</div>
<div class="settings-toggle-switch" id="st-hide-ui"><div class="settings-toggle-knob"></div></div>
</div>
<div class="settings-toggle-row">
<div class="settings-toggle-info">
<span class="settings-toggle-name">Start with sound</span>
<span class="settings-toggle-desc">Automatically unmute when you open the scroller</span>
</div>
<div class="settings-toggle-switch" id="st-start-unmuted"><div class="settings-toggle-knob"></div></div>
</div>
<div class="settings-toggle-row">
<div class="settings-toggle-info">
<span class="settings-toggle-name">Animated background</span>
<span class="settings-toggle-desc">Live video frames behind the player; disable for static thumbnail</span>
</div>
<div class="settings-toggle-switch" id="st-canvas-bg"><div class="settings-toggle-knob"></div></div>
</div>
</div>
<div class="filter-section">
<div class="filter-section-label">Playback</div>
<div class="settings-toggle-row">
<div class="settings-toggle-info">
<span class="settings-toggle-name">Auto-next</span>
<span class="settings-toggle-desc">Automatically advance to the next item when media ends</span>
</div>
<div class="settings-toggle-switch" id="st-auto-next"><div class="settings-toggle-knob"></div></div>
</div>
<div class="settings-toggle-row" style="margin-top:2px;">
<div class="settings-toggle-info">
<span class="settings-toggle-name">Loops before next</span>
<span class="settings-toggle-desc">How many times to play before advancing (videos &amp; audio)</span>
</div>
<input type="number" id="st-auto-next-loops" min="0" max="99" value="1"
style="width:56px;padding:5px 8px;background:rgba(255,255,255,.08);border:1px solid rgba(255,255,255,.15);border-radius:8px;color:#fff;font-size:.88rem;text-align:center;outline:none;" />
</div>
</div>
</div>
</div>
<!-- COMMENTS PANEL -->
<div id="comments-backdrop" class="scroller-backdrop"></div>
<div id="comments-panel" class="scroller-panel" role="dialog" aria-label="Comments">
<div class="panel-handle"><div class="panel-handle-bar"></div></div>
<div class="panel-header">
<span class="panel-title">Comments <span id="comments-count" style="color:rgba(255,255,255,.45);font-weight:400;font-size:.85rem"></span></span>
<a id="comments-open-link" href="#" target="_blank" style="color:var(--accent);font-size:.78rem;font-weight:700;text-decoration:none;padding:4px 8px;">
<i class="fa-solid fa-up-right-from-square" style="margin-right:4px;font-size:.72rem"></i>Open
</a>
</div>
<div id="comments-list">
<div id="comments-loading"><div class="loader-spinner"></div>Loading…</div>
<div id="comments-empty" style="display:none"><i class="fa-regular fa-comment" style="font-size:2rem;display:block;margin-bottom:10px"></i>No comments yet</div>
</div>
@if(typeof session !== 'undefined' && session)
<div id="comments-input-area">
<div id="mention-dropdown"></div>
<div class="comment-input-row">
<button id="comment-emoji-trigger" class="emoji-trigger" title="Emoji" type="button"></button>
<textarea id="comment-input" rows="1" placeholder="Write a comment..." maxlength="2000"></textarea>
<button id="comment-send-btn" disabled><i class="fa-solid fa-paper-plane"></i></button>
</div>
</div>
@else
<div class="comments-login-note"><a href="/login">Log in</a> to comment</div>
@endif
</div>
<!-- TAG BAR — slide-up input, opened by the Tag action button -->
@if(typeof session !== 'undefined' && session)
<div id="tag-bar">
<div id="tag-bar-inner">
<div style="position:relative;flex:1;min-width:0">
<input id="scroll-tag-input" type="text" placeholder="Add a tag to this item…" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" maxlength="70">
<div id="scroll-tag-suggestions"></div>
</div>
<button id="scroll-tag-send-btn" title="Add tag"><i class="fa-solid fa-plus"></i></button>
<button id="tag-bar-close-btn" title="Close"><i class="fa-solid fa-xmark"></i></button>
</div>
</div>
@endif
<!-- SHARE PANEL — slide-up bottom sheet, opened by Share action button -->
<div id="share-backdrop"></div>
<div id="share-panel">
<div id="share-panel-handle"></div>
<div id="share-panel-title">Share</div>
<div class="share-row" id="share-copy-row">
<div class="share-row-icon copy-icon"><i class="fa-solid fa-link"></i></div>
<div class="share-row-text">
<div class="share-row-title">Copy link</div>
<div class="share-row-sub" id="share-copy-sub">Copy to clipboard</div>
</div>
</div>
@if(typeof session !== 'undefined' && session && private_messages)
<div class="share-row" id="share-dm-row">
<div class="share-row-icon dm-icon"><i class="fa-solid fa-paper-plane"></i></div>
<div class="share-row-text">
<div class="share-row-title">Send via DM</div>
<div class="share-row-sub">Share to a user's inbox</div>
</div>
</div>
<div id="share-dm-search">
<input id="share-user-input" type="text" placeholder="Search for a user…" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false">
<div id="share-user-results"></div>
</div>
@endif
</div>
@if(enable_swf)
<script src="/s/ruffle/ruffle.js"></script>
@endif
<script src="/s/js/theme.js?v={{ ts }}"></script>
<script src="/s/js/scroller.js?v={{ ts }}"></script>
</div><!-- /#main -->
</body>
</html>

52
views/search.html Normal file
View File

@@ -0,0 +1,52 @@
@include(snippets/header)
<div class="pagewrapper">
<div id="main">
<div class="f0ckgle">
<div class="search-title">
<span>{{ t('search.title') }}</span>
</div>
<form action="/search" method="GET" class="admin-search">
<input type="text" name="tag" value="{{ searchstring || '' }}" placeholder="tag1, tag2..." />
<button type="submit">🔍</button>
<div class="search-options">
<label>
<input type="checkbox" name="mode" value="strict" {{ mode==='strict' ? 'checked' : '' }}> {{ t('search.strict_mode') }}
</label>
</div>
</form>
<div class="results">
@if(result)
<h2>{{ t('search.results_found').replace('{count}', count).replace('{page}', pagination.page).replace('{total}', pagination.end) }}</h2>
<table style="width: 100%" class="table">
<thead>
<tr>
<th>{{ t('search.col_thumbnail') }}</th>
<th>{{ t('search.col_id') }}</th>
<th>{{ t('search.col_tag') }}</th>
<th>{{ t('search.col_mime') }}</th>
<th>{{ t('search.col_username') }}</th>
<th>{{ t('search.col_score') }}</th>
</tr>
</thead>
<tbody>
@each(result as line)
<tr>
<td style="width: 128px;"><a href="/tag/{{ line.tag }}/{{ line.id }}" target="_blank"><img
src="/t/{{ line.id }}.webp" loading="lazy" /></a></td>
<td><span class="mview_desc">ID:</span><a href="/tag/{{ line.tag }}/{{ line.id }}" target="_blank">{{
line.id }}</a></td>
<td><span class="mview_desc">Tag:</span><a href="/tag/{{ line.tag }}">{{ line.tag }}</a></td>
<td><span class="mview_desc">Mime:</span>{{ line.mime }}</td>
<td><span class="mview_desc">User:</span><a href="/user/{!! line.username !!}/uploads/{{ line.id }}">{{
line.username }}</a></td>
<td><span class="mview_desc">Score:</span>{{ line.score?.toFixed(2) }}</td>
</tr>
@endeach
</tbody>
</table>
@endif
</div>
</div>
</div>
</div>
@include(snippets/footer)

308
views/settings.html Normal file
View File

@@ -0,0 +1,308 @@
@include(snippets/header)
<div class="pagewrapper">
<div id="main">
<div class="settings">
<h1>{{ t('settings.title') }}</h1>
<h2>{{ t('settings.avatar') }}</h2>
<div class="avatar-settings-wrapper">
<div class="avatar-preview-container">
<div class="avatar-preview-label">{{ t('settings.current_avatar') }}</div>
@if(avatar_file)
<a href="/user/{!! session.user !!}" target="_blank">
<img id="avatar-preview" class="avatar-preview-img" src="/a/{{ avatar_file }}">
</a>
@elseif(session.avatar && session.avatar > 0)
<a href="/user/{!! session.user !!}" target="_blank">
<img id="avatar-preview" class="avatar-preview-img" src="/t/{{ session.avatar }}.webp">
</a>
@else
<div id="avatar-preview" class="avatar-preview-img avatar-placeholder">?</div>
@endif
</div>
<div class="avatar-upload-section">
<h4>{{ t('settings.upload_custom_avatar') }}</h4>
<p class="avatar-hint">{{ t('settings.avatar_hint') }}</p>
<div class="avatar-upload-wrapper">
<input type="file" id="avatar-file-input" accept="image/gif,image/jpeg,image/png,image/webp" hidden>
<button type="button" id="avatar-choose-btn" class="button">{{ t('settings.choose_file') }}</button>
<span id="avatar-filename" class="avatar-filename">{{ t('settings.no_file_selected') }}</span>
</div>
<div class="avatar-progress-wrapper" id="avatar-progress-wrapper" style="display: none;">
<div class="avatar-progress-bar">
<div class="avatar-progress-fill" id="avatar-progress-fill"></div>
</div>
<span class="avatar-progress-text" id="avatar-progress-text">0%</span>
</div>
<div class="avatar-upload-actions">
<button type="button" id="avatar-upload-btn" class="button" disabled>{{ t('settings.upload_btn') }}</button>
@if(avatar_file)
<button type="button" id="avatar-remove-btn" class="button button-danger">{{ t('settings.remove_custom') }}</button>
@endif
</div>
<div id="avatar-upload-status" class="avatar-status"></div>
</div>
</div>
@if(enable_profile_description)
<div class="profile-settings-wrapper">
<div class="setting-item">
<label for="profile_description">{{ t('settings.custom_description') }}</label>
<textarea id="profile_description" class="input" placeholder="{{ t('settings.description_placeholder') }}" maxlength="255">{!! session.description || '' !!}</textarea>
<div class="profile-settings-actions">
<button type="button" id="btn-save-description" class="button">{{ t('settings.save_description') }}</button>
<button type="button" id="btn-clear-description" class="button button-danger">{{ t('settings.clear') }}</button>
</div>
<div id="description-status" class="avatar-status"></div>
</div>
</div>
@endif
<h2>{{ t('settings.preferences') }}</h2>
<div class="preferences-settings-wrapper">
<fieldset style="border: 1px solid var(--nav-border-color); padding: 10px; border-radius: 4px; margin-bottom: 15px;">
<legend style="width: auto; padding: 0 5px; font-size: 1.1em; font-weight: bold;">{{ t('settings.ui_section') }}</legend>
<div class="setting-item" style="margin-bottom: 15px;">
<label for="show_motd_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
<input type="checkbox" id="show_motd_toggle" @if(session.show_motd !==false) checked @endif>
<span>{{ t('settings.show_motd') }}</span>
</label>
</div>
<div class="setting-item">
<label for="use_new_layout_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
<input type="checkbox" id="use_new_layout_toggle" @if(session.use_new_layout === true) checked @endif>
<span>{{ t('settings.modern_layout') }}</span>
</label>
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.modern_layout_hint') }}</small>
</div>
<div class="setting-item" style="margin-top: 15px;">
<label for="disable_autoplay_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
<input type="checkbox" id="disable_autoplay_toggle" @if(session.disable_autoplay === true) checked @endif>
<span>{{ t('settings.disable_autoplay') }}</span>
</label>
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.disable_autoplay_hint') }}</small>
</div>
<div class="setting-item" style="margin-top: 15px;">
<label for="disable_swiping_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
<input type="checkbox" id="disable_swiping_toggle" @if(session.disable_swiping === true) checked @endif>
<span>{{ t('settings.disable_swiping') }}</span>
</label>
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.disable_swiping_hint') }}</small>
</div>
<div class="setting-item" style="margin-top: 15px;">
<label for="show_background_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
<input type="checkbox" id="show_background_toggle" @if(session.show_background !== false) checked @endif>
<span>{{ t('settings.enable_bg_blur') }}</span>
</label>
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.enable_bg_blur_hint') }}</small>
</div>
<div class="setting-item" style="margin-top: 15px;">
<label for="quote_emojis_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
<input type="checkbox" id="quote_emojis_toggle" @if(session.quote_emojis !== false) checked @endif>
<span>{{ t('settings.render_emojis') }}</span>
</label>
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.render_emojis_hint') }}</small>
</div>
<div class="setting-item" style="margin-top: 15px;">
<label for="embed_youtube_in_comments_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
<input type="checkbox" id="embed_youtube_in_comments_toggle" @if(session.embed_youtube_in_comments !== false) checked @endif>
<span>{{ t('settings.embed_yt') }}</span>
</label>
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.embed_yt_hint') }}</small>
</div>
@if(show_koepfe)
<div class="setting-item" style="margin-top: 15px;">
<label for="hide_koepfe_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
<input type="checkbox" id="hide_koepfe_toggle" @if(session.hide_koepfe === true) checked @endif>
<span>{{ t('settings.hide_koepfe') }}</span>
</label>
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.hide_koepfe_hint') }}</small>
</div>
@endif
@if(allow_language_change)
<div class="setting-item" style="margin-top: 15px;">
<label for="language_select" style="display: block; margin-bottom: 5px; font-weight: bold;">{{ t('settings.language') }}</label>
<select id="language_select" class="input" style="padding: 6px 10px; max-width: 220px;">
<option value="" @if(!session.language) selected @endif>{{ t('settings.language_default') }}</option>
<option value="en" @if(session.language === 'en') selected @endif>{{ t('settings.language_en') }}</option>
<option value="de" @if(session.language === 'de') selected @endif>{{ t('settings.language_de') }}</option>
<option value="nl" @if(session.language === 'nl') selected @endif>{{ t('settings.language_nl') }}</option>
<option value="zange" @if(session.language === 'zange') selected @endif>{{ t('settings.language_zange') }}</option>
</select>
<br><small class="text-muted">{{ t('settings.language_hint') }}</small>
</div>
@endif
<div class="setting-item" style="margin-top: 15px;">
<label for="wheel_nav_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
<input type="checkbox" id="wheel_nav_toggle">
<span>{{ t('settings.scroll_nav') }}</span>
</label>
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.scroll_nav_hint') }}</small>
</div>
<div class="setting-item" style="margin-top: 20px;">
<label for="username_color_picker" style="display: block; margin-bottom: 5px; font-weight: bold;">{{ t('settings.username_color') }}</label>
<div style="display: flex; align-items: center; gap: 10px; flex-wrap: wrap;">
<input type="color" id="username_color_picker" value="{{ session.username_color || '#ffffff' }}" style="width: 50px; height: 30px; padding: 0; border: 1px solid var(--nav-border-color); cursor: pointer; background: none;">
<input type="text" id="username_color_hex" value="{{ session.username_color || '#ffffff' }}" maxlength="7" placeholder="#ffffff" class="input" style="width: 90px; font-family: monospace; font-size: 0.9em; padding: 4px 8px; height: 30px;">
<button type="button" id="btn-save-username-color" class="button" style="padding: 5px 10px; font-size: 0.85em;">{{ t('settings.save_color') }}</button>
<button type="button" id="btn-reset-username-color" class="button button-secondary" style="padding: 5px 10px; font-size: 0.85em;">{{ t('settings.reset') }}</button>
</div>
<small class="text-muted">{{ t('settings.username_color_hint') }}</small>
</div>
<div class="setting-item" style="margin-top: 20px;">
<label for="website_font_select" style="display: block; margin-bottom: 5px; font-weight: bold;">{{ t('settings.website_font') }}</label>
<div style="display: flex; align-items: center; gap: 10px;">
<select id="website_font_select" class="input" style="flex: 1; max-width: 300px;">
<option value="">{{ t('settings.font_default') }}</option>
@each(fonts as font)
<option value="{{ font.file }}" @if(session.font === font.file) selected @endif>{{ font.name }}</option>
@endeach
</select>
</div>
</div>
<div class="setting-item" style="margin-top: 20px;">
<label for="website_theme_select" style="display: block; margin-bottom: 5px; font-weight: bold;">{{ t('settings.theme') }}</label>
<div style="display: flex; align-items: center; gap: 10px;">
<select id="website_theme_select" class="input" style="flex: 1; max-width: 300px;">
@each(themes as t)
<option value="{{ t }}" @if(theme === t) selected @endif>{{ t }}</option>
@endeach
</select>
</div>
</div>
</fieldset>
@if(enable_swf)
<fieldset style="border: 1px solid var(--nav-border-color); padding: 10px; border-radius: 4px; margin-bottom: 15px;">
<legend style="width: auto; padding: 0 5px; font-size: 1.1em; font-weight: bold;">{{ t('settings.flash_section') }}</legend>
<div class="setting-item" style="margin-bottom: 15px;">
<label for="ruffle_volume_input" style="display: block; margin-bottom: 5px; font-weight: bold;">{{ t('settings.flash_volume') }}</label>
<div style="display: flex; align-items: center; gap: 10px; flex-wrap: wrap;">
<input type="range" id="ruffle_volume_input" min="0" max="1" step="0.01" value="{{ session.ruffle_volume !== undefined && session.ruffle_volume !== null ? session.ruffle_volume : 0.5 }}" class="xd-slider" style="flex: 1; min-width: 140px;">
<span id="ruffle_volume_val" class="xd-slider-val">{{ session.ruffle_volume !== undefined && session.ruffle_volume !== null ? Math.round(session.ruffle_volume * 100) : 50 }}%</span>
</div>
</div>
<div class="setting-item">
<label for="ruffle_background_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
<input type="checkbox" id="ruffle_background_toggle" @if(session.ruffle_background !== false) checked @endif>
<span>{{ t('settings.flash_bg') }}</span>
</label>
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.flash_bg_hint') }}</small>
</div>
<div style="margin-top: 15px;">
<button type="button" id="btn-save-ruffle-settings" class="button" style="padding: 5px 15px;">{{ t('settings.save_flash') }}</button>
<div id="ruffle-settings-status" class="avatar-status"></div>
</div>
</fieldset>
@endif
@if(enable_xd_score)
<fieldset style="border: 1px solid var(--nav-border-color); padding: 10px; border-radius: 4px; margin-bottom: 15px;">
<legend style="width: auto; padding: 0 5px; font-size: 1.1em; font-weight: bold;">{{ t('settings.content_filters') }}</legend>
<div class="setting-item" style="margin-bottom: 10px;">
<label for="min_xd_score_input" style="display: block; margin-bottom: 5px; font-weight: bold;">{{ t('settings.min_xd_score') }}</label>
<div style="display: flex; align-items: center; gap: 10px; flex-wrap: wrap;">
<input type="range" id="min_xd_score_input" min="0" max="100" step="1" value="{{ session.min_xd_score || 0 }}" class="xd-slider" style="flex: 1; min-width: 140px;">
<span id="xd_score_val" class="xd-slider-val">{{ session.min_xd_score || 0 }}</span>
<span id="xd_score_tier_label" class="xd-score-badge" style="display: none;"></span>
<button type="button" id="btn-save-min-xd-score" class="button" style="padding: 5px 10px; font-size: 0.85em;">{{ t('settings.save') }}</button>
<button type="button" id="btn-reset-min-xd-score" class="button button-secondary" style="padding: 5px 10px; font-size: 0.85em;">{{ t('settings.reset') }}</button>
</div>
<small class="text-muted">{{ t('settings.min_xd_score_hint') }}</small>
<div id="xd-score-status" class="avatar-status"></div>
</div>
</fieldset>
@endif
</div>
<h2>{{ t('settings.account') }}</h2>
<div class="account-settings-wrapper" style="background: rgba(0,0,0,0.1); padding: 20px; border-radius: 4px; border: 1px solid var(--nav-border-color); margin-bottom: 30px;">
<table class="table account-info-table" style="margin-bottom: 30px; border-collapse: separate; border-spacing: 0 5px;">
<tbody>
<tr>
<td style="width: 150px; font-weight: bold; color: var(--text-muted); border: none;">{{ t('settings.user_id') }}</td>
<td style="border: none;">{{ session.id }}</td>
</tr>
<tr>
<td style="font-weight: bold; color: var(--text-muted); border: none;">{{ t('settings.username') }}</td>
<td style="border: none;">{!! session.user !!}</td>
</tr>
<tr>
<td style="font-weight: bold; color: var(--text-muted); border: none;">{{ t('settings.display_name') }}</td>
<td style="border: none; display: flex; align-items: center; gap: 10px;">
<input type="text" id="display_name_input" class="input" placeholder="{{ t('settings.display_name_placeholder') }}" value="{!! session.display_name || '' !!}" maxlength="32" style="max-width: 200px; height: 30px; font-size: 0.9em;">
<button type="button" id="btn-update-display-name" class="button" style="padding: 2px 10px; font-size: 0.8em; height: 30px;">{{ t('settings.save') }}</button>
</td>
</tr>
<tr>
<td style="font-weight: bold; color: var(--text-muted); border: none;">{{ t('settings.email') }}</td>
<td id="display-email" style="border: none;">{{ email || t('settings.email_not_set') }}</td>
</tr>
<tr>
<td style="font-weight: bold; color: var(--text-muted); border: none;">{{ t('settings.joined') }}</td>
<td style="border: none;">@if(joined){{ new Date(joined).toLocaleDateString() }}@else {{ t('settings.joined_unknown') }} @endif</td>
</tr>
</tbody>
</table>
<div id="display-name-status" class="avatar-status" style="margin-bottom: 20px;"></div>
<div class="account-actions-grid" style="display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 30px;">
<div class="password-change-section" style="display: flex; flex-direction: column; gap: 15px;">
<h4 style="margin-top: 0; color: var(--accent);">{{ t('settings.change_password') }}</h4>
<form id="password-change-form" style="display: flex; flex-direction: column; gap: 10px;">
<input type="password" id="current_password" class="input" placeholder="{{ t('settings.current_password') }}" required autocomplete="current-password">
<input type="password" id="new_password" class="input" placeholder="{{ t('settings.new_password') }}" required minlength="20" autocomplete="new-password">
<input type="password" id="new_password_confirm" class="input" placeholder="{{ t('settings.confirm_new_password') }}" required minlength="20" autocomplete="new-password">
<button type="submit" id="btn-update-password" class="button">{{ t('settings.update_password') }}</button>
</form>
<div id="password-status" class="avatar-status"></div>
</div>
<div class="email-update-section" style="display: flex; flex-direction: column; gap: 15px;">
<h4 style="margin-top: 0; color: var(--accent);">{{ t('settings.update_email') }}</h4>
<form id="email-update-form" style="display: flex; flex-direction: column; gap: 10px;">
<input type="email" id="email_input" class="input" placeholder="{{ t('settings.new_email') }}" value="{{ email }}" required>
<button type="submit" id="btn-update-email" class="button">{{ t('settings.update_email_btn') }}</button>
</form>
@if(smtp_enabled)
{!! t('settings.email_warning_smtp') !!}
@else
{!! t('settings.email_info_no_smtp') !!}
@endif
<div id="email-status" class="avatar-status"></div>
</div>
</div>
</div>
@if(matrix_enabled)
<h2>{{ t('settings.linked_accounts') }}</h2>
<div class="linked-accounts-wrapper">
<p>{{ t('settings.matrix_link_desc') }}</p>
<div id="linked-accounts-container" style="margin-bottom: 1rem;">
<strong>{{ t('settings.active_links') }}</strong>
<div id="linked-accounts-list" style="margin-top: 0.5rem; display: flex; flex-direction: column; gap: 5px;">
<span class="text-muted">{{ t('settings.loading') }}</span>
</div>
</div>
<div class="link-action-area">
<h4 style="margin-top: 20px;">{{ t('settings.add_new_link') }}</h4>
<p style="margin-bottom: 15px; color: var(--text-muted); font-size: 0.9em;">
{!! t('settings.matrix_instructions') !!}
</p>
<div id="token-display-area" style="display: none; margin-bottom: 15px;">
<div class="alert alert-info">
<strong>{{ t('settings.your_token') }}</strong> <code id="generated-token-code" style="font-size: 1.2em; user-select: all; margin-left: 10px;"></code>
<br><small>{{ t('settings.one_time_use') }}</small>
</div>
</div>
<button id="btn-gen-link-token" class="button button-primary">{{ t('settings.generate_token') }}</button>
</div>
</div>
@endif
<script src="/s/js/settings.js?v=@mtime(/public/s/js/settings.js)"></script>
</div>
</div>
</div>
@include(snippets/footer)

View File

@@ -0,0 +1,47 @@
<div id="excluded-tags-overlay" style="display: none;">
<div id="excluded-tags-close">&times;</div>
<div class="search-container">
<div id="nav_excluded_tags_list"></div>
<div class="nav-exclude">
<input type="text" id="nav_exclude_tag_input" class="input" placeholder="{{ t('filter.tag_placeholder') }}" autocomplete="off" enterkeyhint="enter">
<div id="nav_exclude_suggestions" class="tag-suggestions"></div>
</div>
<!-- modes -->
<div class="mode-filter">
<button id="nav-shuffle-btn" class="shuffle-btn" title="Random Mode — shuffle all items"><span class="shuffle-icon">( - _ - )</span> {{ t('filter.random_mode') }}</button>
<div class="mode-selector">
<a href="/mode/0" class="mode-btn @if(mode==0) active @endif">SFW</a>
<a href="/mode/1" class="mode-btn @if(mode==1) active @endif">NSFW</a>
@if(enable_nsfl)
<a href="/mode/4" class="mode-btn @if(mode==4) active @endif">NSFL</a>
@endif
@if(session.admin || session.is_moderator)
<a href="/mode/2" class="mode-btn @if(mode==2) active @endif">UNT</a>
@endif
<a href="/mode/3" class="mode-btn @if(mode==3) active @endif">ALL</a>
</div>
</div>
<!-- mimes -->
@if(show_mime_picker)
<div class="nav-mime-filter nav-mime-menu" id="nav-mime-menu">
<label class="nav-mime-item"><input type="checkbox" value="video" id="mime-video">{{ t('filter.video') }}</label>
<label class="nav-mime-item"><input type="checkbox" value="audio" id="mime-audio">{{ t('filter.audio') }}</label>
<label class="nav-mime-item"><input type="checkbox" value="image" id="mime-image">{{ t('filter.image') }}</label>
<label class="nav-mime-item"><input type="checkbox" value="flash" id="mime-flash">{{ t('filter.flash') }}</label>
</div>
@endif
@if(session && enable_xd_score)
<!-- xD score filter -->
<div class="nav-xd-filter">
<label class="nav-xd-label">{{ t('filter.min_xd_score') }}</label>
<div class="nav-xd-controls">
<input type="range" id="nav_min_xd_score" min="0" max="100" step="1" value="{{ session.min_xd_score || 0 }}" class="xd-slider">
<span id="nav_xd_val" class="xd-slider-val">{{ session.min_xd_score || 0 }}</span>
<span id="nav_xd_tier_label" class="xd-score-badge" style="display:none; font-size: 0.75em;"></span>
</div>
<button id="nav_xd_save_btn" class="mode-btn" style="margin-top: 6px; width: 100%;">{{ t('filter.apply_filter') }}</button>
<div id="nav_xd_status" style="font-size: 0.8em; color: #9f0; min-height: 1em; margin-top: 3px;"></div>
</div>
@endif
</div>
</div>

549
views/snippets/footer.html Normal file
View File

@@ -0,0 +1,549 @@
@if(session && (session.admin || session.is_moderator))
<div id="mod-action-modal" class="modal-overlay" style="display:none;">
<div class="modal-content">
<h3 id="mod-action-title">{{ t('mod.confirm_action') }}</h3>
<div id="mod-action-content"></div>
<textarea id="mod-reason" class="mod-reason" placeholder="{{ t('mod.reason_placeholder') }}"></textarea>
<div id="mod-action-error" class="error-msg"></div>
<div class="modal-actions">
<button id="mod-action-confirm" class="btn-danger">{{ t('mod.confirm') }}</button>
<button id="mod-action-cancel" class="btn-secondary">{{ t('common.cancel') }}</button>
</div>
</div>
</div>
@endif
@if(session)
<div id="halls-modal" class="modal-overlay" style="display:none;">
<div class="modal-content">
<h3>{{ t('hall.modal_title') }}</h3>
@if(session.admin || session.is_moderator)
<div class="form-group" style="margin-bottom: 8px;">
<label for="hall-select" style="display: block; margin-bottom: 5px; font-size:0.83em; color:#888;">{{ t('hall.site_hall_label') }}</label>
<select id="hall-select" class="form-control" style="width: 100%; background: #333; color: #fff; border: 1px solid #444; padding: 5px;">
<option value="">{{ t('hall.choose_hall') }}</option>
</select>
</div>
<div id="halls-modal-error" class="error-msg" style="display:none; color: #ff5555; margin-bottom: 8px;"></div>
<div class="modal-actions" style="display: flex; gap: 10px; justify-content: flex-end; margin-bottom:14px;">
<button id="halls-modal-confirm" class="btn-primary" style="padding: 6px 14px;">{{ t('hall.add_btn') }}</button>
<button id="halls-modal-remove" class="btn-danger" style="padding: 6px 14px; display:none;">{{ t('hall.remove_btn') }}</button>
</div>
<hr style="border-color:rgba(255,255,255,0.1);margin:0 0 12px 0;">
@endif
<div id="my-halls-section">
<label style="display: block; margin-bottom: 5px; font-size:0.83em; color:#888;">{{ t('hall.my_hall_label') }}</label>
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
<select id="my-hall-select" class="form-control" style="flex:1;min-width:120px;background: #333; color: #fff; border: 1px solid #444; padding: 5px;">
<option value="">{{ t('hall.my_hall_placeholder') }}</option>
</select>
<button id="my-halls-modal-confirm" class="btn-primary" style="padding: 6px 14px; white-space:nowrap;">{{ t('common.add') }}</button>
<button id="my-halls-modal-remove" class="btn-secondary" style="padding: 6px 14px; white-space:nowrap; display:none;">{{ t('common.remove') }}</button>
</div>
<div style="margin-top:6px;display:flex;gap:6px;align-items:center;">
<span style="font-size:0.78em;color:#888;">{{ t('hall.create_new') }}</span>
<input type="text" id="my-hall-new-name" placeholder="{{ t('hall.new_name_placeholder') }}" style="flex:1;background:#2a2a2a;border:1px solid rgba(255,255,255,0.12);color:#fff;padding:3px 8px;border-radius:3px;font-family:var(--font);font-size:0.85em;height:26px;box-sizing:border-box;">
</div>
<div id="my-halls-modal-error" class="error-msg" style="display:none; color: #ff5555; margin-top: 6px; font-size:0.83em;"></div>
</div>
<div class="modal-actions" style="display: flex; gap: 10px; justify-content: flex-end; margin-top:14px;">
<button id="halls-modal-cancel" class="btn-secondary" style="padding: 6px 14px;">{{ t('common.cancel') }}</button>
</div>
</div>
</div>
@endif
@if(show_content_warning)
<div id="content-warning-modal" class="modal-overlay" style="display:none;">
<div class="modal-content content-warning-content">
<h3>{{ t('content_warning.title') }}</h3>
<p>{{ t('content_warning.text') }}</p>
<div class="modal-actions">
<button id="cw-accept" class="btn-danger">{{ t('content_warning.proceed') }}</button>
<button id="cw-decline" class="btn-secondary">{{ t('content_warning.decline') }}</button>
</div>
</div>
</div>
@endif
@if(!private_society || session)
<div id="report-modal" class="modal-overlay" style="display:none;">
<div class="modal-content" style="max-height: 90vh; overflow-y: auto;">
<h3>{{ t('report.title') }}</h3>
<input type="hidden" id="report-item-id">
<input type="hidden" id="report-comment-id">
<input type="hidden" id="report-user-id">
<textarea id="report-reason" class="mod-reason" placeholder="{{ t('report.placeholder') }}"></textarea>
<div id="report-error" class="error-msg"></div>
<div class="modal-actions">
<button id="report-submit" class="btn-danger">{{ t('report.submit') }}</button>
<button id="report-cancel" class="btn-secondary">{{ t('common.cancel') }}</button>
</div>
</div>
</div>
<div id="warning-modal" class="modal-overlay" style="display:none; z-index: 10000;">
<div class="modal-content content-warning-content">
<h3 style="color: var(--danger);">{{ t('account_warning.title') }}</h3>
<p>{{ t('account_warning.text') }}</p>
<blockquote id="warning-reason" style="border-left: 4px solid var(--danger); padding-left: 10px; margin: 10px 0; font-style: italic;"></blockquote>
<input type="hidden" id="warning-id">
<div class="modal-actions">
<button id="warning-acknowledge" class="btn-danger">{{ t('account_warning.acknowledge') }}</button>
</div>
</div>
</div>
<div id="image-modal" class="modal-overlay image-modal-overlay">
<div class="image-modal-container">
<img id="image-modal-img" src="" alt="Expanded View">
</div>
</div>
@endif
@if(session)
@include(snippets/metadata-modal)
@endif
@if(!private_society || session)
<div class="global-sidebar-right">
<div class="sidebar-activity">
<div id="sidebar-activity-container" class="sidebar-comments-list">
<div class="loading">{{ t('sidebar.loading_activity') }}</div>
</div>
</div>
<div class="global-sidebar-right-footer">
<a href="/ranking">{{ t('footer.ranking') }}</a>
<a href="/rules">{{ t('footer.rules') }}</a>
<a href="/about">{{ t('footer.about') }}</a>
<div id="help-button" style="color: var(--accent); font-weight: bold; opacity: 0.7;" title="Keyboard Shortcuts">?</div>
</div>
</div>
@endif
@if(!private_society || session)
<script src="/s/js/sanitizer.js?v={{ ts }}"></script>
<script async src="/s/js/theme.js?v={{ ts }}"></script>
@endif
@if(private_society && !session)
<script>
window.f0ckSession = { logged_in: false, default_theme: "{{ default_theme }}", show_content_warning: @if(show_content_warning) true @else false @endif, use_new_layout: @if(default_layout === 'legacy')false @else true @endif };
(() => {
const loginModal = document.getElementById('login-modal');
const registerModal = document.getElementById('register-modal');
const close = (m) => { if (m) m.style.display = 'none'; };
const open = (m) => { if (m) m.style.display = 'flex'; };
// View switching for login/forgot/reset within the login modal
const switchModalView = (view) => {
if (!loginModal) return;
['login', 'forgot', 'reset'].forEach(v => {
const el = document.getElementById('modal-' + v + '-view');
if (el) el.style.display = (v === view) ? 'block' : 'none';
});
};
if (loginModal) {
const loginClose = document.getElementById('login-modal-close');
if (loginClose) loginClose.addEventListener('click', () => close(loginModal));
loginModal.addEventListener('click', (e) => { if (e.target === loginModal) close(loginModal); });
// Forgot Password link
const modalForgotBtn = document.getElementById('modal-forgot-btn');
if (modalForgotBtn) {
modalForgotBtn.addEventListener('click', (e) => { e.preventDefault(); switchModalView('forgot'); });
}
const forgotToLogin = document.getElementById('forgot-to-login');
if (forgotToLogin) {
forgotToLogin.addEventListener('click', (e) => { e.preventDefault(); switchModalView('login'); });
}
// Check for reset token or login flag in URL
const urlParams = new URLSearchParams(window.location.search);
const resetToken = urlParams.get('token');
if (resetToken) {
const tokenInput = document.getElementById('reset-token');
if (tokenInput) {
tokenInput.value = resetToken;
switchModalView('reset');
loginModal.style.display = 'flex';
const cleanUrl = window.location.pathname + window.location.search.replace(/[?&]token=[^&]+/, '').replace(/[?&]$/, '') + window.location.hash;
window.history.replaceState({}, '', cleanUrl);
}
} else if (urlParams.get('login') === '1') {
switchModalView('login');
loginModal.style.display = 'flex';
const cleanUrl = window.location.pathname + window.location.search.replace(/[?&]login=1/, '').replace(/[?&]$/, '') + window.location.hash;
window.history.replaceState({}, '', cleanUrl);
}
// Login form AJAX
const loginForm = loginModal.querySelector('.login-form');
if (loginForm && loginForm.id !== 'forgot-password-form' && loginForm.id !== 'reset-password-form') {
loginForm.addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(loginForm);
if (formData.get('password') && formData.get('password').length < 20) {
let errDiv = loginForm.querySelector('.flash-error');
if (!errDiv) { errDiv = document.createElement('div'); errDiv.className = 'flash-error'; loginForm.insertBefore(errDiv, loginForm.firstChild); }
errDiv.textContent = 'Invalid username or password.';
return;
}
try {
const res = await fetch('/login', {
method: 'POST',
headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json', 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams(new FormData(loginForm))
});
if (res.redirected) { window.location.href = res.url; return; }
const json = await res.json();
if (json && json.success === false) {
let errDiv = loginForm.querySelector('.flash-error');
if (!errDiv) { errDiv = document.createElement('div'); errDiv.className = 'flash-error'; loginForm.insertBefore(errDiv, loginForm.firstChild); }
errDiv.textContent = json.msg;
}
} catch (err) { console.error('Login error:', err); }
});
}
// Forgot Password form AJAX
const forgotForm = document.getElementById('forgot-password-form');
if (forgotForm) {
forgotForm.addEventListener('submit', async (e) => {
e.preventDefault();
const email = document.getElementById('forgot-email').value;
const status = document.getElementById('forgot-status');
const btn = forgotForm.querySelector('button');
btn.disabled = true; btn.textContent = 'Sending...';
if (status) { status.textContent = ''; status.className = ''; }
try {
const res = await fetch('/forgot-password', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Accept': 'application/json' },
body: new URLSearchParams({ email })
});
const data = await res.json();
if (data.success) {
if (status) { status.textContent = data.msg || 'Success! Check your email.'; status.className = 'flash-success'; }
forgotForm.reset();
} else {
if (status) { status.textContent = data.msg || 'Error sending link.'; status.className = 'flash-error'; }
}
} catch (err) {
if (status) { status.textContent = 'Network error.'; status.className = 'flash-error'; }
} finally { btn.disabled = false; btn.textContent = 'Send Reset Link'; }
});
}
// Reset Password form AJAX
const resetForm = document.getElementById('reset-password-form');
if (resetForm) {
const resetToLogin = document.getElementById('reset-to-login');
resetForm.addEventListener('submit', async (e) => {
e.preventDefault();
const token = document.getElementById('reset-token').value;
const password = document.getElementById('reset-password').value;
const password_confirm = document.getElementById('reset-password-confirm').value;
const status = document.getElementById('reset-status');
const btn = resetForm.querySelector('button');
if (password !== password_confirm) {
if (status) { status.textContent = 'Passwords do not match.'; status.className = 'flash-error'; }
return;
}
if (password.length < 20) {
if (status) { status.textContent = 'Password is too short (minimum 20 characters).'; status.className = 'flash-error'; }
return;
}
btn.disabled = true; btn.textContent = 'Updating...';
if (status) { status.textContent = ''; status.className = ''; }
try {
const res = await fetch('/reset-password', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Accept': 'application/json' },
body: new URLSearchParams({ token, password, password_confirm })
});
const data = await res.json();
if (data.success) {
if (status) { status.textContent = data.msg || 'Password updated successfully!'; status.className = 'flash-success'; }
resetForm.reset();
btn.style.display = 'none';
if (resetToLogin) resetToLogin.style.display = 'inline-block';
} else {
if (status) { status.textContent = data.msg || 'Error resetting password.'; status.className = 'flash-error'; }
}
} catch (err) {
if (status) { status.textContent = 'Network error.'; status.className = 'flash-error'; }
} finally { btn.disabled = false; btn.textContent = 'Update Password'; }
});
if (resetToLogin) {
resetToLogin.addEventListener('click', (e) => {
e.preventDefault();
switchModalView('login');
resetToLogin.style.display = 'none';
resetForm.querySelector('button').style.display = 'inline-block';
});
}
}
}
if (registerModal) {
const regClose = document.getElementById('register-modal-close');
if (regClose) regClose.addEventListener('click', () => close(registerModal));
registerModal.addEventListener('click', (e) => { if (e.target === registerModal) close(registerModal); });
}
// ESC key closes modals
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') { close(loginModal); close(registerModal); }
});
// Auto-open login modal if URL has #login hash
if (window.location.hash === '#login' && loginModal) {
switchModalView('login');
loginModal.style.display = 'flex';
}
const toRegister = document.getElementById('login-to-register');
if (toRegister) toRegister.addEventListener('click', (e) => { e.preventDefault(); close(loginModal); open(registerModal); });
const toLogin = document.getElementById('register-to-login');
if (toLogin) toLogin.addEventListener('click', (e) => { e.preventDefault(); close(registerModal); open(loginModal); });
// Registration form AJAX
const regForm = document.getElementById('modal-register-form');
if (regForm) {
regForm.addEventListener('submit', async (e) => {
e.preventDefault();
const status = document.getElementById('register-status');
const formData = new FormData(regForm);
const btn = regForm.querySelector('button[type="submit"]');
const password = formData.get('password');
const password_confirm = formData.get('password_confirm');
if (password && password.length < 20) {
if (status) {
status.textContent = 'Password is too short (minimum 20 characters).';
status.className = 'flash-error';
}
return;
}
if (password !== password_confirm) {
if (status) {
status.textContent = 'Passwords do not match.';
status.className = 'flash-error';
}
return;
}
btn.disabled = true; btn.textContent = 'Creating...';
try {
const resp = await fetch('/register', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-Requested-With': 'XMLHttpRequest' },
body: new URLSearchParams(new FormData(regForm)),
credentials: 'include'
});
const json = await resp.json();
if (status) { status.textContent = json.msg || ''; status.className = json.success ? 'flash-success' : 'flash-error'; }
if (json.success) setTimeout(() => { close(registerModal); open(loginModal); }, 2000);
} catch (err) {
if (status) { status.textContent = 'Network error.'; status.className = 'flash-error'; }
} finally { btn.disabled = false; btn.textContent = 'Create Account'; }
});
}
})();
</script>
@else
<script src="/s/js/v0ck.js?v={{ ts }}"></script>
<script src="/s/js/danmaku.js?v={{ ts }}"></script>
@if(enable_swf && typeof item !== "undefined" && item.mime === 'application/x-shockwave-flash')
{{-- Ruffle: self-hosted. Place ruffle.js + wasm bundle in public/s/ruffle/ --}}
<script src="/s/ruffle/ruffle.js"></script>
@elseif(enable_swf && typeof item !== "undefined" && item.mime === 'application/vnd.adobe.flash.movie')
<script src="/s/ruffle/ruffle.js"></script>
@endif
<script>
window.f0ckSession = {
strict_mode: @if(session && session.strict_mode) true @else false @endif,
logged_in: @if(session) true @else false @endif,
use_new_layout: @if(session)@if(session.use_new_layout) true @else false @endif@else @if(default_layout === 'legacy')false @else true @endif@endif,
csrf_token: "{{ csrf_token }}",
default_theme: "{{ default_theme }}",
private_society: @if(private_society) true @else false @endif,
show_content_warning: @if(show_content_warning) true @else false @endif,
disable_autoplay: @if(session && session.disable_autoplay) true @else false @endif,
disable_swiping: @if(session && session.disable_swiping) true @else false @endif,
show_background: @if(session)@if(session.show_background !== false) true @else false @endif@else @if(show_background_cfg) true @else false @endif@endif,
nsfl_tag_id: {{ nsfl_tag_id }},
user: @if(session) "{{ session.user }}" @else null @endif,
display_name: @if(session && session.display_name) "{!! session.display_name !!}" @else null @endif,
is_admin: @if(session && session.admin) true @else false @endif,
is_moderator: @if(session && session.is_moderator) true @else false @endif,
enable_swf: @if(enable_swf) true @else false @endif,
enable_danmaku: @if(enable_danmaku) true @else false @endif,
enable_global_chat: @if(enable_global_chat) true @else false @endif,
ruffle_volume: @if(session && session.ruffle_volume !== undefined && session.ruffle_volume !== null) {{ session.ruffle_volume }} @else 0.5 @endif,
ruffle_background: @if(session && session.ruffle_background !== false) true @else false @endif,
quote_emojis: @if(session && session.quote_emojis !== false) true @else false @endif,
embed_youtube_in_comments: @if(session && session.embed_youtube_in_comments !== false) true @else false @endif,
avatar: @if(session && session.avatar) {{ session.avatar }} @else null @endif,
avatar_file: @if(session && session.avatar_file) "{{ session.avatar_file }}" @else null @endif
};
window.f0ckI18n = {
write_comment: "{{ t('comments.write_comment') }}",
post: "{{ t('comments.post') }}",
cancel: "{{ t('comments.cancel') }}",
sidebar_view: "{{ t('sidebar.view') }}",
sidebar_read_more: "{{ t('sidebar.read_more') }}",
sidebar_see_less: "{{ t('sidebar.see_less') }}",
select_file: "{{ t('upload_btn.select_file') }}",
enter_url: "{{ t('upload_btn.enter_url') }}",
tags_required: "{{ t('upload_btn.tags_required') }}",
select_rating: "{{ t('upload_btn.select_rating') }}",
embed_youtube: "{{ t('upload_btn.embed_youtube') }}",
upload_from_url: "{{ t('upload_btn.upload_from_url') }}",
upload: "{{ t('upload_btn.upload') }}",
uploading: "{{ t('upload.uploading') }}",
processing: "{{ t('toast.processing') }}",
upload_await_approval: "{{ t('upload.pending_approval_patient') }}",
// timeago
timeago_just_now: "{{ t('timeago.just_now') }}",
timeago_year: "{{ t('timeago.year') }}",
timeago_years: "{{ t('timeago.years') }}",
timeago_month: "{{ t('timeago.month') }}",
timeago_months: "{{ t('timeago.months') }}",
timeago_day: "{{ t('timeago.day') }}",
timeago_days: "{{ t('timeago.days') }}",
timeago_hour: "{{ t('timeago.hour') }}",
timeago_hours: "{{ t('timeago.hours') }}",
timeago_minute: "{{ t('timeago.minute') }}",
timeago_minutes: "{{ t('timeago.minutes') }}",
timeago_second: "{{ t('timeago.second') }}",
timeago_seconds: "{{ t('timeago.seconds') }}",
timeago_ago: "{{ t('timeago.ago') }}",
lang: "{{ lang }}",
// search overlay
search_placeholder: "{{ t('search_overlay.placeholder') }}",
search_strict_mode: "{{ t('search_overlay.strict_mode') }}",
// toasts
report_success: "{{ t('toast.report_success') }}",
report_error: "{{ t('toast.report_error') }}",
network_error: "{{ t('toast.network_error') }}",
network_error_short: "{{ t('toast.network_error_short') }}",
reason_required: "{{ t('toast.reason_required') }}",
reason_optional: "{{ t('toast.reason_optional') }}",
reason_required_label: "{{ t('toast.reason_required_label') }}",
confirm_yes: "{{ t('toast.yes') }}",
confirm_no: "{{ t('toast.no') }}",
confirm_btn: "{{ t('toast.confirm') }}",
cancel_btn: "{{ t('toast.cancel') }}",
add_tag: "{{ t('toast.add_tag') }}",
item_pinned: "{{ t('toast.item_pinned') }}",
item_unpinned: "{{ t('toast.item_unpinned') }}",
error_adding_tag: "{{ t('toast.error_adding_tag') }}",
error_saving: "{{ t('toast.error_saving') }}",
zomg_on: "{{ t('toast.zomg_on') }}",
zomg_off: "{{ t('toast.zomg_off') }}",
copied: "{{ t('toast.copied') }}",
// actions
fav_added: "{{ t('toast.fav_added') }}",
fav_removed: "{{ t('toast.fav_removed') }}",
subscribed_thread: "{{ t('toast.subscribed_thread') }}",
unsubscribed_thread: "{{ t('toast.unsubscribed_thread') }}",
oc_marked: "{{ t('toast.oc_marked') }}",
oc_removed: "{{ t('toast.oc_removed') }}",
no_tags_excluded: "{{ t('toast.no_tags_excluded') }}",
tag_delete_title: "{{ t('toast.tag_delete_title') }}",
tag_delete_confirm: "{{ t('toast.tag_delete_confirm') }}",
// halls
hall_added: "{{ t('toast.hall_added') }}",
hall_removed: "{{ t('toast.hall_removed') }}",
hall_my_placeholder: "{{ t('hall.my_hall_placeholder') }}",
hall_no_halls: "{{ t('hall.no_halls') }}",
hall_select_a_hall: "{{ t('hall.select_a_hall') }}",
hall_choose_or_create: "{{ t('hall.choose_or_create') }}",
hall_creating: "{{ t('hall.creating') }}",
hall_adding: "{{ t('hall.adding') }}",
hall_removing: "{{ t('hall.removing') }}",
hall_saving: "{{ t('hall.saving') }}",
hall_deleting: "{{ t('hall.deleting') }}",
hall_saved: "{{ t('hall.saved') }}",
hall_created: "{{ t('hall.created') }}",
hall_add_btn: "{{ t('hall.add_btn') }}",
hall_remove_btn: "{{ t('hall.remove_btn') }}",
hall_name_empty: "{{ t('hall.enter_name_error') }}",
hall_enter_name_error: "{{ t('hall.enter_name_error') }}",
hall_slug_empty_error: "{{ t('hall.slug_empty_error') }}",
hall_image_uploaded: "{{ t('hall.image_uploaded') }}",
hall_image_removed: "{{ t('hall.image_removed') }}",
hall_click_upload_hint: "{{ t('hall.click_upload_hint') }}",
// notifications
notif_upload_approved: "{{ t('notifications.upload_approved_short') }}",
notif_upload_pending: "{{ t('notifications.upload_pending_short') }}",
notif_new_report: "{{ t('notifications.new_report_short') }}",
notif_upload_denied: "{{ t('notifications.upload_denied_short') }}",
notif_upload_deleted: "{{ t('notifications.upload_deleted_short') }}",
notif_click_reason: "{{ t('notifications.click_reason') }}",
notif_no_reason: "{{ t('notifications.no_reason') }}",
notif_reason_label: "{{ t('notifications.reason_label') }}",
notif_replied: "{{ t('notifications.replied_short') }}",
notif_subscribed: "{{ t('notifications.subscribed_short') }}",
notif_mentioned: "{{ t('notifications.mentioned_short') }}",
notif_commented: "{{ t('notifications.commented') }}",
notif_system: "{{ t('notifications.system') }}",
notif_admin: "{{ t('notifications.admin') }}",
notif_moderation: "{{ t('notifications.moderation') }}",
no_notifications: "{{ t('nav.no_notifications') }}",
// meme creator
meme: {
text_layer: "{{ t('meme.text_layer') }}",
enter_text: "{{ t('meme.enter_text') }}",
size_label: "{{ t('meme.size_label') }}",
upload_btn: "{{ t('meme.upload_btn') }}"
},
// global chat
chat_title: "{{ t('chat.title') }}",
chat_placeholder: "{{ t('chat.placeholder') }}",
chat_minimize: "{{ t('chat.minimize') }}",
chat_expand: "{{ t('chat.expand') }}",
chat_slow_down: "{{ t('chat.slow_down') }}",
chat_error_send: "{{ t('chat.error_send') }}",
chat_network_error: "{{ t('chat.network_error') }}"
};
</script>
<script src="/s/js/f0ckm.js?v={{ ts }}"></script>
<script src="/s/js/sidebar-activity.js?v={{ ts }}"></script>
<script src="/s/js/flash_yank.js?v={{ ts }}"></script>
@if(show_koepfe && !(session && session.hide_koepfe))
<script>window.f0ckKoepfe = {{ koepfe_json }};</script>
<script src="/s/js/koepfe.js?v={{ ts }}"></script>
@endif
@if(session)
<script src="/s/js/upload.js?v={{ ts }}"></script>
<script src="/s/js/tag_autocomplete.js?v={{ ts }}"></script>
<script src="/s/js/mention_autocomplete.js?v={{ ts }}"></script>
<script src="/s/js/user.js?v={{ ts }}"></script>
@endif
@if(enable_global_chat && session)
<script src="/s/js/globalchat.js?v={{ ts }}"></script>
@endif
@if(session && private_messages)
<script src="/s/js/wordlist.js?v={{ ts }}"></script>
<script src="/s/js/messages.js?v={{ ts }}"></script>
@endif
@if(session && (session.admin || session.is_moderator))
<script src="/s/js/admin.js?v={{ ts }}"></script>
@endif
@endif
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').then((registration) => {
console.log('ServiceWorker registration successful with scope: ', registration.scope);
}, (err) => {
console.log('ServiceWorker registration failed: ', err);
});
});
}
</script>
</body>
</html>

127
views/snippets/header.html Normal file
View File

@@ -0,0 +1,127 @@
<!doctype html>
<html lang="{{ lang || 'en' }}" theme="@if(typeof theme !== 'undefined'){{ theme }}@endif" res="@if(typeof fullscreen !== 'undefined'){{ fullscreen == 1 ? 'fullscreen' : '' }}@endif">
<head>
@if(typeof page_meta !== 'undefined' && page_meta.title)<title>{{ domain }} - {{ page_meta.title }}</title>@elseif(typeof item !== 'undefined')<title>{{ domain }} - {{ item.id }}</title>@else<title>{{ domain }}</title>@endif
<link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#0096ff">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="{{ domain }}">
<style>
html { background-color: #000; color: #fff; }
@if(session && session.font)
{{ "@" }}font-face {
font-family: 'CustomUserFont';
src: url('/s/fonts/{{ session.font }}');
}
@endif
:root {
@if(session && session.font)
--font: 'CustomUserFont', monospace !important;
@endif
}
@if(session && session.font)
*:not(canvas):not(.fa-solid):not(.fa-regular):not(.fa-brands):not(.fa-light):not(.fa-thin):not(.fa-duotone):not([class*=" fa-"]):not([class^="fa-"]) {
font-family: 'CustomUserFont', monospace !important;
}
/* Explicitly protect FA pseudo-elements */
.fa-solid::before, .fa-regular::before, .fa-brands::before,
.fas::before, .far::before, .fab::before, .fa::before {
font-family: "Font Awesome 6 Free", "Font Awesome 6 Brands" !important;
}
@endif
</style>
<link rel="preload" href="/s/vcr.ttf" as="font" type="font/ttf" crossorigin>
<link rel="stylesheet" href="/s/fa/all.min.css">
<link rel="icon" @if(custom_favicon && custom_favicon.length > 0)href="{{ custom_favicon }}"@else type="image/gif" href="/s/img/favicon.gif"@endif />
@if(!private_society || session)
@if(development || (private_society && !session))
<link rel="stylesheet" href="/s/css/f0ckm.css?v={{ ts }}">
@endif
@if(development)
<link rel="stylesheet" href="/s/css/v0ck.css?v={{ ts }}">
@else
<link rel="stylesheet" href="/s/css/bundle.css?v={{ ts }}">
@endif
<link rel="stylesheet" href="/s/css/upload.css?v={{ ts }}">
@endif
<script>window.f0ckThemes = {{ themes_json }}; window.f0ckDefaultTheme = "{{ default_theme }}"; window.f0ckDomain = "{{ domain }}"; window.f0ckGitHash = "{{ git_hash }}"; window.f0ckAllowedImages = {{ allowed_comment_images_json }}; window.f0ckEmbedYoutubeInComments = {{ embed_youtube_in_comments ? 'true' : 'false' }}; window.f0ckEnableYoutubeUpload = {{ enable_youtube_upload ? 'true' : 'false' }}; window.f0ckBrandImages = {{ custom_brand_images_json }};</script>
@if(!private_society || session)
<script src="/s/js/marked.min.js" defer></script>
<script src="/s/js/comments.js?v={{ ts }}" defer></script>
@endif
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
@if(typeof item !== 'undefined')
<link rel="canonical" href="https://{{ domain }}/{{ item.id }}" />
<meta property="og:site_name" content="{{ domain }}" />
<meta property="og:title" content="{{ item.id }}" />
<meta property="og:url" content="https://{{ domain }}/{{ item.id }}" />
<meta property="og:image" content="https://{{ domain }}{{ item.og_thumbnail }}" />
<meta name="description" content="{{ site_description }}" />
<meta property="og:description" content="{{ site_description }}" />
<meta property="og:type" content="website" />
<meta property="twitter:card" content="summary" />
<meta property="twitter:title" content="{{ item.id }}" />
<meta property="twitter:image" content="https://{{ domain }}{{ item.og_thumbnail }}" />
<meta property="twitter:url" content="https://{{ domain }}/{{ item.id }}" />
@else
<meta property="og:site_name" content="{{ domain }}" />
<meta property="og:title" content="@if(typeof page_meta !== 'undefined' && page_meta.title){{ page_meta.title }} - {{ domain }}@else{{ domain }}@endif" />
<meta property="og:url" content="@if(typeof page_meta !== 'undefined' && page_meta.url){{ page_meta.url }}@elsehttps://{{ domain }}@endif" />
<meta property="og:image" content="@if(typeof page_meta !== 'undefined' && page_meta.image){{ page_meta.image }}@else@if(custom_favicon && custom_favicon.length > 0){{ custom_favicon }}@elsehttps://{{ domain }}/s/img/favicon.gif@endif@endif" />
<meta name="description" content="@if(typeof page_meta !== 'undefined' && page_meta.description){{ page_meta.description }}@else{{ site_description }}@endif" />
<meta property="og:description" content="@if(typeof page_meta !== 'undefined' && page_meta.description){{ page_meta.description }}@else{{ site_description }}@endif" />
<meta property="og:type" content="website" />
@endif
</head>
<body class="@if(session)@if(session.use_new_layout)layout-modern@else layout-legacy @endif@else @if(default_layout === 'legacy')layout-legacy@else layout-modern@endif @endif @if(private_society && !session)private-gate-active@endif">
<script>if(localStorage.getItem('sidebarRightHidden')==='true')document.body.classList.add('sidebar-right-hidden');</script>
@if(!private_society || session)
<canvas class="hidden-xs" id="bg"></canvas>
@endif
@include(snippets/navbar)
@if(session)
@include(snippets/excluded-tags-modal)
@endif
@if(session && session.force_password_change)
<style>
#main, footer, .global-sidebar-right, .navbar, #bg {
display: none !important;
}
body {
background: #000 !important;
overflow: hidden !important;
}
</style>
<div id="force-password-modal" class="modal-overlay" style="display: flex; align-items: center; justify-content: center; position: fixed; top: 0; left: 0; width: 100%; height: 100%; z-index: 10000; background: #000;">
<div class="modal-content" style="background: #1a1a1a; padding: 40px; border-radius: 12px; width: 100%; max-width: 500px; box-shadow: 0 10px 40px rgba(0,0,0,0.5); border: 1px solid rgba(0, 150, 255, 0.3);">
<h2 style="margin-top: 0; color: #fff; font-weight: 800; letter-spacing: -0.5px;">{{ t('auth.password_change_required') }}</h2>
<p style="color: #ccc; line-height: 1.6;">{{ t('auth.password_change_desc') }}</p>
<form id="force-password-form" style="margin-top: 30px;">
<div class="form-group" style="margin-bottom: 20px;">
<label style="display: block; margin-bottom: 8px; font-size: 0.8rem; color: #888; text-transform: uppercase;">{{ t('auth.new_password_label') }}</label>
<input type="password" id="force_new_password" required minlength="20" placeholder="{{ t('auth.min_chars_placeholder') }}"
style="width: 100%; padding: 12px 15px; background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1); border-radius: 6px; color: #fff; outline: none; transition: border-color 0.2s;">
</div>
<div class="form-group" style="margin-bottom: 25px;">
<label style="display: block; margin-bottom: 8px; font-size: 0.8rem; color: #888; text-transform: uppercase;">{{ t('auth.confirm_password_label') }}</label>
<input type="password" id="force_new_password_confirm" required minlength="20" placeholder="{{ t('auth.confirm_placeholder') }}"
style="width: 100%; padding: 12px 15px; background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1); border-radius: 6px; color: #fff; outline: none; transition: border-color 0.2s;">
</div>
<div id="force-password-status" style="margin-bottom: 15px; font-size: 0.85rem; display: none; padding: 10px; border-radius: 4px;"></div>
<button type="submit" style="width: 100%; padding: 15px; background: var(--accent, #0096ff); color: #000; border: 0; border-radius: 6px; font-weight: 800; cursor: pointer; text-transform: uppercase; letter-spacing: 1px;">{{ t('auth.update_password_btn') }}</button>
</form>
<div style="margin-top: 20px; text-align: center;">
<a href="/logout" style="color: #666; font-size: 0.8rem; text-decoration: none;">{{ t('auth.or_logout') }}</a>
</div>
</div>
</div>
@endif

View File

@@ -0,0 +1,30 @@
@if(item.mime === 'video/youtube')
<div class="embed-responsive embed-responsive-16by9">
<iframe id="yt-embed" class="embed-responsive-item" width="640" height="360" src="https://www.youtube.com/embed/{!! item.dest.replace('yt:', '') !!}" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>
</div>
@elseif(item.mime.startsWith("video"))
<div class="embed-responsive embed-responsive-16by9">
<video id="my-video" class="embed-responsive-item" width="640" height="360" src="{{ item.dest }}" preload="metadata" data-size="{{ item.size }}" loop playsinline></video>
</div>
@elseif(item.mime.startsWith("audio"))
<div class="embed-responsive embed-responsive-16by9" style="background: url('@if(item.coverart)//w0bm.com{{ item.coverart }}@else/s/img/200.gif@endif') no-repeat center / contain black;">
<audio id="my-video" class="embed-responsive-item" autoplay loop src="{{ item.dest }}" data-setup="{}" data-size="{{ item.size }}" poster="@if(item.coverart){{ item.coverart }}@else/s/img/200.gif@endif" type="{{ item.mime }}"></audio>
<img id="f0ck-audio-cover" src="@if(item.coverart){{ item.coverart }}@else/s/img/200.gif@endif" style="display: none;">
</div>
@elseif(item.mime.startsWith("image"))
<div class="embed-responsive embed-responsive-16by9">
<div class="embed-responsive-image" id="image-scroll">
<a href="{{ item.dest }}" id="elfe" target="_blank"><img id="f0ck-image" class="img-fluid" src="{{ item.dest }}" loading="lazy" decoding="async" /></a>
</div>
</div>
@elseif(item.mime === 'application/x-shockwave-flash' && enable_swf)
<div class="embed-responsive embed-responsive-16by9">
<div id="ruffle-container" class="embed-responsive-item" data-swf="{{ item.dest }}"></div>
</div>
@elseif(item.mime === 'application/vnd.adobe.flash.movie' && enable_swf)
<div class="embed-responsive embed-responsive-16by9">
<div id="ruffle-container" class="embed-responsive-item" data-swf="{{ item.dest }}"></div>
</div>
@else
<h1>404 - Not f0cked</h1>
@endif

View File

@@ -0,0 +1,16 @@
@each(items as item)
<a href="{{ link.main }}{{ item.id }}" class="{{ item.is_pinned ? 'anim-boxshadow ' : '' }}thumb {{ item.has_notification ? 'has-notif' : '' }} {{ item.is_pinned ? 'is-pinned' : '' }}" data-file="{{ item.dest }}" data-mime="{{ item.mime }}" data-user="{!! item.display_name || item.username !!}" data-ext="{{ item.mime.split('/')[1].replace('youtube', 'yt').replace('x-shockwave-flash', 'flash').replace('vnd.adobe.flash.movie', 'flash').toUpperCase() }}" data-mode="{{ item.tag_id == nsfl_tag_id ? 'nsfl' : (item.tag_id == 2 ? 'nsfw' : (item.tag_id == 1 ? 'sfw' : 'null')) }}" style="background-image: url('/t/{{ item.id }}.webp')">
<div class="thumb-indicators">
@if(item.is_pinned)
<i class="fa-solid fa-thumbtack pin-indicator anim"></i>
@endif
@if(item.is_oc)
<span class="oc-indicator anim">OC</span>
@endif
@if(enable_xd_score && item.xd_score > 0)
<span class="thumb-xd-indicator xd-tier-{{ item.xd_score >= 60 ? 5 : (item.xd_score >= 30 ? 4 : (item.xd_score >= 15 ? 3 : (item.xd_score >= 5 ? 2 : 1))) }}" title="xD Score: {{ item.xd_score }}">xD</span>
@endif
</div>
<p></p>
</a>
@endeach

View File

@@ -0,0 +1,26 @@
<div id="metadata-modal" class="modal-overlay" style="display: none;">
<div class="modal-content" style="max-width: 500px;">
<div class="modal-header">
<h3><i class="fa-solid fa-magic"></i> {{ t('metadata_modal.title') }}</h3>
</div>
<div class="modal-body" style="padding: 20px 0;">
<div id="metadata-loading" style="text-align: center; padding: 20px;">
<i class="fa fa-spinner fa-spin fa-2x"></i>
<p>{{ t('metadata_modal.loading') }}</p>
</div>
<div id="metadata-results" style="display: none;">
<p style="font-size: 0.9em; color: var(--accent); margin-bottom: 12px; font-weight: 600; text-transform: uppercase;">{{ t('metadata_modal.found') }}</p>
<div class="meta-suggestions-list" id="metadata-suggestion-list">
<!-- Pills injected here -->
</div>
<div id="metadata-no-results" style="display: none; text-align: center; color: #888; padding: 10px;">
{{ t('metadata_modal.no_results') }}
</div>
</div>
<div id="metadata-error" style="display: none; color: #e74c3c; padding: 10px; text-align: center;"></div>
</div>
<div class="modal-actions" style="display: flex; justify-content: flex-end; gap: 10px;">
<button class="btn-secondary" id="metadata-modal-cancel">{{ t('common.close') }}</button>
</div>
</div>
</div>

369
views/snippets/navbar.html Normal file
View File

@@ -0,0 +1,369 @@
@if(session)
<!-- logged in -->
<nav class="navbar navbar-expand-lg">
<!-- Brand: always visible, flush left -->
<a class="navbar-brand" href="/">
@if(custom_brand_image)
<img id="navbar-logo" src="{{ custom_brand_image }}" alt="{{ domain }}" style="max-height: 40px; vertical-align: middle; max-width: 180px; width: initial;">
@else
<span class="f0ck">{{ domain }}</span>
@endif
</a>
<!-- Nav links: desktop always-on, mobile hidden until toggled -->
<div class="nav-collapse" id="navbarContent">
<div class="nav-links">
<a id="nav-upload-link" style="cursor:pointer;"><i class="fa-solid fa-upload"></i> {{ t('nav.upload') }}</a>
@if(meme_creator)
<a href="/meme">{{ t('nav.meme') }}</a>
@endif
<div class="nav-user-dropdown nav-halls-dropdown">
<a href="/halls" class="nav-halls-btn" title="{{ t('nav.halls') }}">
<i class="fa-solid fa-building-columns"></i>
</a>
</div>
<a href="/tags" title="{{ t('nav.tags') }}"><i class="fa-solid fa-tag"></i></a>
<a href="/abyss" title="{{ t('nav.abyss') }}"><i class="fa-solid fa-dungeon"></i></a>
<a href="#" id="nav-search-btn" title="{{ t('nav.search') }}"><i class="fa-solid fa-magnifying-glass"></i></a>
@if(!/^\/\d$/.test(url.pathname))
<a href="/random" id="nav-random" title="{{ t('nav.random') }}"><i class="fa-solid fa-shuffle"></i></a>
@endif
</div>
</div>
<!-- Right controls: always visible, flush right -->
<div class="nav-right-group">
<!-- Avatar dropdown -->
<div class="nav-user-dropdown">
<button class="nav-user-btn nav-avatar-btn" id="nav-user-toggle">
@if(session.avatar_file)
<img class="nav-avatar-img" src="/a/{{ session.avatar_file }}" alt="{{ session.user }}" onerror="this.src='/a/default.png'">
@elseif(session.avatar && session.avatar > 0)
<img class="nav-avatar-img" src="/t/{{ session.avatar }}.webp" alt="{{ session.user }}" onerror="this.src='/a/default.png'">
@else
<img class="nav-avatar-img" src="/a/default.png" alt="{{ session.user }}">
@endif
<span class="nav-avatar-name" id="nav-display-name" @if(session.username_color)style="color: {{ session.username_color }}"@endif>{!! session.display_name || session.user !!}</span>
<span class="nav-avatar-caret"></span>
</button>
<div class="nav-user-menu" id="nav-user-menu">
<a href="/user/{!! session.user.toLowerCase() !!}">{{ t('nav.profile') }}</a>
<a href="/user/{!! session.user.toLowerCase() !!}/halls">{{ t('nav.my_halls') }}</a>
<a href="/user/{{ session.user.toLowerCase() }}/favs" class="mobile-only">{{ t('nav.favs') }}</a>
@if(session.admin)
<a href="/admin">Admin
@if(typeof session.pending_count !== 'undefined' && session.pending_count > 0)
<span class="notification-dot" title="{{ session.pending_count }} Pending" onclick="event.preventDefault(); window.location.href='/admin/approve';"></span>
@endif
</a>
@endif
@if(session.admin || session.is_moderator)
<a href="/mod">mod
@if(typeof session.pending_count !== 'undefined' && session.pending_count > 0)
<span class="notification-dot" title="{{ session.pending_count }} Pending" onclick="event.preventDefault(); window.location.href='/mod/approve';"></span>
@endif
</a>
@endif
<a href="/settings" class="mobile-only">{{ t('nav.settings') }}</a>
<div class="nav-user-divider"></div>
<a href="/logout" class="mobile-only">{{ t('nav.logout') }}</a>
</div>
</div>
<!-- Notification bell -->
<div id="nav-notifications" class="nav-item-rel">
<a href="#" id="nav-notif-btn" title="Notifications">
<i class="fa-solid fa-bell"></i>
<span class="notif-count" style="display:none">0</span>
</a>
<div id="notif-dropdown" class="notif-dropdown">
<div class="notif-header">
<span>{{ t('nav.notifications') }}</span>
<button id="mark-all-read">{{ t('nav.mark_all_read') }}</button>
</div>
<div class="notif-list">
<div class="notif-empty">{{ t('nav.no_notifications') }}</div>
</div>
<div class="notif-footer">
<a href="/notifications" class="view-all-notifs">{{ t('nav.view_all_notifications') }}</a>
</div>
<div class="submanage">
<a href="/subscriptions">{{ t('nav.manage_subscriptions') }}</a>
</div>
</div>
</div>
<!-- Quick Favs -->
<a href="/user/{{ session.user.toLowerCase() }}/favs" title="Favorites" class="desktop-only"><i class="fa-solid fa-heart"></i></a>
<!-- DM -->
@if(private_messages)
<div id="nav-dm" class="nav-item-rel">
<a href="/messages" id="nav-dm-btn" title="Direct Messages">
<i class="fa-solid fa-envelope"></i>
<span id="dm-badge" class="notif-count-dm" style="display:none">0</span>
</a>
</div>
@endif
<!-- Quick Settings -->
<a href="/settings" title="Settings" class="desktop-only"><i class="fa-solid fa-gear"></i></a>
<!-- Filter -->
<a href="#" id="nav-filter-btn" title="Excluded Tags"><i class="fa-solid fa-filter"></i></a>
<!-- Logout -->
<a href="/logout" title="Logout" class="desktop-only"><i class="fa-solid fa-right-from-bracket"></i></a>
<!-- Hamburger: mobile only -->
<button class="navbar-toggler" type="button" id="nav-toggler"
onclick="document.getElementById('navbarContent').classList.toggle('show'); this.classList.toggle('is-open')">
<span></span><span></span><span></span>
</button>
</div><!-- /.nav-right-group -->
<!-- MOTD: below the nav row -->
<div class="motd-container" id="motd-container" @if(typeof motd==='undefined' || motd==='' ) style="display:none" @endif>
<span class="motd-content" id="motd-display"></span>
<div id="motd-data" style="display:none">@if(typeof motd !== 'undefined'){!! motd !!}@endif</div>
<div id="user-pref-show-motd" style="display:none">@if(session.show_motd !== false)true@else false @endif</div>
</div>
</nav>
@else
<!-- not logged in -->
@if(!private_society)
<nav class="navbar navbar-expand-lg">
<a class="navbar-brand" href="/">
@if(custom_brand_image)
<img id="navbar-logo" src="{{ custom_brand_image }}" alt="{{ domain }}" style="max-height: 40px; vertical-align: middle; max-width: 180px; width: auto;">
@else
<span class="f0ck">{{ domain }}</span>
@endif
</a>
<div class="nav-collapse" id="navbarContent">
<div class="nav-links">
<a href="/tags" title="Tags"><i class="fa-solid fa-tag"></i></a>
<div class="">
<a href="/halls" class="nav-halls-btn" title="Halls">
<i class="fa-solid fa-building-columns"></i>
</a>
<div class="nav-user-menu">
<a href="/halls" style="font-weight: bold; border-bottom: 1px solid rgba(255,255,255,0.1); margin-bottom: 5px;"><i class="fa-solid fa-building-columns"></i> Overview</a>
@if(typeof halls !== 'undefined' && halls.length > 0)
@each(halls as h)
<a href="/h/{{ h.slug }}">{{ h.name }}</a>
@endeach
@else
<a href="/h/legendary">Legendary</a>
@endif
</div>
</div>
<a href="/tags" title="Tags"><i class="fa-solid fa-tag"></i></a>
<a href="/abyss" title="Abyss"><i class="fa-solid fa-dungeon"></i></a>
@if(!/^\/\d$/.test(url.pathname))
<a href="/random" id="nav-random" title="Random"><i class="fa-solid fa-shuffle"></i></a>
@endif
<a href="#" id="nav-search-btn" title="Search"><i class="fa-solid fa-magnifying-glass"></i></a>
</div>
</div>
<div class="nav-right-group">
<div class="nav-user-dropdown">
<button class="nav-user-btn" id="nav-visitor-toggle"><i class="fa-solid fa-user-secret"></i> {{ t('nav.guest') }} <span class="nav-avatar-caret"></span></button>
<div class="nav-user-menu" id="nav-visitor-menu">
<a href="#" id="nav-login-btn">{{ t('nav.login') }}</a>
<a href="#" id="nav-register-btn">{{ t('nav.register') }}</a>
<div class="nav-user-divider"></div>
</div>
</div>
<button class="navbar-toggler" type="button" id="nav-toggler"
onclick="document.getElementById('navbarContent').classList.toggle('show'); this.classList.toggle('is-open')">
<span></span><span></span><span></span>
</button>
</div>
<div class="motd-container" id="motd-container" @if(typeof motd==='undefined' || motd==='' ) style="display:none" @endif>
<span class="motd-content" id="motd-display"></span>
<div id="motd-data" style="display:none">@if(typeof motd !== 'undefined'){!! motd !!}@endif</div>
</div>
</nav>
@endif
@endif
<div id="login-modal" style="display: none;">
<div class="login-modal-content">
<button id="login-modal-close">&times;</button>
<!-- Login View -->
<div id="modal-login-view">
<form class="login-form" method="post" action="/login" novalidate>
<h2 style="text-align: center; margin-bottom: 20px;">{{ t('auth.login_title') }}</h2>
<input type="text" name="username" placeholder="{{ t('auth.username_or_email') }}" autocomplete="off" required />
<input type="password" name="password" placeholder="{{ t('auth.password_placeholder_min') }}" autocomplete="off" required minlength="20" />
<p style="text-align: left; font-size: 0.9em; margin: 0;"><input type="checkbox" id="kmsi-modal" name="kmsi" />
<label for="kmsi-modal">{{ t('auth.stay_signed_in') }}</label>
</p>
<button type="submit">{{ t('auth.login_title') }}</button>
@if(smtp_enabled)
<div style="text-align: center; margin-top: 10px;">
<a href="#" id="modal-forgot-btn" style="font-size: 0.85em; color: var(--accent); text-decoration: underline;">{{ t('auth.forgot_password') }}</a>
</div>
@endif
@if(registration_open || private_society)
<p style="text-align: center; font-size: 0.9em; margin-top: 15px; color: #888;">
{{ t('auth.no_account') }} <a href="#" id="login-to-register" style="color: var(--accent); text-decoration: underline;">{{ t('auth.register_now') }}</a>
</p>
@endif
</form>
</div>
@if(smtp_enabled)
<!-- Forgot Password View -->
<div id="modal-forgot-view" style="display: none;">
<form class="login-form" id="forgot-password-form" novalidate>
<h2 style="text-align: center; margin-bottom: 20px;">{{ t('auth.forgot_title') }}</h2>
<p style="text-align: center; margin-bottom: 20px; font-size: 0.9em; color: #ccc;">{{ t('auth.forgot_desc') }}</p>
<input type="email" id="forgot-email" placeholder="{{ t('auth.email_address') }}" required />
<div id="forgot-status" style="margin-bottom: 15px; text-align: center; font-size: 0.9em;"></div>
<button type="submit">{{ t('auth.send_reset') }}</button>
<div style="text-align: center; margin-top: 20px;">
<a href="#" id="forgot-to-login" style="font-size: 0.9em; color: var(--accent); text-decoration: underline;">{{ t('auth.back_to_login') }}</a>
</div>
</form>
</div>
@endif
<!-- Reset Password View -->
<div id="modal-reset-view" style="display: none;">
<form class="login-form" id="reset-password-form" novalidate>
<h2 style="text-align: center; margin-bottom: 20px;">{{ t('auth.reset_title') }}</h2>
<p style="text-align: center; margin-bottom: 20px; font-size: 0.9em; color: #ccc;">{{ t('auth.reset_desc') }}</p>
<input type="hidden" id="reset-token" />
<input type="password" id="reset-password" placeholder="{{ t('auth.new_password_min') }}" required minlength="20" autocomplete="new-password" />
<input type="password" id="reset-password-confirm" placeholder="{{ t('auth.confirm_new_password') }}" required minlength="20" autocomplete="new-password" />
<div id="reset-status" style="margin-bottom: 15px; text-align: center; font-size: 0.9em;"></div>
<button type="submit">{{ t('auth.update_password') }}</button>
<div style="text-align: center; margin-top: 20px;">
<a href="#" id="reset-to-login" style="font-size: 0.9em; color: var(--accent); text-decoration: underline; display: none;">{{ t('auth.back_to_login') }}</a>
</div>
</form>
</div>
</div>
</div>
@if(!private_society || session)
<!-- Shortcuts Modal -->
<div id="shortcuts-modal" style="display: none;">
<div class="login-modal-content">
<button id="shortcuts-modal-close">&times;</button>
<div class="shortcuts-content">
<h2 style="text-align: center; margin-bottom: 20px;">{{ t('shortcuts.title') }}</h2>
<div class="shortcut-list">
<div class="shortcut-item"><span><kbd>k</kbd></span><span>{{ t('shortcuts.search') }}</span></div>
<div class="shortcut-item"><span><kbd>m</kbd></span><span>{{ t('shortcuts.main_page') }}</span></div>
<div class="shortcut-item"><span><kbd>r</kbd></span><span>{{ t('shortcuts.random') }}</span></div>
<div class="shortcut-item"><span><kbd>z</kbd></span><span>{{ t('shortcuts.shuffle') }}</span></div>
<div class="shortcut-item"><span><kbd>u</kbd></span><span>{{ t('shortcuts.quick_upload') }}</span></div>
<div class="shortcut-item"><span><kbd>c</kbd></span><span>{{ t('shortcuts.toggle_comments') }}</span></div>
<div class="shortcut-item"><span><kbd>h</kbd></span><span>{{ t('shortcuts.toggle_sidebar') }}</span></div>
<div class="shortcut-item"><span><kbd>Ctrl</kbd> + <kbd>&#46;</kbd></span><span>{{ t('shortcuts.focus_comment') }}</span></div>
<div class="shortcut-item"><span><kbd>Ctrl</kbd> + <kbd>Enter</kbd></span><span>{{ t('shortcuts.send_comment') }}</span></div>
<div class="shortcut-item"><span><kbd>s</kbd></span><span>{{ t('shortcuts.flash_yank') }}</span></div>
@if(session)
<div class="shortcut-item"><span><kbd>e</kbd></span><span>{{ t('shortcuts.tag_exclude') }}</span></div>
<div class="shortcut-item"><span><kbd>i</kbd></span><span>{{ t('shortcuts.tag_input') }}</span></div>
@endif
<div class="shortcut-item"><span><kbd>l</kbd></span><span>{{ t('shortcuts.toggle_bg') }}</span></div>
<div class="shortcut-item"><span><kbd id="shortcut-theme">t</kbd></span><span>{{ t('shortcuts.cycle_themes') }}</span></div>
@if(session && session.admin)
<div class="shortcut-item"><span><kbd>x</kbd></span><span>{{ t('shortcuts.delete') }}</span></div>
<div class="shortcut-item"><span><kbd>p</kbd></span><span>{{ t('shortcuts.toggle_rating') }}</span></div>
@endif
<div class="shortcut-item"><span><kbd>Space</kbd></span><span>{{ t('shortcuts.play_pause') }}</span></div>
<div class="shortcut-item"><span><kbd>Arrows</kbd></span><span>{{ t('shortcuts.next_prev') }}</span></div>
<div class="shortcut-item"><span><kbd>Scroll</kbd></span><span>{{ t('shortcuts.scroll_nav') }}</span></div>
<div class="shortcut-item"><span><kbd>?</kbd></span><span>{{ t('shortcuts.show_help') }}</span></div>
</div>
</div>
</div>
</div>
<div id="flash-container"></div>
@endif
<!-- Register Modal -->
<div id="register-modal" style="display: none;">
<div class="login-modal-content">
<button id="register-modal-close">&times;</button>
<form class="login-form" id="modal-register-form" method="post" action="/register" novalidate>
<h2 style="text-align: center; margin-bottom: 20px;">{{ t('auth.register_title') }}</h2>
<div id="register-status" style="margin-bottom: 15px; text-align: center; font-size: 0.9em;"></div>
<input type="text" name="username" placeholder="{{ t('auth.username_placeholder') }}" autocomplete="off" required />
<input type="password" name="password" placeholder="{{ t('auth.password_placeholder') }}" autocomplete="off" required minlength="20"
title="Must be at least 20 characters long." />
<input type="password" name="password_confirm" placeholder="{{ t('auth.confirm_password') }}" autocomplete="off" required minlength="20" />
@if(registration_open)
<input type="email" name="email" placeholder="{{ t('auth.email_placeholder') }}" autocomplete="off" required />
@else
<input type="text" name="token" placeholder="{{ t('auth.invite_token') }}" autocomplete="off" required />
@endif
<input type="text" name="email_confirm_field" style="display: none !important;" tabindex="-1" autocomplete="off" />
<p style="text-align: left; font-size: 0.9em; margin: 0; color: #fff;">
<input type="checkbox" id="tos-modal" name="tos" required />
<label for="tos-modal">@if(private_society){{ t('auth.tos_private') }}@else{{ t('auth.tos_public') }} <a href="/terms" target="_blank" style="color: var(--accent); text-decoration: underline;">{{ t('auth.tos_terms') }}</a>, <a href="/rules" target="_blank" style="color: var(--accent); text-decoration: underline;">{{ t('auth.tos_rules') }}</a> {{ t('auth.tos_age') }}@endif</label>
</p>
<button type="submit">{{ t('auth.create_account') }}</button>
<div style="text-align: center; margin-top: 20px;">
<a href="#" id="register-to-login" style="font-size: 0.9em; color: var(--accent); text-decoration: underline;">{{ t('auth.back_to_login') }}</a>
</div>
</form>
</div>
</div>
<!-- Global Drag & Drop Feature for Logged-in Users -->
@if(session)
<!-- Global Drop Overlay -->
<div id="drop-overlay">
<div class="overlay-content">
<i class="fa-solid fa-cloud-arrow-up" style="font-size: 5rem;"></i>
<h2>{{ t('upload.drop_anywhere') }}</h2>
</div>
</div>
<!-- Upload Drag Modal -->
<div id="upload-drag-modal">
<div class="modal-content">
<button class="modal-close" id="drag-modal-close" title="Cancel Upload">&times;</button>
<div class="modal-body">
<div class="upload-container" style="padding: 0; animation: none; opacity: 1;">
<h2>{{ t('upload.title') }}</h2>
<div class="upload-limit-info">
@if(session.uploads_remaining === undefined)
<span class="limit-unlimited"><i class="fa-solid fa-infinity"></i> {{ t('upload.unlimited') }}</span>
@elseif(session.uploads_remaining === 0)
<span class="limit-exhausted"><i class="fa-solid fa-triangle-exclamation"></i> {{ t('upload.limit_reached') }} (0/{{ upload_limit }})</span>
@else
<span class="limit-remaining"><i class="fa-solid fa-upload"></i> {{ session.uploads_remaining }}/{{ upload_limit }} {{ t('upload.remaining') }}</span>
@endif
</div>
@include(snippets/upload-form)
</div>
</div>
</div>
</div>
<script src="/s/js/upload.js?v={{ ts }}"></script>
<script src="/s/js/f0ck_upload_init.js?v={{ ts }}"></script>
@endif

View File

@@ -0,0 +1,125 @@
@each(notifications as n)
@if(n.type === 'approve')
<a href="/{{ n.item_id }}" class="notif-item {{ n.is_read ? '' : 'unread' }} notif-with-thumb" data-id="{{ n.id }}">
<div class="notif-thumb">
<img src="/t/{{ n.item_id }}.webp" alt="thumb" onerror="this.onerror=null;this.src='/mod/pending/t/{{ n.item_id }}.webp';this.onerror=function(){this.onerror=null;this.src='/mod/deleted/t/{{ n.item_id }}.webp';this.onerror=function(){this.style.display='none';};}" />
</div>
<div class="notif-content">
<div class="notif-user"><strong>{{ t('notifications.system') }}</strong></div>
<div class="notif-msg">{{ t('notifications.upload_approved').replace('{id}', n.item_id) }}</div>
<div class="notif-time">{{ new Date(n.created_at).toLocaleString() }}</div>
</div>
</a>
@elseif(n.type === 'admin_pending')
<a href="/mod/approve" class="notif-item {{ n.is_read ? '' : 'unread' }} notif-with-thumb" data-id="{{ n.id }}">
<div class="notif-thumb">
<img src="/mod/pending/t/{{ n.item_id }}.webp" alt="thumb" onerror="this.onerror=null;this.src='/t/{{ n.item_id }}.webp';this.onerror=function(){this.style.display='none';};" />
</div>
<div class="notif-content">
<div class="notif-user"><strong>{{ t('notifications.admin') }}</strong></div>
<div class="notif-msg">{{ t('notifications.upload_pending').replace('{id}', n.item_id) }}</div>
<div class="notif-time">{{ new Date(n.created_at).toLocaleString() }}</div>
</div>
</a>
@elseif(n.type === 'report')
<a href="/mod/reports" class="notif-item {{ n.is_read ? '' : 'unread' }} notif-with-thumb" data-id="{{ n.id }}">
<div class="notif-thumb">
<img src="/t/{{ n.item_id }}.webp" alt="thumb" onerror="this.onerror=null;this.src='/mod/pending/t/{{ n.item_id }}.webp';this.onerror=function(){this.onerror=null;this.src='/mod/deleted/t/{{ n.item_id }}.webp';this.onerror=function(){this.style.display='none';};}" />
</div>
<div class="notif-content">
<div class="notif-user"><strong>{{ t('notifications.moderation') }}</strong></div>
<div class="notif-msg">{{ t('notifications.new_report').replace('{id}', n.reference_id) }}</div>
<div class="notif-time">{{ new Date(n.created_at).toLocaleString() }}</div>
</div>
</a>
@elseif(n.type === 'deny')
<a href="/{{ n.item_id }}" class="notif-item {{ n.is_read ? '' : 'unread' }} notif-with-thumb" data-id="{{ n.id }}">
<div class="notif-thumb">
<img src="/mod/deleted/t/{{ n.item_id }}.webp" alt="thumb" onerror="this.onerror=null;this.src='/t/{{ n.item_id }}.webp';this.onerror=function(){this.style.display='none'};" />
</div>
<div class="notif-content">
<div class="notif-user"><strong>{{ t('notifications.system') }}</strong></div>
<div class="notif-msg">
<strong>{{ t('notifications.upload_denied').replace('{id}', n.item_id) }}</strong>
<div style="font-size: 0.85em; color: #ffb8b8; margin-top: 4px;">Reason: {{ n.reason }}</div>
</div>
<div class="notif-time">{{ new Date(n.created_at).toLocaleString() }}</div>
</div>
</a>
@elseif(n.type === 'item_deleted')
<a href="/{{ n.item_id }}" class="notif-item {{ n.is_read ? '' : 'unread' }} notif-with-thumb" data-id="{{ n.id }}">
<div class="notif-thumb">
<img src="/mod/deleted/t/{{ n.item_id }}.webp" alt="thumb" onerror="this.onerror=null;this.src='/t/{{ n.item_id }}.webp';this.onerror=function(){this.style.display='none'};" />
</div>
<div class="notif-content">
<div class="notif-user"><strong>{{ t('notifications.moderation') }}</strong></div>
<div class="notif-msg">
<strong>{{ t('notifications.upload_deleted').replace('{id}', n.item_id) }}</strong>
<div style="font-size: 0.85em; color: #ffb8b8; margin-top: 4px;">Reason: {{ n.reason }}</div>
</div>
<div class="notif-time">{{ new Date(n.created_at).toLocaleString() }}</div>
</div>
</a>
@elseif(n.type === 'upload_comment')
<a href="/{{ n.item_id }}#c{{ n.reference_id }}" class="notif-item {{ n.is_read ? '' : 'unread' }} notif-with-thumb" data-id="{{ n.id }}">
<div class="notif-thumb">
<img src="/t/{{ n.item_id }}.webp" alt="thumbnail" onerror="this.style.display='none'">
</div>
<div class="notif-content">
<div class="notif-info"><strong>{{ t('notifications.new_comments') }}</strong></div>
<div class="notif-msg">{{ t('notifications.on_your_upload').replace('{id}', n.item_id) }}</div>
<div class="notif-time">{{ new Date(n.created_at).toLocaleString() }}</div>
</div>
</a>
@elseif(n.type === 'upload_success')
<a href="/{{ n.item_id }}" class="notif-item {{ n.is_read ? '' : 'unread' }} notif-with-thumb" data-id="{{ n.id }}">
<div class="notif-thumb">
<img src="/t/{{ n.item_id }}.webp" alt="thumb" onerror="this.style.display='none'" />
</div>
<div class="notif-content">
<div class="notif-user"><strong>{{ t('notifications.system') }}</strong></div>
<div class="notif-msg">{{ t('notifications.upload_success') }}</div>
<div class="notif-time">{{ new Date(n.created_at).toLocaleString() }}</div>
</div>
</a>
@elseif(n.type === 'upload_error')
<a href="{{ n.item_id ? '/' + n.item_id : '#' }}" class="notif-item {{ n.is_read ? '' : 'unread' }} {{ n.item_id ? 'notif-with-thumb' : '' }}" data-id="{{ n.id }}">
@if(n.item_id)
<div class="notif-thumb">
<img src="/t/{{ n.item_id }}.webp" alt="thumb" onerror="this.onerror=null;this.src='/mod/pending/t/{{ n.item_id }}.webp';this.onerror=function(){this.style.display='none'};" />
</div>
@endif
<div class="notif-content">
<div class="notif-user"><strong>{{ t('notifications.system') }}</strong></div>
<div class="notif-msg">
<strong>{{ t('notifications.upload_error') }}</strong>
@if(n.data && n.data.msg)
<div style="font-size: 0.85em; color: #ffb8b8; margin-top: 4px;">Reason: {{ n.data.msg }}</div>
@endif
@if(n.data && n.data.url)
<div style="font-size: 0.75em; opacity: 0.6; margin-top: 2px; word-break: break-all;">URL: {{ n.data.url }}</div>
@endif
</div>
<div class="notif-time">{{ new Date(n.created_at).toLocaleString() }}</div>
</div>
</a>
@else
<a href="/{{ n.item_id }}#c{{ n.comment_id || n.reference_id }}" class="notif-item {{ n.is_read ? '' : 'unread' }} notif-with-thumb" data-id="{{ n.id }}">
@if(n.item_id)
<div class="notif-thumb">
<img src="/t/{{ n.item_id }}.webp" alt="thumb" onerror="this.style.display='none'" />
</div>
@endif
<div class="notif-content">
<div class="notif-user"><strong @if(n.username_color) style="color: {{ n.username_color }}" @endif>{!! n.from_user !!}</strong></div>
<div class="notif-msg">
@if(n.type === 'comment_reply') {{ t('notifications.replied').replace('{id}', n.item_id) }}
@elseif(n.type === 'subscription') {{ t('notifications.subscribed').replace('{id}', n.item_id) }}
@elseif(n.type === 'mention') {{ t('notifications.mentioned').replace('{id}', n.item_id) }}
@endif
</div>
<div class="notif-time">{{ new Date(n.created_at).toLocaleString() }}</div>
</div>
</a>
@endif
@endeach

View File

@@ -0,0 +1,22 @@
@if(tmp.user)
<h2>user: {{ tmp.user }} @if(tmp.mime)({{ tmp.mime }}s)@else(all)@endif @if(tmp.view_mode === 'favs')favs @endif @if(tmp.view_mode === 'uploads')uploads @endif @if(total !== undefined)- {{ total }} items@endif</h2>
@endif
@if(tmp.tag)
<h2>tag: {{ tmp.tag }} @if(tmp.mime)({{ tmp.mime }}s)@else(all)@endif @if(total !== undefined)- {{ total }} items@endif</h2>
@endif
@if(tmp.hall)
@if(typeof tmp.hall === 'object')
<h2>hall: {!! tmp.hall.name !!} @if(tmp.mime)({{ tmp.mime }}s)@else(all)@endif @if(total !== undefined)- {{ total }} items@endif</h2>
@if(tmp.hall.description)<p class="page-description" style="color: #aaa; margin-top: 5px; font-size: 0.9em;">{!! tmp.hall.description !!}</p>@endif
@else
<h2>hall: {{ tmp.hall }} @if(tmp.mime)({{ tmp.mime }}s)@else(all)@endif @if(total !== undefined)- {{ total }} items@endif</h2>
@endif
@endif
@if(tmp.userHall)
@if(typeof tmp.userHall === 'object')
<h2>hall: {!! tmp.userHall.name !!} @if(tmp.mime)({{ tmp.mime }}s)@else(all)@endif @if(total !== undefined)- {{ total }} items@endif</h2>
@if(tmp.userHall.description)<p class="page-description" style="color: #aaa; margin-top: 5px; font-size: 0.9em;">{!! tmp.userHall.description !!}</p>@endif
@else
<h2>hall: {{ tmp.userHall }} @if(tmp.mime)({{ tmp.mime }}s)@else(all)@endif @if(total !== undefined)- {{ total }} items@endif</h2>
@endif
@endif

View File

@@ -0,0 +1,17 @@
@if(typeof pagination !== "undefined" && typeof link !== "undefined")
<nav class="pagination">
<a href="{{ link.main }}{{ link.path }}{{ pagination.start }}{{ link.suffix }}" class="page-item-1 btn start@if(!pagination.prev) disabled@endif">&laquo;</a>
<a href="{{ link.main }}{{ link.path }}{{ pagination.prev }}{{ link.suffix }}" class="page-item-2 btn prev@if(!pagination.prev) disabled@endif">&lsaquo;</a>
@if(pagination.cheat)
@each(pagination.cheat as i)
@if(i == pagination.page)
<span class="btn disabled">{{ i }}</span>
@else
<a href="{{ link.main }}{{ link.path }}{{ i }}{{ link.suffix }}" class="pagination-int-item btn">{{ i }}</a>
@endif
@endeach
@endif
<a href="{{ link.main }}{{ link.path }}{{ pagination.next }}{{ link.suffix }}" class="page-item-3 btn next@if(!pagination.next) disabled@endif">&rsaquo;</a>
<a href="{{ link.main }}{{ link.path }}{{ pagination.end }}{{ link.suffix }}" class="page-item-4 btn start@if(!pagination.next) disabled@endif">&raquo;</a>
</nav>
@endif

View File

@@ -0,0 +1,17 @@
@each(items as item)
<div class="sub-card {{ item.is_pinned ? 'anim-boxshadow is-pinned' : '' }}" id="sub-{{ item.id }}">
<a href="/{{ item.id }}" class="sub-link">
<div class="thumb-indicators">
@if(item.is_pinned)
<i class="fa-solid fa-thumbtack pin-indicator anim"></i>
@endif
</div>
<img src="{{ item.thumb }}" loading="lazy" />
<div class="sub-info">
<span class="sub-id">#{{ item.id }}</span>
<span class="sub-user">{{ t('subscriptions.by_user').replace('{user}', item.user) }}</span>
</div>
</a>
<button class="unsub-btn" data-id="{{ item.id }}">{{ t('subscriptions.unsubscribe') }}</button>
</div>
@endeach

View File

@@ -0,0 +1,129 @@
<form id="upload-form" class="upload-form" enctype="multipart/form-data" data-mimes='{!! mimes_json !!}' data-max-bytes="{{ max_file_size_bytes }}">
<div class="form-section">
@if(web_url_upload)
<div class="upload-mode-tabs">
<button type="button" class="upload-mode-tab active" data-mode="file">
<i class="fa-solid fa-upload"></i>
{{ t('upload.file_tab') }}
</button>
<button type="button" class="upload-mode-tab" data-mode="url">
<i class="fa-solid fa-link"></i>
@if(enable_youtube_upload){{ t('upload.url_tab_yt') }}@else {{ t('upload.url_tab') }} @endif
</button>
</div>
@endif
<!-- File input area -->
<div class="upload-mode-content" id="mode-file">
<div class="drop-zone" id="upload-form-drop-zone">
<input type="file" class="file-input" name="file" accept="{{ allowed_mimes }}">
<div class="drop-zone-prompt">
<i class="fa-solid fa-cloud-arrow-up" style="font-size: 4rem; opacity: 0.7; margin-bottom: 1rem;"></i>
<p style="font-size: 1.1rem; font-weight: 500;">{{ t('upload.drop_here') }}</p>
<p style="font-size: 0.9rem; opacity: 0.6;">(max {{ max_file_size }})@if(session.admin) <span style="color: var(--accent);">{{ t('upload.admin_boost') }}</span>@endif</p>
</div>
<!-- Preview Container -->
<div class="file-preview" style="display: none;">
<!-- Video will be injected here via JS -->
<div class="file-meta-row">
<div class="file-info">
<span class="file-name"></span>
<span class="file-size"></span>
</div>
<button type="button" class="btn-remove" title="{{ t('upload.remove_file') }}"></button>
</div>
</div>
</div>
</div>
@if(web_url_upload)
<!-- URL input area -->
<div class="upload-mode-content" id="mode-url" style="display: none;">
<div class="url-input-container">
<input type="url" id="url-upload-input" name="url" placeholder="@if(enable_youtube_upload){{ t('upload.url_placeholder_yt') }}@else {{ t('upload.url_placeholder') }}@endif" autocomplete="off">
</div>
<div class="url-type-badge" id="url-type-badge" style="display: none;"></div>
</div>
@endif
<!-- Custom Thumbnail for Flash -->
<div class="form-section" id="custom-thumbnail-section" style="display: none; margin-top: 1rem; border-top: 1px solid rgba(255,255,255,0.05); padding-top: 1rem;">
<label>{{ t('upload.custom_thumbnail') }}</label>
<span style="opacity: 0.5; font-weight: normal;">{{ t('upload.custom_thumbnail_hint') }}</span>
<div class="custom-thumbnail-input">
<input type="file" name="thumbnail" id="upload-thumbnail-input" accept="image/jpeg,image/png,image/webp" style="font-size: 0.9rem;">
</div>
</div>
</div>
<div class="form-section">
<label>{{ t('upload.rating') }} <span class="required">*</span></label>
<div class="rating-options">
<label class="rating-option">
<input type="radio" name="rating" value="sfw" required>
<span class="rating-label sfw">SFW</span>
</label>
<label class="rating-option">
<input type="radio" name="rating" value="nsfw">
<span class="rating-label nsfw">NSFW</span>
</label>
@if(enable_nsfl)
<label class="rating-option">
<input type="radio" name="rating" value="nsfl">
<span class="rating-label nsfl">NSFL</span>
</label>
@endif
</div>
</div>
<div class="form-section">
<label class="oc-option">
<input type="checkbox" name="is_oc" id="upload-oc-checkbox">
<span class="oc-label">{{ t('upload.original_content') }}</span>
</label>
</div>
<div class="form-section">
<label>{{ t('upload.tags') }} <span class="required">*</span> <span class="tag-count">(0/{{ min_tags }} {{ t('upload.tags_minimum') }})</span></label>
<div class="tag-input-container">
<div class="sync-spinner">
<span class="spinner-icon"></span>
<span>{{ t('upload.extracting_title') }}</span>
</div>
<div class="tags-list"></div>
<input type="text" class="tag-input" placeholder="{{ t('upload.tag_placeholder') }}" autocomplete="off" enterkeyhint="enter">
<div class="tag-suggestions"></div>
</div>
<input type="hidden" name="tags" class="tags-hidden">
<div class="meta-suggestions-container" style="display: none;">
<div class="suggestions-header">
<i class="fa-solid fa-wand-magic-sparkles"></i> {{ t('upload.suggestions_header') }}
</div>
<div class="meta-suggestions-list"></div>
</div>
</div>
<div class="form-section">
<label>{{ t('upload.comment') }} <span style="opacity: 0.5; font-weight: normal;">{{ t('upload.comment_optional') }}</span></label>
<div class="upload-comment-input comment-input">
<textarea class="upload-comment" placeholder="{{ t('upload.comment_placeholder') }}" maxlength="2000"></textarea>
<div class="input-actions"></div>
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn-upload" disabled>
<span class="btn-text">{{ t('upload.select_file') }}</span>
<span class="btn-loading" style="display: none;">{{ t('upload.uploading') }}</span>
</button>
</div>
<div class="upload-progress" style="display: none;">
<div class="progress-bar">
<div class="progress-fill"></div>
</div>
<span class="progress-text">0%</span>
</div>
<div class="upload-status"></div>
</form>

68
views/subscriptions.html Normal file
View File

@@ -0,0 +1,68 @@
@include(snippets/header)
<div class="pagewrapper">
<div id="main">
<div style="padding: 20px; max-width: 1200px; margin: 0 auto;">
<h2 style="margin-bottom: 20px; border-bottom: 1px solid #333; padding-bottom: 10px;">{{ t('subscriptions.title') }}</h2>
@if(items.length === 0)
<div style="padding: 20px; background: rgba(0,0,0,0.2); border-radius: 4px; text-align: center;">
{{ t('subscriptions.empty') }}
</div>
@else
<div class="subs-container">
<div class="subs-grid">
@include(snippets/subscriptions-grid)
</div>
<div id="footbar" data-end-msg="You reached the end">
&#9660;
</div>
</div>
@endif
</div>
<script>
// Use a self-invoking function to avoid global scope pollution if possible
// but since it's a click listener on document, it's already global.
// We only want to add it once.
if (!window._subsInit) {
window._subsInit = true;
document.addEventListener('click', async (e) => {
const btn = e.target.closest('.unsub-btn');
if (!btn) return;
if (!confirm('Unsubscribe from this thread?')) return;
const id = btn.dataset.id;
const card = document.getElementById('sub-' + id);
const originalText = btn.textContent;
btn.textContent = '...';
try {
const res = await fetch('/api/subscriptions/' + id + '/delete', { method: 'POST' });
const json = await res.json();
if (json.success) {
if (card) {
card.style.opacity = '0';
setTimeout(() => {
card.remove();
if (document.querySelectorAll('.sub-card').length === 0) {
location.reload();
}
}, 300);
}
} else {
alert('Error: ' + (json.message || 'Failed'));
btn.textContent = originalText;
}
} catch (err) {
console.error(err);
alert('Error removing subscription');
btn.textContent = originalText;
}
});
}
</script>
</div>
</div>
</div>
@include(snippets/footer)

11
views/tag-cards.html Normal file
View File

@@ -0,0 +1,11 @@
@each(toptags as toptag)
<a href="/tag/{{ toptag.safe_tag }}" class="tag-card">
<div class="tag-card-image">
<img src="/tag_image/{{ toptag.encoded_tag }}?m={{ session.mode }}" loading="lazy" alt="{!! toptag.tag !!}">
</div>
<div class="tag-card-content">
<span class="tag-name">#{!! toptag.tag !!}</span>
<span class="tag-count">{{ t('toptags.posts', { count: toptag.total_items }) }}</span>
</div>
</a>
@endeach

12
views/tags-partial.html Normal file
View File

@@ -0,0 +1,12 @@
<div class="container">
<h3 style="text-align: center;">{{ t('nav.tags') }}</h3>
<div class="tags-grid" id="tags-container">
@include(tag-cards)
</div>
</div>
<div class="pagination-container-fluid" @if(typeof hidePagination !=='undefined' && hidePagination) style="display: none;" @endif>
<div class="pagination-wrapper bottom-pagination fixed-pagination">
@include(snippets/pagination)
</div>
</div>

5
views/tags.html Normal file
View File

@@ -0,0 +1,5 @@
@include(snippets/header)
<div id="main">
@include(tags-partial)
</div>
@include(snippets/footer)

31
views/terms.html Normal file
View File

@@ -0,0 +1,31 @@
@include(snippets/header)
<div class="pagewrapper">
<div id="main">
<div class="rules">
@if(terms_text)
<div class="dynamic-page-content" id="terms-dynamic-content"></div>
<textarea id="terms-raw-data" hidden>{!! terms_text !!}</textarea>
<script>
(function() {
var raw = document.getElementById('terms-raw-data');
var el = document.getElementById('terms-dynamic-content');
function render() {
if (raw && el && typeof marked !== 'undefined') {
el.innerHTML = marked.parse(raw.value || '', { gfm: true, breaks: true });
raw.remove();
}
}
if (typeof marked !== 'undefined') {
render();
} else {
document.addEventListener('DOMContentLoaded', render);
}
})();
</script>
@else
<h1>Terms of Service</h1>
@endif
</div>
</div>
</div>
@include(snippets/footer)

35
views/upload.html Normal file
View File

@@ -0,0 +1,35 @@
@include(snippets/header)
<div class="pagewrapper">
<div id="main">
<link rel="stylesheet" href="/s/css/upload.css">
<div class="upload-container" style="opacity: 0;" data-mimes='{!! mimes_json !!}'>
<h2>{{ t('upload_page.title') }}</h2>
<div class="upload-limit-info">
@if(uploads_remaining === null)
<span class="limit-unlimited"><i class="fa-solid fa-infinity"></i> {{ t('upload_page.limit_unlimited') }}</span>
@elseif(uploads_remaining === 0)
<span class="limit-exhausted"><i class="fa-solid fa-triangle-exclamation"></i> {{ t('upload_page.limit_reached').replace('{limit}', upload_limit) }}</span>
@else
<span class="limit-remaining"><i class="fa-solid fa-upload"></i> {{ t('upload_page.limit_remaining').replace('{remaining}', uploads_remaining).replace('{limit}', upload_limit) }}</span>
@endif
</div>
@if(session)
@include(snippets/upload-form)
@else
<div class="login-required">
<h3>{{ t('upload_page.auth_required_title') }}</h3>
<p>{{ t('upload_page.auth_required_text') }}</p>
<a href="/login" class="btn-login login-trigger-btn">{{ t('upload_page.login_btn') }}</a>
</div>
@endif
</div>
<script>
// Form is already initialized by global DOMContentLoaded listener in upload.js
</script>
</div>
</div>
</div>
@include(snippets/footer)

View File

@@ -0,0 +1,68 @@
@each(hallsList as hall)
<div class="hall-manager-card" id="uh-card-{{ hall.slug }}" data-slug="{{ hall.slug }}" data-owner-id="{{ ownerUser.id }}">
<div class="hall-manager-image" style="position:relative;height:140px;overflow:hidden;background:#111;cursor:pointer;" title="Click to upload a custom image">
@if(hall.user_id)
<img src="/user_hall_image/{{ hall.user_id }}/{{ hall.slug }}" alt="{!! hall.name !!}" style="width:100%;height:100%;object-fit:cover;opacity:0.8;">
@else
<img src="/user_hall_image/{{ ownerUser.id }}/{{ hall.slug }}" alt="{!! hall.name !!}" style="width:100%;height:100%;object-fit:cover;opacity:0.8;">
@endif
<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;">
{!! hall.name !!}
@if(hall.is_private)
<span style="font-size:0.6em;opacity:0.7;margin-left:4px;">🔒</span>
@endif
</span>
@if(isOwner || (session && session.admin))
@if(hall.custom_image)
<button class="uh-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="uh-img-upload" accept="image/*" style="display:none;">
@endif
</div>
<div style="padding:12px;">
@if(isOwner || (session && session.admin))
<div style="margin-bottom:8px;">
<label style="font-size:0.8em;color:#888;display:block;">{{ t('common.name') }}</label>
<input type="text" class="uh-name-input" value="{!! hall.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('common.description') }}</label>
<textarea class="uh-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:50px;">@if(hall.description){!! hall.description !!}@endif</textarea>
</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-bottom:4px;">
<label style="display:flex;align-items:center;gap:5px;cursor:pointer;color:#aaa;font-size:0.85em;">
<input type="checkbox" class="uh-private-toggle" @if(hall.is_private) checked @endif>
{{ t('common.private') }}
</label>
</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;">
<button class="uh-btn-save hm-btn" style="background:var(--accent);color:#000;border:none;font-weight:bold;">{{ t('common.save') }}</button>
<a href="/user/{{ ownerUser.user }}/hall/{{ hall.slug }}" class="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: hall.total_items }) }}
</span>
<button class="uh-btn-delete 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>
@else
<div style="font-weight:600;margin-bottom:4px;">
{!! hall.name !!}
@if(hall.is_private)
<span style="font-size:0.7em;opacity:0.6;">🔒</span>
@endif
</div>
@if(hall.description)
<div style="font-size:0.82em;color:#888;margin-bottom:8px;">{!! hall.description !!}</div>
@endif
<a href="/user/{{ ownerUser.user }}/hall/{{ hall.slug }}" class="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:6px;font-size:0.8em;color:#666;">
{{ t('hall.posts', { count: hall.total_items }) }}
</span>
@endif
<div class="uh-card-status" style="margin-top:6px;font-size:0.8em;color:#888;min-height:1.2em;"></div>
</div>
</div>
@endeach

View File

@@ -0,0 +1,290 @@
<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;">
<div style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:10px;margin-bottom:16px;">
<h3 style="margin:0;">{{ t('hall.user_halls_title', { user: ownerUser.user }) }}</h3>
@if(isOwner || (session && session.admin))
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap;">
<input type="text" id="uh-new-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;">
<button id="btn-create-uh" 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.new_hall_btn') }}</button>
<span id="uh-create-status" style="font-size:0.8em;color:#888;"></span>
</div>
@endif
</div>
<div id="hall-manager-grid" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 20px; margin-top: 20px;">
@include(user-hall-cards)
</div>
@if(!hallsList || !hallsList.length)
<p style="color:#888;text-align:center;padding:40px 0;">
@if(isOwner)
{!! t('hall.no_halls_owner') !!}
@else
{{ t('hall.no_halls_guest') }}
@endif
</p>
@endif
</div>
<div class="pagination-container-fluid" @if(typeof hidePagination !=='undefined' && hidePagination) style="display: none;" @endif>
<div class="pagination-wrapper bottom-pagination fixed-pagination">
@include(snippets/pagination)
</div>
</div>
@if(isOwner || (session && session.admin))
<script>
(function() {
var csrf = (window.f0ckSession && window.f0ckSession.csrf_token) || '';
var i18n = window.f0ckI18n || {};
var grid = document.getElementById('hall-manager-grid');
function toSlug(s) {
return s.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
}
// ── Create Hall ─────────────────────────────────────────────────────────────
var newNameInput = document.getElementById('uh-new-name');
var createStatus = document.getElementById('uh-create-status');
var isAdmin = @if(session && session.admin) true @else false @endif;
var ownerUserId = '{{ ownerUser.id }}';
var isOwner = @if(isOwner) true @else false @endif;
// If admin is managing another user, append user_id query param
var qs = (isAdmin && !isOwner) ? '?user_id=' + ownerUserId : '';
async function createHall() {
var name = newNameInput.value.trim();
if (!name) { createStatus.textContent = "{{ t('hall.enter_name_error') }}"; createStatus.style.color = '#f55'; return; }
var slug = toSlug(name);
createStatus.textContent = i18n.hall_creating || "{{ t('hall.creating') }}"; createStatus.style.color = '#888';
try {
var r = await fetch('/api/v2/me/halls' + qs, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-csrf-token': csrf },
body: JSON.stringify({ name: name, slug: slug })
});
var d = await r.json();
if (d.success) {
createStatus.textContent = i18n.hall_created || '✓ Created!'; createStatus.style.color = 'var(--accent)';
newNameInput.value = '';
var card = buildCard(name, slug, d.hall);
grid.appendChild(card);
wireCard(card);
card.scrollIntoView({ behavior: 'smooth', block: 'center' });
setTimeout(function() { createStatus.textContent = ''; }, 2000);
} else {
createStatus.textContent = '✗ ' + (d.msg || d.message || 'Error'); createStatus.style.color = '#f55';
}
} catch(e) { createStatus.textContent = '✗ Network error'; createStatus.style.color = '#f55'; }
}
document.getElementById('btn-create-uh').addEventListener('click', createHall);
newNameInput.addEventListener('keydown', function(e) { if (e.key === 'Enter') createHall(); });
// ── Card builder (for newly created halls) ──────────────────────────────────
function buildCard(name, slug, hall) {
var div = document.createElement('div');
div.className = 'hall-manager-card';
div.id = 'uh-card-' + slug;
div.dataset.slug = slug;
var ownerUser = '{{ ownerUser.user }}';
var ownerUserId = '{{ ownerUser.id }}';
div.dataset.ownerId = ownerUserId;
div.innerHTML =
'<div class="hall-manager-image" style="position:relative;height:140px;overflow:hidden;background:#111;cursor:pointer;" title="{{ t('hall.click_upload_hint') }}">' +
'<img src="/user_hall_image/' + ownerUserId + '/' + 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="uh-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="uh-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;">{{ t('common.description') }}</label>' +
'<textarea class="uh-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:50px;"></textarea></div>' +
'<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-bottom:4px;">' +
'<label style="display:flex;align-items:center;gap:5px;cursor:pointer;color:#aaa;font-size:0.85em;"><input type="checkbox" class="uh-private-toggle"> {{ t('common.private') }}</label>' +
'</div>' +
'<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;">' +
'<button class="uh-btn-save hm-btn" style="background:var(--accent);color:#000;border:none;font-weight:bold;">{{ t('common.save') }}</button>' +
'<a href="/user/' + ownerUser + '/hall/' + slug + '" class="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') }}".replace('{count}', (hall && hall.total_items || 0)) +
'</span>' +
'<button class="uh-btn-delete 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="uh-card-status" style="margin-top:6px;font-size:0.8em;color:#888;min-height:1.2em;"></div>' +
'</div>';
return div;
}
// ── Wire a card ─────────────────────────────────────────────────────────────
function wireCard(card) {
var slug = card.dataset.slug;
var status = card.querySelector('.uh-card-status');
var nameInput = card.querySelector('.uh-name-input');
var descInput = card.querySelector('.uh-desc-input');
var imgEl = card.querySelector('.hall-manager-image img');
var imgContainer = card.querySelector('.hall-manager-image');
var fileInput = card.querySelector('.uh-img-upload');
var nameOverlay = imgContainer && imgContainer.querySelector('span');
var privToggle = card.querySelector('.uh-private-toggle');
function setStatus(msg, color) {
if (!status) return;
status.textContent = msg;
status.style.color = color || '#aaa';
}
// Live name overlay
if (nameInput && nameOverlay) {
nameInput.addEventListener('input', function() { nameOverlay.textContent = nameInput.value; });
}
// Click image → open file picker
if (imgContainer && fileInput) {
imgContainer.addEventListener('click', function(e) {
if (e.target.classList.contains('uh-btn-del-img')) return;
fileInput.click();
});
}
// Upload image
if (fileInput) {
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/me/halls/' + encodeURIComponent(slug) + '/image' + qs, {
method: 'POST',
headers: { 'x-csrf-token': csrf },
body: fd
});
var d = await r.json();
if (d.success) {
if (imgEl) imgEl.src = '/user_hall_image/' + (card.dataset.ownerId || '') + '/' + slug + '?v=' + Date.now();
setStatus('✓ ' + (i18n.hall_image_uploaded || 'Image uploaded'), 'var(--accent)');
// Add remove button if not present
if (!imgContainer.querySelector('.uh-btn-del-img')) {
var xBtn = document.createElement('button');
xBtn.className = 'uh-btn-del-img';
xBtn.title = 'Remove custom image';
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 || 'Upload failed'), '#f55');
}
} catch(e2) { setStatus('✗ Network error', '#f55'); }
e.target.value = '';
});
}
// Remove custom image
function wireDelBtn(btn) {
btn.addEventListener('click', async function(e) {
e.stopPropagation();
setStatus(i18n.hall_removing || 'Removing…');
try {
var r = await fetch('/api/v2/me/halls/' + encodeURIComponent(slug) + '/image' + qs, {
method: 'DELETE',
headers: { 'x-csrf-token': csrf }
});
var d = await r.json();
if (d.success) {
if (imgEl) imgEl.src = '/user_hall_image/' + (card.dataset.ownerId || '') + '/' + slug + '?v=' + Date.now();
btn.remove();
setStatus('✓ ' + (i18n.hall_image_removed || 'Image removed'), 'var(--accent)');
} else { setStatus('✗ ' + (d.msg || 'Error'), '#f55'); }
} catch(e2) { setStatus('✗ Network error', '#f55'); }
});
}
var existingDelBtn = card.querySelector('.uh-btn-del-img');
if (existingDelBtn) wireDelBtn(existingDelBtn);
// Private toggle
if (privToggle) {
privToggle.addEventListener('change', function() {
fetch('/api/v2/me/halls/' + encodeURIComponent(slug) + qs, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', 'x-csrf-token': csrf },
body: JSON.stringify({ is_private: privToggle.checked })
}).then(function(r) { return r.json(); }).then(function(d) {
if (d.success) setStatus(privToggle.checked ? '🔒 Private' : '🌐 Public', 'var(--accent)');
else { privToggle.checked = !privToggle.checked; setStatus('✗ ' + (d.msg || 'Error'), '#f55'); }
}).catch(function() { privToggle.checked = !privToggle.checked; setStatus('✗ Network error', '#f55'); });
});
}
// Save
var saveBtn = card.querySelector('.uh-btn-save');
if (saveBtn) {
saveBtn.addEventListener('click', async function() {
setStatus(i18n.hall_saving || 'Saving…');
var newName = nameInput ? nameInput.value.trim() : null;
var newDesc = descInput ? descInput.value.trim() : null;
var isPrivate = privToggle ? privToggle.checked : undefined;
if (!newName) { setStatus('✗ ' + (i18n.hall_name_empty || 'Name cannot be empty'), '#f55'); return; }
try {
var r = await fetch('/api/v2/me/halls/' + encodeURIComponent(slug) + qs, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', 'x-csrf-token': csrf },
body: JSON.stringify({ name: newName, description: newDesc || null, is_private: isPrivate })
});
var d = await r.json();
if (d.success) {
if (nameOverlay && newName) nameOverlay.textContent = newName;
setStatus('✓ ' + (i18n.saved || 'Saved'), 'var(--accent)');
} else { setStatus('✗ ' + (d.msg || 'Error'), '#f55'); }
} catch(e2) { setStatus('✗ Network error', '#f55'); }
});
}
// Delete
var delBtn = card.querySelector('.uh-btn-delete');
if (delBtn) {
delBtn.addEventListener('click', async function() {
if (!confirm("{{ t('hall.delete_confirm') }}")) return;
setStatus("{{ t('hall.deleting') }}");
try {
var r = await fetch('/api/v2/me/halls/' + encodeURIComponent(slug) + qs, {
method: 'DELETE',
headers: { 'x-csrf-token': csrf }
});
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 || 'Error'), '#f55'); }
} catch(e2) { setStatus('✗ Network error', '#f55'); }
});
}
}
// Wire all existing cards
document.querySelectorAll('.hall-manager-card').forEach(wireCard);
})();
</script>
@endif

5
views/user-halls.html Normal file
View File

@@ -0,0 +1,5 @@
@include(snippets/header)
<div id="main">
@include(user-halls-partial)
</div>
@include(snippets/footer)

403
views/user-partial.html Normal file
View File

@@ -0,0 +1,403 @@
<div class="profile_head">
@if(user.is_ghost)
<div class="profile_head_avatar">
<div class="avatar-placeholder" style="background: #444; color: #888; width: 55px; height: 55px; display: flex; align-items: center; justify-content: center; font-size: 24px; font-weight: bold; border-radius: 4px;">?</div>
</div>
@elseif(user.avatar_file)
<div class="profile_head_avatar">
<img src="/a/{{ user.avatar_file }}" style="display: grid;width: 55px" />
</div>
@elseif(user.avatar && user.avatar > 0)
<div class="profile_head_avatar">
<img src="/t/{{ user.avatar }}.webp" style="display: grid;width: 55px" />
</div>
@else
<div class="profile_head_avatar">
<img src="/a/default.png" style="display: grid;width: 55px" />
</div>
@endif
<div class="layersoffear">
<div class="profile_head_username">
<span @if(user.username_color) style="color: {{ user.username_color }}" @endif>@if(user.admin)&#9889;&nbsp;@elseif(user.is_moderator)&#128737;&nbsp;@endif{!! user.display_name || user.user !!}@if(user.is_ghost) <span class="badge badge-secondary" style="font-size: 0.5em; vertical-align: middle; background-color: #5bc0de; color: #fff; padding: 2px 5px; border-radius: 3px; margin-left: 5px;">LEGACY</span>@endif @if(user.banned) <span class="badge badge-danger" tooltip="{{ user.ban_duration }}" style="font-size: 0.5em; vertical-align: middle; background-color: #d9534f; color: #fff; padding: 2px 5px; border-radius: 3px; margin-left: 5px;">BANNED</span>@endif</span>@if(user.display_name) <span style="font-size: 0.65em; color: #666; font-weight: 400; margin-left: 5px; letter-spacing: 0.5px;">({!! user.user !!})</span>@endif
@if(enable_profile_description && user.description)
<div class="profile_description">{!! user.description !!}</div>
@endif
@if(session && session.id !== user.user_id && private_messages)
<button id="send-dm-btn" class="btn btn-sm btn-outline-info" data-username="{!! user.user !!}" style="margin-left: 10px; padding: 2px 8px; font-size: 0.8em; border: 1px solid var(--accent); color: var(--accent); background: transparent; cursor: pointer;">{{ t('profile.message_btn') }}</button>
@endif
@if(session && session.id === user.user_id)
<!--<button id="subscribe-all-uploads-btn" class="btn btn-sm btn-outline-info" style="margin-left: 10px; padding: 2px 8px; font-size: 0.8em; border: 1px solid var(--accent); color: var(--accent); background: transparent; cursor: pointer;">Subscribe to all my uploads</button>-->
@endif
</div>
<div class="profile_head_user_stats">
@if(user.is_ghost)
<div class="stat-legacy">{{ t('profile.legacy_record') }} <time class="timeago" tooltip="{{ user.timestamp.timefull }}">{{ user.timestamp.timeago }}</time></div>
@else
<div class="stat-id">ID: {{ user.user_id || user.id }}</div>
<div class="stat-joined">{{ t('profile.joined') }} <time class="timeago" tooltip="{{ user.timestamp.timefull }}">{{ user.timestamp.timeago }}</time></div>
@if(!user.is_ghost)
<div class="stat-comments">{{ t('profile.stat_comments') }} <a href="/user/{!! user.user !!}/comments">{{ count.comments }}</a></div>
<div class="stat-tags">{{ t('profile.stat_tags') }} {{ count.tags }}</div>
@if(!user.is_ghost)
<div class="stat-halls">{{ t('profile.stat_halls') }} <a href="/user/{!! user.user !!}/halls">{{ count.halls }}</a></div>
@endif
@endif
@if(session)
@if(session.id !== user.user_id)
@if(session.admin || session.is_moderator)
@if(session.admin || !user.admin)
@if(user.banned)
<button id="unban-user-btn" class="btn btn-sm btn-outline-success" style="margin-left: 10px; padding: 2px 8px; font-size: 0.8em; border: 1px solid #5cb85c; color: #5cb85c; background: transparent; cursor: pointer;">{{ t('profile.unban_btn') }}</button>
@else
<button id="ban-user-btn" class="btn btn-sm btn-outline-danger" style="margin-left: 10px; padding: 2px 8px; font-size: 0.8em; border: 1px solid var(--accent); color: var(--accent); background: transparent; cursor: pointer;">{{ t('profile.ban_btn') }}</button>
@endif
<button id="warn-user-btn" class="btn btn-sm btn-outline-warning" style="margin-left: 10px; padding: 2px 8px; font-size: 0.8em; border: 1px solid #f0ad4e; color: #f0ad4e; background: transparent; cursor: pointer;">{{ t('profile.warn_btn') }}</button>
@endif
@if(session.admin)
<button id="admin-subscribe-user-btn" class="btn btn-sm btn-outline-warning" style="margin-left: 10px; padding: 2px 8px; font-size: 0.8em; border: 1px solid #f0ad4e; color: #f0ad4e; background: transparent; cursor: pointer;">{{ t('profile.subscribe_uploads_btn') }}</button>
@endif
@endif
@endif
@endif
@endif
</div>
</div>
</div>
<div class="user_content_wrapper">
<div class="user-uploads">
<div class="uploads-header">
{{ t('profile.uploads_label') }}: {{ count.f0cks }} <a href="{{ (f0cks.link && f0cks.link.main) ? f0cks.link.main : '#' }}">{{ t('profile.view_all') }}</a>
</div>
@if(count.f0cks)
<div class="posts no-infinite-scroll">
@each(f0cks.items as item)
<a href="{{ f0cks.link.main }}{{ item.id }}" class="{{ item.is_pinned ? 'anim-boxshadow ' : '' }}thumb lazy-thumb {{ item.is_pinned ? 'is-pinned' : '' }}" data-file="{{ item.dest }}" data-mime="{{ item.mime }}" data-user="{!! item.display_name || item.username !!}" data-ext="{{ item.mime.split('/')[1].replace('youtube', 'yt').replace('x-shockwave-flash', 'flash').replace('vnd.adobe.flash.movie', 'flash').toUpperCase() }}" data-mode="{{ item.tag_id == nsfl_tag_id ? 'nsfl' : (item.tag_id == 2 ? 'nsfw' : (item.tag_id == 1 ? 'sfw' : 'null')) }}" data-bg="/t/{{ item.id }}.webp">
<div class="thumb-indicators">
@if(item.is_pinned)
<i class="fa-solid fa-thumbtack pin-indicator anim"></i>
@endif
@if(item.is_oc)
<span class="oc-indicator anim">OC</span>
@endif
</div>
<p></p>
</a>
@endeach
</div>
@else
{{ t('profile.no_uploads') }}
@endif
</div>
@if(!user.is_ghost)
<div class="favs">
<div class="favs-header">
{{ t('profile.favs_label') }}: {{ count.favs }} <a href="@if(favs.link && favs.link.main){{ favs.link.main }}@else#@endif">{{ t('profile.view_all') }}</a>
</div>
@if(count.favs)
<div class="posts no-infinite-scroll">
@each(favs.items as item)
<a href="{{ favs.link.main }}{{ item.id }}" class="{{ item.is_pinned ? 'anim-boxshadow ' : '' }}thumb lazy-thumb {{ item.is_pinned ? 'is-pinned' : '' }}" data-file="{{ item.dest }}" data-mime="{{ item.mime }}" data-user="{!! item.display_name || item.username !!}" data-ext="{{ item.mime.split('/')[1].replace('youtube', 'yt').replace('x-shockwave-flash', 'flash').replace('vnd.adobe.flash.movie', 'flash').toUpperCase() }}" data-mode="{{ item.tag_id == nsfl_tag_id ? 'nsfl' : (item.tag_id == 2 ? 'nsfw' : (item.tag_id == 1 ? 'sfw' : 'null')) }}" data-bg="/t/{{ item.id }}.webp">
<div class="thumb-indicators">
@if(item.is_pinned)
<i class="fa-solid fa-thumbtack pin-indicator anim"></i>
@endif
@if(item.is_oc)
<span class="oc-indicator anim">OC</span>
@endif
</div>
<p></p>
</a>
@endeach
</div>
@else
{{ t('profile.no_favs') }}
@endif
</div>
@endif
</div>
@if(session)
@if(session.id !== user.user_id)
@if(!user.is_ghost)
@if(session.admin || session.is_moderator)
<!-- Ban Modal -->
<div id="ban-modal" class="modal" style="display: none; position: fixed; z-index: 10001; left: 0; top: 0; width: 100%; height: 100%; overflow: auto; background-color: rgba(0,0,0,0.8);">
<div class="modal-content" style="background-color: var(--bg); margin: 15% auto; padding: 20px; border: 1px solid var(--accent); width: 400px; border-radius: 8px;">
<h2 style="color: var(--accent); margin-top: 0;">{{ t('profile.ban_modal_title') }}</h2>
<form id="ban-form">
<div style="margin-bottom: 15px;">
<label style="display: block; margin-bottom: 5px;">{{ t('profile.ban_modal_reason') }}</label>
<input type="text" id="ban-reason" style="width: 100%; padding: 8px; background: #222; border: 1px solid #444; color: #fff;" required>
</div>
<div style="margin-bottom: 20px;">
<label style="display: block; margin-bottom: 5px;">{{ t('profile.ban_modal_duration') }}</label>
<select id="ban-duration" style="width: 100%; padding: 8px; background: #222; border: 1px solid #444; color: #fff;">
<option value="1">{{ t('profile.ban_1h') }}</option>
<option value="24">{{ t('profile.ban_1d') }}</option>
<option value="168">{{ t('profile.ban_1w') }}</option>
<option value="720">{{ t('profile.ban_1m') }}</option>
<option value="permanent">{{ t('profile.ban_permanent') }}</option>
</select>
</div>
<div style="display: flex; justify-content: flex-end; gap: 10px;">
<button type="button" id="ban-modal-close" style="padding: 8px 15px; background: #444; border: none; color: #fff; cursor: pointer;">{{ t('profile.ban_modal_cancel') }}</button>
<button type="submit" style="padding: 8px 15px; background: var(--accent); border: none; color: var(--bg); font-weight: bold; cursor: pointer;">{{ t('profile.ban_modal_confirm') }}</button>
</div>
</form>
</div>
</div>
<!-- Warn Modal -->
<div id="warn-modal" class="modal" style="display: none; position: fixed; z-index: 10001; left: 0; top: 0; width: 100%; height: 100%; overflow: auto; background-color: rgba(0,0,0,0.8);">
<div class="modal-content" style="background-color: var(--bg); margin: 15% auto; padding: 20px; border: 1px solid #f0ad4e; width: 400px; border-radius: 8px;">
<h2 style="color: #f0ad4e; margin-top: 0;">{{ t('profile.warn_modal_title') }}</h2>
<form id="warn-form">
<div style="margin-bottom: 15px;">
<label style="display: block; margin-bottom: 5px;">{{ t('profile.warn_modal_reason') }}</label>
<textarea id="warn-reason" style="width: 100%; padding: 8px; background: #222; border: 1px solid #444; color: #fff; height: 100px;" required></textarea>
<p style="font-size: 0.8em; color: #aaa; margin-top: 5px;">{{ t('profile.warn_modal_hint') }}</p>
</div>
<div style="display: flex; justify-content: flex-end; gap: 10px;">
<button type="button" id="warn-modal-close" style="padding: 8px 15px; background: #444; border: none; color: #fff; cursor: pointer;">{{ t('profile.warn_modal_cancel') }}</button>
<button type="submit" style="padding: 8px 15px; background: #f0ad4e; border: none; color: #000; font-weight: bold; cursor: pointer;">{{ t('profile.warn_modal_submit') }}</button>
</div>
</form>
</div>
</div>
<script>
(function () {
const banBtn = document.getElementById('ban-user-btn');
const unbanBtn = document.getElementById('unban-user-btn');
const warnBtn = document.getElementById('warn-user-btn');
const banModal = document.getElementById('ban-modal');
const warnModal = document.getElementById('warn-modal');
const banClose = document.getElementById('ban-modal-close');
const warnClose = document.getElementById('warn-modal-close');
const banForm = document.getElementById('ban-form');
const warnForm = document.getElementById('warn-form');
const userId = '{{ user.user_id || user.id }}';
if (banBtn) {
banBtn.onclick = () => {
// Filter duration options for moderators
const isAdmin = {{ session.admin ? 'true' : 'false' }};
const durationSelect = document.getElementById('ban-duration');
if (durationSelect && !isAdmin) {
Array.from(durationSelect.options).forEach(opt => {
const val = opt.value;
if (val === 'permanent' || parseInt(val) > 48) {
opt.style.display = 'none';
opt.disabled = true;
}
});
// Ensure a valid option is selected
if (durationSelect.value === 'permanent' || parseInt(durationSelect.value) > 48) {
durationSelect.value = "1";
}
}
banModal.style.display = 'block';
};
}
if (warnBtn) {
warnBtn.onclick = () => warnModal.style.display = 'block';
}
if (unbanBtn) {
unbanBtn.onclick = async () => {
if (!confirm('{{ t('profile.confirm_unban') }}')) return;
try {
const res = await fetch('/api/v2/admin/unban', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': window.f0ckSession?.csrf_token
},
body: JSON.stringify({ user_id: userId })
});
const data = await res.json();
if (data.success) {
if (window.showFlash) window.showFlash('{{ t('profile.unban_success') }}', 'success');
else alert('{{ t('profile.unban_success') }}');
location.reload();
} else {
alert('Error: ' + data.msg);
}
} catch (err) {
alert('Failed to unban user: ' + err.message);
}
};
}
if (banClose) {
banClose.onclick = () => banModal.style.display = 'none';
}
if (warnClose) {
warnClose.onclick = () => warnModal.style.display = 'none';
}
window.onclick = (event) => {
if (event.target == banModal) {
banModal.style.display = 'none';
}
if (event.target == warnModal) {
warnModal.style.display = 'none';
}
};
if (banForm) {
banForm.onsubmit = async (e) => {
e.preventDefault();
const reason = document.getElementById('ban-reason').value;
const duration = document.getElementById('ban-duration').value;
try {
const res = await fetch('/api/v2/admin/ban', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': window.f0ckSession?.csrf_token
},
body: JSON.stringify({ user_id: userId, reason, duration })
});
const data = await res.json();
if (data.success) {
if (window.showFlash) window.showFlash('{{ t('profile.ban_success') }}', 'success');
else alert('{{ t('profile.ban_success') }}');
location.reload();
} else {
alert('Error: ' + data.msg);
}
} catch (err) {
alert('Failed to ban user: ' + err.message);
}
};
}
if (warnForm) {
warnForm.onsubmit = async (e) => {
e.preventDefault();
const reason = document.getElementById('warn-reason').value;
const submitBtn = warnForm.querySelector('button[type="submit"]');
try {
submitBtn.disabled = true;
submitBtn.innerText = '{{ t('profile.warning_issuing') }}';
const payload = new URLSearchParams();
payload.append('user_id', userId);
payload.append('reason', reason);
const res = await fetch('/api/v2/mod/warnings/issue', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-CSRF-Token': window.f0ckSession?.csrf_token
},
body: payload
});
const data = await res.json();
if (data.success) {
document.getElementById('warn-reason').value = '';
warnModal.style.display = 'none';
if (window.showFlash) window.showFlash('{{ t('profile.warning_success') }}', 'success');
} else {
alert('Error: ' + data.msg);
}
} catch (err) {
alert('Failed to issue warning: ' + err.message);
} finally {
submitBtn.disabled = false;
submitBtn.innerText = '{{ t('profile.warning_issue_btn') }}';
}
};
}
const subUserBtn = document.getElementById('admin-subscribe-user-btn');
if (subUserBtn && userId) {
subUserBtn.onclick = async () => {
if (!confirm('{{ t('profile.confirm_subscribe_uploads') }}')) return;
subUserBtn.disabled = true;
subUserBtn.innerText = '{{ t('profile.subscribing') }}';
try {
const res = await fetch('/api/v2/admin/subscribe-user-to-uploads', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': window.f0ckSession?.csrf_token
},
body: JSON.stringify({ user_id: userId })
});
const data = await res.json();
if (data.success) {
alert('{{ t('profile.subscribed') }}');
subUserBtn.innerText = '{{ t('profile.subscribed') }}';
} else {
alert('Error: ' + (data.msg || data.message));
subUserBtn.disabled = false;
subUserBtn.innerText = '{{ t('profile.subscribe_uploads_btn') }}';
}
} catch (err) {
alert('Failed to subscribe user: ' + err.message);
subUserBtn.disabled = false;
subUserBtn.innerText = '{{ t('profile.subscribe_uploads_btn') }}';
}
};
}
})();
</script>
@endif
@endif
@endif
@endif
@if(session && session.id === user.user_id)
<script>
(function () {
const subAllBtn = document.getElementById('subscribe-all-uploads-btn');
if (subAllBtn) {
subAllBtn.onclick = async () => {
if (!confirm('{{ t('profile.confirm_subscribe_uploads') }}')) return;
subAllBtn.disabled = true;
subAllBtn.innerText = '{{ t('profile.subscribing') }}';
try {
const res = await fetch('/api/v2/user/subscribe-all-uploads', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': window.f0ckSession?.csrf_token
}
});
const data = await res.json();
if (data.success) {
alert(data.message);
subAllBtn.innerText = '{{ t('profile.subscribed') }}';
setTimeout(() => {
subAllBtn.innerText = '{{ t('profile.subscribe_to_my_uploads') }}';
subAllBtn.disabled = false;
}, 3000);
} else {
alert('Error: ' + data.message);
subAllBtn.disabled = false;
subAllBtn.innerText = '{{ t('profile.subscribe_to_my_uploads') }}';
}
} catch (err) {
alert('Failed to subscribe: ' + err.message);
subAllBtn.disabled = false;
subAllBtn.innerText = '{{ t('profile.subscribe_to_my_uploads') }}';
}
};
}
})();
</script>
@endif
<div class="pagination-container-fluid" @if(typeof hidePagination !=='undefined' && hidePagination) style="display: none;" @endif>
<div class="pagination-wrapper bottom-pagination fixed-pagination">
@include(snippets/pagination)
</div>
</div>

7
views/user.html Normal file
View File

@@ -0,0 +1,7 @@
@include(snippets/header)
<div class="pagewrapper">
<div id="main">
@include(user-partial)
</div>
</div>
@include(snippets/footer)