hgfhfd
This commit is contained in:
+224
-31
@@ -256,7 +256,7 @@
|
||||
@endif
|
||||
</a>
|
||||
|
||||
<div class="nav-collapse" id="navbarContent">
|
||||
<div class="nav-collapse nav-collapse-guest" id="navbarContent">
|
||||
<div class="nav-links">
|
||||
<a href="/tags" title="Tags"><i class="fa-solid fa-tags"></i></a>
|
||||
@if(halls_enabled)
|
||||
@@ -294,7 +294,7 @@
|
||||
<span id="nav-anon-label">@if(enable_anonymous_access && session && session.is_anon)anonymous@else guest @endif</span>
|
||||
<span class="nav-avatar-caret">▾</span>
|
||||
</button>
|
||||
<div class="nav-user-menu" id="nav-visitor-menu" style="min-width: max-content; width: max-content; white-space: nowrap;">
|
||||
<div class="nav-user-menu" id="nav-visitor-menu" style="min-width: 100%; width: max-content; white-space: nowrap;">
|
||||
@if(enable_anonymous_access && session && session.is_anon)
|
||||
<a href="/favs" id="nav-guest-favs-link" class="mobile-only">{{ t('nav.favs') }}</a>
|
||||
@endif
|
||||
@@ -304,7 +304,9 @@
|
||||
<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>
|
||||
@if(registration_open)
|
||||
<a href="#" id="nav-register-btn">{{ t('nav.register') }}</a>
|
||||
@endif
|
||||
@if(enable_anonymous_access)
|
||||
<div class="nav-user-divider" id="nav-anon-divider" @if(!session || !session.is_anon) style="display:none;" @endif></div>
|
||||
<a href="#" id="nav-anon-logout-btn" @if(!session || !session.is_anon) style="display:none;" @endif><i class="fa-solid fa-right-from-bracket"></i> Logout (Guest)</a>
|
||||
@@ -446,10 +448,6 @@ async function loginWithPasskey() {
|
||||
|
||||
<!-- Tab: Passkey -->
|
||||
<div id="login-panel-passkey" class="lm-panel lm-panel-passkey" style="display: none;">
|
||||
<div class="lm-passkey-icon"><i class="fa-solid fa-fingerprint"></i></div>
|
||||
<p class="lm-passkey-text">
|
||||
Use a saved passkey from Bitwarden, iCloud Keychain, or your OS to sign in without a password.
|
||||
</p>
|
||||
<button type="button" id="modal-login-passkey-btn" class="lm-btn lm-btn-primary" onclick="loginWithPasskey()">
|
||||
<i class="fa-solid fa-fingerprint"></i> Use my passkey
|
||||
</button>
|
||||
@@ -463,9 +461,11 @@ async function loginWithPasskey() {
|
||||
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>
|
||||
@if(registration_open)
|
||||
<button type="button" id="modal-login-register-btn" class="lm-btn lm-btn-ghost" style="margin-top: 8px;">
|
||||
<i class="fa-solid fa-user-plus"></i> Register
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@@ -653,6 +653,10 @@ async function loginWithPasskey() {
|
||||
background: var(--lm-surface); border: 1px solid var(--lm-border); border-left: 2px solid var(--lm-accent);
|
||||
}
|
||||
#anon-setup-modal #anon-setup-view .lm-note i { color: var(--lm-accent); margin-right: 4px; }
|
||||
#anon-setup-modal #anon-setup-view .lm-title { margin-bottom: 18px; }
|
||||
#anon-setup-modal #anon-setup-view .anon-consent { margin: 0 0 16px; text-align: left; }
|
||||
/* Disabled until the privacy consent is ticked (spinner state keeps the progress cursor) */
|
||||
#anon-setup-modal #anon-setup-view .lm-btn:disabled:not(:has(.fa-spinner)) { cursor: not-allowed; opacity: 0.45; }
|
||||
#anon-setup-modal #anon-setup-view .lm-textbtn {
|
||||
margin: 0; padding: 0; background: none; border: 0;
|
||||
font: inherit; font-weight: 600; color: var(--lm-accent); cursor: pointer;
|
||||
@@ -816,20 +820,16 @@ async function loginWithPasskey() {
|
||||
<div id="anon-setup-view">
|
||||
<!-- New user view (no passkey yet) -->
|
||||
<div id="anon-setup-new" class="lm-panel lm-panel-passkey">
|
||||
<div class="lm-passkey-icon"><i class="fa-solid fa-fingerprint"></i></div>
|
||||
<h2 class="lm-title">Create an anonymous passkey</h2>
|
||||
<p class="lm-passkey-text" style="margin-top: 12px;">
|
||||
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 class="lm-note">
|
||||
<i class="fa-solid fa-rotate"></i> You can use it across devices if your passkey manager syncs (e.g. Bitwarden).
|
||||
</p>
|
||||
<p class="lm-note">
|
||||
<i class="fa-solid fa-shield-halved"></i> To stop ban evasion we store a hashed device fingerprint@if(privacy_ip_mode === 'hashed') and a hashed IP address@elseif(privacy_ip_mode === 'raw') and your IP address@endif. <a href="/privacy" target="_blank" class="lm-link">What exactly is stored?</a>
|
||||
</p>
|
||||
<h2 class="lm-title">Create passkey</h2>
|
||||
<div class="lm-row anon-consent">
|
||||
<label class="lm-check lm-check-multiline" for="anon-consent-new">
|
||||
<input type="checkbox" id="anon-consent-new" class="anon-consent-input" />
|
||||
<span class="lm-check-box"><i class="fa-solid fa-check"></i></span>
|
||||
<span>I have read the <a href="/privacy" class="lm-link anon-consent-open">privacy page</a> and accept how my data is processed.</span>
|
||||
</label>
|
||||
</div>
|
||||
<button type="button" id="anon-setup-create-btn" class="lm-btn lm-btn-primary">
|
||||
<i class="fa-solid fa-fingerprint"></i> Create my passkey
|
||||
<i class="fa-solid fa-fingerprint"></i> Create
|
||||
</button>
|
||||
<div id="anon-setup-new-error" class="lm-error" style="display: none;"></div>
|
||||
<p class="lm-footer">
|
||||
@@ -839,13 +839,9 @@ async function loginWithPasskey() {
|
||||
|
||||
<!-- Returning user view (has passkey) -->
|
||||
<div id="anon-setup-returning" class="lm-panel lm-panel-passkey" style="display: none;">
|
||||
<div class="lm-passkey-icon"><i class="fa-solid fa-user-secret"></i></div>
|
||||
<h2 class="lm-title">Use your anonymous passkey</h2>
|
||||
<p class="lm-passkey-text" style="margin-top: 12px;">
|
||||
Pick your saved passkey from Bitwarden, iCloud Keychain, or your OS to continue as the same anonymous user.
|
||||
</p>
|
||||
<h2 class="lm-title">Authenticate with passkey</h2>
|
||||
<button type="button" id="anon-setup-auth-btn" class="lm-btn lm-btn-primary">
|
||||
<i class="fa-solid fa-fingerprint"></i> Use my passkey
|
||||
<i class="fa-solid fa-fingerprint"></i> Authenticate
|
||||
</button>
|
||||
<div id="anon-setup-ret-error" class="lm-error" style="display: none;"></div>
|
||||
<p class="lm-footer">
|
||||
@@ -855,6 +851,64 @@ async function loginWithPasskey() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Privacy consent modal: opened from the "Create passkey" consent checkbox; must be read to the end -->
|
||||
<div id="privacy-consent-modal" style="display: none;">
|
||||
<div class="pc-content">
|
||||
<div class="pc-header">
|
||||
<h3><i class="fa-solid fa-shield-halved"></i> Privacy</h3>
|
||||
<button type="button" id="privacy-consent-close" class="pc-close" aria-label="Close">×</button>
|
||||
</div>
|
||||
<div class="pc-body" id="privacy-consent-body"></div>
|
||||
<div class="pc-footer">
|
||||
<span class="pc-hint" id="privacy-consent-hint">Scroll to the end to accept.</span>
|
||||
<button type="button" id="privacy-consent-accept" class="pc-accept" disabled><i class="fa-solid fa-check"></i> Accept</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<style>
|
||||
#privacy-consent-modal {
|
||||
position: fixed; inset: 0; z-index: 100000;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.85); backdrop-filter: blur(4px);
|
||||
}
|
||||
#privacy-consent-modal .pc-content {
|
||||
width: 100vw; height: 100vh; height: 100dvh;
|
||||
display: flex; flex-direction: column; overflow: hidden;
|
||||
background: var(--bg); border: 0; border-radius: 0;
|
||||
}
|
||||
#privacy-consent-modal .pc-header {
|
||||
flex-shrink: 0; display: flex; align-items: center; justify-content: space-between; gap: 12px;
|
||||
padding: 14px 20px; border-bottom: 1px solid var(--accent);
|
||||
}
|
||||
#privacy-consent-modal .pc-header h3 { display: flex; align-items: center; gap: 10px; margin: 0; font-size: 1.1em; font-weight: 800; text-transform: uppercase; letter-spacing: 0.06em; }
|
||||
#privacy-consent-modal .pc-header h3 i { color: var(--accent); }
|
||||
#privacy-consent-modal .pc-close {
|
||||
width: 36px; height: 36px; margin: 0; padding: 0; flex-shrink: 0; display: inline-flex; align-items: center; justify-content: center;
|
||||
background: none; border: 1px solid rgba(255, 255, 255, 0.25); border-radius: 0;
|
||||
color: var(--text-color, #fff); font-size: 1.6em; line-height: 1; cursor: pointer; transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
#privacy-consent-modal .pc-close:hover { color: var(--accent); border-color: var(--accent); }
|
||||
#privacy-consent-modal .pc-body { flex: 1 1 auto; min-height: 0; overflow-y: auto; overscroll-behavior: contain; }
|
||||
#privacy-consent-modal .pc-body .pv { padding-top: 20px; }
|
||||
#privacy-consent-modal .pc-loading { padding: 40px 20px; text-align: center; color: var(--text-muted, #aaa); }
|
||||
#privacy-consent-modal .pc-footer {
|
||||
flex-shrink: 0; display: flex; align-items: center; justify-content: flex-end; gap: 16px;
|
||||
padding: 12px 20px calc(12px + env(safe-area-inset-bottom, 0px)); border-top: 1px solid rgba(255, 255, 255, 0.1); background: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
#privacy-consent-modal .pc-hint { font-size: 0.85em; color: var(--text-muted, #aaa); }
|
||||
#privacy-consent-modal .pc-accept {
|
||||
height: 42px; padding: 0 22px; margin: 0; font: inherit; font-size: 0.85em; font-weight: 800;
|
||||
text-transform: uppercase; letter-spacing: 0.08em; cursor: pointer; border-radius: 0;
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
background: var(--accent); color: #000; border: 1px solid var(--accent); transition: background 0.18s, color 0.18s;
|
||||
}
|
||||
#privacy-consent-modal .pc-accept:hover:not(:disabled) { background: transparent; color: var(--accent); }
|
||||
#privacy-consent-modal .pc-accept:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
@media (max-width: 600px) {
|
||||
#privacy-consent-modal .pc-footer { flex-direction: column; align-items: stretch; gap: 8px; text-align: center; }
|
||||
#privacy-consent-modal .pc-accept { justify-content: center; }
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
(function() {
|
||||
function closeSetupModal() {
|
||||
@@ -874,10 +928,95 @@ async function loginWithPasskey() {
|
||||
document.getElementById('anon-setup-returning').style.display = 'none';
|
||||
document.getElementById('anon-setup-new').style.display = '';
|
||||
});
|
||||
// Privacy consent (new passkeys only): the checkbox can only be ticked by reading the privacy page in
|
||||
// the consent modal to the end. Accepting is remembered in localStorage and the checkbox never shows again.
|
||||
var CONSENT_KEY = 'f0ck_privacy_accepted';
|
||||
var consentInput = document.getElementById('anon-consent-new');
|
||||
var hasConsent = function() {
|
||||
try { return !!localStorage.getItem(CONSENT_KEY); } catch (e) { return false; }
|
||||
};
|
||||
var syncConsent = function() {
|
||||
var accepted = hasConsent();
|
||||
document.querySelectorAll('#anon-setup-modal .anon-consent').forEach(function(el) { el.style.display = accepted ? 'none' : ''; });
|
||||
var btn = document.getElementById('anon-setup-create-btn');
|
||||
if (btn && !btn.querySelector('.fa-spinner')) btn.disabled = !(accepted || (consentInput && consentInput.checked));
|
||||
};
|
||||
var recordConsent = function() {
|
||||
if (hasConsent()) return true;
|
||||
if (!consentInput || !consentInput.checked) return false;
|
||||
try { localStorage.setItem(CONSENT_KEY, JSON.stringify({ accepted_at: new Date().toISOString() })); } catch (e) {}
|
||||
return true;
|
||||
};
|
||||
|
||||
var pcModal = document.getElementById('privacy-consent-modal');
|
||||
var pcBody = document.getElementById('privacy-consent-body');
|
||||
var pcAccept = document.getElementById('privacy-consent-accept');
|
||||
var pcHint = document.getElementById('privacy-consent-hint');
|
||||
var pcLoaded = false;
|
||||
var pcCheckEnd = function() {
|
||||
if (!pcLoaded) return;
|
||||
var atEnd = pcBody.scrollTop + pcBody.clientHeight >= pcBody.scrollHeight - 8;
|
||||
if (atEnd) {
|
||||
pcAccept.disabled = false;
|
||||
if (pcHint) pcHint.textContent = 'You reached the end.';
|
||||
}
|
||||
};
|
||||
var loadPrivacy = async function() {
|
||||
if (pcLoaded) return;
|
||||
pcBody.innerHTML = '<div class="pc-loading"><i class="fa-solid fa-spinner fa-spin"></i> Loading…</div>';
|
||||
try {
|
||||
var r = await fetch('/privacy', { credentials: 'same-origin' });
|
||||
var doc = new DOMParser().parseFromString(await r.text(), 'text/html');
|
||||
var pv = doc.querySelector('#main .pv');
|
||||
if (!r.ok || !pv) throw new Error('bad response');
|
||||
pcBody.innerHTML = '';
|
||||
doc.querySelectorAll('#main style').forEach(function(st) { pcBody.appendChild(st.cloneNode(true)); });
|
||||
pcBody.appendChild(document.importNode(pv, true));
|
||||
pcBody.querySelectorAll('a[href]').forEach(function(a) { a.target = '_blank'; a.rel = 'noopener'; });
|
||||
} catch (e) {
|
||||
pcBody.innerHTML = '<div class="pc-loading">Could not load the privacy page. <a href="/privacy" target="_blank" class="lm-link">Open it in a new tab</a>.</div>';
|
||||
}
|
||||
pcLoaded = true;
|
||||
};
|
||||
var openConsentModal = async function() {
|
||||
if (!pcModal) return;
|
||||
pcAccept.disabled = true;
|
||||
if (pcHint) pcHint.textContent = 'Scroll to the end to accept.';
|
||||
pcModal.style.display = 'flex';
|
||||
await loadPrivacy();
|
||||
pcBody.scrollTop = 0;
|
||||
// Short content that doesn't scroll counts as read
|
||||
requestAnimationFrame(pcCheckEnd);
|
||||
};
|
||||
var closeConsentModal = function() { if (pcModal) pcModal.style.display = 'none'; };
|
||||
if (pcBody) pcBody.addEventListener('scroll', pcCheckEnd, { passive: true });
|
||||
if (pcAccept) pcAccept.addEventListener('click', function() {
|
||||
if (consentInput) consentInput.checked = true;
|
||||
closeConsentModal();
|
||||
syncConsent();
|
||||
});
|
||||
var pcClose = document.getElementById('privacy-consent-close');
|
||||
if (pcClose) pcClose.addEventListener('click', closeConsentModal);
|
||||
|
||||
if (consentInput) consentInput.addEventListener('click', function(e) {
|
||||
// Ticking goes through the privacy modal; unticking is always allowed
|
||||
if (!consentInput.checked) return syncConsent();
|
||||
e.preventDefault();
|
||||
openConsentModal();
|
||||
});
|
||||
document.querySelectorAll('#anon-setup-modal .anon-consent-open').forEach(function(a) {
|
||||
a.addEventListener('click', function(e) { e.preventDefault(); e.stopPropagation(); openConsentModal(); });
|
||||
});
|
||||
|
||||
syncConsent();
|
||||
// Re-sync whenever the modal is opened (another tab may have accepted meanwhile)
|
||||
if (overlay) new MutationObserver(function() { if (overlay.style.display !== 'none') syncConsent(); }).observe(overlay, { attributes: true, attributeFilter: ['style'] });
|
||||
|
||||
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';
|
||||
if (!recordConsent()) return;
|
||||
createBtn.disabled = true;
|
||||
createBtn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Saving passkey...';
|
||||
try {
|
||||
@@ -893,7 +1032,7 @@ async function loginWithPasskey() {
|
||||
}
|
||||
}
|
||||
createBtn.disabled = false;
|
||||
createBtn.innerHTML = '<i class="fa-solid fa-fingerprint"></i> Create my passkey';
|
||||
createBtn.innerHTML = '<i class="fa-solid fa-fingerprint"></i> Create';
|
||||
}
|
||||
});
|
||||
var authBtn = document.getElementById('anon-setup-auth-btn');
|
||||
@@ -915,7 +1054,7 @@ async function loginWithPasskey() {
|
||||
}
|
||||
}
|
||||
authBtn.disabled = false;
|
||||
authBtn.innerHTML = '<i class="fa-solid fa-fingerprint"></i> Use my passkey';
|
||||
authBtn.innerHTML = '<i class="fa-solid fa-fingerprint"></i> Authenticate';
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -925,10 +1064,12 @@ async function loginWithPasskey() {
|
||||
@if(!private_society || session)
|
||||
<!-- Shortcuts Modal -->
|
||||
<div id="shortcuts-modal" style="display: none;">
|
||||
<div class="login-modal-content">
|
||||
<button id="shortcuts-modal-close">×</button>
|
||||
<div class="shortcuts-content">
|
||||
<h2 style="text-align: center; margin-bottom: 20px;">{{ t('shortcuts.title') }}</h2>
|
||||
<div class="login-modal-content sc-content">
|
||||
<div class="sc-header">
|
||||
<h2><i class="fa-solid fa-keyboard"></i> {{ t('shortcuts.title') }}</h2>
|
||||
<button type="button" id="shortcuts-modal-close" aria-label="Close">×</button>
|
||||
</div>
|
||||
<div class="shortcuts-content sc-body">
|
||||
<div class="shortcut-list">
|
||||
<div class="shortcut-item"><span><kbd>k</kbd></span><span>{{ t('shortcuts.search') }}</span></div>
|
||||
<div class="shortcut-item"><span><kbd>m</kbd></span><span>{{ t('shortcuts.main_page') }}</span></div>
|
||||
@@ -963,6 +1104,58 @@ async function loginWithPasskey() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Keyboard shortcuts modal: style guide (square, flat, accent border), fixed header, only the list scrolls */
|
||||
#shortcuts-modal .sc-content {
|
||||
--sc-muted: var(--text-muted, #8a8f98);
|
||||
--sc-border: rgba(255, 255, 255, 0.1);
|
||||
width: min(520px, 92vw); max-width: none; max-height: min(90vh, 90dvh);
|
||||
display: flex; flex-direction: column; overflow: hidden;
|
||||
margin: 0; padding: 0; text-align: left;
|
||||
background: var(--bg) !important; border: 1px solid var(--accent) !important;
|
||||
border-radius: 0 !important; box-shadow: 0 0 20px rgba(0, 0, 0, 0.5) !important;
|
||||
}
|
||||
#shortcuts-modal .sc-content * { border-radius: 0 !important; }
|
||||
#shortcuts-modal .sc-header {
|
||||
flex-shrink: 0; display: flex; align-items: center; justify-content: space-between; gap: 12px;
|
||||
padding: 16px 20px; border-bottom: 1px solid var(--sc-border);
|
||||
}
|
||||
#shortcuts-modal .sc-header h2 {
|
||||
display: flex; align-items: center; gap: 10px; margin: 0;
|
||||
font-size: 1.05em; font-weight: 800; text-transform: uppercase; letter-spacing: 0.08em;
|
||||
}
|
||||
#shortcuts-modal .sc-header h2 i { color: var(--accent); }
|
||||
#shortcuts-modal #shortcuts-modal-close {
|
||||
position: static; width: 36px; height: 36px; margin: 0; padding: 0; flex-shrink: 0;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
background: none; border: 1px solid rgba(255, 255, 255, 0.25); color: var(--text-color, #fff);
|
||||
font-size: 1.6em; line-height: 1; opacity: 1; cursor: pointer; transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
#shortcuts-modal #shortcuts-modal-close:hover { color: var(--accent); border-color: var(--accent); }
|
||||
#shortcuts-modal .sc-body {
|
||||
flex: 1 1 auto; min-height: 0; overflow-y: auto; overscroll-behavior: contain;
|
||||
padding: 6px 20px 16px; scrollbar-width: thin; scrollbar-color: var(--accent) transparent;
|
||||
}
|
||||
#shortcuts-modal .shortcut-list { gap: 0; margin: 0; }
|
||||
#shortcuts-modal .shortcut-item {
|
||||
align-items: center; margin: 0; padding: 10px 0; border-bottom: 1px solid var(--sc-border);
|
||||
}
|
||||
#shortcuts-modal .shortcut-item:last-child { border-bottom: 0; }
|
||||
#shortcuts-modal .shortcut-item span:last-child { color: var(--sc-muted); opacity: 1; }
|
||||
#shortcuts-modal .shortcut-item kbd {
|
||||
display: inline-block; min-width: 22px; padding: 2px 7px; text-align: center;
|
||||
font-size: 0.85em; font-family: inherit; font-weight: 700;
|
||||
background: transparent; border: 1px solid var(--accent); color: var(--accent); box-shadow: none;
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
#shortcuts-modal { padding: 0 !important; align-items: stretch !important; }
|
||||
#shortcuts-modal .sc-content {
|
||||
width: 100vw; height: 100vh; height: 100dvh; max-height: none;
|
||||
border: 0 !important; box-shadow: none !important;
|
||||
}
|
||||
#shortcuts-modal .sc-body { padding-bottom: calc(16px + env(safe-area-inset-bottom, 0px)); }
|
||||
}
|
||||
</style>
|
||||
<div id="flash-container"></div>
|
||||
@endif
|
||||
|
||||
|
||||
Reference in New Issue
Block a user