init f0ckm
This commit is contained in:
31
views/about.html
Normal file
31
views/about.html
Normal 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
177
views/admin.html
Normal 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
68
views/admin/about.html
Normal file
@@ -0,0 +1,68 @@
|
||||
@include(snippets/header)
|
||||
<div class="pagewrapper">
|
||||
<div id="main" class="admin-container">
|
||||
<div class="container">
|
||||
<h2>About Page Content</h2>
|
||||
<p style="color: #ccc; margin-bottom: 20px;">This text is displayed on the <strong>/about</strong> page. Supports Markdown. Leave empty to show the default static template.</p>
|
||||
|
||||
<div class="admin-motd-form">
|
||||
<form id="about-form" action="/admin/about" method="POST" onsubmit="event.preventDefault(); savePage(this, 'about');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<div style="margin-bottom: 20px;">
|
||||
<label for="about-text" style="display: block; margin-bottom: 8px; color: var(--accent);">About Page Content (Markdown supported)</label>
|
||||
<textarea id="about-text" name="about_text" style="width: 100%; min-height: 300px; background: rgba(0,0,0,0.3); border: 1px solid rgba(255,255,255,0.1); color: #fff; padding: 15px; border-radius: 4px; font-family: inherit; font-size: 1.1em; resize: vertical;">{!! about_text !!}</textarea>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 10px; align-items: center;">
|
||||
<button type="submit" class="btn-primary" style="width: auto; padding: 10px 30px;">Save</button>
|
||||
<button type="button" class="btn-remove" style="width: auto; padding: 10px 20px;" onclick="document.getElementById('about-text').value=''; savePage(document.getElementById('about-form'), 'about');">Clear</button>
|
||||
<span id="about-status" style="margin-left: 10px; font-weight: bold; display: none;"></span>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 40px; padding: 20px; background: rgba(0,0,0,0.2); border-left: 4px solid var(--accent); border-radius: 4px;">
|
||||
<h4 style="color: var(--accent); margin-top: 0;">Tips</h4>
|
||||
<p style="margin-bottom: 0;">Use <strong>Markdown</strong> syntax like <code># Header</code>, <code>**bold**</code>, <code>[links](url)</code>, or plain HTML.<br>
|
||||
If empty, the default static about page is shown instead.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
async function savePage(form, page) {
|
||||
const statusId = page + '-status';
|
||||
const status = document.getElementById(statusId);
|
||||
|
||||
status.textContent = 'Saving...';
|
||||
status.style.color = 'var(--accent)';
|
||||
status.style.display = 'inline';
|
||||
|
||||
try {
|
||||
const res = await fetch(form.action, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-CSRF-Token': window.f0ckSession?.csrf_token
|
||||
},
|
||||
body: new URLSearchParams(new FormData(form))
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
status.textContent = 'Saved!';
|
||||
status.style.color = '#28a745';
|
||||
setTimeout(() => { status.style.display = 'none'; }, 2000);
|
||||
} else {
|
||||
throw new Error(data.msg || 'Unknown error');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Save Error:', err);
|
||||
status.textContent = 'Error: ' + err.message;
|
||||
status.style.color = '#d9534f';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</div>
|
||||
</div>
|
||||
@include(snippets/footer)
|
||||
244
views/admin/approve.html
Normal file
244
views/admin/approve.html
Normal file
@@ -0,0 +1,244 @@
|
||||
@include(snippets/header)
|
||||
<div id="main">
|
||||
<div class="container">
|
||||
<h1>APPROVAL QUEUE</h1>
|
||||
<p>Items here are pending approval.</p>
|
||||
|
||||
@if(pending.length > 0)
|
||||
<h2>Pending Uploads</h2>
|
||||
<table class="table" style="width: 100%; margin-bottom: 30px;">
|
||||
<thead>
|
||||
<tr>
|
||||
<td>Preview</td>
|
||||
<td>ID</td>
|
||||
<td>Uploader</td>
|
||||
<td>Type</td>
|
||||
<td>Tags</td>
|
||||
<td>Action</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@each(pending as post)
|
||||
<tr>
|
||||
<td>
|
||||
<video controls loop muted preload="metadata" style="max-height: 200px; max-width: 300px;">
|
||||
<source src="/b/{{ post.dest }}" type="{{ post.mime }}">
|
||||
</video>
|
||||
</td>
|
||||
<td>{{ post.id }}</td>
|
||||
<td>{!! post.username !!}</td>
|
||||
<td>{{ post.mime }}</td>
|
||||
<td>
|
||||
@each(post.tags as tag)
|
||||
<span class="badge badge-secondary" style="margin-right: 5px;">{!! tag !!}</span>
|
||||
@endeach
|
||||
</td>
|
||||
<td>
|
||||
<a href="/admin/approve/?id={{ post.id }}" class="badge badge-success btn-approve-async">Approve</a>
|
||||
<a href="/admin/deny/?id={{ post.id }}" class="badge badge-danger btn-deny-async">Deny / Delete</a>
|
||||
</td>
|
||||
</tr>
|
||||
@endeach
|
||||
</tbody>
|
||||
</table>
|
||||
@endif
|
||||
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-top: 40px;">
|
||||
<h2 style="color: #ff6b6b; margin: 0;">Soft Deleted</h2>
|
||||
@if(trash.length > 0)
|
||||
<button id="btn-purge-trash" class="badge badge-danger" style="border: none; padding: 10px 15px; cursor: pointer; font-size: 14px;">Purge All Soft-Deleted</button>
|
||||
@endif
|
||||
</div>
|
||||
<p class="text-muted">These items are in the deleted folder but not purged from DB. Approving them will restore them.</p>
|
||||
|
||||
@if(trash.length > 0)
|
||||
<table class="table" style="width: 100%; opacity: 0.8;">
|
||||
<thead>
|
||||
<tr>
|
||||
<td>Preview</td>
|
||||
<td>ID</td>
|
||||
<td>Uploader</td>
|
||||
<td>Type</td>
|
||||
<td>Tags</td>
|
||||
<td>Action</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@each(trash as post)
|
||||
<tr>
|
||||
<td>
|
||||
@if(post.thumbnail)
|
||||
<img src="data:image/webp;base64,{{ post.thumbnail }}" style="max-height: 150px; opacity: 0.6;">
|
||||
@else
|
||||
<span style="color:red;">[File Missing]</span>
|
||||
@endif
|
||||
</td>
|
||||
<td>{{ post.id }}</td>
|
||||
<td>{!! post.username !!}</td>
|
||||
<td>{{ post.mime }}</td>
|
||||
<td>
|
||||
@each(post.tags as tag)
|
||||
<span class="badge badge-secondary" style="margin-right: 5px;">{!! tag !!}</span>
|
||||
@endeach
|
||||
</td>
|
||||
<td>
|
||||
<a href="/admin/approve/?id={{ post.id }}" class="badge badge-warning btn-approve-async">Restore</a>
|
||||
<a href="/admin/deny/?id={{ post.id }}" class="badge badge-danger btn-deny-async">Purge</a>
|
||||
</td>
|
||||
</tr>
|
||||
@endeach
|
||||
</tbody>
|
||||
</table>
|
||||
@else
|
||||
<p style="padding: 20px; border: 1px dashed #444; color: #888;">Trash is empty.</p>
|
||||
@endif
|
||||
|
||||
@if(pending.length === 0 && trash.length === 0)
|
||||
<div style="text-align: center; padding: 50px;">
|
||||
<h3>No pending items.</h3>
|
||||
<p>Go touch grass?</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<br>
|
||||
@if(typeof pages !== 'undefined' && pages > 1)
|
||||
<div class="pagination" style="display: flex; gap: 10px; align-items: center; justify-content: center;">
|
||||
@if(page > 1)
|
||||
<a href="/admin/approve?page={{ page - 1 }}" class="badge badge-secondary">« Prev</a>
|
||||
@endif
|
||||
<span>Page {{ page }} of {{ pages }}</span>
|
||||
@if(page < pages) <a href="/admin/approve?page={{ page + 1 }}" class="badge badge-secondary">Next »</a>
|
||||
@endif
|
||||
</div>
|
||||
<br>
|
||||
@endif
|
||||
|
||||
|
||||
|
||||
<!-- Custom Modal -->
|
||||
<div id="custom-modal" style="display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.7); justify-content: center; align-items: center; z-index: 1000;">
|
||||
<div style="background: #222; color: #fff; padding: 20px; border-radius: 8px; max-width: 400px; text-align: center; border: 1px solid #444;">
|
||||
<h3 id="modal-title" style="margin-top: 0;">Confirm Action</h3>
|
||||
<p id="modal-text">Are you sure?</p>
|
||||
<div style="display: flex; justify-content: space-around; margin-top: 20px;">
|
||||
<button id="modal-cancel" class="badge badge-secondary" style="border: none; padding: 10px 20px; cursor: pointer;">Cancel</button>
|
||||
<button id="modal-confirm" class="badge badge-danger" style="border: none; padding: 10px 20px; cursor: pointer;">Confirm</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(() => {
|
||||
const modal = document.getElementById('custom-modal');
|
||||
const modalTitle = document.getElementById('modal-title');
|
||||
const modalText = document.getElementById('modal-text');
|
||||
const btnConfirm = document.getElementById('modal-confirm');
|
||||
const btnCancel = document.getElementById('modal-cancel');
|
||||
|
||||
let pendingAction = null;
|
||||
|
||||
const showModal = (title, text, action) => {
|
||||
modalTitle.innerText = title;
|
||||
modalText.innerText = text;
|
||||
pendingAction = action;
|
||||
modal.style.display = 'flex';
|
||||
|
||||
btnConfirm.onclick = async () => {
|
||||
if (!pendingAction) return;
|
||||
btnConfirm.disabled = true;
|
||||
btnConfirm.innerText = 'Processing...';
|
||||
try {
|
||||
await pendingAction();
|
||||
closeModal();
|
||||
} catch (e) {
|
||||
alert('Error: ' + e.message);
|
||||
} finally {
|
||||
btnConfirm.disabled = false;
|
||||
btnConfirm.innerText = 'Confirm';
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
modal.style.display = 'none';
|
||||
pendingAction = null;
|
||||
};
|
||||
|
||||
if (btnCancel) btnCancel.onclick = closeModal;
|
||||
|
||||
// Single Deny
|
||||
document.querySelectorAll('.btn-deny-async').forEach(btn => {
|
||||
btn.addEventListener('click', e => {
|
||||
e.preventDefault();
|
||||
const url = btn.getAttribute('href');
|
||||
const row = btn.closest('tr');
|
||||
|
||||
showModal('Deny Item', 'Permanently delete this item?', async () => {
|
||||
const res = await fetch(url, {
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok && data.success) {
|
||||
if (window.flashMessage) window.flashMessage('{!! t('toast.item_deleted_success') !!}', 2000, 'success');
|
||||
row.style.opacity = '0';
|
||||
setTimeout(() => row.remove(), 300);
|
||||
} else {
|
||||
if (window.flashMessage) window.flashMessage(data.msg || '{!! t('toast.report_error') !!}', 3000, 'error');
|
||||
throw new Error(data.msg || 'Request failed');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Single Approve / Restore
|
||||
document.querySelectorAll('.btn-approve-async').forEach(btn => {
|
||||
btn.addEventListener('click', async e => {
|
||||
e.preventDefault();
|
||||
const url = btn.getAttribute('href');
|
||||
const row = btn.closest('tr');
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
if (window.flashMessage) window.flashMessage('{!! t('toast.approve_success') !!}', 2000, 'success');
|
||||
row.style.opacity = '0';
|
||||
setTimeout(() => row.remove(), 300);
|
||||
} else {
|
||||
if (window.flashMessage) window.flashMessage(data.msg || '{!! t('toast.approve_error') !!}', 3000, 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
if (window.flashMessage) window.flashMessage('{!! t('toast.network_error') !!}', 3000, 'error');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Purge All Trash
|
||||
const btnPurgeTrash = document.getElementById('btn-purge-trash');
|
||||
if (btnPurgeTrash) {
|
||||
btnPurgeTrash.addEventListener('click', () => {
|
||||
showModal('Purge Trash', 'Permanently delete ALL items in the trash? This cannot be undone.', async () => {
|
||||
const res = await fetch('/admin/purge-trash-all', { method: 'POST' });
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
location.reload();
|
||||
} else {
|
||||
throw new Error(data.msg || 'Purge failed');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</div>
|
||||
</div>
|
||||
@include(snippets/footer)
|
||||
135
views/admin/emojis.html
Normal file
135
views/admin/emojis.html
Normal file
@@ -0,0 +1,135 @@
|
||||
@include(snippets/header)
|
||||
<div class="pagewrapper">
|
||||
<div id="main" class="admin-container">
|
||||
<div class="container">
|
||||
<h2>Custom Emojis</h2>
|
||||
|
||||
<div class="admin-form-container"
|
||||
style="margin-bottom: 20px; text-align: left; background: var(--dropdown-bg); padding: 15px; border: 1px solid var(--nav-border-color);">
|
||||
<h4>Add New Emoji</h4>
|
||||
<div style="display: flex; gap: 10px; flex-wrap: wrap; align-items: flex-end;">
|
||||
<div>
|
||||
<label style="display: block; font-size: 0.8em; margin-bottom: 5px; opacity: 0.7;">Name</label>
|
||||
<input type="text" id="emoji-name" placeholder="" style="background: var(--bg); border: 1px solid var(--black); padding: 5px; color: var(--white);">
|
||||
</div>
|
||||
<div>
|
||||
<label style="display: block; font-size: 0.8em; margin-bottom: 5px; opacity: 0.7;">Image File</label>
|
||||
<input type="file" id="emoji-file" style="background: var(--bg); border: 1px solid var(--black); padding: 4px; color: var(--white);">
|
||||
</div>
|
||||
<div style="flex-grow: 1;">
|
||||
<label style="display: block; font-size: 0.8em; margin-bottom: 5px; opacity: 0.7;">OR Image URL</label>
|
||||
<input type="text" id="emoji-url" placeholder="" style="background: var(--bg); border: 1px solid var(--black); padding: 5px; color: var(--white); width: 100%;">
|
||||
</div>
|
||||
<button id="add-emoji" class="btn-upload" style="width: auto; padding: 7px 20px; border: 1px solid var(--nav-border-color); background: var(--bg); color: var(--white); cursor: pointer;">Add</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div id="emoji-list" class="emoji-grid">
|
||||
<!-- Populated by JS -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(() => {
|
||||
var i18n = window.f0ckI18n || {};
|
||||
const esc = (s) => (s || '').toString().replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
||||
const loadEmojis = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/v2/emojis');
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
const grid = document.getElementById('emoji-list');
|
||||
if (!grid) return;
|
||||
grid.innerHTML = data.emojis.reverse().map(e =>
|
||||
'<div class="emoji-card">' +
|
||||
'<button class="emoji-delete" onclick="window.emojiAdmin.deleteEmoji(' + e.id + ')" title="Delete">✕</button>' +
|
||||
'<img class="emoji-preview" src="' + e.url + '" alt=":' + esc(e.name) + ':">' +
|
||||
'<span class="emoji-label">:' + esc(e.name) + ':</span>' +
|
||||
'<span class="emoji-url">' + esc(e.url) + '</span>' +
|
||||
'</div>'
|
||||
).join('');
|
||||
}
|
||||
} catch (err) { console.error('[EMOJI_ADMIN] Load Error:', err); }
|
||||
};
|
||||
|
||||
const addEmoji = async (e) => {
|
||||
if (e) e.preventDefault();
|
||||
const name = document.getElementById('emoji-name').value;
|
||||
const url = document.getElementById('emoji-url').value;
|
||||
const fileInput = document.getElementById('emoji-file');
|
||||
|
||||
if (!name || (!url && !fileInput.files[0])) return alert('Fill Name and either URL or File');
|
||||
|
||||
const btn = document.getElementById('add-emoji');
|
||||
const oldText = btn.textContent;
|
||||
btn.disabled = true;
|
||||
btn.textContent = i18n.uploading || 'Uploading...';
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('name', name);
|
||||
formData.append('url', url);
|
||||
if (fileInput.files[0]) {
|
||||
formData.append('file', fileInput.files[0]);
|
||||
}
|
||||
|
||||
try {
|
||||
const headers = { 'X-Requested-With': 'XMLHttpRequest' };
|
||||
const csrf = '{{ csrf_token }}';
|
||||
if (csrf) headers['X-CSRF-Token'] = csrf;
|
||||
|
||||
const res = await fetch('/api/v2/admin/emojis', {
|
||||
method: 'POST',
|
||||
headers: headers,
|
||||
body: formData
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
document.getElementById('emoji-name').value = '';
|
||||
document.getElementById('emoji-url').value = '';
|
||||
document.getElementById('emoji-file').value = '';
|
||||
loadEmojis();
|
||||
} else {
|
||||
alert('Failed: ' + (data.message || data.msg || 'Unknown error'));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[EMOJI_ADMIN] Add Error:', e);
|
||||
alert('Error: ' + e.message);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = oldText;
|
||||
}
|
||||
};
|
||||
|
||||
const deleteEmoji = async (id) => {
|
||||
if (!confirm('Delete this emoji?')) return;
|
||||
try {
|
||||
const res = await fetch('/api/v2/admin/emojis/' + id + '/delete', {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-Token': '{{ csrf_token }}' }
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
loadEmojis();
|
||||
} else {
|
||||
alert('Delete failed');
|
||||
}
|
||||
} catch (e) { console.error(e); }
|
||||
};
|
||||
|
||||
// Global scope for onclick
|
||||
window.emojiAdmin = { deleteEmoji };
|
||||
|
||||
const btnAddEmoji = document.getElementById('add-emoji');
|
||||
if (btnAddEmoji) btnAddEmoji.addEventListener('click', addEmoji);
|
||||
|
||||
// Live Update Listener (SSE dispatched via f0ckm.js)
|
||||
document.addEventListener('f0ck:emojis_updated', loadEmojis);
|
||||
|
||||
loadEmojis();
|
||||
})();
|
||||
</script>
|
||||
</div>
|
||||
</div>
|
||||
@include(snippets/footer)
|
||||
322
views/admin/halls.html
Normal file
322
views/admin/halls.html
Normal file
@@ -0,0 +1,322 @@
|
||||
@include(snippets/header)
|
||||
<div class="pagewrapper">
|
||||
<div id="main">
|
||||
<style>
|
||||
.hm-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 28px;
|
||||
padding: 0 12px;
|
||||
border-radius: 3px;
|
||||
font-size: 0.85em;
|
||||
font-family: var(--font);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
text-decoration: none;
|
||||
box-sizing: border-box;
|
||||
line-height: 1;
|
||||
}
|
||||
</style>
|
||||
<div class="container" style="padding-top: 20px;">
|
||||
<h1>{{ t('hall.manager_title') }}</h1>
|
||||
<p style="color: #aaa; margin-bottom: 20px;">{{ t('hall.manager_desc') }}</p>
|
||||
|
||||
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:20px;">
|
||||
<input type="text" id="new-hall-name" placeholder="{{ t('hall.new_hall_placeholder') }}" style="flex:1;min-width:180px;background:var(--badge-bg);border:1px solid rgba(255,255,255,0.15);color:var(--white);padding:0 10px;height:28px;border-radius:3px;font-family:var(--font);font-size:0.9em;">
|
||||
<select id="new-hall-rating" style="background:var(--badge-bg);border:1px solid rgba(255,255,255,0.15);color:var(--white);padding:0 10px;height:28px;border-radius:3px;font-family:var(--font);font-size:0.9em;">
|
||||
<option value="sfw">🟢 SFW</option>
|
||||
<option value="nsfw">🔴 NSFW</option>
|
||||
<option value="nsfl">💀 NSFL</option>
|
||||
</select>
|
||||
<span id="new-hall-slug-preview" style="font-size:0.78em;color:#555;min-width:80px;"></span>
|
||||
<button id="btn-create-hall" class="hm-btn" style="background:rgba(255,255,255,0.1);color:var(--white);border:1px solid rgba(255,255,255,0.2);font-weight:bold;">{{ t('hall.create_hall_btn') }}</button>
|
||||
<span id="create-hall-status" style="font-size:0.8em;color:#888;"></span>
|
||||
</div>
|
||||
<hr>
|
||||
|
||||
<div id="hall-manager-grid" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 20px; margin-top: 20px;">
|
||||
@each(hallsList as h)
|
||||
<div class="hall-manager-card" id="hall-card-{{ h.slug }}" data-slug="{{ h.slug }}">
|
||||
<div class="hall-manager-image" style="position:relative; height: 140px; overflow:hidden; background:#111; cursor:pointer;" title="{{ t('hall.click_upload_hint') }}">
|
||||
<img src="/hall_image/{{ h.slug }}" alt="{!! h.name !!}" style="width:100%;height:100%;object-fit:cover;opacity:0.8;" id="hall-img-{{ h.slug }}">
|
||||
<span style="position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:1.1em;font-weight:700;color:#fff;text-transform:uppercase;letter-spacing:0.05em;text-shadow:0 0 10px rgba(0,0,0,0.9),0 1px 3px rgba(0,0,0,0.8);pointer-events:none;">{!! h.name !!}</span>
|
||||
@if(h.custom_image)
|
||||
<button class="btn-del-img" title="Remove custom image" style="position:absolute;top:6px;right:6px;width:24px;height:24px;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.7);color:#fff;border:1px solid rgba(255,255,255,0.3);border-radius:50%;cursor:pointer;font-size:0.75em;line-height:1;z-index:2;padding:0;">✕</button>
|
||||
@endif
|
||||
<input type="file" class="hall-img-upload" accept="image/*" style="display:none;">
|
||||
</div>
|
||||
<div style="padding: 12px;">
|
||||
<div style="margin-bottom: 8px;">
|
||||
<label style="font-size:0.8em;color:#888;display:block;">{{ t('common.name') }}</label>
|
||||
<input type="text" class="hall-name-input" value="{!! h.name !!}" style="width:100%;box-sizing:border-box;background:var(--badge-bg);border:1px solid rgba(255,255,255,0.15);color:var(--white);padding:5px 8px;border-radius:3px;font-family:var(--font);">
|
||||
</div>
|
||||
<div style="margin-bottom: 8px;">
|
||||
<label style="font-size:0.8em;color:#888;display:block;">{{ t('hall.slug') }}</label>
|
||||
<input type="text" class="hall-slug-input" value="{!! h.slug !!}" style="width:100%;box-sizing:border-box;background:var(--badge-bg);border:1px solid rgba(255,255,255,0.15);color:var(--white);padding:5px 8px;border-radius:3px;font-family:var(--font);font-size:0.9em;color:#aaa;">
|
||||
</div>
|
||||
<div style="margin-bottom: 8px;">
|
||||
<label style="font-size:0.8em;color:#888;display:block;">{{ t('common.description') }}</label>
|
||||
<textarea class="hall-desc-input" style="width:100%;box-sizing:border-box;background:var(--badge-bg);border:1px solid rgba(255,255,255,0.15);color:var(--white);padding:5px 8px;border-radius:3px;font-family:var(--font);resize:vertical;min-height:60px;">{!! h.description || '' !!}</textarea>
|
||||
</div>
|
||||
<div style="margin-bottom: 8px;">
|
||||
<label style="font-size:0.8em;color:#888;display:block;">{{ t('hall.rating') }}</label>
|
||||
<select class="hall-rating-input" style="width:100%;box-sizing:border-box;background:var(--badge-bg);border:1px solid rgba(255,255,255,0.15);color:var(--white);padding:5px 8px;border-radius:3px;font-family:var(--font);">
|
||||
<option value="sfw" @if(h.rating === 'sfw' || !h.rating) selected @endif>🟢 SFW</option>
|
||||
<option value="nsfw" @if(h.rating === 'nsfw') selected @endif>🔴 NSFW</option>
|
||||
<option value="nsfl" @if(h.rating === 'nsfl') selected @endif>💀 NSFL</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;">
|
||||
<button class="btn-save-hall hm-btn" style="background:var(--accent);color:#000;border:none;font-weight:bold;">{{ t('common.save') }}</button>
|
||||
<a href="/h/{{ h.slug }}" class="hall-view-link hm-btn" style="background:rgba(255,255,255,0.05);color:#aaa;border:1px solid rgba(255,255,255,0.1);">{{ t('common.view') }} →</a>
|
||||
<span style="margin-left:4px;font-size:0.8em;color:#666;">{{ t('hall.posts', { count: h.item_count || 0 }) }}</span>
|
||||
<button class="btn-delete-hall hm-btn" style="margin-left:auto;background:rgba(200,0,0,0.25);color:#f55;border:1px solid rgba(200,0,0,0.4);">🗑 {{ t('common.delete') }}</button>
|
||||
</div>
|
||||
<div class="hall-status" style="margin-top:6px;font-size:0.8em;color:#888;min-height:1.2em;"></div>
|
||||
</div>
|
||||
</div>
|
||||
@endeach
|
||||
</div>
|
||||
|
||||
@if(!hallsList || !hallsList.length)
|
||||
<p style="color:#888;text-align:center;padding:40px 0;">{{ t('hall.no_halls') }}</p>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
var grid = document.getElementById('hall-manager-grid');
|
||||
var i18n = window.f0ckI18n || {};
|
||||
|
||||
// ── Card wiring ─────────────────────────────────────────────────────────
|
||||
function wireCard(card) {
|
||||
var slug = card.dataset.slug;
|
||||
var status = card.querySelector('.hall-status');
|
||||
var nameInput = card.querySelector('.hall-name-input');
|
||||
var slugInput = card.querySelector('.hall-slug-input');
|
||||
var descInput = card.querySelector('.hall-desc-input');
|
||||
var ratingInput = card.querySelector('.hall-rating-input');
|
||||
var imgEl = card.querySelector('.hall-manager-image img');
|
||||
var imgContainer = card.querySelector('.hall-manager-image');
|
||||
var fileInput = card.querySelector('.hall-img-upload');
|
||||
var viewLink = card.querySelector('.hall-view-link');
|
||||
|
||||
function setStatus(msg, color) {
|
||||
status.textContent = msg;
|
||||
status.style.color = color || '#aaa';
|
||||
}
|
||||
|
||||
// Live name overlay
|
||||
var nameOverlay = imgContainer.querySelector('span');
|
||||
nameInput.addEventListener('input', function() {
|
||||
if (nameOverlay) nameOverlay.textContent = nameInput.value;
|
||||
});
|
||||
|
||||
// Auto-sanitize slug on blur
|
||||
slugInput.addEventListener('blur', function() {
|
||||
slugInput.value = slugInput.value.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
||||
});
|
||||
|
||||
// Click image → open file picker
|
||||
imgContainer.addEventListener('click', function(e) {
|
||||
if (e.target.classList.contains('btn-del-img')) return;
|
||||
fileInput.click();
|
||||
});
|
||||
|
||||
// Save name + slug + description
|
||||
card.querySelector('.btn-save-hall').addEventListener('click', async function() {
|
||||
setStatus(i18n.hall_saving || 'Saving...');
|
||||
var newSlug = slugInput.value.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
||||
if (!newSlug) { setStatus("✗ " + (i18n.hall_slug_empty_error || 'Slug cannot be empty'), '#f55'); return; }
|
||||
try {
|
||||
var r = await fetch('/api/v2/admin/halls/' + encodeURIComponent(slug), {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json', 'x-csrf-token': (window.f0ckSession && window.f0ckSession.csrf_token) || '' },
|
||||
body: JSON.stringify({ name: nameInput.value, slug: newSlug, description: descInput.value, rating: ratingInput.value })
|
||||
});
|
||||
var d = await r.json();
|
||||
if (d.success) {
|
||||
// If slug changed, update all references in the DOM
|
||||
if (newSlug !== slug) {
|
||||
slug = newSlug; // update closure variable — all future fetches use new slug
|
||||
card.dataset.slug = newSlug;
|
||||
card.id = 'hall-card-' + newSlug;
|
||||
imgEl.src = '/hall_image/' + newSlug + '?v=' + Date.now();
|
||||
if (viewLink) viewLink.href = '/h/' + newSlug;
|
||||
slugInput.value = newSlug;
|
||||
}
|
||||
setStatus("✓ " + (i18n.hall_saved || 'Saved'), 'var(--accent)');
|
||||
} else {
|
||||
setStatus('✗ ' + d.msg, '#f55');
|
||||
}
|
||||
} catch (e) { setStatus("✗ " + (i18n.hall_error || 'Error'), '#f55'); }
|
||||
});
|
||||
|
||||
// Upload image
|
||||
fileInput.addEventListener('change', async function(e) {
|
||||
var file = e.target.files[0];
|
||||
if (!file) return;
|
||||
setStatus(i18n.uploading || 'Uploading...');
|
||||
var fd = new FormData();
|
||||
fd.append('file', file);
|
||||
try {
|
||||
var r = await fetch('/api/v2/admin/halls/' + encodeURIComponent(slug) + '/image', {
|
||||
method: 'POST',
|
||||
headers: { 'x-csrf-token': (window.f0ckSession && window.f0ckSession.csrf_token) || '' },
|
||||
body: fd
|
||||
});
|
||||
var d = await r.json();
|
||||
if (d.success) {
|
||||
imgEl.src = '/hall_image/' + slug + '?v=' + Date.now();
|
||||
setStatus("✓ " + (i18n.hall_image_uploaded || 'Image uploaded'), 'var(--accent)');
|
||||
if (!imgContainer.querySelector('.btn-del-img')) {
|
||||
var xBtn = document.createElement('button');
|
||||
xBtn.className = 'btn-del-img';
|
||||
xBtn.title = i18n.hall_click_upload_hint || 'Click to upload';
|
||||
xBtn.textContent = '✕';
|
||||
xBtn.style.cssText = 'position:absolute;top:6px;right:6px;width:24px;height:24px;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,0.7);color:#fff;border:1px solid rgba(255,255,255,0.3);border-radius:50%;cursor:pointer;font-size:0.75em;line-height:1;z-index:2;padding:0;';
|
||||
imgContainer.appendChild(xBtn);
|
||||
wireDelBtn(xBtn);
|
||||
}
|
||||
} else {
|
||||
setStatus('✗ ' + d.msg, '#f55');
|
||||
}
|
||||
} catch (e) { setStatus("✗ " + (i18n.hall_error || 'Error'), '#f55'); }
|
||||
e.target.value = '';
|
||||
});
|
||||
|
||||
function wireDelBtn(btn) {
|
||||
btn.addEventListener('click', async function(e) {
|
||||
e.stopPropagation();
|
||||
setStatus(i18n.hall_removing || 'Removing...');
|
||||
try {
|
||||
var r = await fetch('/api/v2/admin/halls/' + encodeURIComponent(slug) + '/image', {
|
||||
method: 'DELETE',
|
||||
headers: { 'x-csrf-token': (window.f0ckSession && window.f0ckSession.csrf_token) || '' }
|
||||
});
|
||||
var d = await r.json();
|
||||
if (d.success) {
|
||||
imgEl.src = '/hall_image/' + slug + '?v=' + Date.now();
|
||||
btn.remove();
|
||||
setStatus("✓ " + (i18n.hall_image_removed || 'Image removed'), 'var(--accent)');
|
||||
} else { setStatus('✗ ' + d.msg, '#f55'); }
|
||||
} catch (e) { setStatus("✗ " + (i18n.hall_error || 'Error'), '#f55'); }
|
||||
});
|
||||
}
|
||||
|
||||
var existingDelBtn = imgContainer.querySelector('.btn-del-img');
|
||||
if (existingDelBtn) wireDelBtn(existingDelBtn);
|
||||
|
||||
// Delete hall
|
||||
card.querySelector('.btn-delete-hall').addEventListener('click', async function() {
|
||||
var confirmMsg = (i18n.hall_delete_confirm || 'Really delete hall "{slug}"?').replace('{slug}', slug);
|
||||
if (!confirm(confirmMsg)) return;
|
||||
setStatus(i18n.hall_deleting || 'Deleting...');
|
||||
try {
|
||||
var r = await fetch('/api/v2/admin/halls/' + encodeURIComponent(slug), {
|
||||
method: 'DELETE',
|
||||
headers: { 'x-csrf-token': (window.f0ckSession && window.f0ckSession.csrf_token) || '' }
|
||||
});
|
||||
var d = await r.json();
|
||||
if (d.success) {
|
||||
card.style.transition = 'opacity 0.3s';
|
||||
card.style.opacity = '0';
|
||||
setTimeout(function() { card.remove(); }, 300);
|
||||
} else { setStatus('✗ ' + d.msg, '#f55'); }
|
||||
} catch (e) { setStatus('✗ Error', '#f55'); }
|
||||
});
|
||||
}
|
||||
|
||||
// Wire all existing cards on load
|
||||
document.querySelectorAll('.hall-manager-card').forEach(wireCard);
|
||||
|
||||
// ── Create Hall ──────────────────────────────────────────────────────────
|
||||
var newHallName = document.getElementById('new-hall-name');
|
||||
var newHallRating = document.getElementById('new-hall-rating');
|
||||
var slugPreview = document.getElementById('new-hall-slug-preview');
|
||||
var createBtn = document.getElementById('btn-create-hall');
|
||||
var createStatus = document.getElementById('create-hall-status');
|
||||
|
||||
function toSlug(s) {
|
||||
return s.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
newHallName.addEventListener('input', function() {
|
||||
var slug = toSlug(newHallName.value);
|
||||
slugPreview.textContent = slug ? '/' + slug : '';
|
||||
});
|
||||
|
||||
function buildCard(name, slug, rating) {
|
||||
var ratingOpts = ['sfw','nsfw','nsfl'].map(function(r) {
|
||||
return '<option value="' + r + '"' + (r === rating ? ' selected' : '') + '>' + (r === 'sfw' ? '🟢 SFW' : r === 'nsfw' ? '🔴 NSFW' : '💀 NSFL') + '</option>';
|
||||
}).join('');
|
||||
var div = document.createElement('div');
|
||||
div.className = 'hall-manager-card';
|
||||
div.id = 'hall-card-' + slug;
|
||||
div.dataset.slug = slug;
|
||||
div.innerHTML =
|
||||
'<div class="hall-manager-image" style="position:relative;height:140px;overflow:hidden;background:#111;cursor:pointer;" title="Click to upload a custom image">' +
|
||||
'<img src="/hall_image/' + slug + '" alt="' + name + '" style="width:100%;height:100%;object-fit:cover;opacity:0.8;">' +
|
||||
'<span style="position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:1.1em;font-weight:700;color:#fff;text-transform:uppercase;letter-spacing:0.05em;text-shadow:0 0 10px rgba(0,0,0,0.9),0 1px 3px rgba(0,0,0,0.8);pointer-events:none;">' + name + '</span>' +
|
||||
'<input type="file" class="hall-img-upload" accept="image/*" style="display:none;">' +
|
||||
'</div>' +
|
||||
'<div style="padding:12px;">' +
|
||||
'<div style="margin-bottom:8px;"><label style="font-size:0.8em;color:#888;display:block;">' + (i18n.common_name || 'Name') + '</label>' +
|
||||
'<input type="text" class="hall-name-input" value="' + name + '" style="width:100%;box-sizing:border-box;background:var(--badge-bg);border:1px solid rgba(255,255,255,0.15);color:var(--white);padding:5px 8px;border-radius:3px;font-family:var(--font);"></div>' +
|
||||
'<div style="margin-bottom:8px;"><label style="font-size:0.8em;color:#888;display:block;">' + (i18n.hall_slug || 'Slug') + '</label>' +
|
||||
'<input type="text" class="hall-slug-input" value="' + slug + '" style="width:100%;box-sizing:border-box;background:var(--badge-bg);border:1px solid rgba(255,255,255,0.15);color:var(--white);padding:5px 8px;border-radius:3px;font-family:var(--font);font-size:0.9em;color:#aaa;"></div>' +
|
||||
'<div style="margin-bottom:8px;"><label style="font-size:0.8em;color:#888;display:block;">' + (i18n.common_description || 'Description') + '</label>' +
|
||||
'<textarea class="hall-desc-input" style="width:100%;box-sizing:border-box;background:var(--badge-bg);border:1px solid rgba(255,255,255,0.15);color:var(--white);padding:5px 8px;border-radius:3px;font-family:var(--font);resize:vertical;min-height:60px;"></textarea></div>' +
|
||||
'<div style="margin-bottom:8px;"><label style="font-size:0.8em;color:#888;display:block;">' + (i18n.hall_rating || 'Rating') + '</label>' +
|
||||
'<select class="hall-rating-input" style="width:100%;box-sizing:border-box;background:var(--badge-bg);border:1px solid rgba(255,255,255,0.15);color:var(--white);padding:5px 8px;border-radius:3px;font-family:var(--font);">' + ratingOpts + '</select></div>' +
|
||||
'<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;">' +
|
||||
'<button class="btn-save-hall hm-btn" style="background:var(--accent);color:#000;border:none;font-weight:bold;">' + (i18n.common_save || 'Save') + '</button>' +
|
||||
'<a href="/h/' + slug + '" class="hall-view-link hm-btn" style="background:rgba(255,255,255,0.05);color:#aaa;border:1px solid rgba(255,255,255,0.1);">' + (i18n.common_view || 'View') + ' →</a>' +
|
||||
'<span style="margin-left:4px;font-size:0.8em;color:#666;">' + (i18n.hall_posts || '{count} posts').replace('{count}', '0') + '</span>' +
|
||||
'<button class="btn-delete-hall hm-btn" style="margin-left:auto;background:rgba(200,0,0,0.25);color:#f55;border:1px solid rgba(200,0,0,0.4);">🗑 ' + (i18n.common_delete || 'Delete') + '</button>' +
|
||||
'</div>' +
|
||||
'<div class="hall-status" style="margin-top:6px;font-size:0.8em;color:#888;min-height:1.2em;"></div>' +
|
||||
'</div>';
|
||||
return div;
|
||||
}
|
||||
|
||||
async function createHall() {
|
||||
var name = newHallName.value.trim();
|
||||
if (!name) { createStatus.textContent = i18n.hall_enter_name_error || 'Enter a name'; return; }
|
||||
var slug = toSlug(name);
|
||||
var rating = newHallRating ? newHallRating.value : 'sfw';
|
||||
createStatus.textContent = i18n.hall_creating || 'Creating...';
|
||||
createStatus.style.color = '#888';
|
||||
try {
|
||||
var r = await fetch('/api/v2/admin/halls', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'x-csrf-token': (window.f0ckSession && window.f0ckSession.csrf_token) || '' },
|
||||
body: JSON.stringify({ name: name, slug: slug, rating: rating })
|
||||
});
|
||||
var d = await r.json();
|
||||
if (d.success) {
|
||||
createStatus.textContent = "✓ " + (i18n.hall_saved || 'Saved') + "!";
|
||||
createStatus.style.color = 'var(--accent)';
|
||||
var card = buildCard(name, slug, rating);
|
||||
grid.appendChild(card);
|
||||
wireCard(card);
|
||||
// Scroll new card into view
|
||||
card.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
// Reset form
|
||||
newHallName.value = '';
|
||||
slugPreview.textContent = '';
|
||||
setTimeout(function() { createStatus.textContent = ''; }, 2000);
|
||||
} else {
|
||||
createStatus.textContent = '✗ ' + d.msg;
|
||||
createStatus.style.color = '#f55';
|
||||
}
|
||||
} catch (e) { createStatus.textContent = "✗ " + (i18n.hall_error || 'Error'); createStatus.style.color = '#f55'; }
|
||||
}
|
||||
|
||||
createBtn.addEventListener('click', createHall);
|
||||
newHallName.addEventListener('keydown', function(e) { if (e.key === 'Enter') createHall(); });
|
||||
})();
|
||||
</script>
|
||||
</div>
|
||||
</div>
|
||||
@include(snippets/footer)
|
||||
20
views/admin/log.html
Normal file
20
views/admin/log.html
Normal file
@@ -0,0 +1,20 @@
|
||||
@include(snippets/header)
|
||||
<div class="pagewrapper">
|
||||
<div id="main">
|
||||
@if(log)
|
||||
<h1>last {{ log.length }} entries:</h1>
|
||||
<div class="logwrap">
|
||||
@each(log as line)
|
||||
<p>{{ line }}</p>
|
||||
@endeach
|
||||
</div>
|
||||
<script>
|
||||
(() => {
|
||||
const d = document.querySelector("div.logwrap");
|
||||
d.scrollTop = d.scrollHeight;
|
||||
})();
|
||||
</script>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@include(snippets/footer)
|
||||
159
views/admin/memes.html
Normal file
159
views/admin/memes.html
Normal file
@@ -0,0 +1,159 @@
|
||||
@include(snippets/header)
|
||||
<div class="pagewrapper">
|
||||
<div id="main" class="admin-container">
|
||||
<div class="container">
|
||||
<h2>Meme Manager</h2>
|
||||
|
||||
<div class="admin-form-container"
|
||||
style="margin-bottom: 20px; text-align: left; background: var(--dropdown-bg); padding: 15px; border: 1px solid var(--nav-border-color);">
|
||||
<h4>Add New Meme Template</h4>
|
||||
<div style="display: flex; flex-direction: column; gap: 10px; max-width: 500px;">
|
||||
<input type="text" id="meme-id" placeholder="Template ID (e.g. surprised-pikachu)" style="background: var(--bg); border: 1px solid var(--black); padding: 8px; color: var(--white);">
|
||||
<input type="text" id="meme-name" placeholder="Display Name (e.g. Surprised Pikachu)" style="background: var(--bg); border: 1px solid var(--black); padding: 8px; color: var(--white);">
|
||||
<input type="text" id="meme-url" placeholder="Image URL (Alternative to upload)" style="background: var(--bg); border: 1px solid var(--black); padding: 8px; color: var(--white);">
|
||||
<div style="margin: 5px 0; color: #888; font-size: 0.8em; text-align: center;">- OR -</div>
|
||||
<input type="file" id="meme-file" accept="image/*" style="background: var(--bg); border: 1px solid var(--black); padding: 8px; color: var(--white);">
|
||||
<input type="text" id="meme-category" placeholder="Category (e.g. Classic, Reaction)" style="background: var(--bg); border: 1px solid var(--black); padding: 8px; color: var(--white);">
|
||||
<button id="add-meme" class="btn-upload" style="width: auto; padding: 10px 20px; border: 1px solid var(--nav-border-color); background: var(--bg); color: var(--white); cursor: pointer; align-self: flex-start;">Add Template</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-table-container" style="overflow-x: auto;">
|
||||
<table style="width: 100%; border-collapse: collapse; color: var(--white);">
|
||||
<thead>
|
||||
<tr style="border-bottom: 1px solid var(--nav-border-color); text-align: left;">
|
||||
<th style="padding: 10px;">Preview</th>
|
||||
<th style="padding: 10px;">Name</th>
|
||||
<th style="padding: 10px;">Category</th>
|
||||
<th style="padding: 10px;">Template ID</th>
|
||||
<th style="padding: 10px;">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="meme-list">
|
||||
<!-- Populated by JS -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(() => {
|
||||
var i18n = window.f0ckI18n || {};
|
||||
console.log('[MEME_ADMIN] Initializing');
|
||||
const esc = (s) => (s || '').toString().replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
||||
const loadMemes = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/v2/memes');
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
const tbody = document.getElementById('meme-list');
|
||||
if (!tbody) return;
|
||||
tbody.innerHTML = data.memes.map(m =>
|
||||
'<tr style="border-bottom: 1px solid rgba(255,255,255,0.05);">' +
|
||||
'<td style="padding: 10px;"><img src="' + m.url + '" style="height: 60px; width: 60px; object-fit: contain; border-radius: 4px; background: #000;"></td>' +
|
||||
'<td style="padding: 10px;">' + esc(m.name) + '</td>' +
|
||||
'<td style="padding: 10px;"><span style="background: rgba(255,255,255,0.1); padding: 2px 8px; border-radius: 10px; font-size: 0.85em;">' + esc(m.category || 'General') + '</span></td>' +
|
||||
'<td style="padding: 10px; font-family: monospace; opacity: 0.7;">' + esc(m.template_id) + '</td>' +
|
||||
'<td style="padding: 10px;">' +
|
||||
'<button onclick="window.memeAdmin.deleteMeme(' + m.id + ')" class="btn-remove" style="padding: 5px 15px; font-size: 0.8em; background: #c00; color: white; border: none; cursor: pointer; border-radius: 2px;">Delete</button>' +
|
||||
'</td>' +
|
||||
'</tr>'
|
||||
).join('');
|
||||
}
|
||||
} catch (err) { console.error('[MEME_ADMIN] Load Error:', err); }
|
||||
};
|
||||
|
||||
const addMeme = async (e) => {
|
||||
if (e) e.preventDefault();
|
||||
console.log('[MEME_ADMIN] addMeme triggered');
|
||||
|
||||
const template_id = document.getElementById('meme-id').value;
|
||||
const name = document.getElementById('meme-name').value;
|
||||
const url = document.getElementById('meme-url').value;
|
||||
const category = document.getElementById('meme-category').value;
|
||||
const fileInput = document.getElementById('meme-file');
|
||||
|
||||
if (!template_id || !name || (!url && !fileInput.files[0])) {
|
||||
return alert('Fill all fields (ID, Name, and either URL or File)');
|
||||
}
|
||||
|
||||
const btn = document.getElementById('add-meme');
|
||||
const oldText = btn.textContent;
|
||||
btn.disabled = true;
|
||||
btn.textContent = i18n.uploading || 'Uploading...';
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('template_id', template_id);
|
||||
formData.append('name', name);
|
||||
formData.append('url', url);
|
||||
formData.append('category', category);
|
||||
if (fileInput.files[0]) {
|
||||
formData.append('file', fileInput.files[0]);
|
||||
}
|
||||
|
||||
try {
|
||||
const headers = {
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
};
|
||||
const csrf = '{{ csrf_token }}';
|
||||
if (csrf) headers['X-CSRF-Token'] = csrf;
|
||||
|
||||
const res = await fetch('/api/v2/admin/memes', {
|
||||
method: 'POST',
|
||||
headers: headers,
|
||||
body: formData
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
document.getElementById('meme-id').value = '';
|
||||
document.getElementById('meme-name').value = '';
|
||||
document.getElementById('meme-url').value = '';
|
||||
document.getElementById('meme-category').value = '';
|
||||
document.getElementById('meme-file').value = '';
|
||||
loadMemes();
|
||||
} else {
|
||||
alert('Server Error: ' + (data.message || data.msg || 'Unknown error'));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[MEME_ADMIN] Post Error:', e);
|
||||
alert('Submission failed: ' + e.message);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = oldText;
|
||||
}
|
||||
};
|
||||
|
||||
const deleteMeme = async (id) => {
|
||||
if (!confirm('Delete this template?')) return;
|
||||
try {
|
||||
const res = await fetch('/api/v2/admin/memes/' + id + '/delete', {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-Token': '{{ csrf_token }}' }
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
loadMemes();
|
||||
} else {
|
||||
alert('Delete failed');
|
||||
}
|
||||
} catch (e) { console.error(e); }
|
||||
};
|
||||
|
||||
// Global scope for onclick handlers
|
||||
window.memeAdmin = { deleteMeme };
|
||||
|
||||
const btnAddMeme = document.getElementById('add-meme');
|
||||
if (btnAddMeme) {
|
||||
console.log('[MEME_ADMIN] Registering click listener');
|
||||
btnAddMeme.addEventListener('click', addMeme);
|
||||
} else {
|
||||
console.error('[MEME_ADMIN] Add button not found!');
|
||||
}
|
||||
|
||||
loadMemes();
|
||||
})();
|
||||
</script>
|
||||
</div>
|
||||
</div>
|
||||
@include(snippets/footer)
|
||||
77
views/admin/motd.html
Normal file
77
views/admin/motd.html
Normal file
@@ -0,0 +1,77 @@
|
||||
@include(snippets/header)
|
||||
<div class="pagewrapper">
|
||||
<div id="main" class="admin-container">
|
||||
<div class="container">
|
||||
<h2>Message of the Day (MOTD)</h2>
|
||||
<p style="color: #ccc; margin-bottom: 20px;">This message is displayed <strong>inside the navbar</strong> (at the bottom) so it stays visible while scrolling. Supports Markdown and HTML.</p>
|
||||
|
||||
<div class="admin-motd-form">
|
||||
<form id="motd-form" action="/admin/motd" method="POST" onsubmit="event.preventDefault(); saveMotd(this);">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<div style="margin-bottom: 20px;">
|
||||
<label for="motd-text" style="display: block; margin-bottom: 8px; color: var(--accent);">MOTD Content (Markdown supported)</label>
|
||||
<textarea id="motd-text" name="motd" style="width: 100%; min-height: 200px; background: rgba(0,0,0,0.3); border: 1px solid rgba(255,255,255,0.1); color: #fff; padding: 15px; border-radius: 4px; font-family: inherit; font-size: 1.1em; resize: vertical;">{!! motd !!}</textarea>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 10px; align-items: center;">
|
||||
<button type="submit" class="btn-primary" style="width: auto; padding: 10px 30px;">Save MOTD</button>
|
||||
<button type="button" class="btn-remove" style="width: auto; padding: 10px 20px;" onclick="document.getElementById('motd-text').value=''; saveMotd(document.getElementById('motd-form'));">Clear MOTD</button>
|
||||
<span id="motd-status" style="margin-left: 10px; font-weight: bold; display: none;"></span>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 40px; padding: 20px; background: rgba(0,0,0,0.2); border-left: 4px solid var(--accent); border-radius: 4px;">
|
||||
<h4 style="color: var(--accent); margin-top: 0;">Preview Tip</h4>
|
||||
<p style="margin-bottom: 0;">You can use <strong>Markdown</strong> syntax like <code># Header</code>, <code>**bold**</code>, <code>[links](url)</code>, or standard HTML. The message updates instantly site-wide when you click Save.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
async function saveMotd(form) {
|
||||
const status = document.getElementById('motd-status');
|
||||
const textarea = document.getElementById('motd-text');
|
||||
const motd = textarea.value;
|
||||
|
||||
status.textContent = 'Saving...';
|
||||
status.style.color = 'var(--accent)';
|
||||
status.style.display = 'inline';
|
||||
|
||||
try {
|
||||
const res = await fetch(form.action, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-CSRF-Token': window.f0ckSession?.csrf_token
|
||||
},
|
||||
body: new URLSearchParams(new FormData(form))
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
status.textContent = 'Saved!';
|
||||
status.style.color = '#28a745';
|
||||
|
||||
if (typeof window.updateMotdUI === 'function') {
|
||||
window['motd_dismissed'] = false; // Force show on save
|
||||
window.updateMotdUI(motd);
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
status.style.display = 'none';
|
||||
}, 2000);
|
||||
} else {
|
||||
throw new Error(data.msg || 'Unknown error');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('MOTD Save Error:', err);
|
||||
status.textContent = 'Error: ' + err.message;
|
||||
status.style.color = '#d9534f';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</div>
|
||||
</div>
|
||||
@include(snippets/footer)
|
||||
26
views/admin/recover.html
Normal file
26
views/admin/recover.html
Normal file
@@ -0,0 +1,26 @@
|
||||
@include(snippets/header)
|
||||
<div id="main">
|
||||
<table class="table" style="width: 100%">
|
||||
<thead>
|
||||
<tr>
|
||||
<td></td>
|
||||
<td>ID</td>
|
||||
<td>f0cker</td>
|
||||
<td>mime</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@each(posts as post)
|
||||
<tr>
|
||||
<td><img src="data:image/webp;base64,{{ post.thumbnail }}" /></td>
|
||||
<td>{{ post.id }}</td>
|
||||
<td>{!! post.username !!}</td>
|
||||
<td>{{ post.mime }}</td>
|
||||
<td><a href="/admin/recover/?id={{ post.id }}">recover</a></td>
|
||||
</tr>
|
||||
@endeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@include(snippets/footer)
|
||||
68
views/admin/rules.html
Normal file
68
views/admin/rules.html
Normal file
@@ -0,0 +1,68 @@
|
||||
@include(snippets/header)
|
||||
<div class="pagewrapper">
|
||||
<div id="main" class="admin-container">
|
||||
<div class="container">
|
||||
<h2>Rules Page Content</h2>
|
||||
<p style="color: #ccc; margin-bottom: 20px;">This text is displayed on the <strong>/rules</strong> page. Supports Markdown. Leave empty to show the default static rules template.</p>
|
||||
|
||||
<div class="admin-motd-form">
|
||||
<form id="rules-form" action="/admin/rules" method="POST" onsubmit="event.preventDefault(); savePage(this, 'rules');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<div style="margin-bottom: 20px;">
|
||||
<label for="rules-text" style="display: block; margin-bottom: 8px; color: var(--accent);">Rules Page Content (Markdown supported)</label>
|
||||
<textarea id="rules-text" name="rules_text" style="width: 100%; min-height: 300px; background: rgba(0,0,0,0.3); border: 1px solid rgba(255,255,255,0.1); color: #fff; padding: 15px; border-radius: 4px; font-family: inherit; font-size: 1.1em; resize: vertical;">{!! rules_text !!}</textarea>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 10px; align-items: center;">
|
||||
<button type="submit" class="btn-primary" style="width: auto; padding: 10px 30px;">Save</button>
|
||||
<button type="button" class="btn-remove" style="width: auto; padding: 10px 20px;" onclick="document.getElementById('rules-text').value=''; savePage(document.getElementById('rules-form'), 'rules');">Clear</button>
|
||||
<span id="rules-status" style="margin-left: 10px; font-weight: bold; display: none;"></span>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 40px; padding: 20px; background: rgba(0,0,0,0.2); border-left: 4px solid var(--accent); border-radius: 4px;">
|
||||
<h4 style="color: var(--accent); margin-top: 0;">Tips</h4>
|
||||
<p style="margin-bottom: 0;">Use <strong>Markdown</strong> syntax like <code># Header</code>, <code>**bold**</code>, <code>[links](url)</code>, or plain HTML.<br>
|
||||
If empty, the default static rules page is shown instead.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
async function savePage(form, page) {
|
||||
const statusId = page + '-status';
|
||||
const status = document.getElementById(statusId);
|
||||
|
||||
status.textContent = 'Saving...';
|
||||
status.style.color = 'var(--accent)';
|
||||
status.style.display = 'inline';
|
||||
|
||||
try {
|
||||
const res = await fetch(form.action, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-CSRF-Token': window.f0ckSession?.csrf_token
|
||||
},
|
||||
body: new URLSearchParams(new FormData(form))
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
status.textContent = 'Saved!';
|
||||
status.style.color = '#28a745';
|
||||
setTimeout(() => { status.style.display = 'none'; }, 2000);
|
||||
} else {
|
||||
throw new Error(data.msg || 'Unknown error');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Save Error:', err);
|
||||
status.textContent = 'Error: ' + err.message;
|
||||
status.style.color = '#d9534f';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</div>
|
||||
</div>
|
||||
@include(snippets/footer)
|
||||
80
views/admin/sessions.html
Normal file
80
views/admin/sessions.html
Normal file
@@ -0,0 +1,80 @@
|
||||
@include(snippets/header)
|
||||
<div class="pagewrapper">
|
||||
<div id="main" class="session-grid">
|
||||
<h2 class="session-page-title">
|
||||
Sessions
|
||||
<span class="session-stats">
|
||||
@if(activeUsers > 0)
|
||||
({{ activeUsers }} active: {{ activeUserList.join(', ') }})
|
||||
@else
|
||||
(0 active)
|
||||
@endif
|
||||
</span>
|
||||
</h2>
|
||||
@each(sessions as s)
|
||||
<div class="session-card {{ s.id === session.sess_id ? 'current' : '' }}">
|
||||
<div class="session-header">
|
||||
<span class="session-user">{!! s.user !!}</span>
|
||||
<div class="session-badges">
|
||||
@if(s.id === session.sess_id)
|
||||
<span class="badge badge-current">Current</span>
|
||||
@endif
|
||||
@if(s.kmsi)
|
||||
<span class="badge badge-kmsi" title="Keep Me Signed In">KMSI</span>
|
||||
@endif
|
||||
<span class="session-id">#{{ s.id }}</span>
|
||||
@if(s.id !== session.sess_id)
|
||||
<a href="javascript:void(0)" onclick="deleteSession({{ s.id }}, this)" class="session-delete"
|
||||
title="Delete Session">✖</a>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
<div class="session-body">
|
||||
<div class="session-info">
|
||||
<span class="label">Browser:</span>
|
||||
<span class="value browser-info" title="{{ s.browser }}">{{ s.browser }}</span>
|
||||
</div>
|
||||
<div class="session-info">
|
||||
<span class="label">Created:</span>
|
||||
<span class="value">{{ new Date(s.created_at * 1e3).toLocaleString("de-DE") }}</span>
|
||||
</div>
|
||||
<div class="session-info">
|
||||
<span class="label">Last Used:</span>
|
||||
<span class="value">{{ new Date(s.last_used * 1e3).toLocaleString("de-DE") }}</span>
|
||||
</div>
|
||||
<div class="session-info">
|
||||
<span class="label">Last Action:</span>
|
||||
<span class="value">{{ s.last_action }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@endeach
|
||||
<script>
|
||||
async function deleteSession(id, el) {
|
||||
if (!confirm('Are you sure you want to delete this session?')) return;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/v2/admin/sessions/delete', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id })
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success) {
|
||||
// Remove the card
|
||||
const card = el.closest('.session-card');
|
||||
card.style.opacity = '0';
|
||||
setTimeout(() => card.remove(), 200);
|
||||
} else {
|
||||
alert(data.msg || 'Failed to delete session');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert('An error occurred');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</div>
|
||||
</div>
|
||||
@include(snippets/footer)
|
||||
68
views/admin/terms.html
Normal file
68
views/admin/terms.html
Normal file
@@ -0,0 +1,68 @@
|
||||
@include(snippets/header)
|
||||
<div class="pagewrapper">
|
||||
<div id="main" class="admin-container">
|
||||
<div class="container">
|
||||
<h2>Terms of Service Page Content</h2>
|
||||
<p style="color: #ccc; margin-bottom: 20px;">This text is displayed on the <strong>/terms</strong> page. Supports Markdown. Leave empty to show the default static template.</p>
|
||||
|
||||
<div class="admin-motd-form">
|
||||
<form id="terms-form" action="/admin/terms" method="POST" onsubmit="event.preventDefault(); savePage(this, 'terms');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<div style="margin-bottom: 20px;">
|
||||
<label for="terms-text" style="display: block; margin-bottom: 8px; color: var(--accent);">Terms Page Content (Markdown supported)</label>
|
||||
<textarea id="terms-text" name="terms_text" style="width: 100%; min-height: 300px; background: rgba(0,0,0,0.3); border: 1px solid rgba(255,255,255,0.1); color: #fff; padding: 15px; border-radius: 4px; font-family: inherit; font-size: 1.1em; resize: vertical;">{!! terms_text !!}</textarea>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 10px; align-items: center;">
|
||||
<button type="submit" class="btn-primary" style="width: auto; padding: 10px 30px;">Save</button>
|
||||
<button type="button" class="btn-remove" style="width: auto; padding: 10px 20px;" onclick="document.getElementById('terms-text').value=''; savePage(document.getElementById('terms-form'), 'terms');">Clear</button>
|
||||
<span id="terms-status" style="margin-left: 10px; font-weight: bold; display: none;"></span>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 40px; padding: 20px; background: rgba(0,0,0,0.2); border-left: 4px solid var(--accent); border-radius: 4px;">
|
||||
<h4 style="color: var(--accent); margin-top: 0;">Tips</h4>
|
||||
<p style="margin-bottom: 0;">Use <strong>Markdown</strong> syntax like <code># Header</code>, <code>**bold**</code>, <code>[links](url)</code>, or plain HTML.<br>
|
||||
If empty, the default static terms page is shown instead.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
async function savePage(form, page) {
|
||||
const statusId = page + '-status';
|
||||
const status = document.getElementById(statusId);
|
||||
|
||||
status.textContent = 'Saving...';
|
||||
status.style.color = 'var(--accent)';
|
||||
status.style.display = 'inline';
|
||||
|
||||
try {
|
||||
const res = await fetch(form.action, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-CSRF-Token': window.f0ckSession?.csrf_token
|
||||
},
|
||||
body: new URLSearchParams(new FormData(form))
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
status.textContent = 'Saved!';
|
||||
status.style.color = '#28a745';
|
||||
setTimeout(() => { status.style.display = 'none'; }, 2000);
|
||||
} else {
|
||||
throw new Error(data.msg || 'Unknown error');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Save Error:', err);
|
||||
status.textContent = 'Error: ' + err.message;
|
||||
status.style.color = '#d9534f';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</div>
|
||||
</div>
|
||||
@include(snippets/footer)
|
||||
101
views/admin/tokens.html
Normal file
101
views/admin/tokens.html
Normal file
@@ -0,0 +1,101 @@
|
||||
@include(snippets/header)
|
||||
<div class="pagewrapper">
|
||||
<div id="main" class="admin-container">
|
||||
<div class="admin-header-flex">
|
||||
<h2>Invite Tokens</h2>
|
||||
<button id="generate-token" class="btn-upload" style="width: auto; padding: 10px 20px;">Generate New Token</button>
|
||||
</div>
|
||||
|
||||
<div class="upload-form">
|
||||
<table class="responsive-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Token</th>
|
||||
<th>Status</th>
|
||||
<th>Source</th>
|
||||
<th>Used By</th>
|
||||
<th>Created</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="token-list">
|
||||
<!-- Populated by JS -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const loadTokens = async () => {
|
||||
try {
|
||||
console.log('Loading tokens...');
|
||||
const res = await fetch('/api/v2/admin/tokens');
|
||||
const data = await res.json();
|
||||
console.log('Tokens data:', data);
|
||||
if (data.success) {
|
||||
const tbody = document.getElementById('token-list');
|
||||
if (!tbody) return;
|
||||
tbody.innerHTML = data.tokens.map(t =>
|
||||
'<tr>' +
|
||||
'<td data-label="Token" style="font-family: monospace; font-size: 1.1em; color: var(--accent);">' + t.token + '</td>' +
|
||||
'<td data-label="Status">' +
|
||||
(t.is_used ? '<span style="color: #ff6b6b">Used</span>' : '<span style="color: #51cf66">Available</span>') +
|
||||
'</td>' +
|
||||
'<td data-label="Source">' +
|
||||
(t.created_by_matrix ? '<span style="color: #0DBD8B">[Matrix] ' + t.created_by_matrix + '</span>' :
|
||||
(t.created_by_discord ? '<span style="color: #5865F2"><i class="fab fa-discord"></i> ' + t.created_by_discord + '</span>' :
|
||||
(t.created_by_name ? 'Web/Admin (' + t.created_by_name + ')' : 'Web/Admin'))) +
|
||||
'</td>' +
|
||||
'<td data-label="Used By">' + (t.used_by_name || '-') + '</td>' +
|
||||
'<td data-label="Created">' + (t.created_at ? new Date(parseInt(t.created_at) * 1000).toLocaleString() : '-') + '</td>' +
|
||||
'<td data-label="Actions">' +
|
||||
(!t.is_used ? '<button onclick="deleteToken(' + t.id + ')" class="btn-remove" style="padding: 5px 10px; font-size: 0.8em;">Delete</button>' : '') +
|
||||
'</td>' +
|
||||
'</tr>'
|
||||
).join('');
|
||||
}
|
||||
} catch (e) { console.error(e); }
|
||||
};
|
||||
|
||||
const generateToken = async () => {
|
||||
console.log('Generating...');
|
||||
try {
|
||||
const res = await fetch('/api/v2/admin/tokens/create', {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-Token': window.f0ckSession?.csrf_token }
|
||||
});
|
||||
const data = await res.json();
|
||||
console.log('Gen result:', data);
|
||||
if (data.success) {
|
||||
loadTokens();
|
||||
} else {
|
||||
alert('Failed: ' + data.msg);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert('Error: ' + e.message);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteToken = async (id) => {
|
||||
if (!confirm('Delete this token?')) return;
|
||||
const res = await fetch('/api/v2/admin/tokens/delete', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': window.f0ckSession?.csrf_token
|
||||
},
|
||||
body: JSON.stringify({ id })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
loadTokens();
|
||||
}
|
||||
};
|
||||
|
||||
const btnGenToken = document.getElementById('generate-token');
|
||||
if (btnGenToken) btnGenToken.addEventListener('click', generateToken);
|
||||
loadTokens();
|
||||
</script>
|
||||
</div>
|
||||
</div>
|
||||
@include(snippets/footer)
|
||||
389
views/admin/users.html
Normal file
389
views/admin/users.html
Normal file
@@ -0,0 +1,389 @@
|
||||
@include(snippets/header)
|
||||
|
||||
<div class="pagewrapper">
|
||||
<div id="main" class="admin-container">
|
||||
<style>
|
||||
.admin-users-table {
|
||||
width: 100%;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0 8px;
|
||||
color: var(--white);
|
||||
}
|
||||
.admin-users-table th {
|
||||
padding: 15px;
|
||||
text-align: left;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 1px;
|
||||
color: #888;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.05);
|
||||
}
|
||||
.admin-users-table tr {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.admin-users-table tbody tr {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
.admin-users-table tbody tr:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
.admin-users-table td {
|
||||
padding: 15px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.status-badge {
|
||||
padding: 4px 10px;
|
||||
border-radius: 20px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
display: inline-block;
|
||||
}
|
||||
.status-active { background: linear-gradient(135deg, #28a745, #20c997); color: #fff; }
|
||||
.status-pending { background: linear-gradient(135deg, #f08c00, #ffc107); color: #000; }
|
||||
.status-banned { background: linear-gradient(135deg, #e03131, #f03e3e); color: #fff; }
|
||||
|
||||
.method-tag {
|
||||
font-size: 0.7rem;
|
||||
padding: 2px 8px;
|
||||
background: rgba(var(--accent-rgb, 0, 150, 255), 0.1);
|
||||
border: 1px solid rgba(var(--accent-rgb, 0, 150, 255), 0.2);
|
||||
color: var(--accent);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.stat-box {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 0.85rem;
|
||||
color: #ccc;
|
||||
transition: color 0.2s, transform 0.1s;
|
||||
}
|
||||
.stat-box:hover {
|
||||
color: var(--accent);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.stat-box svg { opacity: 0.6; }
|
||||
|
||||
.btn-modern {
|
||||
border: 0;
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
color: #fff;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn-modern:hover { opacity: 0.8; }
|
||||
.btn-ban { background: #e03131; }
|
||||
.btn-unban { background: #28a745; }
|
||||
.btn-files { background: #f08c00; }
|
||||
.btn-comms { background: #4dabf7; }
|
||||
.btn-verify { background: #5c7cfa; }
|
||||
|
||||
.user-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
object-fit: cover;
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
}
|
||||
.avatar-placeholder {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: linear-gradient(135deg, #333, #111);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: bold;
|
||||
color: var(--accent);
|
||||
font-size: 1.2rem;
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="container">
|
||||
<div style="display: flex; justify-content: space-between; align-items: flex-end; margin-bottom: 30px; gap: 20px; flex-wrap: wrap;">
|
||||
<div>
|
||||
<h2 style="margin: 0; font-weight: 800; letter-spacing: -0.5px;">User Management</h2>
|
||||
<p style="color: #888; margin: 5px 0 0 0;">Administration hub for <span id="total-count">{!! total !!}</span> registered members.</p>
|
||||
</div>
|
||||
<div style="flex-grow: 1; max-width: 400px; position: relative;">
|
||||
<input type="text" id="user-search" placeholder="Search by name or email..."
|
||||
style="width: 100%; padding: 12px 20px; background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1); border-radius: 8px; color: #fff; outline: none; transition: border-color 0.2s;"
|
||||
value="{{ q }}">
|
||||
<div id="search-spinner" style="position: absolute; right: 15px; top: 12px; display: none;">
|
||||
<svg class="spinner" width="20" height="20" viewBox="0 0 50 50" style="animation: rotate 2s linear infinite;">
|
||||
<circle cx="25" cy="25" r="20" fill="none" stroke="var(--accent)" stroke-width="5" stroke-dasharray="90,150" stroke-dashoffset="0" style="stroke-linecap: round;"></circle>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="upload-form">
|
||||
<table class="admin-users-table responsive-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>User & Contact</th>
|
||||
<th>Activity</th>
|
||||
<th>Registration</th>
|
||||
<th>Account Age</th>
|
||||
<th>Status</th>
|
||||
<th style="text-align: right;">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="user-table-body">
|
||||
@include(admin/users_list)
|
||||
</tbody>
|
||||
</table>
|
||||
<div id="loading-trigger" style="height: 20px; margin-top: 10px;"></div>
|
||||
<div id="no-users-msg" style="display: {{ users.length === 0 ? 'block' : 'none' }}; padding: 40px; text-align: center; color: #666;">
|
||||
No users matched your search.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function updateUI(id, data, action) {
|
||||
var rowId = id ? id : ('ghost-' + data.username);
|
||||
var statusCell = document.getElementById('status-cell-' + rowId);
|
||||
var actionsDiv = document.getElementById('actions-' + rowId);
|
||||
if (!statusCell || !actionsDiv) return console.error('UI elements not found for ID', rowId);
|
||||
|
||||
if (action === 'activate') {
|
||||
statusCell.innerHTML = '<span class="status-badge status-active">Active</span>';
|
||||
var verifyBtn = actionsDiv.querySelector('.btn-verify');
|
||||
if (verifyBtn) verifyBtn.remove();
|
||||
} else if (action === 'ban') {
|
||||
statusCell.innerHTML = '<span class="status-badge status-banned">Banned</span>';
|
||||
var banBtn = actionsDiv.querySelector('.btn-ban');
|
||||
if (banBtn) {
|
||||
banBtn.textContent = 'Unban';
|
||||
banBtn.className = 'btn-modern btn-unban';
|
||||
// We re-bind the onclick via HTML because simple assignment doesn't preserve the 'this' context in the same way with these handlers
|
||||
}
|
||||
var verifyBtn = actionsDiv.querySelector('.btn-verify');
|
||||
if (verifyBtn) verifyBtn.remove();
|
||||
} else if (action === 'unban') {
|
||||
statusCell.innerHTML = '<span class="status-badge status-active">Active</span>';
|
||||
var unbanBtn = actionsDiv.querySelector('.btn-unban');
|
||||
if (unbanBtn) {
|
||||
unbanBtn.textContent = 'Ban';
|
||||
unbanBtn.className = 'btn-modern btn-ban';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function activateUser(btn) {
|
||||
var id = btn.dataset.id;
|
||||
var userName = btn.dataset.name;
|
||||
|
||||
ModAction.confirm('Verify User', 'Manually verify account for <strong>' + escHTML(userName) + '</strong>?', async () => {
|
||||
var res = await fetch('/api/v2/admin/users/activate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ user_id: id })
|
||||
});
|
||||
var data = await res.json();
|
||||
if (data.success) {
|
||||
showFlash(data.msg, 'success');
|
||||
updateUI(id, data, 'activate');
|
||||
} else {
|
||||
throw new Error(data.msg || 'Failed to verify user');
|
||||
}
|
||||
}, { hideReason: true });
|
||||
}
|
||||
|
||||
async function banUser(btn) {
|
||||
var id = btn.dataset.id;
|
||||
var userName = btn.dataset.name;
|
||||
|
||||
ModAction.confirm('Ban User', 'Reason for banning <strong>' + escHTML(userName) + '</strong>?', async (reason) => {
|
||||
var res = await fetch('/api/v2/admin/ban', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ user_id: id, reason: reason, duration: 'permanent' })
|
||||
});
|
||||
var data = await res.json();
|
||||
if (data.success) {
|
||||
showFlash('User ' + escHTML(userName) + ' banned.', 'success');
|
||||
updateUI(id, data, 'ban');
|
||||
} else {
|
||||
throw new Error(data.msg || 'Failed to ban user');
|
||||
}
|
||||
}, { hideReason: false, confirmText: 'Ban User' });
|
||||
}
|
||||
|
||||
async function unbanUser(btn) {
|
||||
var id = btn.dataset.id;
|
||||
var userName = btn.dataset.name;
|
||||
|
||||
ModAction.confirm('Unban User', 'Unban account for <strong>' + escHTML(userName) + '</strong>?', async () => {
|
||||
var res = await fetch('/api/v2/admin/unban', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ user_id: id })
|
||||
});
|
||||
var data = await res.json();
|
||||
if (data.success) {
|
||||
showFlash('User ' + escHTML(userName) + ' unbanned.', 'success');
|
||||
updateUI(id, data, 'unban');
|
||||
} else {
|
||||
throw new Error(data.msg || 'Failed to unban user');
|
||||
}
|
||||
}, { hideReason: true });
|
||||
}
|
||||
|
||||
async function deleteUploads(btn) {
|
||||
var id = btn.dataset.id;
|
||||
var userName = btn.dataset.name;
|
||||
|
||||
ModAction.confirm('Delete Uploads', 'Are you SURE you want to delete ALL uploads by <strong>' + escHTML(userName) + '</strong>? This cannot be undone.', async () => {
|
||||
var res = await fetch('/api/v2/admin/users/bulk-delete-items', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ user_id: id, username: btn.dataset.username })
|
||||
});
|
||||
var data = await res.json();
|
||||
if (data.success) {
|
||||
showFlash(data.msg, 'success');
|
||||
} else {
|
||||
throw new Error(data.msg || 'Deletion failed');
|
||||
}
|
||||
}, { hideReason: true, confirmText: 'Delete Everything' });
|
||||
}
|
||||
|
||||
async function deleteComments(btn) {
|
||||
var id = btn.dataset.id;
|
||||
var userName = btn.dataset.name;
|
||||
|
||||
ModAction.confirm('Delete Comments', 'Are you SURE you want to delete ALL comments by <strong>' + escHTML(userName) + '</strong>? This will be permanent.', async () => {
|
||||
var res = await fetch('/api/v2/admin/users/bulk-delete-comments', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ user_id: id, username: btn.dataset.username })
|
||||
});
|
||||
var data = await res.json();
|
||||
if (data.success) {
|
||||
showFlash(data.msg, 'success');
|
||||
} else {
|
||||
throw new Error(data.msg || 'Deletion failed');
|
||||
}
|
||||
}, { hideReason: true, confirmText: 'Delete All Comments' });
|
||||
}
|
||||
|
||||
async function adminSetDisplayName(btn) {
|
||||
var id = btn.dataset.id;
|
||||
var userName = btn.dataset.name;
|
||||
var currentDisplay = btn.dataset.display || '';
|
||||
|
||||
var hint = currentDisplay
|
||||
? 'Current nick: <strong style="color: var(--accent);">' + escHTML(currentDisplay) + '</strong><br>Enter a new stylized name, or leave empty to clear it.'
|
||||
: 'Enter a stylized display name for <strong>' + escHTML(userName) + '</strong> (e.g. <code>F.O.O</code>). Leave empty to clear.';
|
||||
|
||||
ModAction.confirm('Set Display Name', hint, async (newName) => {
|
||||
var res = await fetch('/api/v2/admin/users/set-display-name', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ user_id: id, display_name: newName || '' })
|
||||
});
|
||||
var data = await res.json();
|
||||
if (data.success) {
|
||||
showFlash(data.msg || 'Display name updated.', 'success');
|
||||
// Update button data attribute for next edit
|
||||
btn.dataset.display = data.display_name || '';
|
||||
// Refresh the row name link in the table
|
||||
var rowId = 'user-row-' + id;
|
||||
var row = document.getElementById(rowId);
|
||||
if (row) {
|
||||
var link = row.querySelector('.user-info-cell a');
|
||||
if (link && data.display_name) {
|
||||
link.innerHTML = '<span style="color: var(--accent);">' + escHTML(data.display_name) + '</span> <span style="font-size: 0.75em; color: #666;">(' + escHTML(userName) + ')</span>';
|
||||
} else if (link) {
|
||||
link.textContent = userName;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw new Error(data.msg || 'Failed to set display name');
|
||||
}
|
||||
}, { hideReason: false, allowEmpty: true, confirmText: 'Set Nick', placeholder: currentDisplay || 'e.g. F.O.O' });
|
||||
}
|
||||
|
||||
var currentPage = {!! page !!};
|
||||
var hasMore = {!! hasMore ? 'true' : 'false' !!};
|
||||
var isLoading = false;
|
||||
var searchQuery = '{{ q }}';
|
||||
|
||||
var searchInput = document.getElementById('user-search');
|
||||
var tableBody = document.getElementById('user-table-body');
|
||||
var loadingTrigger = document.getElementById('loading-trigger');
|
||||
var noUsersMsg = document.getElementById('no-users-msg');
|
||||
var spinner = document.getElementById('search-spinner');
|
||||
var countSpan = document.getElementById('total-count');
|
||||
|
||||
async function fetchUsers(page, q, append) {
|
||||
if (isLoading) return;
|
||||
isLoading = true;
|
||||
if (!append) spinner.style.display = 'block';
|
||||
|
||||
try {
|
||||
var url = '/admin/users?page=' + page + '&q=' + encodeURIComponent(q);
|
||||
var res = await fetch(url, { headers: { 'X-Requested-With': 'XMLHttpRequest' } });
|
||||
|
||||
// Update state from headers
|
||||
var total = res.headers.get('X-Total-Count');
|
||||
var hasMoreHeader = res.headers.get('X-Has-More');
|
||||
|
||||
if (total !== null) countSpan.textContent = total;
|
||||
if (hasMoreHeader !== null) hasMore = (hasMoreHeader === 'true');
|
||||
|
||||
var html = await res.text();
|
||||
|
||||
if (append) {
|
||||
tableBody.insertAdjacentHTML('beforeend', html);
|
||||
} else {
|
||||
tableBody.innerHTML = html;
|
||||
}
|
||||
|
||||
noUsersMsg.style.display = (tableBody.children.length === 0) ? 'block' : 'none';
|
||||
} catch (e) {
|
||||
console.error('Fetch failed', e);
|
||||
} finally {
|
||||
isLoading = false;
|
||||
spinner.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Search Input Handling (Debounced)
|
||||
var searchTimeout;
|
||||
searchInput.addEventListener('input', function() {
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(function() {
|
||||
searchQuery = searchInput.value;
|
||||
currentPage = 1;
|
||||
fetchUsers(1, searchQuery, false);
|
||||
}, 300);
|
||||
});
|
||||
|
||||
// Infinite Scroll
|
||||
var observer = new IntersectionObserver(function(entries) {
|
||||
if (entries[0].isIntersecting && hasMore && !isLoading) {
|
||||
currentPage++;
|
||||
fetchUsers(currentPage, searchQuery, true);
|
||||
}
|
||||
}, { threshold: 0.1 });
|
||||
observer.observe(loadingTrigger);
|
||||
|
||||
// Styling for spinner
|
||||
var style = document.createElement('style');
|
||||
style.innerHTML = '@keyframes rotate { 100% { transform: rotate(360deg); } }';
|
||||
document.head.appendChild(style);
|
||||
</script>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@include(snippets/footer)
|
||||
91
views/admin/users_list.html
Normal file
91
views/admin/users_list.html
Normal file
@@ -0,0 +1,91 @@
|
||||
@for(let u of users)
|
||||
<tr id="user-row-{{ u.id || 'ghost-' + u.login }}">
|
||||
<td data-label="User & Contact">
|
||||
<div class="user-info-cell" style="display: flex; align-items: center; gap: 15px;">
|
||||
@if(u.avatar_file)
|
||||
<img src="/a/{{ u.avatar_file }}" class="user-avatar" alt="Avatar" style="width: 45px; height: 45px; border-radius: 10px; object-fit: cover; border: 2px solid rgba(255,255,255,0.1);">
|
||||
@else
|
||||
<div class="avatar-placeholder" style="width: 45px; height: 45px; border-radius: 10px; display: flex; align-items: center; justify-content: center; background: rgba(255,255,255,0.05); color: var(--accent); font-weight: bold; border: 2px solid rgba(255,255,255,0.1); font-size: 1.2rem; {{ !u.id ? 'background: #333; color: #666;' : '' }}">{{ !u.id ? '?' : u.user.charAt(0).toUpperCase() }}</div>
|
||||
@endif
|
||||
<div>
|
||||
<a href="/user/{{ u.login }}" target="_blank" style="color: #fff; font-weight: 800; font-size: 1.1rem; text-decoration: none; display: block; margin-bottom: 2px;">@if(u.display_name)<span style="color: var(--accent);">{!! u.display_name !!}</span> <span style="font-size: 0.75em; color: #666;">({!! u.user !!})</span>@else{!! u.user !!}@endif</a>
|
||||
<div style="font-size: 0.8rem; color: #888; letter-spacing: 0.2px;">{{ !u.id ? 'Ghost User / Legacy' : (u.email || 'no email') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td data-label="Activity">
|
||||
<div style="display: flex; align-items: center; gap: 15px; white-space: nowrap;">
|
||||
<a href="/user/{{ u.login }}" target="_blank" class="stat-box" title="Uploads" style="text-decoration: none;">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M17 8l-5-5-5 5M12 3v12"/></svg>
|
||||
<strong>{{ u.upload_count }}</strong>
|
||||
</a>
|
||||
<a href="/user/{{ u.login }}/comments" target="_blank" class="stat-box" title="Comments" style="text-decoration: none;">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/></svg>
|
||||
<strong>{{ u.comment_count }}</strong>
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
<td data-label="Registration">
|
||||
<div style="font-size: 0.85rem; color: #eee; font-weight: 600; cursor: help;" tooltip="Method: {{ u.reg_method }}">{{ new Date(u.created_at).toLocaleDateString() }}</div>
|
||||
</td>
|
||||
<td data-label="Account Age">
|
||||
<div style="font-size: 0.85rem; font-weight: 600; color: #aaa;">{{ Math.floor(u.age_days) }} Days</div>
|
||||
</td>
|
||||
<td data-label="Status" id="status-cell-{{ u.id || 'ghost-' + u.login }}">
|
||||
@if(!u.id)
|
||||
<span class="status-badge" style="background: rgba(255,255,255,0.05); color: #888; border: 1px dashed rgba(255,255,255,0.1);">Legacy</span>
|
||||
@else
|
||||
@if(u.banned)
|
||||
<span class="status-badge status-banned">Banned</span>
|
||||
@else
|
||||
@if(u.activated)
|
||||
<span class="status-badge status-active">Active</span>
|
||||
@else
|
||||
<span class="status-badge status-pending">Pending</span>
|
||||
@endif
|
||||
@endif
|
||||
|
||||
@if(u.failed_attempts > 0)
|
||||
@if(u.failed_attempts >= 5)
|
||||
<span class="status-badge" style="background: rgba(255, 0, 0, 0.1); color: #ff4d4d; border: 1px solid rgba(255, 0, 0, 0.2); font-weight: 700;">IP LOCKED</span>
|
||||
@else
|
||||
<span class="status-badge" style="background: rgba(255, 255, 0, 0.1); color: #ffcc00; border: 1px solid rgba(255, 255, 0, 0.2);">{{ u.failed_attempts }} Tries</span>
|
||||
@endif
|
||||
@endif
|
||||
@endif
|
||||
</td>
|
||||
<td data-label="Actions">
|
||||
<div id="actions-{{ u.id || 'ghost-' + u.login }}" class="user-actions-row" style="display: flex; gap: 8px; justify-content: flex-end; flex-wrap: wrap;">
|
||||
@if(u.id && u.login !== 'deleted_user')
|
||||
@if(!u.activated && !u.banned)
|
||||
<button data-id="{{ u.id }}" data-name="{!! u.user !!}" data-username="{{ u.login }}" onclick="activateUser(this)" class="btn-modern btn-verify">Verify</button>
|
||||
@endif
|
||||
@if(u.banned)
|
||||
<button data-id="{{ u.id }}" data-name="{!! u.user !!}" data-username="{{ u.login }}" onclick="unbanUser(this)" class="btn-modern btn-unban">Unban</button>
|
||||
@else
|
||||
<button data-id="{{ u.id }}" data-name="{!! u.user !!}" data-username="{{ u.login }}" onclick="banUser(this)" class="btn-modern btn-ban">Ban</button>
|
||||
@endif
|
||||
@endif
|
||||
|
||||
@if(u.id)
|
||||
<button data-id="{{ u.id }}" data-name="{!! u.user !!}" data-username="{{ u.login }}" onclick="deleteUploads(this)" class="btn-modern btn-files">Del Files</button>
|
||||
<button data-id="{{ u.id }}" data-name="{!! u.user !!}" data-username="{{ u.login }}" onclick="deleteComments(this)" class="btn-modern btn-comms">Del Comms</button>
|
||||
<button data-id="{{ u.id }}" data-name="{!! u.user !!}" data-username="{{ u.login }}" onclick="adminBulkDeleteHalls(this)" class="btn-modern btn-comms" title="Delete all user halls" style="background: rgba(255, 0, 255, 0.1); color: #ff00ff; border: 1px solid rgba(255, 0, 255, 0.2);"><i class="fa fa-folder-open"></i> Del Halls</button>
|
||||
@if(u.failed_attempts > 0)
|
||||
<button data-username="{{ u.login }}" onclick="adminResetLoginAttempts(this)" class="btn-modern btn-pw" title="Reset Login Attempts" style="background: rgba(255, 204, 0, 0.1); color: #ffcc00; border: 1px solid rgba(255, 204, 0, 0.2);"><i class="fa fa-unlock"></i> Reset IP</button>
|
||||
@endif
|
||||
@else
|
||||
<button data-id="" data-name="{{ u.user }}" data-username="{{ u.login }}" onclick="deleteUploads(this)" class="btn-modern btn-files">Del Legacy Files</button>
|
||||
@endif
|
||||
|
||||
@if(u.id && u.login !== 'deleted_user')
|
||||
<button data-id="{{ u.id }}" data-name="{!! u.user !!}" data-username="{{ u.login }}" data-display="{!! u.display_name || '' !!}" onclick="adminSetDisplayName(this)" class="btn-modern btn-nick" style="background: rgba(100, 200, 100, 0.1); color: #64c864; border: 1px solid rgba(100, 200, 100, 0.2);" title="Set stylized display name">✏ Nick</button>
|
||||
<button data-id="{{ u.id }}" data-name="{!! u.user !!}" data-username="{{ u.login }}" onclick="adminSetPassword(this)" class="btn-modern btn-pw" style="background: rgba(var(--accent-rgb, 0, 150, 255), 0.1); color: var(--accent, #0096ff); border: 1px solid rgba(var(--accent-rgb, 0, 150, 255), 0.2);">Set PW</button>
|
||||
<button data-id="{{ u.id }}" data-name="{!! u.user !!}" data-username="{{ u.login }}" onclick="adminDeleteUser(this)" class="btn-modern btn-delete" style="background: rgba(217, 83, 79, 0.1); color: #d9534f; border: 1px solid rgba(217, 83, 79, 0.2);">Delete</button>
|
||||
@elseif(u.login === 'deleted_user')
|
||||
<span style="font-size: 0.8rem; color: #666; font-style: italic; padding: 5px 10px;">Protected System Account</span>
|
||||
@endif
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@endfor
|
||||
1
views/ajax-item.html
Normal file
1
views/ajax-item.html
Normal file
@@ -0,0 +1 @@
|
||||
@include(item-partial)
|
||||
13
views/banned.html
Normal file
13
views/banned.html
Normal 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)
|
||||
28
views/comments_user-partial.html
Normal file
28
views/comments_user-partial.html
Normal 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
7
views/comments_user.html
Normal 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
20
views/error-partial.html
Normal 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
22
views/error.html
Normal 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
22
views/hall-cards.html
Normal 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
12
views/halls-partial.html
Normal 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
5
views/halls.html
Normal 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
33
views/index-partial.html
Normal 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">
|
||||
▼
|
||||
</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
139
views/index.html
Normal 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
|
||||
192
views/item-partial-legacy.html
Normal file
192
views/item-partial-legacy.html
Normal 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) <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) <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>
|
||||
163
views/item-partial-modern.html
Normal file
163
views/item-partial-modern.html
Normal 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) <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) <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
13
views/item-partial.html
Normal 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
19
views/item.html
Normal 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
27
views/login.html
Normal 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
8
views/matrix.html
Normal 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
68
views/meme-creator.html
Normal 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
54
views/meme-select.html
Normal 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)
|
||||
48
views/messages-conversation.html
Normal file
48
views/messages-conversation.html
Normal 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
19
views/messages.html
Normal 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
29
views/mod.html
Normal 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
321
views/mod/approve.html
Normal 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">« Prev</a>
|
||||
@endif
|
||||
<span>Page {!! page !!} of {!! pages !!}</span>
|
||||
@if(page < pages) <a href="/mod/approve?page={!! page + 1 !!}" class="badge badge-secondary">Next »</a>@endif
|
||||
</div>
|
||||
<br>
|
||||
@endif
|
||||
|
||||
<!-- Custom Modal -->
|
||||
<div id="custom-modal" style="display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.7); justify-content: center; align-items: center; z-index: 1000;">
|
||||
<div style="background: #222; color: #fff; padding: 20px; border-radius: 8px; max-width: 400px; text-align: center; border: 1px solid #444;">
|
||||
<h3 id="modal-title" style="margin-top: 0;">Confirm Action</h3>
|
||||
<p id="modal-text">Are you sure?</p>
|
||||
<div 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
236
views/mod/audit.html
Normal 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">« 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 »</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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
};
|
||||
|
||||
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
81
views/mod/motd.html
Normal 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
352
views/mod_reports.html
Normal 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 + ', "resolved")">Resolve</button> ' +
|
||||
'<button class="btn btn-sm btn-danger" onclick="window.resolveReport(' + r.id + ', "rejected")">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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
|
||||
// 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
38
views/notifications.html
Normal 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
121
views/ranking.html
Normal 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">⚡</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
44
views/register.html
Normal 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
31
views/rules.html
Normal 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
955
views/scroller.html
Normal 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 & 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 & 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
52
views/search.html
Normal 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
308
views/settings.html
Normal 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)
|
||||
47
views/snippets/excluded-tags-modal.html
Normal file
47
views/snippets/excluded-tags-modal.html
Normal file
@@ -0,0 +1,47 @@
|
||||
<div id="excluded-tags-overlay" style="display: none;">
|
||||
<div id="excluded-tags-close">×</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
549
views/snippets/footer.html
Normal 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
127
views/snippets/header.html
Normal 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
|
||||
30
views/snippets/item-media.html
Normal file
30
views/snippets/item-media.html
Normal 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
|
||||
16
views/snippets/items-grid.html
Normal file
16
views/snippets/items-grid.html
Normal 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
|
||||
26
views/snippets/metadata-modal.html
Normal file
26
views/snippets/metadata-modal.html
Normal 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
369
views/snippets/navbar.html
Normal 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">×</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">×</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>.</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">×</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">×</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
|
||||
125
views/snippets/notifications-list.html
Normal file
125
views/snippets/notifications-list.html
Normal 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
|
||||
22
views/snippets/page-title.html
Normal file
22
views/snippets/page-title.html
Normal 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
|
||||
17
views/snippets/pagination.html
Normal file
17
views/snippets/pagination.html
Normal 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">«</a>
|
||||
<a href="{{ link.main }}{{ link.path }}{{ pagination.prev }}{{ link.suffix }}" class="page-item-2 btn prev@if(!pagination.prev) disabled@endif">‹</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">›</a>
|
||||
<a href="{{ link.main }}{{ link.path }}{{ pagination.end }}{{ link.suffix }}" class="page-item-4 btn start@if(!pagination.next) disabled@endif">»</a>
|
||||
</nav>
|
||||
@endif
|
||||
17
views/snippets/subscriptions-grid.html
Normal file
17
views/snippets/subscriptions-grid.html
Normal 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
|
||||
129
views/snippets/upload-form.html
Normal file
129
views/snippets/upload-form.html
Normal 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
68
views/subscriptions.html
Normal 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">
|
||||
▼
|
||||
</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
11
views/tag-cards.html
Normal 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
12
views/tags-partial.html
Normal 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
5
views/tags.html
Normal file
@@ -0,0 +1,5 @@
|
||||
@include(snippets/header)
|
||||
<div id="main">
|
||||
@include(tags-partial)
|
||||
</div>
|
||||
@include(snippets/footer)
|
||||
31
views/terms.html
Normal file
31
views/terms.html
Normal 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
35
views/upload.html
Normal 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)
|
||||
68
views/user-hall-cards.html
Normal file
68
views/user-hall-cards.html
Normal 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
|
||||
|
||||
290
views/user-halls-partial.html
Normal file
290
views/user-halls-partial.html
Normal 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
5
views/user-halls.html
Normal 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
403
views/user-partial.html
Normal 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)⚡ @elseif(user.is_moderator)🛡 @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
7
views/user.html
Normal file
@@ -0,0 +1,7 @@
|
||||
@include(snippets/header)
|
||||
<div class="pagewrapper">
|
||||
<div id="main">
|
||||
@include(user-partial)
|
||||
</div>
|
||||
</div>
|
||||
@include(snippets/footer)
|
||||
Reference in New Issue
Block a user