Files
f0ckm/views/settings.html
T
2026-09-29 04:35:35 +02:00

1844 lines
107 KiB
HTML

@include(snippets/header)
<div class="pagewrapper">
<div id="main">
<div class="settings">
<!-- Quick navigation -->
<nav id="settings-quicknav" aria-label="Settings sections">
<a href="#preferences"><i class="fa-solid fa-sliders"></i> {{ t('settings.preferences') }}</a>
@if(!session.is_anon)
<a href="#profile"><i class="fa-solid fa-user-pen"></i> {{ t('settings.profile') }}</a>
@endif
@if(session.is_anon)
<a href="#anon-passkey"><i class="fa-solid fa-fingerprint"></i> Passkey</a>
@else
<a href="#passkeys"><i class="fa-solid fa-fingerprint"></i> Passkeys</a>
@endif
@if(!session.is_anon)
<a href="#account"><i class="fa-solid fa-shield-halved"></i> {{ t('settings.account') }}</a>
@endif
@if(!session.is_anon && enable_user_api_keys)
<a href="#apikey"><i class="fa-solid fa-key"></i> API Key</a>
@endif
@if(!session.is_anon && (matrix_enabled || telegram_enabled))
<a href="#linked"><i class="fa-solid fa-link"></i> {{ t('settings.linked_accounts') }}</a>
@endif
@if(!session.is_anon && enable_user_invites)
<a href="#invites"><i class="fa-solid fa-ticket"></i> {{ t('invites.section_title') }}</a>
@endif
@if(enable_data_export)
<a href="#export"><i class="fa-solid fa-file-export"></i> {{ t('settings.export_data_title') }}</a>
@endif
</nav>
<style>
/* Settings: style guide (square, flat, theme colours, no glow).
Inline styles on this page hard-code radii/backgrounds, so surfaces and corners are forced. */
.settings {
--st-accent: var(--accent, #0096ff);
--st-text: var(--text-color, var(--white, #fff));
--st-muted: var(--text-muted, #8a8f98);
--st-surface: rgba(255, 255, 255, 0.04);
--st-border: rgba(255, 255, 255, 0.1);
--st-gap: 12px; /* space between quicknav and the section it scrolled to */
/* --settings-qn-bottom is measured by JS: bottom edge of the fixed quicknav */
padding-top: calc(var(--settings-qn-bottom, 100px) - var(--navbar-h, 50px) + 16px);
padding-bottom: var(--settings-tail, 40px);
text-align: left;
}
/* No blurred item backdrop here: the #bg canvas keeps the last item's image after AJAX navigation.
Lives in this page's <style>, so it is gone again as soon as another page is loaded. */
body:has(#main > .settings) #bg { opacity: 0 !important; visibility: hidden !important; }
/* Corners: none, anywhere (profile card included) */
.settings *, .settings *::before, .settings *::after,
#settings-quicknav, #settings-quicknav * { border-radius: 0 !important; }
/* Quicknav: flat bar, segmented square chips, active = solid accent */
#settings-quicknav {
gap: 4px; padding: 8px 16px; box-sizing: border-box; transition: right 0.3s ease-in-out;
background: var(--bg, #111); border-bottom: 1px solid var(--st-border, rgba(255,255,255,0.1));
box-shadow: none;
}
/* In the page modal: the quicknav sticks to the top of the modal body (no fixed offset needed) */
/* overflow: the global .settings { overflow-x: hidden } would make .settings its own scroll
container, so the sticky quicknav would stick inside a box that never scrolls. clip cuts off
the same overflow without creating one, so it sticks to the modal body. */
.settings.in-page-modal { display: block; padding-top: 0; overflow: visible !important; overflow-x: clip !important; }
.settings.in-page-modal > #settings-quicknav {
position: sticky; top: 0; left: auto; right: auto; z-index: 5; margin: 0 0 16px;
background: var(--bg, #111);
}
/* Height matched to the sidebar tab row (JS): one line, sideways scroll without a visible scrollbar */
#settings-quicknav.qn-synced {
flex-wrap: nowrap; align-items: center; padding-top: 0; padding-bottom: 0;
overflow-x: auto; overflow-y: hidden; overscroll-behavior-x: contain;
scrollbar-width: none;
}
#settings-quicknav.qn-synced::-webkit-scrollbar { display: none; }
#settings-quicknav.qn-synced a { flex-shrink: 0; }
#settings-quicknav a {
display: inline-flex; align-items: center; gap: 6px;
padding: 5px 12px; font-size: 0.72em; font-weight: 800;
text-transform: uppercase; letter-spacing: 0.08em;
border: 1px solid rgba(255, 255, 255, 0.1); background: transparent;
color: var(--text-muted, #8a8f98);
}
#settings-quicknav a i { color: var(--accent); transition: color 0.15s; }
#settings-quicknav a:hover { color: var(--text-color, #fff); border-color: var(--accent); background: transparent; }
#settings-quicknav a.active, html[theme] #settings-quicknav a.active {
background: var(--accent); border-color: var(--accent); color: #000 !important;
}
#settings-quicknav a.active i { color: #000; }
/* Headings */
.settings > h1 {
margin: 0 0 6px; font-size: 1.8em; font-weight: 800; letter-spacing: -0.01em; line-height: 1.15;
color: var(--st-text); display: flex; align-items: center; gap: 12px;
}
.settings > h1 i { color: var(--st-accent); }
.settings > h2[id] {
scroll-margin-top: calc(var(--settings-qn-bottom, 100px) + var(--st-gap));
display: flex; align-items: center; gap: 10px;
margin: 32px 0 12px; padding: 0 0 8px;
font-size: 0.95em; font-weight: 800; text-transform: uppercase; letter-spacing: 0.1em;
color: var(--st-text); border-bottom: 1px solid var(--st-border);
}
.settings > h2[id]::before { content: ''; width: 3px; height: 14px; background: var(--st-accent); flex-shrink: 0; }
.settings > h2[id] i { color: var(--st-accent); }
/* Target heading: accent underline instead of the global inset/background highlight */
.settings > h2[id].anchor-target {
box-shadow: none !important; background: transparent !important; padding-left: 0;
border-bottom-color: var(--st-accent);
}
.settings h4 { font-weight: 700; color: var(--st-text) !important; }
/* Paragraphs carry no margin of their own (many set one inline); the block after one keeps a gap */
.settings p { margin: 0 !important; }
.settings p + :not(p, script, style) { margin-top: 12px; }
.settings h4 i { color: var(--st-accent); }
/* Cards / panels */
.settings fieldset,
.settings :is(.account-settings-wrapper, .export-settings-wrapper, .linked-accounts-wrapper) {
background: var(--st-surface) !important; border: 1px solid var(--st-border) !important;
padding: 18px !important; box-shadow: none; margin-bottom: 20px !important;
}
.settings fieldset > legend {
display: flex; align-items: center; gap: 8px; padding: 0 8px !important;
font-size: 0.72em !important; font-weight: 800 !important; text-transform: uppercase; letter-spacing: 0.12em;
color: var(--st-muted);
}
.settings fieldset > legend i { color: var(--st-accent); }
.settings #invite-request-section,
.settings :is(.invite-criterion, .invite-token-row) { background: rgba(0, 0, 0, 0.2) !important; border-color: var(--st-border) !important; }
.settings .invite-criterion.met { border-color: rgba(80, 200, 120, 0.5) !important; }
.settings .invite-criterion.unmet { border-color: rgba(255, 90, 90, 0.35) !important; }
.settings code { background: rgba(0, 0, 0, 0.3) !important; }
.settings .setting-item > label > span { color: var(--st-text); }
/* Live profile card: flat, user colour as the frame, accent only on interaction */
.settings .editable-profile-card { box-shadow: none !important; transition: border-color 0.15s; }
.settings .editable-profile-card:hover { box-shadow: none !important; border-color: var(--st-accent); }
/* The infobox avatar is 22px for comments; the editor needs a real target */
.settings .editable-profile-card { gap: 14px; padding: 12px; align-items: flex-start; }
.settings .editable-avatar-container { width: 88px; height: 88px; }
.settings .editable-avatar-container #live-preview-avatar {
display: block; width: 100%; height: 100%; object-fit: cover; border: 1px solid var(--st-border);
}
@media (max-width: 600px) {
.settings .editable-avatar-container { width: 64px; height: 64px; }
}
.settings .editable-avatar-container .avatar-hover-overlay { background: rgba(0, 0, 0, 0.6); font-size: 1.4rem; }
.settings #avatar-progress-wrapper {
position: absolute; inset: 0; z-index: 4; pointer-events: none;
align-items: center; justify-content: center; background: rgba(0, 0, 0, 0.55);
}
.settings #avatar-progress-text {
color: #fff; font-size: 0.85rem; font-weight: 800; letter-spacing: 0.06em;
font-variant-numeric: tabular-nums; text-shadow: 0 1px 2px rgba(0, 0, 0, 0.8);
}
.settings #avatar-progress-fill {
position: absolute; left: 0; bottom: 0; height: 3px; width: 0;
background: var(--st-accent); transition: width 0.15s linear;
}
.settings #banner-progress-wrapper { position: absolute; inset: 0 0 auto 0; height: 0; z-index: 4; pointer-events: none; }
.settings #banner-progress-fill {
position: absolute; top: 0; left: 0; height: 3px; width: 0;
background: var(--st-accent); transition: width 0.15s linear;
}
.settings #banner-progress-text {
position: absolute; top: 8px; right: 8px; display: inline-flex; align-items: center; gap: 6px;
padding: 3px 8px; background: rgba(0, 0, 0, 0.75); border: 1px solid var(--st-accent);
color: #fff; font-size: 0.68rem; font-weight: 800; text-transform: uppercase; letter-spacing: 0.08em;
font-variant-numeric: tabular-nums; white-space: nowrap;
}
.settings .editable-profile-card.is-banner-uploading #desc-edit-controls :is(#banner-choose-btn, #banner-edit-btn, #banner-remove-btn) {
opacity: 0.5; pointer-events: none;
}
.settings #desc-edit-controls .button i { pointer-events: none; } /* delegated handlers check the button's id */
.settings .editable-avatar-container.is-uploading { pointer-events: none; }
.settings .editable-avatar-container.is-uploading .avatar-hover-overlay,
.settings .editable-avatar-container.is-uploading .avatar-quick-remove { opacity: 0 !important; }
.settings .editable-avatar-container:hover .avatar-preview-img { outline: 1px solid var(--st-accent); outline-offset: -1px; }
.settings .avatar-quick-remove {
top: 0; right: 0; width: 20px; height: 20px;
background: #000; color: #ff6b6b; border: 1px solid rgba(255, 107, 107, 0.6);
box-shadow: none; transition: background 0.15s, color 0.15s;
}
.settings .avatar-quick-remove:hover { transform: none; background: #ff6b6b; color: #000; }
.settings .avatar-quick-remove i { pointer-events: none; } /* click target must be the button (settings.js checks its id) */
.settings .color-picker-badge {
width: 22px; height: 22px; border: 1px solid var(--st-border); box-shadow: none;
text-shadow: none; transition: border-color 0.15s;
}
.settings .color-picker-badge:hover { transform: none; border-color: var(--st-text); }
.settings .user-infobox-username[contenteditable="true"],
.settings .user-infobox-description[contenteditable="true"] {
border: 1px solid transparent; border-bottom-color: var(--st-border);
transition: border-color 0.15s, background 0.15s;
}
.settings .user-infobox-description[contenteditable="true"] { border-color: var(--st-border); }
.settings :is(.user-infobox-username, .user-infobox-description)[contenteditable="true"]:hover {
border-color: var(--st-border); border-bottom-color: var(--st-accent); background: var(--st-surface);
}
.settings :is(.user-infobox-username, .user-infobox-description)[contenteditable="true"]:focus {
outline: none; border: 1px solid var(--st-accent); background: rgba(0, 0, 0, 0.3);
}
.settings #desc-edit-controls .button { min-height: 28px; padding: 0 10px !important; font-size: 0.68em !important; }
/* Form controls */
.settings :is(input:not([type=checkbox]):not([type=radio]):not([type=file]):not([type=range]):not([type=color]):not([type=hidden]), select, textarea) {
background: var(--st-surface) !important; border: 1px solid var(--st-border) !important;
color: var(--st-text) !important; font-family: inherit; box-shadow: none !important; outline: none;
transition: border-color 0.15s;
}
.settings :is(input:not([type=checkbox]):not([type=radio]):not([type=file]):not([type=range]):not([type=color]):not([type=hidden]), select) {
min-height: 34px; padding: 6px 10px !important; box-sizing: border-box;
}
.settings :is(input, select, textarea):focus { border-color: var(--st-accent) !important; }
.settings select option { background: var(--bg, #111); color: var(--st-text); }
.settings :is(input[type=checkbox], input[type=radio]) { accent-color: var(--st-accent); }
/* Buttons: solid accent primary, outline secondary, outline red danger; no lift, no glow */
.settings .button {
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
min-height: 34px; padding: 0 16px; box-sizing: border-box;
border: 1px solid var(--st-border); background: transparent; color: var(--st-text);
font-weight: 800; text-transform: uppercase; letter-spacing: 0.06em; font-size: 0.76em;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.settings .button:hover:not(:disabled) { transform: none; filter: none; border-color: var(--st-accent); color: var(--st-accent); }
.settings :is(.button-primary, button[type=submit].button) { background: var(--st-accent); border-color: var(--st-accent); color: #000; }
.settings :is(.button-primary, button[type=submit].button):hover:not(:disabled) { background: transparent; color: var(--st-accent); }
.settings .button-danger { background: transparent; border-color: rgba(255, 107, 107, 0.5); color: #ff6b6b; }
.settings .button-danger:hover:not(:disabled) { background: #ff6b6b; border-color: #ff6b6b; color: #000; }
.settings .button:disabled { opacity: 0.5; cursor: not-allowed; }
.settings :is(button, a, input, select, textarea):focus-visible { outline: 2px solid var(--st-accent); outline-offset: 2px; }
</style>
<script>
(function(){
var nav = document.getElementById('settings-quicknav');
var root = nav && nav.closest('.settings');
if (!nav || !root) return;
var GAP = 12; // keep in sync with --st-gap
// Opened in the page modal (sidebar-footer style): its body scrolls, not the window, and the
// quicknav sticks to the top of it instead of being fixed under the navbar
var sc = nav.closest('.pgm-body');
if (sc) root.classList.add('in-page-modal');
function scrollTop() { return sc ? sc.scrollTop : window.scrollY; }
function scrollToY(y) { if (sc) sc.scrollTop = y; else window.scrollTo({ top: y, behavior: 'instant' }); }
function viewBottom() { return sc ? sc.getBoundingClientRect().bottom : window.innerHeight; }
function atEnd() {
return sc
? sc.scrollTop + sc.clientHeight >= sc.scrollHeight - 2
: window.scrollY + window.innerHeight >= document.documentElement.scrollHeight - 2;
}
// The quicknav is fixed and wraps onto more lines on narrow screens, so its bottom edge is
// measured instead of assumed; CSS uses it for the content offset and the scroll-margin.
// Horizontal fit: the quicknav is fixed, so it has to end where the content does when the right
// sidebar sits beside it (same rules as the page modal: small phones overlay, never make room)
var rightOverride = null; // set by the sidebar toggle before/after its slide (f0ck:sidebar-right-layout)
function sidebarEl() {
return document.querySelector('.global-sidebar-right, .item-sidebar-right, .index-sidebar-right');
}
function contentRight() {
var vw = window.innerWidth;
if (vw <= 599) return 0;
if (rightOverride && performance.now() < rightOverride.until) return rightOverride.right;
var rs = sidebarEl();
var open = !!rs && !document.body.classList.contains('sidebar-right-hidden')
&& getComputedStyle(rs).display !== 'none' && rs.offsetWidth > 0;
return open ? Math.min(Math.round(rs.offsetWidth), Math.round(vw / 2)) : 0;
}
function measure() {
if (!nav.isConnected) return;
// In the page modal the modal already sits beside the sidebar and its header matches the tabs
var right = sc ? 0 : contentRight();
nav.style.right = sc ? '' : right + 'px';
// Sidebar beside the content: exactly the height of its tab row, one continuous bar under the navbar
var rs = right > 0 ? sidebarEl() : null;
var tabs = rs ? rs.querySelector('.sidebar-tabs') : null;
var tabsH = tabs ? tabs.getBoundingClientRect().height : 0;
if (tabsH > 0) {
nav.style.height = tabsH + 'px';
nav.classList.add('qn-synced');
} else {
nav.style.height = '';
nav.classList.remove('qn-synced');
}
var bottom = Math.ceil(nav.getBoundingClientRect().bottom);
root.style.setProperty('--settings-qn-bottom', bottom + 'px');
// Enough tail space that even the last section can scroll up to sit right under the quicknav
var heads = root.querySelectorAll(':scope > h2[id]');
var last = heads[heads.length - 1];
if (last) {
var lastTop = last.getBoundingClientRect().top;
var contentEnd = root.getBoundingClientRect().bottom - (parseFloat(getComputedStyle(root).paddingBottom) || 0);
var need = viewBottom() - bottom - GAP - (contentEnd - lastTop);
root.style.setProperty('--settings-tail', Math.max(40, Math.ceil(need)) + 'px');
}
}
function targetY(el) {
return Math.max(0, Math.round(el.getBoundingClientRect().top + scrollTop() - nav.getBoundingClientRect().bottom - GAP));
}
var links = Array.prototype.slice.call(nav.querySelectorAll('a[href^="#"]'));
var _active = null;
function setActive(href) {
if (href === _active) return;
_active = href;
links.forEach(function(a) { a.classList.toggle('active', a.getAttribute('href') === href); });
// Single-line (synced) quicknav can overflow sideways: bring the active chip into view.
// Adjusts only the nav's own scrollLeft, never the page.
var link = nav.querySelector('a[href="' + href + '"]');
if (link && nav.scrollWidth > nav.clientWidth) {
var l = link.offsetLeft - 16, r = link.offsetLeft + link.offsetWidth + 16;
if (l < nav.scrollLeft) nav.scrollTo({ left: l, behavior: 'smooth' });
else if (r > nav.scrollLeft + nav.clientWidth) nav.scrollTo({ left: r - nav.clientWidth, behavior: 'smooth' });
}
}
function highlightTarget(el) {
root.querySelectorAll('.anchor-target').forEach(function(c) { c.classList.remove('anchor-target'); });
if (el) el.classList.add('anchor-target');
}
// Scroll-spy: active tab = last section whose heading has reached the quicknav line.
// At the very bottom the last section wins even if its heading can't reach the line.
// After a click the clicked tab stays active until the user scrolls themselves, so a short
// section right below can't take over the highlight
var _lock = null;
function spy() {
if (_anim || _lock || !nav.isConnected) return;
// A section counts as current once its heading is in the top 30% of the visible area
// (below the quicknav), not only when it touches the quicknav
var navBottom = nav.getBoundingClientRect().bottom;
var line = navBottom + GAP + Math.max(2, (viewBottom() - navBottom) * 0.3);
var current = links[0];
for (var i = 0; i < links.length; i++) {
var el = root.querySelector(links[i].getAttribute('href'));
if (el && el.getBoundingClientRect().top <= line) current = links[i];
}
if (atEnd()) current = links[links.length - 1];
if (current) setActive(current.getAttribute('href'));
}
// Smooth scroll driven per frame: the destination is re-read every frame, so sections that
// change height mid-scroll (passkeys, invites, API key load async) are absorbed into the motion
// and it ends exactly GAP px under the quicknav, with no correction snap afterwards.
var _anim = null;
function ease(t) { return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; }
function stopAnim() { if (_anim) { cancelAnimationFrame(_anim.raf); _anim = null; } }
function scrollToSection(el, smooth) {
stopAnim();
measure();
if (!smooth) { scrollToY(targetY(el)); return; }
var startY = scrollTop();
var dist = Math.abs(targetY(el) - startY);
if (dist < 1) return;
var duration = Math.min(900, Math.max(350, 250 + dist * 0.25));
var t0 = null;
var anim = _anim = { raf: 0 };
function frame(now) {
if (_anim !== anim || !el.isConnected) return;
if (t0 === null) t0 = now;
var p = Math.min(1, (now - t0) / duration);
var y = startY + (targetY(el) - startY) * ease(p);
scrollToY(y);
if (p < 1) { anim.raf = requestAnimationFrame(frame); return; }
_anim = null;
}
anim.raf = requestAnimationFrame(frame);
}
function go(hash, smooth) {
if (!hash || !nav.querySelector('a[href="' + hash + '"]')) return false;
var el = root.querySelector(hash);
if (!el) return false;
setActive(hash);
highlightTarget(el);
scrollToSection(el, smooth);
return true;
}
measure();
if (window.ResizeObserver) {
var ro = new ResizeObserver(function() {
if (!nav.isConnected) { ro.disconnect(); return; }
measure();
});
ro.observe(nav);
ro.observe(root);
}
// Sidebar open/close: the body class flips when the slide ends; the event announces the new width up front
var bodyMo = new MutationObserver(function() {
if (!nav.isConnected) { bodyMo.disconnect(); return; }
measure();
});
bodyMo.observe(document.body, { attributes: true, attributeFilter: ['class'] });
if (sc) {
var scQueued = false;
sc.addEventListener('scroll', function() {
if (scQueued) return;
scQueued = true;
requestAnimationFrame(function() { scQueued = false; spy(); });
}, { passive: true });
}
// Direct load with #hash: jump there (after a tick too: AJAX navigation sets the URL after this runs)
if (go(location.hash, false)) _lock = location.hash; else spy();
setTimeout(function() { if (location.hash && root.querySelector(location.hash) && !_anim) go(location.hash, false); else spy(); }, 50);
nav.addEventListener('click', function(e) {
var link = e.target.closest('a[href^="#"]');
if (!link) return;
e.preventDefault();
// Keep the site's global link handler out: it would scroll same-page #links itself (centred),
// which fights this scroll and, on the already active tab, lands on a different section
e.stopPropagation();
var href = link.getAttribute('href');
if (location.hash !== href) history.replaceState(history.state, '', href);
_lock = href;
go(href, true);
});
// Window listeners are bound once (AJAX navigation re-runs this script) and delegate to the
// current page instance. User input cancels a running animation so it never fights the user.
window._settingsQn = {
cancel: function(e) {
// Input on the quicknav itself is the click that is about to start a new scroll
if (e && e.target && e.target.closest && e.target.closest('#settings-quicknav')) return;
var had = _anim || _lock;
_lock = null;
if (_anim) { stopAnim(); highlightTarget(null); }
if (had) spy();
},
spy: spy,
resize: measure,
sidebarLayout: function(e) {
var d = (e && e.detail) || {};
rightOverride = {
right: d.open && !d.overlay ? Math.min(Math.round(d.width || 0), Math.round(window.innerWidth / 2)) : 0,
until: performance.now() + 800
};
measure();
}
};
if (!window._settingsQnBound) {
window._settingsQnBound = true;
var q = function(fn) { return function(e) { var s = window._settingsQn; if (s && s[fn]) s[fn](e); }; };
['wheel', 'touchstart', 'keydown', 'mousedown'].forEach(function(ev) {
window.addEventListener(ev, q('cancel'), { passive: true });
});
var spyQueued = false;
window.addEventListener('scroll', function() {
if (spyQueued) return;
spyQueued = true;
requestAnimationFrame(function() { spyQueued = false; q('spy')(); });
}, { passive: true });
window.addEventListener('resize', q('resize'), { passive: true });
window.addEventListener('f0ck:sidebar-right-layout', function(e) { q('sidebarLayout')(e); });
}
})();
</script>
<!-- ═══════════════════════════════ PREFERENCES ═══════════════════════════════ -->
<h2 id="preferences"><i class="fa-solid fa-sliders"></i> {{ t('settings.preferences') }}</h2>
<div class="preferences-settings-wrapper">
<!-- 1. Appearance & Theme -->
<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-palette"></i> {{ t('settings.appearance_section') }}</legend>
<div class="setting-item" style="margin-top: 10px;">
<label for="website_theme_select" style="display: block; margin-bottom: 5px; font-weight: bold;">{{ t('settings.theme') }}</label>
<div style="display: flex; align-items: center; gap: 10px;">
<select id="website_theme_select" class="input" style="flex: 1; max-width: 300px;">
@each(themes as t)
<option value="{{ t }}" @if(theme===t) selected @endif>{{ t }}</option>
@endeach
</select>
</div>
</div>
<div class="setting-item" style="margin-top: 18px;">
<label for="website_font_select" style="display: block; margin-bottom: 5px; font-weight: bold;">{{ t('settings.website_font') }}</label>
<div style="display: flex; align-items: center; gap: 10px;">
<select id="website_font_select" class="input" style="flex: 1; max-width: 300px;">
<option value="">{{ t('settings.font_default') }}</option>
@each(fonts as font)
<option value="{{ font.file }}" @if(session.font===font.file) selected @endif>{{ font.name }}</option>
@endeach
</select>
</div>
</div>
@if(allow_language_change)
<div class="setting-item" style="margin-top: 18px;">
<label for="language_select" style="display: block; margin-bottom: 5px; font-weight: bold;">{{ t('settings.language') }}</label>
<select id="language_select" class="input" style="padding: 6px 10px; max-width: 300px;">
<option value="" @if(!session.language) selected @endif>{{ t('settings.language_default') }}</option>
<option value="en" @if(session.language==='en' ) selected @endif>{{ t('settings.language_en') }}</option>
<option value="de" @if(session.language==='de' ) selected @endif>{{ t('settings.language_de') }}</option>
<option value="nl" @if(session.language==='nl' ) selected @endif>{{ t('settings.language_nl') }}</option>
<option value="zange" @if(session.language==='zange' ) selected @endif>{{ t('settings.language_zange') }}</option>
</select>
<small class="text-muted" style="display: block; margin-top: 4px;">{{ t('settings.language_hint') }}</small>
</div>
@endif
<div class="setting-item" style="margin-top: 18px;">
<label for="show_background_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
<input type="checkbox" id="show_background_toggle" @if(session.show_background !==false) checked @endif>
<span>{{ t('settings.enable_bg_blur') }}</span>
</label>
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.enable_bg_blur_hint') }}</small>
</div>
<div class="setting-item" style="margin-top: 15px;">
<label for="onara_enabled_toggle" style="cursor: @if(onara_cfg !== undefined && onara_cfg !== null) not-allowed @else pointer @endif; display: flex; align-items: center; gap: 10px;">
<input type="checkbox" id="onara_enabled_toggle" @if(onara_cfg !== undefined && onara_cfg !== null) disabled @if(onara_cfg) checked @endif @endif>
<span>Enable Onara viewer</span>
</label>
<small class="text-muted" style="margin-left: 25px;">
@if(onara_cfg !== undefined && onara_cfg !== null)
Controlled by site configuration (@if(onara_cfg)enabled globally@else disabled globally@endif).
@else
Open items in the fullscreen Onara modal instead of navigating to the item page. Fine-tune appearance in the <strong>Tuner → Background</strong> tab.
@endif
</small>
</div>
<div class="setting-item" style="margin-top: 15px;">
<label for="show_motd_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
<input type="checkbox" id="show_motd_toggle" @if(session.show_motd !==false) checked @endif>
<span>{{ t('settings.show_motd') }}</span>
</label>
</div>
</fieldset>
<!-- 2. Interface & Layout -->
<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-desktop"></i> {{ t('settings.ui_section') }}</legend>
<div class="setting-item">
<label for="use_new_layout_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
<input type="checkbox" id="use_new_layout_toggle" @if(session.use_new_layout===true) checked @endif>
<span>{{ t('settings.modern_layout') }}</span>
</label>
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.modern_layout_hint') }}</small>
</div>
@if(!session.use_new_layout)
<div class="setting-item" style="margin-top: 15px;">
<label for="alternative_infobox_toggle"
style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
<input type="checkbox" id="alternative_infobox_toggle" @if(session.use_alternative_infobox===true) checked @endif>
<span>{{ t('settings.alternative_infobox') }}</span>
</label>
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.alternative_infobox_hint') }}</small>
</div>
<div class="setting-item" style="margin-top: 15px;">
<label for="alternative_steuerung_toggle"
style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
<input type="checkbox" id="alternative_steuerung_toggle" @if(session.use_alternative_steuerung===true) checked @endif>
<span>{{ t('settings.alternative_steuerung') }}</span>
</label>
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.alternative_steuerung_hint') }}</small>
</div>
@endif
@if(show_koepfe)
<div class="setting-item" style="margin-top: 15px;">
<label for="hide_koepfe_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
<input type="checkbox" id="hide_koepfe_toggle" @if(session.hide_koepfe===true) checked @endif>
<span>{{ t('settings.hide_koepfe') }}</span>
</label>
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.hide_koepfe_hint') }}</small>
</div>
@endif
</fieldset>
<!-- 3. Playback & Navigation Controls -->
<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-play"></i> Playback & Navigation</legend>
<div class="setting-item">
<label for="disable_autoplay_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
<input type="checkbox" id="disable_autoplay_toggle" @if(session.disable_autoplay===true) checked @endif>
<span>{{ t('settings.disable_autoplay') }}</span>
</label>
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.disable_autoplay_hint') }}</small>
</div>
<div class="setting-item" style="margin-top: 15px;">
<label for="disable_swiping_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
<input type="checkbox" id="disable_swiping_toggle" @if(session.disable_swiping===true) checked @endif>
<span>{{ t('settings.disable_swiping') }}</span>
</label>
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.disable_swiping_hint') }}</small>
</div>
<div class="setting-item" style="margin-top: 15px;">
<label for="wheel_nav_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
<input type="checkbox" id="wheel_nav_toggle">
<span>{{ t('settings.scroll_nav') }}</span>
</label>
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.scroll_nav_hint') }}</small>
</div>
<div class="setting-item" style="margin-top: 15px;">
<label for="image_expand_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
<input type="checkbox" id="image_expand_toggle">
<span>{{ t('settings.image_expand_on_click') || 'Expand images inline on click' }}</span>
</label>
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.image_expand_on_click_hint') || 'Instead of opening the scroll zoom modal, clicking an image will expand it to full size within the page.' }}</small>
</div>
<div class="setting-item" style="margin-top: 15px;">
<label for="f_for_fullscreen_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
<input type="checkbox" id="f_for_fullscreen_toggle">
<span>{{ t('settings.f_for_fullscreen') || 'F is for Fullscreen' }}</span>
</label>
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.f_for_fullscreen_hint') || 'Pressing F will toggle fullscreen in the video player instead of favoriting the post (Shift+F to favorite).' }}</small>
</div>
</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>
<div class="setting-item">
<label for="comment_display_mode_select" style="display: block; margin-bottom: 5px; font-weight: bold;">{{ t('settings.comment_display_mode') }}</label>
<select id="comment_display_mode_select" class="input" style="padding: 6px 10px; max-width: 300px;" @if(session.force_comment_display_mode) disabled @endif>
<option value="0" @if(session.comment_display_mode==0) selected @endif>{{ t('settings.comment_display_tree') }}</option>
<option value="1" @if(session.comment_display_mode==1) selected @endif>{{ t('settings.comment_display_linear') }}</option>
</select>
<small class="text-muted" style="display: block; margin-top: 4px;">
@if(session.force_comment_display_mode)
<strong>{{ t('settings.forced_mode_notice') || 'This setting is managed by an administrator.' }}</strong>
@else
{{ t('settings.comment_display_mode_hint') }}
@endif
</small>
</div>
<div class="setting-item" style="margin-top: 18px;">
<label for="quote_emojis_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
<input type="checkbox" id="quote_emojis_toggle" @if(session.quote_emojis !==false) checked @endif>
<span>{{ t('settings.render_emojis') }}</span>
</label>
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.render_emojis_hint') }}</small>
</div>
<div class="setting-item" style="margin-top: 15px;">
<label for="embed_youtube_in_comments_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
<input type="checkbox" id="embed_youtube_in_comments_toggle" @if(session.embed_youtube_in_comments !==false) checked @endif>
<span>{{ t('settings.embed_yt') }}</span>
</label>
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.embed_yt_hint') }}</small>
</div>
</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);">
<legend style="width: auto; padding: 0 8px; font-size: 1.05em; font-weight: bold;"><i class="fa-solid fa-eye-slash"></i> {{ t('settings.content_preferences_section') }}</legend>
<div class="setting-item">
<label for="blur_sfw_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
<input type="checkbox" id="blur_sfw_toggle">
<span>{{ t('settings.blur_sfw') }}</span>
</label>
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.blur_sfw_hint') }}</small>
</div>
<div class="setting-item" style="margin-top: 15px;">
<label for="blur_nsfw_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
<input type="checkbox" id="blur_nsfw_toggle">
<span>{{ t('settings.blur_nsfw') }}</span>
</label>
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.blur_nsfw_hint') }}</small>
</div>
<div class="setting-item" style="margin-top: 15px;">
<label for="blur_nsfl_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
<input type="checkbox" id="blur_nsfl_toggle">
<span>{{ t('settings.blur_nsfl') }}</span>
</label>
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.blur_nsfl_hint') }}</small>
</div>
<div class="setting-item" style="margin-top: 15px;">
<label for="blur_untagged_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
<input type="checkbox" id="blur_untagged_toggle">
<span>{{ t('settings.blur_untagged') }}</span>
</label>
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.blur_untagged_hint') }}</small>
</div>
<div class="setting-item" style="margin-top: 15px;">
<label for="blur_detail_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
<input type="checkbox" id="blur_detail_toggle">
<span>{{ t('settings.blur_detail') || 'Click to reveal item on detail page' }}</span>
</label>
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.blur_detail_hint') || 'Require clicking to reveal blurred media on the post detail page instead of showing it immediately.' }}</small>
</div>
<div class="setting-item" style="margin-top: 15px;">
<label for="hide_item_ratings_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
<input type="checkbox" id="hide_item_ratings_toggle">
<span>{{ t('settings.hide_item_ratings') || 'Hide item ratings' }}</span>
</label>
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.hide_item_ratings_hint') || 'Don\'t show SFW/NSFW rating indicators on main page items' }}</small>
</div>
</fieldset>
<!-- 6. Privacy & Upload Defaults -->
<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-user-shield"></i> Privacy & Defaults</legend>
<div class="setting-item">
<label for="favorites_private_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
<input type="checkbox" id="favorites_private_toggle" @if(session.favorites_private===true) checked @endif>
<span>{{ t('settings.favorites_private') }}</span>
</label>
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.favorites_private_hint') }}</small>
<div class="sub-setting-item" style="margin-left: 25px; margin-top: 10px;">
<label for="hide_fav_badge_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
<input type="checkbox" id="hide_fav_badge_toggle" @if(session.hide_fav_badge===true) checked @endif>
<span>{{ t('settings.hide_fav_badge') }}</span>
</label>
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.hide_fav_badge_hint') }}</small>
</div>
</div>
@if(allow_user_upload_visibility !== false && !session.is_anon)
<div class="setting-item" style="margin-top: 18px;">
<label for="default_upload_visibility_select" style="display: block; font-weight: bold; margin-bottom: 5px;">
<span>{{ t('settings.default_upload_visibility') }}</span>
</label>
<select id="default_upload_visibility_select" class="form-control" style="width: 220px; padding: 6px 10px; background: var(--bg-color); color: var(--text-color); border: 1px solid var(--nav-border-color); border-radius: 4px;">
<option value="0" @if(session.default_upload_visibility === 0 || (!session.default_upload_visibility && session.default_upload_visibility !== 0)) selected @endif>{{ t('visibility.public') }}</option>
<option value="1" @if(session.default_upload_visibility === 1) selected @endif>{{ t('visibility.unlisted') }}</option>
<option value="2" @if(session.default_upload_visibility === 2) selected @endif>{{ t('visibility.private') }}</option>
</select>
<small class="text-muted" style="display: block; margin-top: 5px;">{{ t('settings.default_upload_visibility_hint') }}</small>
</div>
@endif
</fieldset>
<!-- 7. Notifications -->
<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-bell"></i> {{ t('settings.notifications_section') }}</legend>
<div class="setting-item">
<label class="checkbox-container" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
<input type="checkbox" id="chk-receive-system-notifications" @if(session.receive_system_notifications !==false) checked @endif>
<span>{{ t('settings.receive_system_notifications') }}</span>
</label>
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.receive_system_notifications_hint') }}</small>
</div>
<div class="setting-item" style="margin-top: 15px;">
<label class="checkbox-container" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
<input type="checkbox" id="chk-receive-user-notifications" @if(session.receive_user_notifications !==false) checked @endif>
<span>{{ t('settings.receive_user_notifications') }}</span>
</label>
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.receive_user_notifications_hint') }}</small>
</div>
<div class="setting-item" style="margin-top: 15px;">
<label class="checkbox-container" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
<input type="checkbox" id="chk-do-not-disturb" @if(session.do_not_disturb===true) checked @endif>
<span>{{ t('settings.do_not_disturb') }}</span>
</label>
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.do_not_disturb_hint') }}</small>
</div>
</fieldset>
<!-- 8. Flash / SWF Settings (if enabled) -->
@if(enable_swf)
<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-bolt"></i> {{ t('settings.flash_section') }}</legend>
<div class="setting-item">
<label for="ruffle_background_toggle"
style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
<input type="checkbox" id="ruffle_background_toggle" @if(session.ruffle_background !==false) checked @endif>
<span>{{ t('settings.flash_bg') }}</span>
</label>
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.flash_bg_hint') }}</small>
</div>
<div id="ruffle-settings-status" class="avatar-status" style="margin-top: 10px;"></div>
</fieldset>
@endif
</div>
@if(!session.is_anon)
<!-- ═══════════════════════════════ PROFILE ═══════════════════════════════ -->
<h2 id="profile"><i class="fa-solid fa-user-pen"></i> {{ t('settings.profile') }}</h2>
<p class="text-muted" style="margin-bottom: 16px; font-size: 0.9em; display: flex; align-items: center; gap: 8px;">
<i class="fa-solid fa-wand-magic-sparkles" style="color: var(--accent);"></i> Click directly on your profile card below to edit your avatar, banner, name, color, or bio.
</p>
<div class="direct-edit-profile-card-wrapper" style="position: relative; max-width: 750px; margin-bottom: 30px;">
<!-- Interactive User Card -->
<div class="user-infobox-block editable-profile-card" id="live-profile-preview-box"
style="--author-accent: {{ session.username_color || 'var(--accent)' }}; --author-border: {{ session.username_color || 'var(--accent)' }}; @if(banner_file && user_banner_enabled) --author-banner: url('/a/{{ banner_file }}?t={{ Date.now() }}'); --author-banner-position: {{ banner_position === 'center' ? 'center top' : (banner_position || 'center top') }}; --author-banner-size: {{ banner_size || 'cover' }}; @endif">
<!-- Banner upload progress: bar along the card's top edge + badge (settings.js toggles display) -->
<div id="banner-progress-wrapper" style="display: none;" role="progressbar" aria-label="Banner upload" aria-valuemin="0" aria-valuemax="100">
<div id="banner-progress-fill"></div>
<span id="banner-progress-text"></span>
</div>
<!-- Avatar with Hover Overlay -->
<div class="user-infobox-avatar editable-avatar-container" id="avatar-choose-btn" title="Click to change avatar">
<a href="#" onclick="event.preventDefault(); document.getElementById('avatar-file-input').click();" style="display:block; position:relative; width:100%; height:100%;">
@if(avatar_file)
<img id="live-preview-avatar" class="avatar-preview-img" src="/a/{{ avatar_file }}" />
@elseif(session.avatar && session.avatar > 0)
<img id="live-preview-avatar" class="avatar-preview-img" src="/t/{{ session.avatar }}.webp" />
@else
<img id="live-preview-avatar" class="avatar-preview-img" src="/a/default.png" style="border-color: #444;" />
@endif
<div class="avatar-hover-overlay">
<i class="fa-solid fa-camera"></i>
</div>
</a>
<!-- Upload progress, drawn over the live preview (settings.js toggles display) -->
<div id="avatar-progress-wrapper" style="display: none;" role="progressbar" aria-label="Avatar upload" aria-valuemin="0" aria-valuemax="100">
<span id="avatar-progress-text"></span>
<div id="avatar-progress-fill"></div>
</div>
@if(avatar_file)
<button type="button" class="avatar-quick-remove" id="avatar-remove-btn" title="Remove custom avatar">
<i class="fa-solid fa-xmark"></i>
</button>
@endif
</div>
<div class="user-infobox-info">
<div class="user-infobox-header">
<div class="user-infobox-username-container" style="display: flex; align-items: center; gap: 8px; flex-wrap: wrap;">
<!-- Editable Display Name -->
<div class="inline-edit-username-wrapper">
<span id="live-preview-username" class="user-infobox-username" contenteditable="true" spellcheck="false" title="Click to edit display name">{!! session.display_name || session.user !!}</span>
<i class="fa-solid fa-pen" style="font-size: 0.65em; opacity: 0.5; margin-left: 2px;" title="Editable"></i>
</div>
<!-- Color Picker Badge -->
<div class="inline-color-picker-wrapper" style="position: relative; display: inline-flex; align-items: center;">
<label for="username_color_picker" class="color-picker-badge" id="color-picker-badge" style="background: {{ session.username_color || 'var(--accent)' }};" title="Click to change username color">
<i class="fa-solid fa-palette"></i>
</label>
<!-- Sits exactly under the badge: the native picker popup anchors to this input's box -->
<input type="color" id="username_color_picker" value="{{ session.username_color || '#ffffff' }}" tabindex="-1" aria-hidden="true" style="position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; padding: 0; border: 0; opacity: 0; pointer-events: none;">
<input type="text" id="username_color_hex" value="{{ session.username_color || '#ffffff' }}" style="display: none;">
</div>
<!-- Hidden Backing Save Buttons for JS event trigger -->
<button type="button" id="btn-update-display-name" class="inline-save-btn" style="display: none;"><i class="fa-solid fa-check"></i> Save Name</button>
<button type="button" id="btn-save-username-color" style="display: none;">Save Color</button>
<button type="button" id="btn-reset-username-color" style="display: none;">Reset</button>
</div>
<span class="user-infobox-timestamp"><a href="#" class="timestamp-link"><time>just now</time></a></span>
</div>
<div class="user-infobox-body">
<!-- Editable Description -->
<div class="inline-edit-description-wrapper">
<div class="user-infobox-description" id="live-preview-description" contenteditable="true" spellcheck="false" title="Click to edit description" data-placeholder="Click to write a bio...">
{!! session.description || '' !!}
</div>
<div class="description-edit-controls" id="desc-edit-controls" style="display: flex; margin-top: 10px; gap: 8px; align-items: center; flex-wrap: wrap;">
<button type="button" id="btn-save-description" class="button button-sm" style="padding: 4px 10px; font-size: 0.8em;"><i class="fa-solid fa-floppy-disk"></i> {{ t('settings.save_description') }}</button>
<button type="button" id="btn-clear-description" class="button button-danger button-sm" style="padding: 4px 10px; font-size: 0.8em;"><i class="fa-solid fa-trash"></i> {{ t('settings.clear') }}</button>
@if(user_banner_enabled)
<button type="button" id="banner-choose-btn" class="button button-sm" style="padding: 4px 10px; font-size: 0.8em;"><i class="fa-solid fa-camera"></i> Change Banner</button>
@if(banner_file)
<button type="button" id="banner-edit-btn" class="button button-sm" style="padding: 4px 10px; font-size: 0.8em;"><i class="fa-solid fa-arrows-up-down-left-right"></i> Reposition</button>
<button type="button" id="banner-remove-btn" class="button button-danger button-sm" style="padding: 4px 10px; font-size: 0.8em;"><i class="fa-solid fa-trash"></i> Remove Banner</button>
@endif
@endif
</div>
</div>
<div class="user-infobox-actions">
<i class="iconset fa-solid fa-heart" title="Favorite"></i>
</div>
</div>
</div>
</div>
<!-- Hidden Backing Elements for JavaScript compatibility -->
<input type="file" id="avatar-file-input" accept="image/gif,image/jpeg,image/png,image/webp" style="display: none;">
<span id="avatar-filename" style="display: none;"></span>
<button type="button" id="avatar-upload-btn" style="display: none;"></button>
<input type="file" id="banner-file-input" accept="image/gif,image/jpeg,image/png,image/webp" style="display: none;">
<span id="banner-filename" style="display: none;"></span>
<button type="button" id="banner-upload-btn" style="display: none;"></button>
<input type="text" id="display_name_input" value="{!! session.display_name || '' !!}" style="display: none;">
@if(enable_profile_description)
<textarea id="profile_description" style="display: none;">{!! session.description || '' !!}</textarea>
@endif
</div>
@endif
<!-- Passkey info cards, shared by the anonymous "Passkey" and the registered "Passkeys" sections -->
<style>
.settings .pk-list { display: flex; flex-direction: column; gap: 8px; margin-bottom: 16px; }
.settings .pk-card {
display: flex; align-items: center; gap: 12px; padding: 12px 14px;
background: rgba(0, 0, 0, 0.25); border: 1px solid var(--st-border); border-left: 3px solid var(--st-accent);
}
.settings .pk-card > .pk-icon { color: var(--st-accent); font-size: 1.2em; flex-shrink: 0; width: 20px; text-align: center; }
.settings .pk-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 4px; }
.settings .pk-title { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-weight: 700; font-size: 0.92em; color: var(--st-text); }
.settings .pk-chip {
display: inline-flex; align-items: center; gap: 5px; padding: 1px 7px;
font-size: 0.68em; font-weight: 800; text-transform: uppercase; letter-spacing: 0.08em;
border: 1px solid var(--st-border); color: var(--st-muted);
}
.settings .pk-chip.is-primary { border-color: var(--st-accent); color: var(--st-accent); }
.settings .pk-chip.is-current { border-color: #51cf66; color: #51cf66; }
.settings .pk-chip.is-current i { font-size: 0.6em; }
.settings .pk-locked { color: var(--st-muted); padding: 0 10px; flex-shrink: 0; }
.settings .pk-meta { display: flex; flex-wrap: wrap; gap: 4px 14px; font-size: 0.76em; color: var(--st-muted); }
.settings .pk-meta span { display: inline-flex; align-items: center; gap: 5px; white-space: nowrap; }
.settings .pk-meta code { background: none !important; padding: 0; color: var(--st-text); font-size: 1em; }
.settings .pw-off-notice {
display: flex; gap: 10px; align-items: flex-start; padding: 10px 12px; font-size: 0.85em;
border: 1px solid var(--st-accent); background: rgba(0, 0, 0, 0.25); color: var(--st-text);
}
.settings .pw-off-notice > i { color: var(--st-accent); margin-top: 2px; }
.settings .pw-disable { display: flex; flex-direction: column; align-items: flex-start; gap: 8px; margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--st-border); }
.settings #pw-disable-form { flex-direction: column; gap: 8px; width: 100%; }
.settings .pk-count { font-size: 0.72em; font-weight: 800; text-transform: uppercase; letter-spacing: 0.08em; color: var(--st-muted); margin-bottom: 8px; }
</style>
<script>
(function() {
if (window.f0ckPasskeyCard) return;
function esc(s) { return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
var rtf = window.Intl && Intl.RelativeTimeFormat ? new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' }) : null;
function rel(d) {
var t = new Date(d).getTime();
if (!t) return '—';
var diff = (t - Date.now()) / 1000, a = Math.abs(diff);
if (!rtf) return new Date(t).toLocaleDateString();
if (a < 60) return rtf.format(Math.round(diff), 'second');
if (a < 3600) return rtf.format(Math.round(diff / 60), 'minute');
if (a < 86400) return rtf.format(Math.round(diff / 3600), 'hour');
if (a < 86400 * 30) return rtf.format(Math.round(diff / 86400), 'day');
if (a < 86400 * 365) return rtf.format(Math.round(diff / (86400 * 30)), 'month');
return rtf.format(Math.round(diff / (86400 * 365)), 'year');
}
function full(d) { var t = new Date(d); return isNaN(t) ? '' : t.toLocaleString(); }
function shortId(id) { id = String(id || ''); return id.length > 14 ? id.slice(0, 6) + '…' + id.slice(-6) : id; }
// opts.actions: extra HTML at the end of the card (e.g. a delete button)
window.f0ckPasskeyCard = function(pk, opts) {
opts = opts || {};
var provider = pk.provider && pk.provider !== 'Undisclosed'
? '<span class="pk-chip" title="Stored in"><i class="fa-solid fa-vault"></i> ' + esc(pk.provider) + '</span>'
: '<span class="pk-chip" title="The authenticator did not disclose which app or device stores this passkey">Provider unknown</span>';
var primary = pk.primary ? '<span class="pk-chip is-primary" title="Your anonymous identity (fingerprint) comes from this passkey">Identity</span>' : '';
if (pk.current) primary += '<span class="pk-chip is-current" title="You are signed in with this passkey right now; it cannot be removed from this session"><i class="fa-solid fa-circle"></i> This session</span>';
return '<div class="pk-card">' +
'<i class="fa-solid fa-key pk-icon"></i>' +
'<div class="pk-main">' +
'<div class="pk-title">' + esc(pk.name || 'Passkey') + provider + primary + '</div>' +
'<div class="pk-meta">' +
'<span title="Credential ID: ' + esc(pk.credential_id || '') + '"><i class="fa-solid fa-hashtag"></i><code>' + esc(shortId(pk.credential_id)) + '</code></span>' +
'<span title="' + esc(full(pk.created_at)) + '"><i class="fa-solid fa-calendar-plus"></i> Added ' + esc(rel(pk.created_at)) + '</span>' +
'<span title="' + esc(full(pk.last_used)) + '"><i class="fa-solid fa-clock-rotate-left"></i> Last used ' + esc(rel(pk.last_used)) + '</span>' +
'</div>' +
'</div>' +
// The passkey this session runs on can't be removed (the server refuses it too)
(pk.current
? '<span class="pk-locked" title="In use by this session"><i class="fa-solid fa-lock"></i></span>'
: (opts.actions || '')) +
'</div>';
};
})();
</script>
@if(session.is_anon)
<!-- ═══════════════════════════════ ANONYMOUS PASSKEY IDENTITY ═══════════════════════════════ -->
<h2 id="anon-passkey"><i class="fa-solid fa-fingerprint"></i> Passkey</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;">
<div style="margin-bottom: 16px;">
<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 class="pk-count" id="settings-anon-pk-count"></div>
<div class="pk-list" id="settings-anon-pk-list"><p style="color: var(--text-muted); font-size: 0.9em; margin: 0;">Loading…</p></div>
<script>
(function() {
var list = document.getElementById('settings-anon-pk-list');
var count = document.getElementById('settings-anon-pk-count');
function load() {
fetch('/api/v2/anon/passkeys', { credentials: 'same-origin' })
.then(function(r) { return r.json(); })
.then(function(d) {
if (!list.isConnected) return;
if (!d.success) { list.innerHTML = '<p style="color: #e06c75; font-size: 0.9em; margin: 0;">Failed to load passkeys.</p>'; return; }
var pks = d.passkeys || [];
count.textContent = pks.length + ' / ' + d.max + ' passkeys';
list.innerHTML = pks.length
? pks.map(function(pk) { return window.f0ckPasskeyCard(pk); }).join('')
: '<p style="color: var(--text-muted); font-size: 0.9em; margin: 0;">No passkeys found.</p>';
})
.catch(function() { list.innerHTML = '<p style="color: #e06c75; font-size: 0.9em; margin: 0;">Failed to load passkeys.</p>'; });
}
load();
// Passkeys are added in the Manage modal: refresh when coming back to the page
window.addEventListener('focus', function onFocus() {
if (!list.isConnected) { window.removeEventListener('focus', onFocus); return; }
load();
});
})();
</script>
<div style="display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 20px;">
<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 id="invite-request-section" style="padding: 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);">
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 12px;">
<i class="fa-solid fa-ticket" style="color: var(--accent); font-size: 1.1em;"></i>
<strong style="color: var(--text-color); font-size: 1.05em;">Want full features?</strong>
</div>
<p style="margin: 0 0 12px; line-height: 1.5;">
Registered accounts can upload items, customize avatars, create API keys, and invite friends.
Registration requires an <strong>invite token</strong> — request one below and an admin will review it.
</p>
<!-- Status container (populated by JS) -->
<div id="invite-req-status" style="display: none; margin-bottom: 12px;"></div>
<!-- Request form (hidden when request already exists) -->
<div id="invite-req-form-wrapper">
<div style="margin-bottom: 10px;">
<textarea id="invite-req-reason" placeholder="Why do you want to join? (optional, max 500 chars)" maxlength="500" rows="2" style="width: 100%; box-sizing: border-box; background: rgba(0,0,0,0.25); color: var(--text-color); border: 1px solid rgba(255,255,255,0.1); border-radius: 4px; padding: 8px 10px; font-family: inherit; font-size: 0.95em; resize: vertical;"></textarea>
</div>
<button type="button" id="invite-req-submit" class="button button-primary" style="display: inline-flex; align-items: center; gap: 6px;">
<i class="fa-solid fa-paper-plane"></i> Request Invite Token
</button>
</div>
<!-- Token display (shown when approved) -->
<div id="invite-req-token-display" style="display: none; margin-top: 10px;"></div>
<div id="invite-req-error" style="display: none; margin-top: 10px; padding: 8px 12px; background: rgba(255,80,80,0.12); border: 1px solid rgba(255,80,80,0.25); border-radius: 4px; color: #ff6b6b; font-size: 0.9em;"></div>
</div>
<script>
(function() {
var statusEl = document.getElementById('invite-req-status');
var formWrapper = document.getElementById('invite-req-form-wrapper');
var tokenDisplay = document.getElementById('invite-req-token-display');
var errorEl = document.getElementById('invite-req-error');
var submitBtn = document.getElementById('invite-req-submit');
var reasonEl = document.getElementById('invite-req-reason');
function showError(msg) {
errorEl.textContent = msg;
errorEl.style.display = msg ? '' : 'none';
}
function renderStatus(data) {
if (!data || !data.status) {
statusEl.style.display = 'none';
formWrapper.style.display = '';
tokenDisplay.style.display = 'none';
return;
}
statusEl.style.display = '';
if (data.status === 'pending') {
formWrapper.style.display = 'none';
statusEl.innerHTML =
'<div style="display: flex; align-items: center; gap: 8px; padding: 10px 14px; background: rgba(255,193,7,0.08); border: 1px solid rgba(255,193,7,0.2); border-radius: 4px;">' +
'<i class="fa-solid fa-clock" style="color: #ffc107; font-size: 1.1em;"></i>' +
'<div>' +
'<strong style="color: #ffc107;">Request pending</strong>' +
'<div style="font-size: 0.9em; margin-top: 2px; color: var(--text-muted);">Submitted ' + new Date(data.created_at).toLocaleString() + ' — waiting for admin review.</div>' +
'</div>' +
'</div>';
} else if (data.status === 'approved') {
formWrapper.style.display = 'none';
statusEl.innerHTML =
'<div style="display: flex; align-items: center; gap: 8px; padding: 10px 14px; background: rgba(81,207,102,0.08); border: 1px solid rgba(81,207,102,0.2); border-radius: 4px;">' +
'<i class="fa-solid fa-circle-check" style="color: #51cf66; font-size: 1.1em;"></i>' +
'<div>' +
'<strong style="color: #51cf66;">Request approved!</strong>' +
'<div style="font-size: 0.9em; margin-top: 2px; color: var(--text-muted);">Your invite token is ready. Use it to register your account.</div>' +
'</div>' +
'</div>';
if (data.token) {
tokenDisplay.style.display = '';
tokenDisplay.innerHTML =
'<div style="display: flex; align-items: center; gap: 10px; flex-wrap: wrap;">' +
'<code style="background: rgba(0,0,0,0.3); padding: 8px 14px; border-radius: 4px; color: #5bc0be; font-family: monospace; font-size: 1.1em; letter-spacing: 0.5px; user-select: all;">' + data.token + '</code>' +
'<button type="button" class="button button-primary" id="invite-register-now-btn" style="display: inline-flex; align-items: center; gap: 6px;">' +
'<i class="fa-solid fa-user-plus"></i> Register Now' +
'</button>' +
'</div>';
var regBtn = document.getElementById('invite-register-now-btn');
if (regBtn) regBtn.addEventListener('click', function() {
var modal = document.getElementById('register-modal');
if (modal) {
var tokenInput = modal.querySelector('input[name="token"]');
if (tokenInput) tokenInput.value = data.token;
if (typeof openModal === 'function') {
openModal(modal);
} else {
modal.style.display = 'flex';
}
}
});
}
} else if (data.status === 'denied' || data.status === 'revoked') {
formWrapper.style.display = '';
const isRevoked = data.status === 'revoked';
statusEl.innerHTML =
'<div style="display: flex; align-items: center; gap: 8px; padding: 10px 14px; background: rgba(255,107,107,0.08); border: 1px solid rgba(255,107,107,0.2); border-radius: 4px;">' +
'<i class="fa-solid fa-circle-xmark" style="color: #ff6b6b; font-size: 1.1em;"></i>' +
'<div>' +
'<strong style="color: #ff6b6b;">' + (isRevoked ? 'Token revoked' : 'Request denied') + '</strong>' +
'<div style="font-size: 0.9em; margin-top: 2px; color: var(--text-muted);">' + (isRevoked ? 'Your invite token was revoked by an admin. You can submit a new request.' : 'Your previous request was denied. You can submit a new one.') + '</div>' +
'</div>' +
'</div>';
}
}
// Check status on load
fetch('/api/v2/invite-request/status')
.then(function(r) { return r.json(); })
.then(function(d) {
if (d.success) renderStatus(d);
})
.catch(function() {});
// Submit request
if (submitBtn) submitBtn.addEventListener('click', function() {
showError('');
submitBtn.disabled = true;
submitBtn.style.opacity = '0.6';
var reason = reasonEl ? reasonEl.value : '';
fetch('/api/v2/invite-request', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': window.f0ckSession ? window.f0ckSession.csrf_token : ''
},
body: JSON.stringify({ reason: reason })
})
.then(function(r) { return r.json(); })
.then(function(d) {
submitBtn.disabled = false;
submitBtn.style.opacity = '1';
if (d.success) {
renderStatus({ status: 'pending', created_at: new Date().toISOString() });
} else {
showError(d.msg || 'Failed to submit request');
}
})
.catch(function() {
submitBtn.disabled = false;
submitBtn.style.opacity = '1';
showError('Network error. Please try again.');
});
});
// Poll for status changes when there's a pending request
var _invitePollTimer = null;
function startInvitePoll() {
if (_invitePollTimer) return;
_invitePollTimer = setInterval(function() {
fetch('/api/v2/invite-request/status')
.then(function(r) { return r.json(); })
.then(function(d) {
if (d.success) {
renderStatus(d);
// Stop polling once resolved
if (d.status !== 'pending' && _invitePollTimer) {
clearInterval(_invitePollTimer);
_invitePollTimer = null;
}
}
})
.catch(function() {});
}, 15000); // Check every 15 seconds
}
// Start polling if initial status is pending
fetch('/api/v2/invite-request/status')
.then(function(r) { return r.json(); })
.then(function(d) {
if (d.success) {
renderStatus(d);
if (d.status === 'pending') startInvitePoll();
}
})
.catch(function() {});
})();
</script>
</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: 0;">
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: 12px 0 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() {
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;
}
container.classList.add('pk-list');
container.innerHTML = passkeys.map(function(pk) {
return window.f0ckPasskeyCard(pk, {
actions: '<button class="button button-danger" title="Remove this passkey" onclick="settingsPasskeyDelete(' + escHtml(JSON.stringify(pk.credential_id)) + ')"><i class="fa-solid fa-trash"></i></button>'
});
}).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) {
console.warn('[SETTINGS_PASSKEY] Add passkey error:', err);
if (status) {
status.style.display = 'block';
status.style.color = '#e06c75';
if (err && err.name === 'NotAllowedError') {
status.textContent = 'Passkey creation was cancelled or blocked. If Bitwarden or another password manager is locked, please unlock it.';
} else {
status.textContent = 'Error: ' + ((err && err.message) || 'Unknown error');
}
}
} 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
@if(!session.is_anon)
<!-- ═══════════════════════════════ ACCOUNT ═══════════════════════════════ -->
<h2 id="account"><i class="fa-solid fa-shield-halved"></i> {{ t('settings.account') }}</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;">
<table class="table account-info-table"
style="margin-bottom: 30px; border-collapse: separate; border-spacing: 0 5px;">
<tbody>
<tr>
<td style="width: 150px; font-weight: bold; color: var(--text-muted); border: none;">{{ t('settings.user_id') }}</td>
<td style="border: none;">{{ session.id }}</td>
</tr>
<tr>
<td style="font-weight: bold; color: var(--text-muted); border: none;">{{ t('settings.username') }}</td>
<td style="border: none;">{!! session.user !!}</td>
</tr>
<tr>
<td style="font-weight: bold; color: var(--text-muted); border: none;">{{ t('settings.email') }}</td>
<td id="display-email" style="border: none;">{{ email || t('settings.email_not_set') }}</td>
</tr>
<tr>
<td style="font-weight: bold; color: var(--text-muted); border: none;">{{ t('settings.joined') }}</td>
<td style="border: none;">@if(joined){{ new Date(joined).toLocaleDateString() }}@else {{ t('settings.joined_unknown') }} @endif</td>
</tr>
</tbody>
</table>
<div class="account-actions-grid"
style="display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 30px;">
<div class="password-change-section" style="display: flex; flex-direction: column; gap: 15px;">
@if(password_disabled)
<h4 style="margin-top: 0; color: var(--accent);"><i class="fa-solid fa-key"></i> Set a password</h4>
<div class="pw-off-notice">
<i class="fa-solid fa-fingerprint"></i>
<div><strong>Password login is disabled.</strong> This account can only be accessed with a passkey. Setting a password turns password login back on.</div>
</div>
@else
<h4 style="margin-top: 0; color: var(--accent);"><i class="fa-solid fa-key"></i> {{ t('settings.change_password') }}</h4>
@endif
<form id="password-change-form" style="display: flex; flex-direction: column; gap: 10px;">
@if(!password_disabled)
<input type="password" id="current_password" class="input"
placeholder="{{ t('settings.current_password') }}" required autocomplete="current-password">
@endif
<input type="password" id="new_password" class="input" placeholder="{{ t('settings.new_password') }}"
required minlength="20" autocomplete="new-password">
<input type="password" id="new_password_confirm" class="input"
placeholder="{{ t('settings.confirm_new_password') }}" required minlength="20"
autocomplete="new-password">
<button type="submit" id="btn-update-password" class="button">@if(password_disabled)Set password@else{{ t('settings.update_password') }}@endif</button>
</form>
<div id="password-status" class="avatar-status"></div>
@if(!password_disabled)
<!-- Passkey-only: switch off password login (needs the current password and a passkey) -->
<div class="pw-disable">
<button type="button" id="btn-pw-disable-open" class="button button-danger"><i class="fa-solid fa-lock"></i> Disable password</button>
<small class="text-muted">Sign in with a passkey only. Requires at least one passkey; setting a password later turns it back on.</small>
<form id="pw-disable-form" style="display: none;">
<input type="password" id="pw-disable-current" class="input" placeholder="{{ t('settings.current_password') }}" required autocomplete="current-password">
<div style="display: flex; gap: 8px; flex-wrap: wrap;">
<button type="submit" class="button button-danger"><i class="fa-solid fa-lock"></i> Confirm: passkey only</button>
<button type="button" id="btn-pw-disable-cancel" class="button">Cancel</button>
</div>
</form>
<div id="pw-disable-status" class="avatar-status"></div>
</div>
<script>
(function() {
var openBtn = document.getElementById('btn-pw-disable-open');
var form = document.getElementById('pw-disable-form');
var input = document.getElementById('pw-disable-current');
var status = document.getElementById('pw-disable-status');
if (!openBtn || !form) return;
function setStatus(msg, ok) { status.textContent = msg; status.style.color = ok ? '#51cf66' : '#ff6b6b'; }
openBtn.addEventListener('click', function() {
form.style.display = 'flex'; openBtn.style.display = 'none'; setStatus('', true); input.focus();
});
document.getElementById('btn-pw-disable-cancel').addEventListener('click', function() {
form.style.display = 'none'; openBtn.style.display = ''; input.value = ''; setStatus('', true);
});
form.addEventListener('submit', function(e) {
e.preventDefault();
var btn = form.querySelector('button[type="submit"]');
btn.disabled = true;
fetch('/api/v2/settings/password/disable', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': (window.f0ckSession && window.f0ckSession.csrf_token) || '' },
body: JSON.stringify({ current_password: input.value })
})
.then(function(r) { return r.json(); })
.then(function(d) {
btn.disabled = false;
setStatus(d.msg || (d.success ? 'Done' : 'Failed'), !!d.success);
if (d.success) setTimeout(function() { location.reload(); }, 900);
})
.catch(function() { btn.disabled = false; setStatus('Network error', false); });
});
})();
</script>
@endif
</div>
<div class="email-update-section" style="display: flex; flex-direction: column; gap: 15px;">
<h4 style="margin-top: 0; color: var(--accent);"><i class="fa-solid fa-envelope"></i> {{ t('settings.update_email') }}</h4>
<form id="email-update-form" style="display: flex; flex-direction: column; gap: 10px;">
<input type="email" id="email_input" class="input" placeholder="{{ t('settings.new_email') }}"
value="{{ email }}" required>
<button type="submit" id="btn-update-email" class="button">{{ t('settings.update_email_btn') }}</button>
</form>
@if(smtp_enabled)
{!! t('settings.email_warning_smtp') !!}
@else
{!! t('settings.email_info_no_smtp') !!}
@endif
<div id="email-status" class="avatar-status"></div>
</div>
</div>
</div>
@endif
@if(!session.is_anon && enable_user_api_keys)
<!-- ═══════════════════════════════ API KEY ═══════════════════════════════ -->
<h2 id="apikey"><i class="fa-solid fa-key"></i> Upload API Key</h2>
<div id="api-key-section" 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: 15px;">
Use this key to upload files via external tools or scripts like ShareX.
The key grants <strong>upload access only</strong> to your account.
</p>
<div id="api-key-status-box" style="margin-bottom: 15px;">
<span class="text-muted" id="api-key-loading-text">Loading&hellip;</span>
</div>
<!-- Shown only after regenerate — full key revealed once for copying -->
<div id="api-key-reveal" style="display:none; margin-bottom: 15px;">
<div style="background: rgba(0,180,100,0.12); border: 1px solid rgba(0,180,100,0.4); border-radius: 4px; padding: 14px;">
<strong style="display:block; margin-bottom: 6px;">&#9888; Copy your key now &mdash; it will not be shown again in full:</strong>
<div style="display: flex; align-items: center; gap: 10px; flex-wrap: wrap;">
<code id="api-key-full-display"
style="flex: 1; word-break: break-all; font-size: 0.85em; background: rgba(0,0,0,0.25); padding: 8px 10px; border-radius: 4px; user-select: all;"></code>
<button type="button" id="btn-copy-api-key" class="button"
style="white-space: nowrap; padding: 6px 14px;">Copy</button>
</div>
</div>
</div>
<div style="display: flex; gap: 10px; flex-wrap: wrap; align-items: center;">
<button type="button" id="btn-regen-api-key" class="button button-primary">Generate / Regenerate Key</button>
<a id="btn-sharex-download" href="/api/v2/settings/api-key/sharex-config" download style="display:none; box-sizing:border-box; line-height:normal;" class="button">&#11123; ShareX Config</a>
<button type="button" id="btn-revoke-api-key" class="button button-danger" style="display:none;">Revoke Key</button>
</div>
<div id="api-key-action-status" class="avatar-status" style="margin-top: 10px;"></div>
</div>
@endif
@if(!session.is_anon && (matrix_enabled || telegram_enabled))
<!-- ═══════════════════════════════ LINKED ACCOUNTS ═══════════════════════════════ -->
<h2 id="linked"><i class="fa-solid fa-link"></i> {{ t('settings.linked_accounts') }}</h2>
<div class="linked-accounts-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>{{ t('settings.matrix_link_desc') }}</p>
<div id="linked-accounts-container" style="margin-bottom: 1rem;">
<strong>{{ t('settings.active_links') }}</strong>
<div id="linked-accounts-list" style="margin-top: 0.5rem; display: flex; flex-direction: column; gap: 5px;">
<span class="text-muted">{{ t('settings.loading') }}</span>
</div>
</div>
<div class="link-action-area">
<h4 style="margin-top: 20px;">{{ t('settings.add_new_link') }}</h4>
<p style="margin-bottom: 10px; color: var(--text-muted); font-size: 0.9em;">
{!! t('settings.matrix_instructions') !!}
</p>
@if(telegram_enabled)
<p style="margin-bottom: 15px; color: var(--text-muted); font-size: 0.9em;">
<strong>Telegram:</strong> Generate a token below, then send <code>!link &lt;TOKEN&gt;</code> to the Telegram bot.
</p>
@endif
<div id="token-display-area" style="display: none; margin-bottom: 15px;">
<div class="alert alert-info">
<strong>{{ t('settings.your_token') }}</strong> <code id="generated-token-code"
style="font-size: 1.2em; user-select: all; margin-left: 10px;"></code>
<br><small>{{ t('settings.one_time_use') }}</small>
</div>
</div>
<button id="btn-gen-link-token" class="button button-primary">{{ t('settings.generate_token') }}</button>
</div>
</div>
@endif
@if(!session.is_anon && enable_user_invites)
<!-- ═══════════════════════════════ INVITES ═══════════════════════════════ -->
<h2 id="invites"><i class="fa-solid fa-ticket"></i> {{ t('invites.section_title') }}</h2>
<div id="invite-section" 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 id="invite-section-desc" style="color: var(--text-muted); margin-bottom: 15px;">{{ t('invites.section_desc') }}</p>
<!-- Criteria grid -->
<div id="invite-criteria-grid" style="display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 10px; margin-bottom: 18px;">
<div class="invite-criterion" id="ic-uploads">
<span class="ic-icon">⋯</span>
<span class="ic-label">{{ t('invites.criteria_uploads') }}</span>
<span class="ic-values"></span>
</div>
<div class="invite-criterion" id="ic-age">
<span class="ic-icon">⋯</span>
<span class="ic-label">{{ t('invites.criteria_age') }}</span>
<span class="ic-values"></span>
</div>
<div class="invite-criterion" id="ic-comments">
<span class="ic-icon">⋯</span>
<span class="ic-label">{{ t('invites.criteria_comments') }}</span>
<span class="ic-values"></span>
</div>
<div class="invite-criterion" id="ic-tags">
<span class="ic-icon">⋯</span>
<span class="ic-label">{{ t('invites.criteria_tags') }}</span>
<span class="ic-values"></span>
</div>
</div>
<!-- Eligibility badge + slot indicator -->
<div id="invite-status-line" style="margin-bottom: 16px; display: flex; align-items: center; gap: 14px; flex-wrap: wrap;">
<span id="invite-eligible-badge" style="font-weight: bold; font-size: 0.95em;"></span>
<span id="invite-slots-info" style="color: var(--text-muted); font-size: 0.9em;"></span>
</div>
<!-- Token list -->
<div id="invite-token-list" style="margin-bottom: 16px; display: flex; flex-direction: column; gap: 8px;">
<span class="text-muted" style="font-size: 0.9em;">{{ t('invites.loading') }}</span>
</div>
<!-- Generate button + status -->
<div style="display: flex; gap: 10px; align-items: center; flex-wrap: wrap;">
<button type="button" id="btn-gen-invite" class="button button-primary" disabled>{{ t('invites.generate_btn') }}</button>
</div>
<div id="invite-action-status" class="avatar-status" style="margin-top: 10px;"></div>
</div>
<style>
.invite-criterion {
display: flex;
flex-direction: column;
gap: 2px;
padding: 10px 14px;
border-radius: 6px;
border: 1px solid var(--nav-border-color);
background: rgba(255,255,255,0.03);
font-size: 0.88em;
}
.invite-criterion.met {
border-color: rgba(80, 200, 120, 0.5);
background: rgba(80, 200, 120, 0.07);
}
.invite-criterion.unmet {
border-color: rgba(255, 90, 90, 0.35);
background: rgba(255, 90, 90, 0.05);
}
.ic-icon { font-size: 1.2em; }
.ic-label { font-weight: bold; color: var(--text-muted); }
.ic-values { font-family: monospace; font-size: 1.05em; }
.invite-token-row {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
padding: 10px 14px;
border-radius: 6px;
border: 1px solid var(--nav-border-color);
background: rgba(255,255,255,0.02);
font-size: 0.88em;
}
.invite-token-code {
font-family: monospace;
font-size: 1.05em;
letter-spacing: 0.04em;
color: var(--accent);
user-select: all;
flex: 1;
word-break: break-all;
}
.invite-token-status-used { color: #ff6b6b; }
.invite-token-status-unused { color: #51cf66; }
.invite-refresh-note { font-size: 0.82em; color: var(--text-muted); }
</style>
<!-- i18n strings for invite JS (rendered server-side) -->
<div id="invite-i18n" style="display:none"
data-eligible="{{ t('invites.eligible') }}"
data-not-eligible="{{ t('invites.not_eligible') }}"
data-slots-used="{{ t('invites.slots_used') }}"
data-days="{{ t('invites.criteria_days') }}"
data-no-invites="{{ t('invites.no_invites') }}"
data-status-unused="{{ t('invites.status_unused') }}"
data-status-used-by="{{ t('invites.status_used_by') }}"
data-delete-btn="{{ t('invites.delete_btn') }}"
data-delete-confirm="{{ t('invites.delete_confirm') }}"
data-slot-refreshes-on="{{ t('invites.slot_refreshes_on') }}"
data-slot-refreshed="{{ t('invites.slot_refreshed') }}"
data-generating="{{ t('invites.generating') }}"
data-admin-desc="{{ t('invites.admin_desc') }}"
></div>
<script>
(function() {
const ds = document.getElementById('invite-i18n')?.dataset || {};
const T = (key, vars) => {
let str = ds[key] || key;
if (vars) Object.entries(vars).forEach(([k, v]) => { str = str.replaceAll('{' + k + '}', String(v)); });
return str;
};
const statusEl = document.getElementById('invite-action-status');
const genBtn = document.getElementById('btn-gen-invite');
const tokenList = document.getElementById('invite-token-list');
const setStatus = (msg, ok) => {
statusEl.textContent = msg;
statusEl.style.color = ok ? '#51cf66' : '#ff6b6b';
};
const formatDate = ts => {
if (!ts) return '—';
const d = (typeof ts === 'number' || /^\d+$/.test(String(ts)))
? new Date(parseInt(ts) * 1000)
: new Date(ts);
return d.toLocaleDateString();
};
const loadInvites = async () => {
try {
const res = await fetch('/api/v2/settings/invites');
const data = await res.json();
if (!data.success) {
tokenList.innerHTML = '<span style="color:#ff6b6b">' + (data.msg || 'Error') + '</span>';
return;
}
if (data.is_admin) {
const desc = document.getElementById('invite-section-desc');
if (desc) desc.textContent = T('adminDesc');
const grid = document.getElementById('invite-criteria-grid');
const statusLine = document.getElementById('invite-status-line');
if (grid) grid.style.display = 'none';
if (statusLine) statusLine.style.display = 'none';
} else {
const criteriaMap = {
'ic-uploads': data.criteria.uploads,
'ic-age': data.criteria.age_days,
'ic-comments': data.criteria.comments,
'ic-tags': data.criteria.tags,
};
Object.entries(criteriaMap).forEach(([id, c]) => {
const el = document.getElementById(id);
if (!el) return;
el.classList.toggle('met', c.met);
el.classList.toggle('unmet', !c.met);
el.querySelector('.ic-icon').textContent = c.met ? '✓' : '✗';
const unit = id === 'ic-age' ? ' ' + T('days') : '';
el.querySelector('.ic-values').textContent = c.current + ' / ' + c.required + unit;
});
}
const badge = document.getElementById('invite-eligible-badge');
badge.textContent = data.eligible ? T('eligible') : T('notEligible');
badge.style.color = data.eligible ? '#51cf66' : '#ff6b6b';
document.getElementById('invite-slots-info').textContent =
T('slotsUsed', { used: data.slots_consumed, total: data.slots_total });
genBtn.disabled = !data.eligible || (!data.is_admin && data.slots_available <= 0);
if (!data.tokens || data.tokens.length === 0) {
tokenList.innerHTML = '<span class="text-muted" style="font-size:0.9em;">' + T('noInvites') + '</span>';
} else {
tokenList.innerHTML = data.tokens.map(tok => {
const isUsed = tok.is_used;
const usedAt = tok.used_at ? formatDate(tok.used_at) : null;
let refreshNote = '';
if (isUsed && tok.used_at) {
const usedAtMs = (typeof tok.used_at === 'number' || /^\d+$/.test(String(tok.used_at)))
? parseInt(tok.used_at) * 1000
: new Date(tok.used_at).getTime();
const refreshDate = new Date(usedAtMs + 30 * 24 * 60 * 60 * 1000);
refreshNote = '<span class="invite-refresh-note"> — ' +
(refreshDate > new Date()
? T('slotRefreshesOn', { date: refreshDate.toLocaleDateString() })
: T('slotRefreshed')) +
'</span>';
}
const userLink = tok.used_by_name
? '<a href="/user/' + encodeURIComponent(tok.used_by_name.toLowerCase()) + '" style="color:inherit;text-decoration:underline;">' + tok.used_by_name + '</a>'
: '?';
const statusHtml = isUsed
? '<span class="invite-token-status-used">' + T('statusUsedBy', { user: userLink }) + ' (' + (usedAt || '?') + ')</span>' + refreshNote
: '<span class="invite-token-status-unused">' + T('statusUnused') + '</span>';
const deleteBtn = !isUsed
? '<button class="button button-danger" style="padding:3px 10px;font-size:0.8em;" data-invite-delete="' + tok.id + '">' + T('deleteBtn') + '</button>'
: '';
return '<div class="invite-token-row">' +
'<span class="invite-token-code">' + tok.token + '</span>' +
statusHtml + deleteBtn +
'</div>';
}).join('');
tokenList.querySelectorAll('[data-invite-delete]').forEach(btn => {
btn.addEventListener('click', async () => {
if (!confirm(T('deleteConfirm'))) return;
const res = await fetch('/api/v2/settings/invites/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.f0ckSession?.csrf_token },
body: JSON.stringify({ id: +btn.dataset.inviteDelete })
});
const d = await res.json();
if (d.success) { loadInvites(); } else { setStatus(d.msg || 'Error', false); }
});
});
}
} catch (e) {
console.error('[Invites] load error:', e);
tokenList.innerHTML = '<span style="color:#ff6b6b">Failed to load</span>';
}
};
genBtn.addEventListener('click', async () => {
const origLabel = genBtn.textContent;
genBtn.disabled = true;
genBtn.textContent = T('generating');
statusEl.textContent = '';
try {
const res = await fetch('/api/v2/settings/invites/create', {
method: 'POST',
headers: { 'X-CSRF-Token': window.f0ckSession?.csrf_token }
});
const data = await res.json();
if (data.success) {
setStatus('✓', true);
loadInvites();
} else {
setStatus(data.msg || 'Error', false);
genBtn.disabled = false;
}
} catch (e) {
setStatus('Network error', false);
genBtn.disabled = false;
} finally {
genBtn.textContent = origLabel;
}
});
loadInvites();
})();
</script>
@endif
@if(enable_data_export)
<!-- ═══════════════════════════════ EXPORT ═══════════════════════════════ -->
<h2 id="export"><i class="fa-solid fa-file-export"></i> {{ t('settings.export_data_title') || 'Export Data' }}</h2>
<div class="export-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>{{ t('settings.export_data_desc') || 'Download a copy of your data. This process happens entirely in your browser to protect your privacy and save server resources.' }}</p>
<div class="setting-item" style="margin-bottom: 15px;">
<label class="checkbox-container" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
<input type="checkbox" id="export_uploads" checked>
<span>{{ t('settings.export_uploads') || 'Your Uploads' }}</span>
</label>
</div>
<div class="setting-item" style="margin-bottom: 20px;">
<label class="checkbox-container" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
<input type="checkbox" id="export_favorites" checked>
<span>{{ t('settings.export_favorites') || 'Your Favorites' }}</span>
</label>
</div>
<div id="export-progress-container" style="display: none; margin-bottom: 20px;">
<div class="progress-bar-wrapper" style="height: 20px; background: rgba(255,255,255,0.1); border-radius: 10px; overflow: hidden; margin-bottom: 5px;">
<div id="export-progress-bar" style="height: 100%; width: 0%; background: var(--accent); transition: width 0.3s;"></div>
</div>
<div id="export-status-text"
style="font-size: 0.9em; color: var(--text-muted);"
data-fetching="{{ t('settings.export_fetching_data') }}"
data-processing="{{ t('settings.export_processing_files') }}"
data-generating="{{ t('settings.export_generating_zip') }}"
data-complete="{{ t('settings.export_complete') }}"
data-failed="{{ t('settings.export_failed') }}"
data-select-option="{{ t('settings.export_select_option') }}"
data-no-data="{{ t('settings.export_no_data') }}"
data-failed-alert="{{ t('settings.export_failed_alert') }}"
data-domain="{{ site_domain }}"
><span id="export-status-msg">{{ t('settings.export_preparing') || 'Preparing...' }}</span><span class="export-dots" id="export-animated-dots" style="display:none;"><span>.</span><span>.</span><span>.</span></span></div>
</div>
<button type="button" id="btn-start-export" class="button button-primary">
<i class="fa-solid fa-download" style="margin-right: 5px;"></i> {{ t('settings.start_export') || 'Generate Export (ZIP)' }}
</button>
</div>
@endif
<style>
@keyframes exportDotBounce {
0%, 80%, 100% { transform: translateY(0); opacity: 0.3; }
40% { transform: translateY(-5px); opacity: 1; }
}
.export-dots span {
display: inline-block;
animation: exportDotBounce 1.2s infinite ease-in-out;
}
.export-dots span:nth-child(2) { animation-delay: 0.2s; }
.export-dots span:nth-child(3) { animation-delay: 0.4s; }
</style>
<script src="/s/js/jszip.min.js"></script>
<script src="/s/js/settings.js?v=@mtime(/public/s/js/settings.js)"></script>
</div>
</div>
</div>
@include(snippets/footer)