alotta good shit

This commit is contained in:
2026-09-19 06:20:37 +02:00
parent 74f7884525
commit 1477d56658
45 changed files with 4363 additions and 2069 deletions
+122 -1
View File
@@ -35,10 +35,37 @@
@endif
</ul>
<hr style="margin: 20px 0; border: 0; border-top: 1px solid rgba(255,255,255,0.1);">
<!-- Navbar Brand Image -->
<div class="settings-item" id="brand-image-section" style="background: rgba(0,0,0,0.2); padding: 15px; border-radius: 4px; margin-top: 10px;">
<label style="display: block; font-weight: bold; color: var(--accent); margin-bottom: 8px;">Navbar Brand Image</label>
<p style="margin: 0 0 12px 0; font-size: 0.8em; color: #aaa;">Upload a logo to display in the site navbar instead of plain text. Accepted: gif, jpg, png, webp, svg &mdash; max 2&nbsp;MB.</p>
<div style="display: flex; align-items: center; gap: 16px; flex-wrap: wrap;">
<!-- Preview -->
<div id="brand-preview-wrap" style="width: 160px; height: 54px; background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.12); border-radius: 4px; display: flex; align-items: center; justify-content: center; overflow: hidden; flex-shrink: 0;">
@if(current_brand_image)
<img id="brand-preview" src="{{ current_brand_image }}" alt="current brand" style="max-height: 48px; max-width: 150px; object-fit: contain;">
@else
<span id="brand-preview" style="font-size: 0.75em; color: #666; font-style: italic;">No image set</span>
@endif
</div>
<!-- Controls -->
<div style="display: flex; flex-direction: column; gap: 8px;">
<label for="brand-file-input" style="display: inline-block; background: var(--accent); color: #000; padding: 7px 16px; border-radius: 4px; cursor: pointer; font-size: 0.82em; font-weight: bold; transition: opacity 0.2s;"
onmouseover="this.style.opacity='0.85'" onmouseout="this.style.opacity='1'">Choose Image</label>
<input type="file" id="brand-file-input" accept="image/gif,image/jpeg,image/png,image/webp,image/svg+xml" style="display:none;" onchange="uploadBrandImage(this)">
<button id="brand-remove-btn" onclick="removeBrandImage()" style="background: rgba(220,53,69,0.15); border: 1px solid rgba(220,53,69,0.4); color: #dc3545; padding: 7px 16px; border-radius: 4px; cursor: pointer; font-size: 0.82em; font-weight: bold; transition: background 0.2s;"
onmouseover="this.style.background='rgba(220,53,69,0.3)'" onmouseout="this.style.background='rgba(220,53,69,0.15)'"@if(!current_brand_image) disabled style="background: rgba(220,53,69,0.05); border: 1px solid rgba(220,53,69,0.15); color: #884040; padding: 7px 16px; border-radius: 4px; cursor: not-allowed; font-size: 0.82em; font-weight: bold;"@endif>Remove</button>
</div>
</div>
<span id="brand-status" style="display: block; margin-top: 10px; font-size: 0.8em; font-weight: bold;"></span>
</div>
<hr style="margin: 20px 0; border: 0; border-top: 1px solid rgba(255,255,255,0.1);">
<div class="settings-toggle" style="background: rgba(0,0,0,0.2); padding: 15px; border-radius: 4px; display: flex; align-items: center; justify-content: space-between;">
<div>
<label style="display: block; font-weight: bold; color: var(--accent);">Manual Upload Approval</label>
@@ -199,6 +226,100 @@
btn.textContent = 'Regenerate All';
}
}
async function uploadBrandImage(input) {
const file = input.files[0];
if (!file) return;
const status = document.getElementById('brand-status');
const removeBtn = document.getElementById('brand-remove-btn');
status.textContent = 'Uploading…';
status.style.color = 'var(--accent)';
const csrfToken = (window.f0ckSession && window.f0ckSession.csrf_token) || '{{ csrf_token }}';
const fd = new FormData();
fd.append('file', file);
try {
const res = await fetch('/admin/brand_image/upload', {
method: 'POST',
headers: {
'X-Requested-With': 'XMLHttpRequest',
'X-CSRF-Token': csrfToken
},
body: fd
});
const data = await res.json();
if (!data.success) throw new Error(data.msg || 'Upload failed');
status.textContent = '✓ Brand image updated!';
status.style.color = '#28a745';
// Update preview
const wrap = document.getElementById('brand-preview-wrap');
let preview = document.getElementById('brand-preview');
if (!preview || preview.tagName !== 'IMG') {
wrap.innerHTML = '';
preview = document.createElement('img');
preview.id = 'brand-preview';
preview.alt = 'current brand';
preview.style.cssText = 'max-height:48px;max-width:150px;object-fit:contain;';
wrap.appendChild(preview);
}
preview.src = data.url;
// Enable remove button
removeBtn.disabled = false;
removeBtn.style.cssText = 'background:rgba(220,53,69,0.15);border:1px solid rgba(220,53,69,0.4);color:#dc3545;padding:7px 16px;border-radius:4px;cursor:pointer;font-size:0.82em;font-weight:bold;transition:background 0.2s;';
setTimeout(() => { status.textContent = ''; }, 3000);
} catch (err) {
status.textContent = 'Error: ' + err.message;
status.style.color = '#d9534f';
} finally {
input.value = '';
}
}
async function removeBrandImage() {
const status = document.getElementById('brand-status');
const removeBtn = document.getElementById('brand-remove-btn');
if (!confirm('Remove the custom brand image? The navbar will revert to text.')) return;
status.textContent = 'Removing…';
status.style.color = 'var(--accent)';
const csrfToken = (window.f0ckSession && window.f0ckSession.csrf_token) || '{{ csrf_token }}';
try {
const res = await fetch('/admin/brand_image/delete', {
method: 'POST',
headers: {
'X-Requested-With': 'XMLHttpRequest',
'X-CSRF-Token': csrfToken
}
});
const data = await res.json();
if (!data.success) throw new Error(data.msg || 'Remove failed');
status.textContent = '✓ Brand image removed.';
status.style.color = '#28a745';
// Reset preview
const wrap = document.getElementById('brand-preview-wrap');
wrap.innerHTML = '<span id="brand-preview" style="font-size:0.75em;color:#666;font-style:italic;">No image set</span>';
// Disable remove button
removeBtn.disabled = true;
removeBtn.style.cssText = 'background:rgba(220,53,69,0.05);border:1px solid rgba(220,53,69,0.15);color:#884040;padding:7px 16px;border-radius:4px;cursor:not-allowed;font-size:0.82em;font-weight:bold;';
setTimeout(() => { status.textContent = ''; }, 3000);
} catch (err) {
status.textContent = 'Error: ' + err.message;
status.style.color = '#d9534f';
}
}
</script>
+39 -45
View File
@@ -124,17 +124,43 @@
<span class="user-infobox-timestamp"><a href="/{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}" class="timestamp-link"><time class="timeago" tooltip="{{ item.timestamp.timefull }}">{{item.timestamp.timeago }}</time></a></span>
</div>
<div class="user-infobox-body">
<div class="user-infobox-description">
@if(!is_anonymized){!! item.author_description || '' !!}@endif
</div>
<div class="user-infobox-actions">
@if(session)
@if(user_has_favorited)
<i class="iconset fa-solid fa-heart" id="a_favo" data-item-id="{{ item.id }}" title="Favorite"></i>
@else
<i class="iconset fa-regular fa-heart" id="a_favo" data-item-id="{{ item.id }}" title="Favorite"></i>
@endif
@endif
<div class="gapRight">
@if(session)
@if(user_has_favorited)
<i class="iconset fa-solid fa-heart" id="a_favo" data-item-id="{{ item.id }}" title="Favorite"></i>
@else
<i class="iconset fa-regular fa-heart" id="a_favo" data-item-id="{{ item.id }}" title="Favorite"></i>
@endif
<i class="iconset fa-solid fa-circle-info" id="a_info" data-item-id="{{ item.id }}" title="{{ t('info_modal.button_title') || 'Post & File Info' }}"></i>
@if(enable_comments)
<i class="iconset {{ isSubscribed ? 'fa-solid' : 'fa-regular' }} fa-bell" id="subscribe-btn" data-item-id="{{ item.id }}" title="{{ isSubscribed ? 'Subscribed' : 'Subscribe' }}"></i>
@endif
<i class="iconset fa-solid fa-triangle-exclamation report-item-btn" data-item-id="{{ item.id }}" title="Report this post"></i>
@if(halls_enabled)
<i class="iconset fa-solid fa-layer-group" 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>
@endif
@if(can_manage_item)
@if(enable_oc)
<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>
@endif
@if(can_extract_meta)
<i class="iconset fa-solid fa-magic" id="a_metadata" data-item-id="{{ item.id }}" @if(item.mime === 'video/youtube') data-src="https://www.youtube.com/watch?v={{ item.dest.replace('yt:', '') }}" @endif 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-ban{{ item.visibility === 3 ? ' active' : '' }}" id="a_unavailable" data-item-id="{{ item.id }}" data-visibility="{{ item.visibility || 0 }}" title="{{ item.visibility === 3 ? 'Make Available (Public)' : 'Make Unavailable (451)' }}" @if(item.visibility === 3) style="color: var(--danger, #ff4444);" @endif></i>
<i class="iconset fa-solid fa-xmark" id="a_delete" title="Delete"></i>
@endif
@else
<i class="iconset fa-solid fa-circle-info" id="a_info" data-item-id="{{ item.id }}" title="{{ t('info_modal.button_title') || 'Post & File Info' }}"></i>
<i class="iconset fa-solid fa-triangle-exclamation report-item-btn" data-item-id="{{ item.id }}" title="Report this post"></i>
@endif
</div>
<span id="oc-badge-container-infobox">@if(item.is_oc)<span class="oc-badge" tooltip="Original Content">OC</span>@endif</span>
</div>
</div>
@@ -157,40 +183,6 @@
</span>@endif</span>
<div class="gapRight">
@if(!user_alternative_infobox && session)
@if(user_has_favorited)
<i class="iconset fa-solid fa-heart" id="a_favo" data-item-id="{{ item.id }}" title="Favorite"></i>
@else
<i class="iconset fa-regular fa-heart" id="a_favo" data-item-id="{{ item.id }}" title="Favorite"></i>
@endif
@endif
@if(session)
<i class="iconset fa-solid fa-circle-info" id="a_info" data-item-id="{{ item.id }}" title="{{ t('info_modal.button_title') || 'Post & File Info' }}"></i>
<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(halls_enabled)
<i class="iconset fa-solid fa-layer-group" 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>
@endif
@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 }}" @if(item.mime === 'video/youtube') data-src="https://www.youtube.com/watch?v={{ item.dest.replace('yt:', '') }}" @endif 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-ban{{ item.visibility === 3 ? ' active' : '' }}" id="a_unavailable" data-item-id="{{ item.id }}" data-visibility="{{ item.visibility || 0 }}" title="{{ item.visibility === 3 ? 'Make Available (Public)' : 'Make Unavailable (451)' }}" @if(item.visibility === 3) style="color: var(--danger, #ff4444);" @endif></i>
<i class="iconset fa-solid fa-xmark" id="a_delete" title="Delete"></i>
@endif
@else
<i class="iconset fa-solid fa-circle-info" id="a_info" data-item-id="{{ item.id }}" title="{{ t('info_modal.button_title') || 'Post & File Info' }}"></i>
<i class="iconset fa-solid fa-triangle-exclamation report-item-btn" data-item-id="{{ item.id }}" title="Report this post"></i>
@endif
</div>
<span class="badge badge-dark" id="tags" data-item-id="{{ item.id }}" data-can-manage="{{ can_manage_item ? 'true' : 'false' }}">
<span class="tags-inner">
@if(!item.is_sfw && !item.is_nsfw && !item.is_nsfl)
@@ -244,7 +236,7 @@
</div>
</div>
@if(session || !hide_comments_from_public)
@if(enable_comments && (session || !hide_comments_from_public))
<div id="comments-container"
data-item-id="{{ item.id }}"
@if(session) data-user="{{ session.user }}" @endif
@@ -262,7 +254,9 @@
</div>
@endif
<button class="mobile-scroll-to-top" title="Back to top" aria-label="Scroll to top"><i class="fa-solid fa-chevron-up"></i></button>
@if(enable_comments)
<script id="initial-subscription" type="application/json">{{ isSubscribed }}</script>
@endif
</div>
+7 -1
View File
@@ -13,7 +13,7 @@
</div>
@endif
@if(session || !hide_comments_from_public)
@if(enable_comments && (session || !hide_comments_from_public))
<div id="comments-container"
data-item-id="{{ item.id }}"
@if(session) data-user="{{ session.user }}" @endif
@@ -162,10 +162,14 @@
@endif
@if(session)
<i class="iconset fa-solid fa-circle-info" id="a_info" data-item-id="{{ item.id }}" title="{{ t('info_modal.button_title') || 'Post & File Info' }}"></i>
@if(enable_comments)
<i class="iconset {{ isSubscribed ? 'fa-solid' : 'fa-regular' }} fa-bell" id="subscribe-btn" data-item-id="{{ item.id }}" title="{{ isSubscribed ? 'Subscribed' : 'Subscribe' }}"></i>
@endif
<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)
@if(enable_oc)
<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>
@endif
<i class="iconset fa-solid fa-magic" id="a_metadata" data-item-id="{{ item.id }}" @if(item.mime === 'video/youtube') data-src="https://www.youtube.com/watch?v={{ item.dest.replace('yt:', '') }}" @endif 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>
@@ -199,7 +203,9 @@
</div>
</div>
@if(enable_comments)
<script id="initial-subscription" type="application/json">{{ isSubscribed }}</script>
@endif
</div>
{{-- RIGHT SIDEBAR: recent activity --}}
+103 -2
View File
@@ -4,6 +4,93 @@
<h1>AUDIT LOG</h1>
<p>Actions performed by moderators and admins.</p>
<hr>
<!-- Filter bar -->
<form id="audit-filter-form" class="audit-filter-bar" method="get" action="/mod/audit">
<select name="action" id="audit-filter-action" class="audit-filter-input">
<option value="">All actions</option>
<optgroup label="Items">
<option value="approve_item" {!! filterAction === 'approve_item' ? 'selected' : '' !!}>approve_item</option>
<option value="deny_item" {!! filterAction === 'deny_item' ? 'selected' : '' !!}>deny_item</option>
<option value="deny_item_multi" {!! filterAction === 'deny_item_multi' ? 'selected' : '' !!}>deny_item_multi</option>
<option value="purge_item" {!! filterAction === 'purge_item' ? 'selected' : '' !!}>purge_item</option>
<option value="purge_item_multi" {!! filterAction === 'purge_item_multi' ? 'selected' : '' !!}>purge_item_multi</option>
<option value="delete_item" {!! filterAction === 'delete_item' ? 'selected' : '' !!}>delete_item</option>
<option value="pin_item" {!! filterAction === 'pin_item' ? 'selected' : '' !!}>pin_item</option>
<option value="unpin_item" {!! filterAction === 'unpin_item' ? 'selected' : '' !!}>unpin_item</option>
<option value="toggle_tag" {!! filterAction === 'toggle_tag' ? 'selected' : '' !!}>toggle_tag</option>
</optgroup>
<optgroup label="Comments">
<option value="delete_comment" {!! filterAction === 'delete_comment' ? 'selected' : '' !!}>delete_comment</option>
<option value="delete_attachment" {!! filterAction === 'delete_attachment' ? 'selected' : '' !!}>delete_attachment</option>
</optgroup>
<optgroup label="Halls">
<option value="add_to_hall" {!! filterAction === 'add_to_hall' ? 'selected' : '' !!}>add_to_hall</option>
<option value="remove_from_hall" {!! filterAction === 'remove_from_hall' ? 'selected' : '' !!}>remove_from_hall</option>
<option value="create_hall" {!! filterAction === 'create_hall' ? 'selected' : '' !!}>create_hall</option>
<option value="rename_hall" {!! filterAction === 'rename_hall' ? 'selected' : '' !!}>rename_hall</option>
<option value="update_hall" {!! filterAction === 'update_hall' ? 'selected' : '' !!}>update_hall</option>
<option value="update_hall_metadata" {!! filterAction === 'update_hall_metadata' ? 'selected' : '' !!}>update_hall_metadata</option>
</optgroup>
<optgroup label="Users">
<option value="ban_user" {!! filterAction === 'ban_user' ? 'selected' : '' !!}>ban_user</option>
<option value="unban_user" {!! filterAction === 'unban_user' ? 'selected' : '' !!}>unban_user</option>
<option value="ban_ip" {!! filterAction === 'ban_ip' ? 'selected' : '' !!}>ban_ip</option>
<option value="unban_ip" {!! filterAction === 'unban_ip' ? 'selected' : '' !!}>unban_ip</option>
<option value="ban_fingerprint" {!! filterAction === 'ban_fingerprint' ? 'selected' : '' !!}>ban_fingerprint</option>
<option value="unban_fingerprint" {!! filterAction === 'unban_fingerprint' ? 'selected' : '' !!}>unban_fingerprint</option>
<option value="ban_hardware" {!! filterAction === 'ban_hardware' ? 'selected' : '' !!}>ban_hardware</option>
<option value="unban_hardware" {!! filterAction === 'unban_hardware' ? 'selected' : '' !!}>unban_hardware</option>
<option value="issue_warning" {!! filterAction === 'issue_warning' ? 'selected' : '' !!}>issue_warning</option>
<option value="admin_set_role" {!! filterAction === 'admin_set_role' ? 'selected' : '' !!}>admin_set_role</option>
<option value="admin_reset_password" {!! filterAction === 'admin_reset_password' ? 'selected' : '' !!}>admin_reset_password</option>
<option value="admin_delete_user" {!! filterAction === 'admin_delete_user' ? 'selected' : '' !!}>admin_delete_user</option>
<option value="admin_rename_user" {!! filterAction === 'admin_rename_user' ? 'selected' : '' !!}>admin_rename_user</option>
<option value="admin_create_user" {!! filterAction === 'admin_create_user' ? 'selected' : '' !!}>admin_create_user</option>
<option value="admin_reassign_uploads" {!! filterAction === 'admin_reassign_uploads' ? 'selected' : '' !!}>admin_reassign_uploads</option>
<option value="admin_bulk_delete_items" {!! filterAction === 'admin_bulk_delete_items' ? 'selected' : '' !!}>admin_bulk_delete_items</option>
<option value="admin_bulk_delete_comments" {!! filterAction === 'admin_bulk_delete_comments' ? 'selected' : '' !!}>admin_bulk_delete_comments</option>
<option value="admin_bulk_delete_halls" {!! filterAction === 'admin_bulk_delete_halls' ? 'selected' : '' !!}>admin_bulk_delete_halls</option>
<option value="manual_verify_user" {!! filterAction === 'manual_verify_user' ? 'selected' : '' !!}>manual_verify_user</option>
<option value="lock_user_layout" {!! filterAction === 'lock_user_layout' ? 'selected' : '' !!}>lock_user_layout</option>
<option value="unlock_user_layout" {!! filterAction === 'unlock_user_layout' ? 'selected' : '' !!}>unlock_user_layout</option>
<option value="admin_set_display_name" {!! filterAction === 'admin_set_display_name' ? 'selected' : '' !!}>admin_set_display_name</option>
<option value="admin_reset_login_attempts" {!! filterAction === 'admin_reset_login_attempts' ? 'selected' : '' !!}>admin_reset_login_attempts</option>
</optgroup>
<optgroup label="Tags">
<option value="nsfp_add" {!! filterAction === 'nsfp_add' ? 'selected' : '' !!}>nsfp_add</option>
<option value="nsfp_remove" {!! filterAction === 'nsfp_remove' ? 'selected' : '' !!}>nsfp_remove</option>
</optgroup>
<optgroup label="Reports">
<option value="resolve_report" {!! filterAction === 'resolve_report' ? 'selected' : '' !!}>resolve_report</option>
</optgroup>
<optgroup label="System">
<option value="purge_trash" {!! filterAction === 'purge_trash' ? 'selected' : '' !!}>purge_trash</option>
<option value="update_motd" {!! filterAction === 'update_motd' ? 'selected' : '' !!}>update_motd</option>
<option value="update_config_file" {!! filterAction === 'update_config_file' ? 'selected' : '' !!}>update_config_file</option>
<option value="run_cleanup_manual" {!! filterAction === 'run_cleanup_manual' ? 'selected' : '' !!}>run_cleanup_manual</option>
<option value="update_about_text" {!! filterAction === 'update_about_text' ? 'selected' : '' !!}>update_about_text</option>
<option value="update_rules_text" {!! filterAction === 'update_rules_text' ? 'selected' : '' !!}>update_rules_text</option>
<option value="update_terms_text" {!! filterAction === 'update_terms_text' ? 'selected' : '' !!}>update_terms_text</option>
<option value="add_wordfilter" {!! filterAction === 'add_wordfilter' ? 'selected' : '' !!}>add_wordfilter</option>
<option value="delete_wordfilter" {!! filterAction === 'delete_wordfilter' ? 'selected' : '' !!}>delete_wordfilter</option>
</optgroup>
</select>
<input
type="text"
name="user"
id="audit-filter-user"
class="audit-filter-input"
placeholder="Filter by moderator…"
value="{!! filterUser !!}"
autocomplete="off"
>
<button type="submit" class="badge badge-secondary audit-filter-btn">Filter</button>
@if(filterAction || filterUser)
<a href="/mod/audit" class="badge badge-secondary audit-filter-btn audit-filter-clear">✕ Clear</a>
@endif
</form>
<div class="audit-grid" id="audit-grid">
@each(logs as entry)
<div class="audit-card">
@@ -11,7 +98,10 @@
<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 class="audit-card-header-right">
<div class="audit-card-time">{!! entry.created_at_fmt !!}</div>
<span class="audit-entry-id">#<span class="audit-entry-id-val">{!! entry.id !!}</span></span>
</div>
</div>
<div class="audit-card-body">
<div class="audit-card-row">
@@ -123,6 +213,10 @@
var loading = false;
var hasMore = currentPage < totalPages;
// Read active filters so infinite scroll preserves them
var activeFilterAction = '{{ filterAction }}' || '';
var activeFilterUser = '{{ filterUser }}' || '';
if (pagination) pagination.style.display = 'none';
window.addEventListener('scroll', function () {
@@ -143,7 +237,11 @@
try {
var next = currentPage + 1;
var res = await fetch('/mod/audit?page=' + next, {
var url = '/mod/audit?page=' + next;
if (activeFilterAction) url += '&action=' + encodeURIComponent(activeFilterAction);
if (activeFilterUser) url += '&user=' + encodeURIComponent(activeFilterUser);
var res = await fetch(url, {
headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
var data = await res.json();
@@ -159,7 +257,10 @@
'<div class="audit-card-user">' +
'<a href="/user/' + encodeURIComponent(log.username) + '">' + log.username + '</a>' +
'</div>' +
'<div class="audit-card-header-right">' +
'<div class="audit-card-time">' + (log.created_at || '') + '</div>' +
'<span class="audit-entry-id">#<span class="audit-entry-id-val">' + (log.id || '') + '</span></span>' +
'</div>' +
'</div>' +
'<div class="audit-card-body">' +
'<div class="audit-card-row">' +
+236 -420
View File
@@ -3,448 +3,264 @@
<div class="pagewrapper">
<div id="main">
<style>
.mod-reports-table {
width: 100%;
border-collapse: separate;
border-spacing: 0 8px;
color: var(--white);
.rp-page { max-width: 900px; margin: 0 auto; padding: 30px 15px 60px; }
.rp-header { margin-bottom: 28px; }
.rp-header h2 { margin: 0; font-weight: 800; letter-spacing: -0.5px; }
.rp-header p { color: #888; margin: 5px 0 0 0; font-size: 0.9em; }
.rp-filter-bar { display: flex; gap: 10px; align-items: center; margin-bottom: 24px; flex-wrap: wrap; }
.rp-filter-bar select {
background: rgba(255,255,255,0.05);
border: 1px solid rgba(255,255,255,0.12);
border-radius: 6px;
color: #fff;
padding: 7px 12px;
outline: none;
cursor: pointer;
font-size: 0.88em;
}
.mod-reports-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);
}
.mod-reports-table tr {
transition: all 0.2s ease;
}
.mod-reports-table tbody tr {
background: rgba(255, 255, 255, 0.02);
}
.mod-reports-table tbody tr:hover:not(.expanded-report) {
background: rgba(255, 255, 255, 0.05);
}
.mod-reports-table td {
padding: 15px;
vertical-align: middle;
}
.mod-reports-table .btn, .mod-reports-table button, .btn-modern {
border-radius: 4px !important;
font-size: 0.75rem;
.rp-filter-bar button {
background: rgba(255,255,255,0.05);
border: 1px solid rgba(255,255,255,0.12);
border-radius: 6px;
color: #fff;
padding: 7px 14px;
cursor: pointer;
font-size: 0.85em;
font-weight: 600;
transition: background 0.15s;
}
.rp-filter-bar button:hover { background: rgba(255,255,255,0.1); }
.rp-feed { display: flex; flex-direction: column; gap: 14px; }
.rp-state-msg { color: #666; font-style: italic; text-align: center; padding: 40px 0; }
/* Card */
.rp-card {
background: rgba(255,255,255,0.025);
border: 1px solid rgba(255,255,255,0.07);
border-radius: 10px;
overflow: hidden;
transition: border-color 0.2s;
}
.rp-card:hover { border-color: rgba(255,255,255,0.14); }
.rp-card.illegal-flag { border-left: 3px solid #dc3545; }
/* Card top bar */
.rp-card-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 16px;
background: rgba(0,0,0,0.2);
border-bottom: 1px solid rgba(255,255,255,0.05);
gap: 12px;
flex-wrap: wrap;
}
.rp-card-bar-left { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.rp-card-id {
font-family: monospace;
font-size: 0.78em;
color: #666;
background: rgba(0,0,0,0.3);
border: 1px solid rgba(255,255,255,0.08);
border-radius: 4px;
padding: 1px 7px;
}
.rp-card-time { font-size: 0.78em; color: #666; }
.rp-status-badge {
font-size: 0.7em;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 8px 16px;
border: 0;
cursor: pointer;
transition: all 0.2s ease;
padding: 2px 8px;
border-radius: 10px;
}
.btn-modern {
background: rgba(255, 255, 255, 0.05);
.rp-status-pending { background: rgba(255,193,7,0.15); color: #ffc107; border: 1px solid rgba(255,193,7,0.3); }
.rp-status-resolved { background: rgba(40,167,69,0.15); color: #28a745; border: 1px solid rgba(40,167,69,0.3); }
.rp-status-rejected { background: rgba(108,117,125,0.2); color: #aaa; border: 1px solid rgba(108,117,125,0.3); }
/* Card body */
.rp-card-body {
display: flex;
gap: 0;
}
/* Media preview column */
.rp-preview {
width: 180px;
min-width: 180px;
background: #000;
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
}
.rp-preview img,
.rp-preview video {
width: 180px;
height: 140px;
object-fit: cover;
display: block;
}
.rp-preview-link {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0,0,0,0.55);
opacity: 0;
transition: opacity 0.2s;
color: #fff;
font-size: 1.4em;
text-decoration: none;
}
.rp-preview:hover .rp-preview-link { opacity: 1; }
.rp-no-preview {
width: 180px;
height: 140px;
display: flex;
align-items: center;
justify-content: center;
color: #444;
font-size: 2em;
}
/* Info column */
.rp-info {
flex: 1;
padding: 14px 16px;
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
}
.rp-meta-row {
display: flex;
gap: 16px;
flex-wrap: wrap;
font-size: 0.83em;
align-items: center;
}
.rp-meta-label { color: #555; text-transform: uppercase; font-size: 0.75em; font-weight: 700; letter-spacing: 0.5px; }
.rp-reporter-link { color: var(--accent); font-weight: 600; text-decoration: none; }
.rp-reporter-link:hover { text-decoration: underline; }
.rp-reporter-ip { color: #555; font-size: 0.88em; }
.rp-target-link { color: #ddd; font-weight: 500; text-decoration: none; }
.rp-target-link:hover { color: #fff; }
.rp-open-link { color: var(--accent); margin-left: 4px; font-size: 0.85em; }
/* Category badges */
.rp-cats { display: flex; flex-wrap: wrap; gap: 5px; }
.rp-cat {
display: inline-flex;
align-items: center;
padding: 2px 9px;
border-radius: 10px;
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.4px;
}
/* Reason */
.rp-reason {
font-size: 0.85em;
color: #bbb;
line-height: 1.5;
word-break: break-word;
background: rgba(0,0,0,0.2);
border-left: 2px solid rgba(255,255,255,0.08);
padding: 6px 10px;
border-radius: 0 4px 4px 0;
max-height: 80px;
overflow-y: auto;
}
/* Actions footer */
.rp-card-actions {
display: flex;
gap: 7px;
flex-wrap: wrap;
align-items: center;
padding: 10px 16px;
border-top: 1px solid rgba(255,255,255,0.05);
background: rgba(0,0,0,0.1);
}
.rp-btn {
border: none;
border-radius: 5px;
padding: 5px 13px;
font-size: 0.78em;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.4px;
cursor: pointer;
transition: opacity 0.15s, transform 0.1s;
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 5px;
line-height: 1.5;
}
.rp-btn:hover { opacity: 0.85; transform: translateY(-1px); }
.rp-btn-resolve { background: #28a745; color: #fff; }
.rp-btn-reject { background: #495057; color: #fff; }
.rp-btn-delete { background: #dc3545; color: #fff; }
.rp-btn-warn { background: #ffc107; color: #000; }
.rp-btn-ban { background: #b71c1c; color: #fff; }
.rp-btn-unavail { background: #6f42c1; color: #fff; }
.rp-btn-avail { background: #28a745; color: #fff; }
.rp-btn-view { background: rgba(255,255,255,0.08); color: #fff; border: 1px solid rgba(255,255,255,0.12); }
.rp-btn-secondary { background: #6c757d; color: #fff; }
.rp-sep { width: 1px; height: 20px; background: rgba(255,255,255,0.08); margin: 0 2px; }
.rp-anon-note { color: #555; font-size: 0.78em; font-style: italic; }
/* Pagination */
.rp-pagination { display: flex; align-items: center; justify-content: center; gap: 10px; margin-top: 24px; }
.rp-pagination button {
background: rgba(255,255,255,0.05);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 6px;
color: #fff;
padding: 6px 16px;
cursor: pointer;
font-size: 0.85em;
transition: background 0.15s;
}
.btn-modern:hover {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255,255,255,0.2);
.rp-pagination button:hover { background: rgba(255,255,255,0.1); }
.rp-pagination .rp-page-info { color: #666; font-size: 0.85em; }
@media (max-width: 600px) {
.rp-preview { width: 120px; min-width: 120px; }
.rp-preview img, .rp-preview video, .rp-no-preview { width: 120px; }
}
.btn-success { background: #28a745 !important; color: #fff !important; }
.btn-danger { background: #dc3545 !important; color: #fff !important; }
.btn-warning { background: #ffc107 !important; color: #000 !important; }
.btn-secondary { background: #6c757d !important; color: #fff !important; }
</style>
<div class="container mod-reports-page">
<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 Reports</h2>
<p style="color: #888; margin: 5px 0 0 0;">Review and resolve content flags from the community.</p>
</div>
<div class="container rp-page">
<div class="rp-header">
<h2>User Reports</h2>
<p>Review and resolve content flags from the community.</p>
</div>
<div style="margin-bottom: 25px; display: flex; gap: 10px; align-items: center;">
<select id="report-status-filter" style="width: 180px; background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1); border-radius: 6px; color: #fff; padding: 8px 12px; outline: none; cursor: pointer;">
<div class="rp-filter-bar">
<select id="report-status-filter">
<option value="pending">Pending</option>
<option value="resolved">Resolved</option>
<option value="rejected">Rejected</option>
</select>
<button class="btn-modern" onclick="loadReports(1)">Refresh</button>
<button onclick="loadReports(1)"><i class="fa-solid fa-rotate-right"></i> Refresh</button>
</div>
<div class="table-responsive" style="border: none;">
<table class="mod-reports-table responsive-table">
<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 id="reports-feed" class="rp-feed">
<div class="rp-state-msg">Loading reports…</div>
</div>
<div id="reports-pagination" style="text-align: center; margin-top: 15px;"></div>
<div id="reports-pagination" class="rp-pagination"></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>';
} else if (r.resolved_item_id) {
targetHtml += 'Item: <a href="#" onclick="window.expandItem(event, ' + r.id + ')">#' + r.resolved_item_id + '</a>';
} else if (r.reported_user_name) {
targetHtml += 'User: <a href="/user/' + r.reported_user_name + '">' + r.reported_user_name + '</a>';
}
let actionHtml = '';
if (status === 'pending') {
actionHtml =
'<button class="btn btn-sm btn-success" onclick="window.resolveReport(' + r.id + ', &quot;resolved&quot;)">Resolve</button> ' +
'<button class="btn btn-sm btn-danger" onclick="window.resolveReport(' + r.id + ', &quot;rejected&quot;)">Reject</button>';
}
let reporterHtml = '';
if (r.reporter_name) {
reporterHtml = '<a href="/user/' + r.reporter_name + '" style="color: var(--accent); font-weight: bold;">' + r.reporter_name + '</a>' + (r.reporter_ip ? ' <span style="font-size: 0.75rem; color: #888;">(' + r.reporter_ip + ')</span>' : '');
} else {
reporterHtml = '<span style="color: #aaa; font-style: italic;">Guest' + (r.reporter_ip ? ' (' + r.reporter_ip + ')' : '') + '</span>';
}
const tr = document.createElement('tr');
tr.innerHTML =
'<td data-label="ID">' + r.id + '</td>' +
'<td data-label="Reporter">' + reporterHtml + '</td>' +
'<td data-label="Target">' + targetHtml + '</td>' +
'<td data-label="Reason"><span style="opacity: 0.8;">' + r.reason + '</span></td>' +
'<td data-label="Date"><span style="font-size: 0.85rem; color: #888;">' + new Date(r.created_at).toLocaleString() + '</span></td>' +
'<td data-label="Actions">' + actionHtml + '</td>';
tbody.appendChild(tr);
});
// Pagination
pag.innerHTML = '';
if (data.pages > 1) {
if (data.page > 1) {
pag.innerHTML += '<button class="btn-modern" style="margin-right: 5px;" onclick="window.loadReports(' + (data.page - 1) + ')">Prev</button> ';
}
pag.innerHTML += '<span style="font-size: 0.85rem; color: #888; margin: 0 10px;">Page ' + data.page + ' of ' + data.pages + '</span>';
if (data.page < data.pages) {
pag.innerHTML += '<button class="btn-modern" style="margin-left: 5px;" 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);
if (window.NotificationSystemInstance && typeof window.NotificationSystemInstance.pollDebounced === 'function') {
window.NotificationSystemInstance.pollDebounced();
}
} 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 === 'video/youtube') {
const ytId = r.resolved_item_dest.replace('yt:', '');
previewHtml = '<div><iframe width="444" height="250" src="https://www.youtube.com/embed/' + ytId + '" frameborder="0" allowfullscreen style="' + baseStyle + '"></iframe></div>';
} else if (mime === 'application/pdf') {
previewHtml = '<div><iframe src="' + src + '#toolbar=0" style="' + baseStyle + ' width: 444px; height: 250px;" frameborder="0" allowfullscreen></iframe></div>';
} else if (mime.startsWith('image/')) {
previewHtml = '<div><img src="' + src + '" style="' + baseStyle + ' background: #000;"></div>';
} else if (mime.startsWith('audio/')) {
previewHtml = '<div><audio src="' + src + '" controls style="' + baseStyle + '"></audio></div>';
} else {
previewHtml = '<div><video src="' + src + '" controls loop style="' + baseStyle + ' background: #000;"></video></div>';
}
}
if (isComment) {
let escapedContent = (r.comment_body || '[Deleted or Empty]')
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
// Handle Emojis
if (window.emojiMap) {
escapedContent = escapedContent.replace(/:([a-z0-9_]+):/g, function(match, code) {
var url = window.emojiMap.get(code.toLowerCase());
if (url) {
return '<img src="' + url + '" style="height:24px;vertical-align:middle;" alt="' + code + '" title=":' + code + ':">';
}
return match;
});
}
previewHtml += '<div style="background: rgba(0,0,0,0.5); padding: 15px; border: 1px solid #444; color: #eee; font-family: monospace; max-height: 250px; overflow-y: auto; white-space: pre-wrap; font-size: 0.9rem;">' +
'<strong>Reported Comment:</strong><br><br>' + escapedContent +
'</div>';
}
let buttonsHtml = '';
// Delete Item and Make Unavailable buttons for direct Item reports
if (isItem && !isComment) {
buttonsHtml += '<button class="btn btn-danger" onclick="window.adminDeleteItem(' + r.resolved_item_id + ')">Delete Item</button>';
const isUnav = r.resolved_item_visibility === 3;
buttonsHtml += ' <button class="btn ' + (isUnav ? 'btn-success' : 'btn-warning') + '" onclick="window.modToggleUnavailable(' + r.resolved_item_id + ', ' + (r.resolved_item_visibility || 0) + ')">' + (isUnav ? 'Make Available' : 'Make Unavailable (451)') + '</button>';
}
if (isComment) {
buttonsHtml += '<button class="btn btn-danger" onclick="window.adminDeleteComment(' + r.comment_id + ')">Delete Comment</button>';
if (r.resolved_item_id) {
buttonsHtml += '<a href="/' + r.resolved_item_id + '" class="btn btn-info" style="text-decoration: none; color: white;" 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-warning" 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-danger" 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(255,255,255,0.01); border-left: 4px solid var(--accent); padding: 30px; box-shadow: inset 0 0 20px rgba(0,0,0,0.4);">' +
'<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 +
(r.reporter_id && r.reporter_name ? '<button class="btn btn-secondary" onclick="window.modWarnUser(' + r.reporter_id + ')">Warn Reporter (' + r.reporter_name + ')</button>' : (r.reporter_ip ? '<span style="color: #888; font-size: 0.85rem;">Reporter: Guest (' + r.reporter_ip + ')</span>' : '')) +
'</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.modToggleUnavailable = function(id, currentVis) {
const willBeUnavailable = currentVis !== 3;
const targetVis = willBeUnavailable ? 3 : 0;
const actionText = willBeUnavailable ? 'Make Unavailable (serves HTTP 451 to non-logged in visitors)' : 'Make Available (Public)';
window.ModAction.confirm('Item Visibility', actionText + ' for item #' + id + '?', async () => {
const params = new URLSearchParams();
params.append('postid', id);
params.append('id', id);
params.append('visibility', targetVis);
const res = await fetch('/api/v2/item/visibility', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-CSRF-Token': window.f0ckSession?.csrf_token
},
body: params
});
const data = await res.json();
if (data.success) {
if (window.showFlash) window.showFlash(willBeUnavailable ? 'Item marked unavailable (451)' : 'Item restored to public', 'success');
const item = window.currentReports.find(x => x.resolved_item_id === id);
if (item) item.resolved_item_visibility = targetVis;
window.loadReports(window.currentPage);
} else {
throw new Error(data.msg || 'Failed to update visibility');
}
});
};
window.modWarnUser = function(userId) {
window.ModAction.confirm('Warn User ID ' + userId, '', 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>
<script src="/s/js/mod-reports.js?v=1789785562"></script>
</div>
</div>
</div>
@include(snippets/footer)
+4
View File
@@ -7,8 +7,12 @@
<button id="mark-all-read-page" class="btn-small">{{ t('notifications.mark_all_read') }}</button>
</div>
<div class="notif-page-tabs">
@if(enable_comments)
<button class="notif-page-tab @if(activeTab === 'user') active @endif" data-tab="user">{{ t('nav.notif_tab_user') }}</button>
<button class="notif-page-tab @if(activeTab === 'system') active @endif" data-tab="system">{{ t('nav.notif_tab_system') }}</button>
@else
<button class="notif-page-tab active" data-tab="system">{{ t('nav.notif_tab_system') }}</button>
@endif
</div>
<div id="notifications-container" class="posts notifications-list-full" data-page="{{ pagination.page }}" data-tab="{{ activeTab }}">
@include(snippets/notifications-list)
+8
View File
@@ -1077,12 +1077,20 @@
<div id="scroller-notif-dropdown" class="notif-dropdown" style="position:fixed; z-index:99999; display:none;">
<div class="notif-header">
<div class="notif-tabs">
@if(enable_comments)
<button class="notif-tab active" data-tab="user">{{ t('nav.notif_tab_user') }} <span class="notif-tab-badge" id="scroller-notif-tab-badge-user" style="display:none">0</span></button>
<button class="notif-tab" data-tab="system">{{ t('nav.notif_tab_system') }} <span class="notif-tab-badge" id="scroller-notif-tab-badge-system" style="display:none">0</span></button>
@else
<button class="notif-tab active" data-tab="system">{{ t('nav.notif_tab_system') }} <span class="notif-tab-badge" id="scroller-notif-tab-badge-system" style="display:none">0</span></button>
@endif
</div>
<button id="scroller-mark-all-read" title="{{ t('nav.mark_all_read') }}"><i class="fa-solid fa-check-double"></i></button>
</div>
@if(enable_comments)
<div class="notif-list" id="scroller-notif-list" data-active-tab="user">
@else
<div class="notif-list" id="scroller-notif-list" data-active-tab="system">
@endif
<div class="notif-empty">{{ t('nav.no_notifications') }}</div>
</div>
<div class="notif-footer">
+92 -28
View File
@@ -356,6 +356,7 @@
</fieldset>
<!-- 4. Comments & Discussion -->
@if(enable_comments)
<fieldset style="border: 1px solid var(--nav-border-color); padding: 12px 16px; border-radius: 6px; margin-bottom: 20px; background: rgba(0,0,0,0.06);">
<legend style="width: auto; padding: 0 8px; font-size: 1.05em; font-weight: bold;"><i class="fa-solid fa-comments"></i> Comments & Discussion</legend>
@@ -390,6 +391,7 @@
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.embed_yt_hint') }}</small>
</div>
</fieldset>
@endif
<!-- 5. Content Preferences & Blurring -->
<fieldset style="border: 1px solid var(--nav-border-color); padding: 12px 16px; border-radius: 6px; margin-bottom: 20px; background: rgba(0,0,0,0.06);">
@@ -528,30 +530,20 @@
</div>
@if(session.is_anon)
<!-- ═══════════════════════════════ ANONYMOUS SSH IDENTITY ═══════════════════════════════ -->
<h2 id="anon-ssh"><i class="fa-solid fa-key"></i> Anonymous SSH Identity</h2>
<!-- ═══════════════════════════════ ANONYMOUS PASSKEY IDENTITY ═══════════════════════════════ -->
<h2 id="anon-passkey"><i class="fa-solid fa-fingerprint"></i> Anonymous Passkey Identity</h2>
<div class="account-settings-wrapper" style="background: rgba(0,0,0,0.1); padding: 20px; border-radius: 6px; border: 1px solid var(--nav-border-color); margin-bottom: 30px;">
<p style="color: var(--text-muted); margin-bottom: 16px;">
You are currently browsing anonymously using an OpenSSH Ed25519 keypair. Your favorites and comments are tied to this cryptographic identity without requiring a username, password, or email.
You are browsing anonymously. Your identity is protected by a <strong>passkey</strong> stored in your OS or password manager. No private key ever touches this browser's storage.
</p>
<div style="margin-bottom: 16px;">
<label style="font-weight: bold; color: var(--text-muted); display: block; margin-bottom: 6px;">OpenSSH SHA256 Fingerprint</label>
<code id="settings-anon-fp" style="background: rgba(0,0,0,0.3); padding: 6px 12px; border-radius: 4px; display: inline-block; color: #5bc0be; font-family: monospace;">{{ session.fingerprint || 'Loading...' }}</code>
</div>
<div style="margin-bottom: 20px;">
<label style="font-weight: bold; color: var(--text-muted); display: block; margin-bottom: 6px;">OpenSSH Public Key</label>
<div style="display: flex; gap: 10px; align-items: center; flex-wrap: wrap;">
<input type="text" id="settings-anon-pubkey" readonly class="input" style="flex: 1; min-width: 250px; font-family: monospace; font-size: 0.85em;" value="" placeholder="Loading public key...">
<button type="button" class="button" onclick="if(window.f0ckAnonSSH) window.f0ckAnonSSH.copyPublicKey();"><i class="fa-solid fa-copy"></i> Copy</button>
</div>
<label style="font-weight: bold; color: var(--text-muted); display: block; margin-bottom: 6px;">Short Fingerprint</label>
<code id="settings-anon-fp" style="background: rgba(0,0,0,0.3); padding: 6px 12px; border-radius: 4px; display: inline-block; color: #5bc0be; font-family: monospace;">{{ session.fingerprint ? session.fingerprint.slice(7, 15) : 'Loading...' }}</code>
</div>
<div style="display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 20px;">
<button type="button" class="button button-primary" onclick="if(window.f0ckAnonSSH) window.f0ckAnonSSH.downloadPrivateKey();"><i class="fa-solid fa-download"></i> Download id_ed25519</button>
<button type="button" class="button" onclick="if(window.f0ckAnonSSH) window.f0ckAnonSSH.downloadPublicKey();"><i class="fa-solid fa-download"></i> Download id_ed25519.pub</button>
<button type="button" class="button" onclick="const m = document.getElementById('anon-ssh-modal'); if(m) m.style.display='flex';"><i class="fa-solid fa-key"></i> Import / Supply Key</button>
<button type="button" class="button button-primary" onclick="if(window.f0ckAnonPasskey) { window.f0ckAnonPasskey.openModal(); } else { const m = document.getElementById('anon-passkey-modal'); if(m) m.style.display='flex'; }"><i class="fa-solid fa-fingerprint"></i> Manage Passkey</button>
</div>
<div style="padding: 12px 16px; background: rgba(255,255,255,0.03); border-radius: 6px; border: 1px solid rgba(255,255,255,0.08); font-size: 0.88em; color: var(--text-muted);">
@@ -559,20 +551,92 @@
<strong>Want full features?</strong> Registered accounts can upload items, customize avatars, create API keys, and invite friends. You can <a href="#" onclick="event.preventDefault(); const m=document.getElementById('register-modal'); if(m) m.style.display='flex';" style="color: var(--accent); text-decoration: underline;">register an account</a> at any time.
</div>
</div>
@endif
@if(!session.is_anon && session)
<!-- ═══════════════════════════════ PASSKEYS ═══════════════════════════════ -->
<h2 id="passkeys"><i class="fa-solid fa-fingerprint"></i> Passkeys</h2>
<div class="account-settings-wrapper" style="background: rgba(0,0,0,0.1); padding: 20px; border-radius: 6px; border: 1px solid var(--nav-border-color); margin-bottom: 30px;">
<p style="color: var(--text-muted); margin-bottom: 16px;">
Passkeys let you sign in without a password using your OS, Bitwarden, or any compatible password manager. They are phishing-resistant and device-bound.
</p>
<div id="settings-passkey-list" style="margin-bottom: 16px;">
<p style="color: var(--text-muted); font-size: 0.9em;">Loading...</p>
</div>
<div style="display: flex; gap: 10px; flex-wrap: wrap;">
<button type="button" class="button button-primary" id="settings-passkey-add-btn" onclick="settingsPasskeyAdd()">
<i class="fa-solid fa-plus"></i> Add a passkey
</button>
</div>
<div id="settings-passkey-status" style="margin-top: 10px; font-size: 0.88em; display: none;"></div>
</div>
<script>
(function() {
const syncKey = () => {
if (window.f0ckAnonSSH) {
const pk = document.getElementById('settings-anon-pubkey');
if (pk && window.f0ckAnonSSH.pubkey) pk.value = window.f0ckAnonSSH.pubkey;
const fp = document.getElementById('settings-anon-fp');
if (fp && window.f0ckAnonSSH.fingerprint) fp.textContent = window.f0ckAnonSSH.fingerprint;
(function() {
async function loadPasskeys() {
const container = document.getElementById('settings-passkey-list');
if (!container) return;
try {
const res = await fetch('/api/v2/settings/passkeys');
const data = await res.json();
const passkeys = data.passkeys || [];
if (passkeys.length === 0) {
container.innerHTML = '<p style="color: var(--text-muted); font-size: 0.9em;">No passkeys registered yet.</p>';
return;
}
};
syncKey();
window.addEventListener('f0ck:anon_session_ready', syncKey);
document.addEventListener('DOMContentLoaded', syncKey);
})();
container.innerHTML = passkeys.map(function(pk) {
return '<div style="display: flex; align-items: center; gap: 10px; padding: 10px 12px; background: rgba(0,0,0,0.25); border-radius: 5px; border: 1px solid rgba(255,255,255,0.07); margin-bottom: 8px;">' +
'<i class="fa-solid fa-key" style="color: var(--accent); font-size: 1.1em; flex-shrink: 0;"></i>' +
'<div style="flex: 1; min-width: 0;">' +
'<div style="font-weight: 600; font-size: 0.9em;">' + escHtml(pk.name || 'Passkey') + '</div>' +
'<div style="font-size: 0.78em; color: var(--text-muted);">Added ' + new Date(pk.created_at).toLocaleDateString() + ' &nbsp;·&nbsp; Last used ' + new Date(pk.last_used).toLocaleDateString() + '</div>' +
'</div>' +
'<button class="button button-danger" style="font-size: 0.8em; padding: 4px 10px;" onclick="settingsPasskeyDelete(' + JSON.stringify(pk.credential_id) + ')"><i class="fa-solid fa-trash"></i></button>' +
'</div>';
}).join('');
} catch (e) {
container.innerHTML = '<p style="color: #e06c75; font-size: 0.9em;">Failed to load passkeys.</p>';
}
}
function escHtml(s) { return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
window.settingsPasskeyAdd = async function() {
var status = document.getElementById('settings-passkey-status');
var btn = document.getElementById('settings-passkey-add-btn');
if (!window.f0ckPasskeyManager) { alert('Passkey manager not loaded.'); return; }
if (!window.PublicKeyCredential) { alert('Passkeys are not supported in this browser.'); return; }
var name = prompt('Name this passkey (e.g. "Bitwarden", "iPhone"):', 'Passkey') || 'Passkey';
if (btn) btn.disabled = true;
if (status) status.style.display = 'none';
try {
await window.f0ckPasskeyManager.addPasskey(name);
if (status) { status.style.display = 'block'; status.style.color = '#98c379'; status.textContent = 'Passkey added!'; }
await loadPasskeys();
} catch (err) {
if (err.name !== 'NotAllowedError') {
if (status) { status.style.display = 'block'; status.style.color = '#e06c75'; status.textContent = 'Error: ' + err.message; }
}
} finally {
if (btn) btn.disabled = false;
}
};
window.settingsPasskeyDelete = async function(credentialId) {
if (!confirm('Remove this passkey? You will no longer be able to use it to sign in.')) return;
var status = document.getElementById('settings-passkey-status');
try {
await window.f0ckPasskeyManager.deletePasskey(credentialId);
if (status) { status.style.display = 'block'; status.style.color = '#98c379'; status.textContent = 'Passkey removed.'; }
await loadPasskeys();
} catch (err) {
if (status) { status.style.display = 'block'; status.style.color = '#e06c75'; status.textContent = 'Error: ' + err.message; }
}
};
loadPasskeys();
})();
</script>
@endif
+31 -1
View File
@@ -70,10 +70,36 @@
<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>
<p>Please describe why you want to report this f0ck.</p>
<p style="margin-bottom: 12px; color: var(--text-muted, #aaa); font-size: 0.9em;">Select one or more reasons and optionally add details below.</p>
<input type="hidden" id="report-item-id">
<input type="hidden" id="report-comment-id">
<input type="hidden" id="report-user-id">
<div id="report-categories" style="display: flex; flex-direction: column; gap: 8px; margin-bottom: 14px;">
<label class="report-category-option" style="display: flex; align-items: center; gap: 10px; padding: 9px 12px; border: 1px solid var(--nav-border-color, #444); border-radius: 6px; cursor: pointer; background: var(--bg-secondary, rgba(255,255,255,0.03)); transition: border-color 0.15s, background 0.15s; user-select: none;">
<input type="checkbox" name="report_category" value="wrong_rating" class="report-cat-check" style="accent-color: var(--accent, #e65c00); width: 16px; height: 16px; cursor: pointer; flex-shrink: 0;">
<span style="font-size: 0.9em;"><strong style="color: var(--text-color, #fff);">Wrong Rating</strong> <span style="color: var(--text-muted, #aaa);">— Content is marked SFW but should be NSFW, or vice versa.</span></span>
</label>
<label class="report-category-option" style="display: flex; align-items: center; gap: 10px; padding: 9px 12px; border: 1px solid var(--nav-border-color, #444); border-radius: 6px; cursor: pointer; background: var(--bg-secondary, rgba(255,255,255,0.03)); transition: border-color 0.15s, background 0.15s; user-select: none;">
<input type="checkbox" name="report_category" value="spam" class="report-cat-check" style="accent-color: var(--accent, #e65c00); width: 16px; height: 16px; cursor: pointer; flex-shrink: 0;">
<span style="font-size: 0.9em;"><strong style="color: var(--text-color, #fff);">Spam</strong> <span style="color: var(--text-muted, #aaa);">— Repeated, unwanted, or promotional content.</span></span>
</label>
<label class="report-category-option" style="display: flex; align-items: center; gap: 10px; padding: 9px 12px; border: 1px solid var(--nav-border-color, #444); border-radius: 6px; cursor: pointer; background: var(--bg-secondary, rgba(255,255,255,0.03)); transition: border-color 0.15s, background 0.15s; user-select: none;">
<input type="checkbox" name="report_category" value="duplicate" class="report-cat-check" style="accent-color: var(--accent, #e65c00); width: 16px; height: 16px; cursor: pointer; flex-shrink: 0;">
<span style="font-size: 0.9em;"><strong style="color: var(--text-color, #fff);">Duplicate</strong> <span style="color: var(--text-muted, #aaa);">— This content has already been posted before.</span></span>
</label>
<label class="report-category-option" style="display: flex; align-items: center; gap: 10px; padding: 9px 12px; border: 1px solid var(--nav-border-color, #444); border-radius: 6px; cursor: pointer; background: var(--bg-secondary, rgba(255,255,255,0.03)); transition: border-color 0.15s, background 0.15s; user-select: none;">
<input type="checkbox" name="report_category" value="copyright" class="report-cat-check" style="accent-color: var(--accent, #e65c00); width: 16px; height: 16px; cursor: pointer; flex-shrink: 0;">
<span style="font-size: 0.9em;"><strong style="color: var(--text-color, #fff);">Copyright</strong> <span style="color: var(--text-muted, #aaa);">— Content infringes on intellectual property rights.</span></span>
</label>
<label class="report-category-option" style="display: flex; align-items: center; gap: 10px; padding: 9px 12px; border: 1px solid var(--nav-border-color, #444); border-radius: 6px; cursor: pointer; background: var(--bg-secondary, rgba(255,255,255,0.03)); transition: border-color 0.15s, background 0.15s; user-select: none;">
<input type="checkbox" name="report_category" value="illegal" class="report-cat-check" style="accent-color: var(--accent, #e65c00); width: 16px; height: 16px; cursor: pointer; flex-shrink: 0;">
<span style="font-size: 0.9em;"><strong style="color: var(--text-color, #fff); color: var(--danger, #ff4444);">Illegal Content</strong> <span style="color: var(--text-muted, #aaa);">— Content that may violate laws or platform rules.</span></span>
</label>
<label class="report-category-option" style="display: flex; align-items: center; gap: 10px; padding: 9px 12px; border: 1px solid var(--nav-border-color, #444); border-radius: 6px; cursor: pointer; background: var(--bg-secondary, rgba(255,255,255,0.03)); transition: border-color 0.15s, background 0.15s; user-select: none;">
<input type="checkbox" name="report_category" value="other" class="report-cat-check" style="accent-color: var(--accent, #e65c00); width: 16px; height: 16px; cursor: pointer; flex-shrink: 0;">
<span style="font-size: 0.9em;"><strong style="color: var(--text-color, #fff);">Other</strong> <span style="color: var(--text-muted, #aaa);">— Something else not listed above.</span></span>
</label>
</div>
<textarea id="report-reason" class="mod-reason" placeholder="{{ t('report.placeholder') }}"></textarea>
@if(recaptcha_enabled && !session)
<div id="modal-report-recaptcha" data-sitekey="{{ recaptcha_site_key }}" style="margin: 10px 0; display: flex; justify-content: center;"></div>
@@ -122,9 +148,11 @@
<div class="global-sidebar-right">
<div class="sidebar-activity">
<div class="sidebar-tabs">
@if(enable_comments)
<button type="button" class="sidebar-tab active" data-tab="comments" title="{{ t('sidebar.recent_comments') }}" aria-label="{{ t('sidebar.recent_comments') }}">
<i class="fa-solid fa-comments"></i>
</button>
@endif
<button type="button" class="sidebar-tab" data-tab="recommendations" title="{{ t('sidebar.recommendations') }}" aria-label="{{ t('sidebar.recommendations') }}">
<i class="fa-solid fa-compass"></i>
</button>
@@ -138,12 +166,14 @@
<i class="fa-solid fa-sliders"></i>
</button>
</div>
@if(enable_comments)
<div id="sidebar-activity-container" class="sidebar-comments-list sidebar-tab-content active" data-tab-content="comments">
<div class="sidebar-loading-state">
<i class="fa-solid fa-circle-notch fa-spin"></i>
<span>{{ t('sidebar.loading_activity') }}</span>
</div>
</div>
@endif
<div id="sidebar-recommendations-container" class="sidebar-recommendations-list sidebar-tab-content" data-tab-content="recommendations" style="display: none;">
<div class="sidebar-loading-state">
<i class="fa-solid fa-circle-notch fa-spin"></i>
+3 -1
View File
@@ -67,11 +67,13 @@
@endif
<link rel="stylesheet" href="/s/css/upload.css?v={{ ts }}">
@endif
<script>window.f0ckThemes = {{ themes_json }}; window.f0ckDefaultTheme = "{{ default_theme }}"; window.f0ckDefaultLayout = "{{ default_layout }}"; 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 }}; window.f0ckMediaBase = "{{ paths_images }}"; window.f0ckShitpostMode = {{ shitpost_mode ? 'true' : 'false' }}; window.f0ckShitpostRequireRating = {{ shitpost_require_rating ? 'true' : 'false' }}; window.f0ckShitpostMinTags = {{ shitpost_min_tags || 0 }}; window.f0ckEnableItemTitle = {{ enable_item_title ? 'true' : 'false' }}; window.f0ckCommentBannerEnabled = @if(comment_banner_enabled) true @else false @endif; window.f0ckServerAudioTuner = {{ audio_tuner_json || 'null' }};</script>
<script>window.f0ckThemes = {{ themes_json }}; window.f0ckDefaultTheme = "{{ default_theme }}"; window.f0ckDefaultLayout = "{{ default_layout }}"; 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 }}; window.f0ckMediaBase = "{{ paths_images }}"; window.f0ckShitpostMode = {{ shitpost_mode ? 'true' : 'false' }}; window.f0ckShitpostRequireRating = {{ shitpost_require_rating ? 'true' : 'false' }}; window.f0ckShitpostMinTags = {{ shitpost_min_tags || 0 }}; window.f0ckEnableItemTitle = {{ enable_item_title ? 'true' : 'false' }}; window.f0ckCommentBannerEnabled = @if(comment_banner_enabled) true @else false @endif; window.f0ckServerAudioTuner = {{ audio_tuner_json || 'null' }}; window.f0ckEnableComments = {{ enable_comments ? 'true' : 'false' }};</script>
@if(!private_society || session)
@if(enable_comments)
<script src="/s/js/marked.min.js" defer></script>
<script src="/s/js/comments.js?v={{ ts }}" defer></script>
@endif
@endif
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0" />
@if(typeof item !== 'undefined')
+7 -2
View File
@@ -31,6 +31,11 @@
<div class="album-counter-pill" title="Subf0cks">
<i class="fa-solid fa-layer-group"></i>
<span class="album-current-idx">1</span> / <span class="album-total-count">{{ item.album.length }}</span>
@if(can_manage_item)
<button type="button" class="album-sub-delete-btn" id="a_delete_sub" title="Delete current slide from album" aria-label="Delete slide" style="background: none; border: none; color: #ff6b6b; margin-left: 8px; cursor: pointer; pointer-events: auto; padding: 0 2px;">
<i class="fa-solid fa-trash-can"></i>
</button>
@endif
</div>
<button type="button" class="album-btn album-btn-next" title="{{ t('album.next') || 'Next' }}" aria-label="Next">
<i class="fa-solid fa-chevron-right"></i>
@@ -63,7 +68,7 @@
</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="auto" data-size="{{ item.size }}" loop playsinline></video>
<video id="my-video" class="embed-responsive-item" width="640" height="360" src="{{ item.dest }}" preload="auto" data-size="{{ item.size }}" loop playsinline @if(!session || session.disable_autoplay !== true) autoplay @endif></video>
</div>
@elseif(item.mime.startsWith("audio"))
<div class="embed-responsive embed-responsive-16by9" style="background: #000;">
@@ -79,7 +84,7 @@
<i class="fa-solid fa-music"></i>
</div>
</div>
<audio id="my-video" class="embed-responsive-item" preload="auto" loop crossorigin="anonymous" src="{{ item.dest }}" data-setup="{}" data-size="{{ item.size }}" @if(item.coverart)poster="{{ item.coverart }}"@endif type="{{ item.mime }}"></audio>
<audio id="my-video" class="embed-responsive-item" preload="auto" loop crossorigin="anonymous" src="{{ item.dest }}" data-setup="{}" data-size="{{ item.size }}" @if(item.coverart)poster="{{ item.coverart }}"@endif type="{{ item.mime }}" @if(!session || session.disable_autoplay !== true) autoplay @endif></audio>
<img id="f0ck-audio-cover" @if(item.coverart)src="{{ item.coverart }}"@endif style="display: none;">
</div>
@elseif(item.mime.startsWith("image"))
+272 -165
View File
@@ -66,7 +66,7 @@
@endif
@endif
@if(enable_anonymous_access && session.is_anon)
<a href="#" id="nav-user-anon-identity-btn" onclick="event.preventDefault(); if(window.f0ckAnonSSH) { window.f0ckAnonSSH.openModal(); } else { const m = document.getElementById('anon-ssh-modal'); if (m) m.style.display='flex'; }"><i class="fa-solid fa-key"></i> Key Management</a>
<a href="#" id="nav-user-anon-identity-btn" onclick="event.preventDefault(); if(window.f0ckAnonPasskey) { window.f0ckAnonPasskey.openModal(); } else { const m = document.getElementById('anon-passkey-modal'); if (m) m.style.display='flex'; }"><i class="fa-solid fa-fingerprint"></i> Passkey Identity</a>
@endif
<a href="/user/{{ (session.is_anon && session.login ? session.login : session.user).toLowerCase() }}/favs" class="mobile-only">{{ t('nav.favs') }}</a>
<a href="/settings" class="mobile-only">{{ t('nav.settings') }}</a>
@@ -84,20 +84,30 @@
<div id="notif-dropdown" class="notif-dropdown">
<div class="notif-header">
<div class="notif-tabs">
@if(enable_comments)
<button class="notif-tab active" data-tab="user">{{ t('nav.notif_tab_user') }} <span class="notif-tab-badge" id="notif-tab-badge-user" style="display:none">0</span></button>
<button class="notif-tab" data-tab="system">{{ t('nav.notif_tab_system') }} <span class="notif-tab-badge" id="notif-tab-badge-system" style="display:none">0</span></button>
@else
<button class="notif-tab active" data-tab="system">{{ t('nav.notif_tab_system') }} <span class="notif-tab-badge" id="notif-tab-badge-system" style="display:none">0</span></button>
@endif
</div>
<button id="mark-all-read" title="{{ t('nav.mark_all_read') }}"><i class="fa-solid fa-check-double"></i></button>
</div>
@if(enable_comments)
<div class="notif-list" data-active-tab="user">
@else
<div class="notif-list" data-active-tab="system">
@endif
<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>
@if(enable_comments)
<div class="submanage">
<a href="/subscriptions">{{ t('nav.manage_subscriptions') }}</a>
</div>
@endif
</div>
</div>
@@ -286,7 +296,7 @@
@endif
@if(enable_anonymous_access)
<a href="#" id="nav-anon-identity-btn" @if(!session || !session.is_anon) style="display:none;" @endif onclick="event.preventDefault(); if(window.f0ckAnonSSH) { window.f0ckAnonSSH.openModal(); } else { const m = document.getElementById('anon-ssh-modal'); if (m) m.style.display='flex'; }"><i class="fa-solid fa-key"></i> Key Management</a>
<a href="#" id="nav-anon-identity-btn" @if(!session || !session.is_anon) style="display:none;" @endif onclick="event.preventDefault(); if(window.f0ckAnonPasskey) { window.f0ckAnonPasskey.openModal(); } else { const m = document.getElementById('anon-passkey-modal'); if (m) m.style.display='flex'; }"><i class="fa-solid fa-fingerprint"></i> Passkey Identity</a>
<a href="/settings" id="nav-anon-settings-btn" @if(!session || !session.is_anon) style="display:none;" @endif><i class="fa-solid fa-gear"></i> Settings</a>
@endif
<a href="#" id="nav-login-btn">{{ t('nav.login') }}</a>
@@ -318,40 +328,141 @@
<script>
// Passkey login for registered users — fires only on explicit button click
async function loginWithPasskey() {
const btn = document.getElementById('modal-login-passkey-btn');
const errEl = document.getElementById('modal-passkey-error');
if (errEl) errEl.style.display = 'none';
if (!window.PublicKeyCredential) {
if (errEl) { errEl.style.display = ''; errEl.textContent = 'Passkeys are not supported in this browser.'; }
return;
}
if (btn) { btn.disabled = true; btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Waiting for passkey...'; }
function b64urlToArr(s) { return Uint8Array.from(atob(s.split('-').join('+').split('_').join('/')), c=>c.charCodeAt(0)); }
function arrToB64url(buf) { return btoa(String.fromCharCode(...new Uint8Array(buf))).split('+').join('-').split('/').join('_').replace(/=+$/,''); }
try {
const beginRes = await fetch('/api/v2/settings/passkeys/login/begin', { method: 'POST', headers: {'Content-Type':'application/json'}, body: '{}' });
const beginData = await beginRes.json();
if (!beginData.success) throw new Error(beginData.msg || 'Server error');
const opts = beginData.options;
const cred = await navigator.credentials.get({ publicKey: {
challenge: b64urlToArr(opts.challenge),
rpId: opts.rpId,
allowCredentials: (opts.allowCredentials || []).map(c => ({ type: c.type, id: b64urlToArr(c.id) })),
userVerification: opts.userVerification || 'preferred',
timeout: opts.timeout || 60000
}});
const finishRes = await fetch('/api/v2/settings/passkeys/login/finish', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
challenge: opts.challenge,
credentialId: arrToB64url(cred.rawId),
clientDataJSON: arrToB64url(cred.response.clientDataJSON),
authenticatorData: arrToB64url(cred.response.authenticatorData),
signature: arrToB64url(cred.response.signature)
})
});
const finishData = await finishRes.json();
if (!finishData.success) throw new Error(finishData.msg || 'Authentication failed');
window.location.reload();
} catch (e) {
if (e && e.name !== 'NotAllowedError' && errEl) {
errEl.style.display = '';
errEl.textContent = e.message || 'Passkey login failed.';
}
if (btn) { btn.disabled = false; btn.innerHTML = '<i class="fa-solid fa-fingerprint"></i> Login with passkey'; }
}
}
</script>
<div id="login-modal" style="display: none;">
<div class="login-modal-content">
<button id="login-modal-close">&times;</button>
<!-- Login View -->
<div id="modal-login-view">
<form class="login-form" method="post" action="/login" novalidate>
<h2 style="text-align: center; margin-bottom: 20px;">{{ t('auth.login_title') }}</h2>
<input type="text" name="username" placeholder="{{ t('auth.username_or_email') }}" autocomplete="off" required />
<input type="password" name="password" placeholder="{{ t('auth.password_placeholder_min') }}" autocomplete="off" required />
<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>
<h2 style="text-align: center; margin-bottom: 18px;">{{ t('auth.login_title') }}</h2>
<!-- Tab bar -->
<div id="login-tabs" style="display: flex; border-bottom: 1px solid rgba(255,255,255,0.1); margin-bottom: 20px;">
<button type="button" id="login-tab-creds" onclick="switchLoginTab('creds')"
style="flex: 1; background: none; border: none; border-bottom: 2px solid var(--accent, #0096ff); color: var(--text-color, #fff); padding: 8px 0; font-size: 0.9em; cursor: pointer; font-weight: 600; transition: color 0.15s;">
<i class="fa-solid fa-key" style="margin-right:5px;"></i>Credentials
</button>
<button type="button" id="login-tab-passkey" onclick="switchLoginTab('passkey')"
style="flex: 1; background: none; border: none; border-bottom: 2px solid transparent; color: var(--text-muted, #888); padding: 8px 0; font-size: 0.9em; cursor: pointer; font-weight: 600; transition: color 0.15s;">
<i class="fa-solid fa-fingerprint" style="margin-right:5px;"></i>Passkey
</button>
</div>
<!-- Tab: Credentials -->
<div id="login-panel-creds">
<form class="login-form" method="post" action="/login" novalidate style="padding: 0; background: none; box-shadow: none; border: none;">
<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 />
<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>
<!-- Tab: Passkey -->
<div id="login-panel-passkey" style="display: none; text-align: center; padding: 8px 0 4px;">
<div style="font-size: 2.6em; margin-bottom: 14px;">🔑</div>
<p style="font-size: 0.88em; color: var(--text-muted, #aaa); margin: 0 0 20px; line-height: 1.55;">
Use a saved passkey from Bitwarden, iCloud Keychain, or your OS to sign in without a password.
</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
@if(enable_anonymous_access)
<div style="margin-top: 15px; border-top: 1px solid rgba(255,255,255,0.1); padding-top: 12px; text-align: center;">
<button type="button" id="modal-login-as-anon-btn" class="btn btn-sm" style="background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.2); color: #ddd; border-radius: 4px; padding: 7px 14px; font-size: 0.88em; cursor: pointer; width: 100%; transition: background 0.2s;">
<i class="fa-solid fa-user-secret"></i> Login as anonymous
</button>
</div>
@endif
</form>
<button type="button" id="modal-login-passkey-btn"
style="width: 100%; padding: 11px; background: var(--accent, #0096ff); color: #fff; border: none; border-radius: 5px; font-size: 0.95em; font-weight: 600; cursor: pointer; margin-bottom: 8px;"
onclick="loginWithPasskey()">
<i class="fa-solid fa-fingerprint"></i> Use my passkey
</button>
<div id="modal-passkey-error" style="display:none; color:#e06c75; font-size:0.82em; margin-top:6px;"></div>
</div>
@if(enable_anonymous_access)
<div style="margin-top: 16px; border-top: 1px solid rgba(255,255,255,0.08); padding-top: 12px; text-align: center;">
<button type="button" id="modal-login-as-anon-btn"
style="background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.15); color: #999; border-radius: 4px; padding: 7px 14px; font-size: 0.85em; cursor: pointer; width: 100%; transition: background 0.2s;"
onclick="event.preventDefault(); if(window.f0ckAnonPasskey) window.f0ckAnonPasskey.openSetupModal(); else { const m=document.getElementById('anon-setup-modal'); if(m) m.style.display='flex'; }">
<i class="fa-solid fa-user-secret"></i> Login as anonymous
</button>
</div>
@endif
</div>
<script>
function switchLoginTab(tab) {
var isCreds = tab === 'creds';
document.getElementById('login-panel-creds').style.display = isCreds ? '' : 'none';
document.getElementById('login-panel-passkey').style.display = isCreds ? 'none' : '';
var tCreds = document.getElementById('login-tab-creds');
var tPk = document.getElementById('login-tab-passkey');
if (tCreds) { tCreds.style.borderBottomColor = isCreds ? 'var(--accent, #0096ff)' : 'transparent'; tCreds.style.color = isCreds ? 'var(--text-color, #fff)' : 'var(--text-muted, #888)'; }
if (tPk) { tPk.style.borderBottomColor = isCreds ? 'transparent' : 'var(--accent, #0096ff)'; tPk.style.color = isCreds ? 'var(--text-muted, #888)' : 'var(--text-color, #fff)'; }
}
</script>
@if(smtp_enabled)
<!-- Forgot Password View -->
<div id="modal-forgot-view" style="display: none;">
@@ -387,169 +498,165 @@
</div>
@if(enable_anonymous_access)
<!-- Anonymous OpenSSH Ed25519 Identity Modal -->
<div id="anon-ssh-modal" style="display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.75); backdrop-filter: blur(4px); z-index: 99999; align-items: center; justify-content: center;">
<div class="login-modal-content" style="max-width: 540px; width: 92vw; max-height: 90vh; overflow-y: auto; text-align: left; padding: 25px; border: 1px solid rgba(255,255,255,0.15); border-radius: 8px; background: var(--bg-primary, #111); box-shadow: 0 10px 40px rgba(0,0,0,0.8);">
<button id="anon-ssh-modal-close" style="position: absolute; top: 15px; right: 15px; background: none; border: none; color: var(--text-muted, #aaa); font-size: 1.4em; cursor: pointer; line-height: 1;">&times;</button>
<!-- Anonymous Passkey Identity Modal -->
<div id="anon-passkey-modal" style="display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.75); backdrop-filter: blur(4px); z-index: 99999; align-items: center; justify-content: center;">
<div class="login-modal-content" style="max-width: 500px; width: 92vw; max-height: 90vh; overflow-y: auto; text-align: left; padding: 25px; border: 1px solid rgba(255,255,255,0.15); border-radius: 8px; background: var(--bg-primary, #111); box-shadow: 0 10px 40px rgba(0,0,0,0.8); position: relative;">
<button id="anon-passkey-modal-close" style="position: absolute; top: 15px; right: 15px; background: none; border: none; color: var(--text-muted, #aaa); font-size: 1.4em; cursor: pointer; line-height: 1;">&times;</button>
<div style="display: flex; align-items: center; gap: 10px; margin-bottom: 8px;">
<i class="fa-solid fa-key" style="color: var(--accent, #0096ff); font-size: 1.3em;"></i>
<h3 style="margin: 0; font-size: 1.25em; color: var(--text-color, #fff);">Anonymous SSH Identity</h3>
<i class="fa-solid fa-fingerprint" style="color: var(--accent, #0096ff); font-size: 1.3em;"></i>
<h3 style="margin: 0; font-size: 1.25em; color: var(--text-color, #fff);">Anonymous Passkey Identity</h3>
</div>
<p style="margin: 0 0 15px 0; font-size: 0.85em; color: var(--text-muted, #aaa); line-height: 1.4;">
Your browser holds an <strong>OpenSSH Ed25519</strong> private key. Your comments and favorites belong to this key without needing a password.
<p style="margin: 0 0 18px 0; font-size: 0.85em; color: var(--text-muted, #aaa); line-height: 1.4;">
Your anonymous identity is protected by a <strong>passkey</strong> stored in your OS or password manager (e.g. Bitwarden).
No private key ever touches this browser's storage.
</p>
<!-- Tabs -->
<div style="display: flex; gap: 8px; margin-bottom: 15px; border-bottom: 1px solid rgba(255,255,255,0.1); padding-bottom: 8px;">
<button type="button" id="anon-tab-btn-identity" class="btn btn-sm btn-primary" style="font-size: 0.85em; padding: 5px 12px;">My Identity</button>
<button type="button" id="anon-tab-btn-import" class="btn btn-sm btn-secondary" style="font-size: 0.85em; padding: 5px 12px;">Import / Supply Key</button>
</div>
<!-- Tab 1: Current Identity -->
<div id="anon-tab-identity">
<div style="margin-bottom: 12px;">
<label style="display: block; font-size: 0.8em; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted, #888); margin-bottom: 4px;">Fingerprint</label>
<div style="display: flex; align-items: center; gap: 8px; background: rgba(0,0,0,0.4); padding: 8px 12px; border-radius: 4px; border: 1px solid rgba(255,255,255,0.08);">
<code id="anon-ssh-fp-display" style="font-family: monospace; font-size: 0.88em; color: var(--accent, #00d2ff); word-break: break-all; flex: 1;">Generating...</code>
<button type="button" id="anon-copy-fp-btn" title="Copy Fingerprint" class="btn btn-sm" style="background: transparent; border: none; color: #aaa; cursor: pointer; padding: 4px;"><i class="fa-solid fa-copy"></i></button>
</div>
<!-- Identity info -->
<div style="margin-bottom: 14px; padding: 12px; background: rgba(0,0,0,0.35); border-radius: 6px; border: 1px solid rgba(255,255,255,0.07);">
<div style="margin-bottom: 8px;">
<label style="display: block; font-size: 0.75em; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted, #888); margin-bottom: 3px;">Short Fingerprint</label>
<code id="anon-pk-fp-display" style="font-family: monospace; font-size: 1em; color: var(--accent, #00d2ff);"></code>
</div>
<div style="margin-bottom: 15px;">
<label style="display: block; font-size: 0.8em; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted, #888); margin-bottom: 4px;">OpenSSH Public Key (<code>id_ed25519.pub</code>)</label>
<textarea id="anon-ssh-pub-display" readonly rows="2" style="width: 100%; box-sizing: border-box; font-family: monospace; font-size: 0.82em; background: rgba(0,0,0,0.4); color: #ddd; border: 1px solid rgba(255,255,255,0.08); border-radius: 4px; padding: 8px; resize: none;"></textarea>
</div>
<div style="display: flex; flex-wrap: wrap; gap: 8px;">
<button type="button" id="anon-copy-pub-btn" class="btn btn-sm btn-secondary" style="font-size: 0.85em; padding: 6px 12px;"><i class="fa-solid fa-copy"></i> Copy Public Key</button>
<button type="button" id="anon-dl-priv-btn" class="btn btn-sm btn-primary" style="font-size: 0.85em; padding: 6px 12px;"><i class="fa-solid fa-download"></i> Download id_ed25519</button>
<button type="button" id="anon-dl-pub-btn" class="btn btn-sm btn-secondary" style="font-size: 0.85em; padding: 6px 12px;"><i class="fa-solid fa-download"></i> Download id_ed25519.pub</button>
<div>
<label style="display: block; font-size: 0.75em; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted, #888); margin-bottom: 3px;">Credential ID (short)</label>
<code id="anon-pk-cred-display" style="font-family: monospace; font-size: 0.78em; color: var(--text-muted, #aaa); word-break: break-all;"></code>
</div>
</div>
<!-- Tab 2: Import Key -->
<div id="anon-tab-import" style="display: none;">
<p style="font-size: 0.85em; color: var(--text-muted, #aaa); margin-top: 0; margin-bottom: 10px;">
Paste your existing <code>id_ed25519</code> OpenSSH private key or raw 32-byte seed to restore your anonymous identity on this browser.
</p>
<textarea id="anon-import-key-input" rows="4" placeholder="-----BEGIN OPENSSH PRIVATE KEY-----&#10;...&#10;-----END OPENSSH PRIVATE KEY-----" style="width: 100%; box-sizing: border-box; font-family: monospace; font-size: 0.82em; background: rgba(0,0,0,0.4); color: #ddd; border: 1px solid rgba(255,255,255,0.08); border-radius: 4px; padding: 8px; resize: vertical; margin-bottom: 10px;"></textarea>
<input type="file" id="anon-import-file-elem" style="display: none;" />
<div style="display: flex; gap: 8px; align-items: center;">
<button type="button" id="anon-upload-key-btn" class="btn btn-sm btn-secondary" style="font-size: 0.85em; padding: 6px 12px;"><i class="fa-solid fa-upload"></i> Upload File</button>
<button type="button" id="anon-submit-import-btn" class="btn btn-sm btn-primary" style="font-size: 0.85em; padding: 6px 16px;"><i class="fa-solid fa-check"></i> Activate Key</button>
</div>
<div id="anon-import-status" style="margin-top: 10px; font-size: 0.85em; display: none;"></div>
<!-- Actions -->
<div style="display: flex; flex-wrap: wrap; gap: 8px;">
<button type="button" id="anon-pk-add-btn" class="btn btn-sm btn-primary" style="font-size: 0.85em; padding: 6px 14px;">
<i class="fa-solid fa-plus"></i> Add another passkey
</button>
</div>
<p style="margin: 14px 0 0; font-size: 0.8em; color: var(--text-muted, #888); line-height: 1.4;">
To use this identity on another device, simply sign in with your passkey manager (Bitwarden, iCloud Keychain, etc.) — it syncs automatically.
</p>
</div>
</div>
<script>
(function(){
// Tab switching
const tabBtnId = document.getElementById('anon-tab-btn-identity');
const tabBtnImp = document.getElementById('anon-tab-btn-import');
const tabId = document.getElementById('anon-tab-identity');
const tabImp = document.getElementById('anon-tab-import');
if(tabBtnId && tabBtnImp && tabId && tabImp){
tabBtnId.addEventListener('click', function(){
tabId.style.display = 'block';
tabImp.style.display = 'none';
tabBtnId.classList.remove('btn-secondary'); tabBtnId.classList.add('btn-primary');
tabBtnImp.classList.remove('btn-primary'); tabBtnImp.classList.add('btn-secondary');
});
tabBtnImp.addEventListener('click', function(){
tabId.style.display = 'none';
tabImp.style.display = 'block';
tabBtnImp.classList.remove('btn-secondary'); tabBtnImp.classList.add('btn-primary');
tabBtnId.classList.remove('btn-primary'); tabBtnId.classList.add('btn-secondary');
// Keep legacy modal ID working for any nav onclick handlers that reference anon-ssh-modal
var legacyAlias = document.getElementById('anon-ssh-modal');
if (!legacyAlias) {
// Create a minimal alias element that delegates to the passkey modal
var alias = document.createElement('div');
alias.id = 'anon-ssh-modal';
alias.style.display = 'none';
Object.defineProperty(alias.style, 'display', {
set: function(v) { if (v === 'flex' || v === 'block') { var m = document.getElementById('anon-passkey-modal'); if (m) m.style.display = 'flex'; } }
});
document.body.appendChild(alias);
}
// Copy buttons
const copyPubBtn = document.getElementById('anon-copy-pub-btn');
if(copyPubBtn){
copyPubBtn.addEventListener('click', function(){
if(window.f0ckAnonSSH) window.f0ckAnonSSH.copyPublicKey();
var modalClose = document.getElementById('anon-passkey-modal-close');
if (modalClose) {
modalClose.addEventListener('click', function() {
var m = document.getElementById('anon-passkey-modal');
if (m) m.style.display = 'none';
});
}
const copyFpBtn = document.getElementById('anon-copy-fp-btn');
if(copyFpBtn){
copyFpBtn.addEventListener('click', function(){
const fp = document.getElementById('anon-ssh-fp-display')?.textContent;
if(fp && navigator.clipboard){
navigator.clipboard.writeText(fp).then(function(){
if(typeof window.showToastNotification === 'function') window.showToastNotification('Fingerprint copied!');
});
}
});
var overlay = document.getElementById('anon-passkey-modal');
if (overlay) {
overlay.addEventListener('click', function(e) { if (e.target === overlay) overlay.style.display = 'none'; });
}
})();
</script>
@endif
// Download buttons
const dlPriv = document.getElementById('anon-dl-priv-btn');
if(dlPriv){
dlPriv.addEventListener('click', function(){
if(window.f0ckAnonSSH) window.f0ckAnonSSH.downloadPrivateKey();
});
}
const dlPub = document.getElementById('anon-dl-pub-btn');
if(dlPub){
dlPub.addEventListener('click', function(){
if(window.f0ckAnonSSH) window.f0ckAnonSSH.downloadPublicKey();
});
}
@if(enable_anonymous_access)
<!-- Anonymous Passkey Setup Modal — shown when user clicks "Login as anonymous" -->
<div id="anon-setup-modal" style="display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.8); backdrop-filter: blur(4px); z-index: 99999; align-items: center; justify-content: center;">
<div style="max-width: 420px; width: 92vw; padding: 28px; border: 1px solid rgba(255,255,255,0.12); border-radius: 10px; background: var(--bg-primary, #111); box-shadow: 0 12px 48px rgba(0,0,0,0.9); position: relative; text-align: center;">
<button id="anon-setup-modal-close" style="position: absolute; top: 14px; right: 14px; background: none; border: none; color: var(--text-muted, #aaa); font-size: 1.3em; cursor: pointer; line-height: 1;">&times;</button>
// File upload trigger
const uploadBtn = document.getElementById('anon-upload-key-btn');
const fileElem = document.getElementById('anon-import-file-elem');
const inputElem = document.getElementById('anon-import-key-input');
if(uploadBtn && fileElem){
uploadBtn.addEventListener('click', function(){ fileElem.click(); });
fileElem.addEventListener('change', function(){
if(fileElem.files && fileElem.files[0]){
const reader = new FileReader();
reader.onload = function(e){
if(inputElem) inputElem.value = e.target.result;
};
reader.readAsText(fileElem.files[0]);
}
});
}
<!-- New user view (no passkey yet) -->
<div id="anon-setup-new">
<div style="font-size: 2.4em; margin-bottom: 12px;">🔑</div>
<h3 style="margin: 0 0 10px; font-size: 1.15em; color: var(--text-color, #fff);">Create an anonymous passkey</h3>
<p style="margin: 0 0 18px; font-size: 0.88em; color: var(--text-muted, #aaa); line-height: 1.55;">
Your browser will ask you to save a <strong>passkey</strong> — a secure credential stored in Bitwarden, iCloud Keychain, or your OS.
No account, no email, no password. Your comments and favorites are tied to this passkey.
</p>
<p style="margin: 0 0 20px; font-size: 0.82em; color: var(--text-muted, #888); line-height: 1.4;">
You can use it across devices if your passkey manager syncs (e.g. Bitwarden).
</p>
<button type="button" id="anon-setup-create-btn" style="width: 100%; padding: 11px; background: var(--accent, #0096ff); color: #fff; border: none; border-radius: 5px; font-size: 0.95em; font-weight: 600; cursor: pointer; margin-bottom: 10px;">
<i class="fa-solid fa-fingerprint"></i> Create my passkey
</button>
<div id="anon-setup-new-error" style="display: none; color: #e06c75; font-size: 0.83em; margin-top: 6px;"></div>
<button type="button" id="anon-setup-switch-existing" style="background: none; border: none; color: var(--text-muted, #888); font-size: 0.8em; cursor: pointer; text-decoration: underline; margin-top: 4px;">
Already have one? Use existing passkey
</button>
</div>
// Submit import
const submitImpBtn = document.getElementById('anon-submit-import-btn');
const statusEl = document.getElementById('anon-import-status');
if(submitImpBtn){
submitImpBtn.addEventListener('click', async function(){
const keyVal = inputElem?.value;
if(!keyVal || !keyVal.trim()){
if(statusEl){ statusEl.style.display = 'block'; statusEl.style.color = '#ff4444'; statusEl.textContent = 'Please paste a key or select a file'; }
return;
}
try {
submitImpBtn.disabled = true;
submitImpBtn.textContent = 'Importing...';
await window.f0ckAnonSSH.importKey(keyVal);
if(statusEl){
statusEl.style.display = 'block';
statusEl.style.color = '#00C851';
statusEl.textContent = 'Key activated successfully! Reloading...';
}
setTimeout(function(){ window.location.reload(); }, 800);
} catch(err){
submitImpBtn.disabled = false;
submitImpBtn.innerHTML = '<i class="fa-solid fa-check"></i> Activate Key';
if(statusEl){
statusEl.style.display = 'block';
statusEl.style.color = '#ff4444';
statusEl.textContent = 'Import error: ' + (err.message || err);
}
}
});
<!-- Returning user view (has passkey) -->
<div id="anon-setup-returning" style="display: none;">
<div style="font-size: 2.4em; margin-bottom: 12px;">👤</div>
<h3 style="margin: 0 0 10px; font-size: 1.15em; color: var(--text-color, #fff);">Use your anonymous passkey</h3>
<p style="margin: 0 0 20px; font-size: 0.88em; color: var(--text-muted, #aaa); line-height: 1.55;">
Pick your saved passkey from Bitwarden, iCloud Keychain, or your OS to continue as the same anonymous user.
</p>
<button type="button" id="anon-setup-auth-btn" style="width: 100%; padding: 11px; background: var(--accent, #0096ff); color: #fff; border: none; border-radius: 5px; font-size: 0.95em; font-weight: 600; cursor: pointer; margin-bottom: 10px;">
<i class="fa-solid fa-fingerprint"></i> Use my passkey
</button>
<div id="anon-setup-ret-error" style="display: none; color: #e06c75; font-size: 0.83em; margin-top: 6px;"></div>
<button type="button" id="anon-setup-switch-new" style="background: none; border: none; color: var(--text-muted, #888); font-size: 0.8em; cursor: pointer; text-decoration: underline; margin-top: 4px;">
Create a new passkey instead
</button>
</div>
</div>
</div>
<script>
(function() {
function closeSetupModal() {
var m = document.getElementById('anon-setup-modal'); if (m) m.style.display = 'none';
}
var closeBtn = document.getElementById('anon-setup-modal-close');
if (closeBtn) closeBtn.addEventListener('click', closeSetupModal);
var overlay = document.getElementById('anon-setup-modal');
if (overlay) overlay.addEventListener('click', function(e) { if (e.target === overlay) closeSetupModal(); });
var switchToExisting = document.getElementById('anon-setup-switch-existing');
var switchToNew = document.getElementById('anon-setup-switch-new');
if (switchToExisting) switchToExisting.addEventListener('click', function() {
document.getElementById('anon-setup-new').style.display = 'none';
document.getElementById('anon-setup-returning').style.display = '';
});
if (switchToNew) switchToNew.addEventListener('click', function() {
document.getElementById('anon-setup-returning').style.display = 'none';
document.getElementById('anon-setup-new').style.display = '';
});
var createBtn = document.getElementById('anon-setup-create-btn');
if (createBtn) createBtn.addEventListener('click', async function() {
var errEl = document.getElementById('anon-setup-new-error');
if (errEl) errEl.style.display = 'none';
createBtn.disabled = true;
createBtn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Saving passkey...';
try {
await window.f0ckAnonPasskey.doRegister();
} catch(e) {
if (errEl && e && e.name !== 'NotAllowedError') { errEl.style.display = ''; errEl.textContent = e.message || 'Something went wrong.'; }
createBtn.disabled = false;
createBtn.innerHTML = '<i class="fa-solid fa-fingerprint"></i> Create my passkey';
}
});
var authBtn = document.getElementById('anon-setup-auth-btn');
if (authBtn) authBtn.addEventListener('click', async function() {
var errEl = document.getElementById('anon-setup-ret-error');
if (errEl) errEl.style.display = 'none';
authBtn.disabled = true;
authBtn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Waiting for passkey...';
try {
await window.f0ckAnonPasskey.doAuthenticate();
} catch(e) {
if (errEl && e && e.name !== 'NotAllowedError') { errEl.style.display = ''; errEl.textContent = e.message || 'Authentication failed.'; }
authBtn.disabled = false;
authBtn.innerHTML = '<i class="fa-solid fa-fingerprint"></i> Use my passkey';
}
});
})();
</script>
@endif
+2
View File
@@ -104,7 +104,9 @@
<div class="stat-joined" tooltip="{{ user.timestamp.timefull }}" data-iso="{{ user.timestamp.timefull }}">{{ t('profile.age_days', { n: user.age_days }) }}</div>
@if(!user.is_ghost)
@if(enable_comments)
<div class="stat-comments">{{ t('profile.stat_comments') }} <a href="/user/{!! user.user !!}/comments">{{ count.comments }}</a></div>
@endif
<div class="stat-tags">{{ t('profile.stat_tags') }} <a href="/user/{!! user.user !!}/tags">{{ count.tags }}</a></div>
@if(!user.is_ghost)
<div class="stat-halls">{{ t('profile.stat_halls') }} <a href="/user/{!! user.user !!}/halls">{{ count.halls }}</a></div>