hgfhfd
This commit is contained in:
@@ -49,8 +49,6 @@
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
@else
|
||||
<h1>About</h1>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -304,9 +304,7 @@
|
||||
@elseif(session && session.is_anon && !anon_permissions.comment)
|
||||
<div class="lock-notice">🔒 Anonymous comments are disabled.</div>
|
||||
@else
|
||||
<div class="comment-input main-input">
|
||||
<textarea disabled></textarea>
|
||||
</div>
|
||||
@include(snippets/comment-input)
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@@ -24,9 +24,7 @@
|
||||
@elseif(session && session.is_anon && !anon_permissions.comment)
|
||||
<div class="lock-notice">🔒 Anonymous comments are disabled.</div>
|
||||
@else
|
||||
<div class="comment-input main-input">
|
||||
<textarea disabled></textarea>
|
||||
</div>
|
||||
@include(snippets/comment-input)
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
+29
-15
@@ -15,14 +15,22 @@
|
||||
<span class="pv-stat-label">IP logging</span>
|
||||
@if(pv.log_ips)<span class="pv-pill is-on">On</span>@else<span class="pv-pill is-off">Off</span>@endif
|
||||
</div>
|
||||
@if(pv.log_ips)
|
||||
<div class="pv-stat">
|
||||
<span class="pv-stat-label">IP storage</span>
|
||||
@if(pv.ip_mode === 'hashed')<span class="pv-pill is-good">Hashed (HMAC-SHA256)</span>@elseif(pv.ip_mode === 'raw')<span class="pv-pill is-warn">Plain text</span>@else<span class="pv-pill is-good">Not stored</span>@endif
|
||||
@if(pv.hash_ips)<span class="pv-pill is-good">Hashed (HMAC-SHA256)</span>@else<span class="pv-pill is-warn">Plain text</span>@endif
|
||||
</div>
|
||||
@endif
|
||||
<div class="pv-stat">
|
||||
<span class="pv-stat-label">Anonymous login</span>
|
||||
@if(pv.anon)<span class="pv-pill is-on">Enabled</span>@else<span class="pv-pill is-off">Disabled</span>@endif
|
||||
</div>
|
||||
@if(pv.anon)
|
||||
<div class="pv-stat">
|
||||
<span class="pv-stat-label">Device fingerprinting</span>
|
||||
@if(pv.hw)<span class="pv-pill is-on">On</span>@else<span class="pv-pill is-good">Off</span>@endif
|
||||
</div>
|
||||
@endif
|
||||
<div class="pv-stat">
|
||||
<span class="pv-stat-label">Transport</span>
|
||||
@if(pv.https)<span class="pv-pill is-good">HTTPS</span>@else<span class="pv-pill is-warn">HTTP</span>@endif
|
||||
@@ -31,7 +39,7 @@
|
||||
<p class="pv-small">
|
||||
@if(pv.ip_mode === 'hashed')IP addresses are logged, but only ever written to the database as <code>HMAC-SHA256(ip, server_secret)</code>. The raw address is not persisted.@endif
|
||||
@if(pv.ip_mode === 'raw')IP addresses are logged and written to the database in plain text.@endif
|
||||
@if(pv.ip_mode === 'off')IP addresses are not written to the database. They are only held in memory while a request is processed (e.g. to check bans and rate limits).@endif
|
||||
@if(pv.ip_mode === 'off')IP addresses are not stored. The only exception is brute-force protection: login attempts keep a keyed hash of the IP for {{ pv.ret.login }}, and a moderator ban stores the hash of the banned address.@endif
|
||||
</p>
|
||||
</section>
|
||||
|
||||
@@ -41,21 +49,21 @@
|
||||
<p>A cleanup job runs hourly and deletes or blanks data older than these periods.</p>
|
||||
<div class="pv-table">
|
||||
<div class="pv-row pv-row-head"><span>Data</span><span>Kept for</span><span>What happens after</span></div>
|
||||
<div class="pv-row"><span>Stored IP addresses</span><span class="@if(pv.ret_on.ip)pv-ok@else pv-warn@endif">{{ pv.ret.ip }}</span><span><code>user_ips</code> rows deleted; IP columns on sessions, anonymous identities, uploads, comments, reports and ToS acceptances set to <code>NULL</code>.</span></div>
|
||||
<div class="pv-row"><span>Anonymous activity log</span><span class="@if(pv.ret_on.activity)pv-ok@else pv-warn@endif">{{ pv.ret.activity }}</span><span>Rows deleted (action, IP, identity and device fingerprint).</span></div>
|
||||
@if(pv.log_ips)<div class="pv-row"><span>Stored IP addresses</span><span class="@if(pv.ret_on.ip)pv-ok@else pv-warn@endif">{{ pv.ret.ip }}</span><span><code>user_ips</code> rows deleted; IP columns on sessions, anonymous identities, uploads, comments, reports and ToS acceptances set to <code>NULL</code>.</span></div>@endif
|
||||
<div class="pv-row"><span>Anonymous activity log</span><span class="@if(pv.ret_on.activity)pv-ok@else pv-warn@endif">{{ pv.ret.activity }}</span><span>Rows deleted (action, identity fingerprint@if(pv.hw), device fingerprint@endif@if(pv.log_ips), IP@endif).</span></div>
|
||||
<div class="pv-row"><span>Login attempts</span><span class="@if(pv.ret_on.login)pv-ok@else pv-warn@endif">{{ pv.ret.login }}</span><span>Rows deleted (hashed IP, attempted username, result).</span></div>
|
||||
<div class="pv-row"><span>Unused sessions</span><span class="@if(pv.ret_on.sessions)pv-ok@else pv-warn@endif">{{ pv.ret.sessions }}</span><span>Session deleted after this long without use; that device is logged out.</span></div>
|
||||
<div class="pv-row"><span>Device fingerprint</span><span class="@if(pv.ret_on.fp)pv-ok@else pv-warn@endif">{{ pv.ret.fp }}</span><span>Cleared from anonymous identities that haven't been used for this long.</span></div>
|
||||
@if(pv.hw)<div class="pv-row"><span>Device fingerprint</span><span class="@if(pv.ret_on.fp)pv-ok@else pv-warn@endif">{{ pv.ret.fp }}</span><span>Cleared from anonymous identities that haven't been used for this long.</span></div>@endif
|
||||
<div class="pv-row"><span>Active bans</span><span>Until expiry</span><span>Banned IP hashes and fingerprints are kept until the ban expires or is lifted.</span></div>
|
||||
<div class="pv-row"><span>Your content</span><span>Until deleted</span><span>Uploads, comments, favourites and your account itself.</span></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{{-- ── IP addresses ── --}}
|
||||
{{-- ── IP addresses (only when IPs are stored) ── --}}
|
||||
@if(pv.log_ips)
|
||||
<section class="pv-sec">
|
||||
<h2><i class="fa-solid fa-network-wired"></i> IP addresses</h2>
|
||||
<p>The client IP is taken from the first of <code>CF-Connecting-IP</code>, <code>True-Client-IP</code>, <code>X-Client-IP</code>, <code>X-Real-IP</code>, <code>X-Forwarded-For</code> (first entry) or the TCP peer address.</p>
|
||||
@if(pv.log_ips)
|
||||
<p>With IP logging on, the IP is recorded @if(pv.hash_ips)as an HMAC@else in plain text@endif in:</p>
|
||||
<ul class="pv-list">
|
||||
<li><code>user_sessions.ip</code>: updated on each request of a logged-in session</li>
|
||||
@@ -63,30 +71,33 @@
|
||||
<li><code>anon_identities.created_ip / last_ip</code> and <code>anon_activity_log.ip</code> for anonymous identities</li>
|
||||
<li><code>items.uploader_ip</code>, <code>comments.ip</code>, <code>reports.reporter_ip</code></li>
|
||||
</ul>
|
||||
@endif
|
||||
@if(pv.hash_ips)
|
||||
<p><strong>Hashing:</strong> <code>HMAC-SHA256</code> keyed with a server-side secret, stored as 64 hex characters. The same IP always yields the same hash, which is what makes bans and abuse correlation work; without the secret the hash can't be reversed or recomputed. This is pseudonymisation, not anonymisation: whoever holds the secret could test candidate IPs against it.</p>
|
||||
<p><strong>Hashing:</strong> <code>HMAC-SHA256</code> keyed with a server-side secret, stored as 64 hex characters. The same IP always produces the same hash, which is what lets bans and rate limits work. Without the secret a hash can't be reversed or even recomputed, so a leaked database reveals no IP addresses.@if(pv.ip_secret_env) The secret is supplied through the server environment and is not part of the configuration file or database backups.@endif</p>
|
||||
<p class="pv-small">Legally, hashed IPs are still <em>pseudonymised</em> personal data (GDPR Art. 4(5)), not anonymous: the operator, who holds the secret, can check whether a given IP matches a stored hash. That is how a ban recognises a returning address, and it is why hashed IPs are also subject to the retention period above.</p>
|
||||
@endif
|
||||
<p><strong>Always, regardless of the logging setting:</strong> login and registration attempts store an HMAC of the IP in <code>login_attempts</code> for brute-force rate limiting, and a moderator ban stores the banned IP's hash in <code>banned_ips</code>.</p>
|
||||
</section>
|
||||
@endif
|
||||
|
||||
@if(pv.anon)
|
||||
{{-- ── Anonymous login ── --}}
|
||||
<section class="pv-sec">
|
||||
<h2><i class="fa-solid fa-user-secret"></i> Anonymous login (WebAuthn passkey)</h2>
|
||||
<p>No email, password or name is involved. <em>Login as Anonymous</em> creates a standard WebAuthn passkey in your authenticator (browser, OS, Bitwarden, iCloud Keychain, …).</p>
|
||||
<p class="pv-note"><i class="fa-solid fa-key"></i> <span><strong>Hardware security keys work too.</strong> A FIDO2 key such as a <strong>YubiKey</strong> (YubiKey 5 series, Security Key series) can hold the passkey: choose "security key" / "USB or NFC" when your browser asks where to save it, then plug in or tap the key. The private key is generated on the YubiKey and can never be exported from it, so the passkey is bound to that physical key and does not sync. The key may ask for its PIN and a touch. Since a lost key means a lost identity, register a second passkey (another YubiKey or a password manager) as a backup.</span></p>
|
||||
<h3>Registration</h3>
|
||||
<ol class="pv-steps">
|
||||
<li>The server sends creation options: relying party <code>{{ pv.domain }}</code>, a random single-use challenge, algorithm <strong>ES256</strong> (ECDSA P-256, COSE <code>-7</code>), <code>attestation: "none"</code>, and a user handle of 16 random bytes named <code>anon@{{ pv.domain }}</code> / "Anonymous". Nothing about you goes into it.</li>
|
||||
<li>Your authenticator generates a key pair. The <strong>private key never leaves the authenticator</strong>.</li>
|
||||
<li>The server verifies the response and stores: the <strong>credential ID</strong>, the <strong>public key</strong> (SPKI), the signature counter, and the authenticator's <strong>AAGUID</strong> (identifies the authenticator model, e.g. a password manager; with attestation "none" it is often all zeros).</li>
|
||||
<li>The server verifies the response and stores: the <strong>credential ID</strong>, the <strong>public key</strong> (SPKI), the signature counter, and the authenticator's <strong>AAGUID</strong> (identifies the authenticator model, e.g. a password manager; with attestation "none" it is often all zeros; for a YubiKey it can reveal the key model, e.g. "YubiKey 5 NFC", but never its serial number).</li>
|
||||
<li>Your identity is derived from the credential ID: <code>SHA256:base64(SHA-256(credential_id))</code>; the account name is <code>anon_</code> plus the first 8 hex characters of that hash (e.g. <code>anon_1ad1e20c</code>).</li>
|
||||
</ol>
|
||||
<h3>Login</h3>
|
||||
<p>The server issues a random single-use challenge; your authenticator signs it with the private key and the server verifies the signature with the stored public key. Up to 4 passkeys can be attached to one identity. If all of them are lost, the identity cannot be recovered: nothing else links it to you.</p>
|
||||
</section>
|
||||
|
||||
{{-- ── Device fingerprint ── --}}
|
||||
{{-- ── Device fingerprint (only when enabled) ── --}}
|
||||
@if(pv.hw)
|
||||
<section class="pv-sec">
|
||||
<h2><i class="fa-solid fa-microchip"></i> Device fingerprint</h2>
|
||||
<p>At anonymous login and when adding a passkey, your browser computes a device fingerprint used <strong>only for ban enforcement</strong> (so a banned user can't just create a new identity). It is not used for advertising or cross-site tracking.</p>
|
||||
@@ -101,11 +112,12 @@
|
||||
</ul>
|
||||
<p>The values are concatenated and hashed <strong>in the browser</strong> with SHA-256; only <code>HW:<64 hex></code> is sent. The raw values never reach the server. The hash is cached in <code>localStorage</code> (<code>f0ck_anon_hw_fp</code>) and stored server-side in <code>anon_identities.hw_fingerprint</code> and the activity log, and compared against banned device hashes.</p>
|
||||
</section>
|
||||
@endif
|
||||
|
||||
{{-- ── Activity log ── --}}
|
||||
<section class="pv-sec">
|
||||
<h2><i class="fa-solid fa-list-check"></i> Anonymous activity log</h2>
|
||||
<p><code>anon_activity_log</code> records these actions of anonymous identities: login/session handshake, favourite, unfavourite, favourites import. Each row holds the action, target item, time, identity fingerprint, device fingerprint, and the IP @if(pv.ip_mode === 'hashed')(hashed)@endif @if(pv.ip_mode === 'off')(empty, as IP logging is off)@endif. It exists for moderation and ban cascades.</p>
|
||||
<p><code>anon_activity_log</code> records these actions of anonymous identities: login/session handshake, favourite, unfavourite, favourites import. Each row holds the action, target item, time, identity fingerprint@if(pv.hw), device fingerprint@endif@if(pv.log_ips), IP@if(pv.hash_ips) (hashed)@endif@endif. It exists for moderation and ban cascades.</p>
|
||||
</section>
|
||||
@endif
|
||||
|
||||
@@ -115,7 +127,7 @@
|
||||
<ul class="pv-list">
|
||||
<li><strong><code>session</code> cookie</strong>: 32 random bytes; the server stores only its SHA-256, so a database leak doesn't expose usable sessions. Flags: <code>HttpOnly</code>, <code>SameSite=Lax</code>@if(pv.https), <code>Secure</code>@endif.</li>
|
||||
<li>Per session the server keeps: user agent string, creation time, last-used time, the last path requested, a CSRF token@if(pv.log_ips), and the IP@endif.</li>
|
||||
<li><code>localStorage</code>: UI preferences, and for anonymous users the device fingerprint hash.</li>
|
||||
<li><code>localStorage</code>: UI preferences@if(pv.hw), and for anonymous users the device fingerprint hash@endif.</li>
|
||||
<li><code>f0ck_banned</code> cookie / <code>f0ck_anon_tombstone</code>: only set if you are banned, to show the ban notice.</li>
|
||||
</ul>
|
||||
<p>Registered accounts: passwords are hashed with <strong>scrypt</strong> (random 16-byte salt, 64-byte key). The plain password is never stored.</p>
|
||||
@@ -123,7 +135,7 @@
|
||||
|
||||
<section class="pv-sec">
|
||||
<h2><i class="fa-solid fa-ban"></i> Not collected</h2>
|
||||
<p>For anonymous identities: no email, real name, phone number or password. No third-party analytics, trackers or ad networks are involved in authentication.</p>
|
||||
<p>For anonymous identities: no email, real name, phone number or password@if(pv.anon)@if(!pv.hw), and no device fingerprint: device fingerprinting is disabled on this instance@endif@endif. No third-party analytics, trackers or ad networks are involved in authentication.</p>
|
||||
</section>
|
||||
|
||||
<p class="pv-foot">Questions? See <a href="/about">About</a>@if(mail) or write to <a href="mailto:{!! mail !!}">{!! mail !!}</a>@endif.</p>
|
||||
@@ -152,10 +164,12 @@
|
||||
.pv a { color: var(--pv-accent); }
|
||||
.pv code { padding: 1px 5px; font-size: 0.86em; background: var(--pv-surface); border: 1px solid var(--pv-border); word-break: break-word; }
|
||||
.pv-small { font-size: 0.88em; color: var(--pv-muted); }
|
||||
.pv-note { display: flex; gap: 10px; margin: 0 0 12px; padding: 10px 14px; font-size: 0.9em; background: var(--pv-surface); border: 1px solid var(--pv-border); border-left: 3px solid var(--pv-accent); }
|
||||
.pv-note > i { color: var(--pv-accent); margin-top: 4px; }
|
||||
.pv-steps, .pv-list { margin: 0 0 12px; padding-left: 22px; }
|
||||
.pv-steps li, .pv-list li { margin-bottom: 6px; }
|
||||
|
||||
.pv-status { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1px; margin-bottom: 12px; background: var(--pv-border); border: 1px solid var(--pv-border); }
|
||||
.pv-status { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 1px; margin-bottom: 12px; background: var(--pv-border); border: 1px solid var(--pv-border); }
|
||||
.pv-stat { display: flex; flex-direction: column; gap: 8px; padding: 12px 14px; background: var(--bg, #000); }
|
||||
.pv-stat-label { font-size: 0.72em; font-weight: 800; text-transform: uppercase; letter-spacing: 0.08em; color: var(--pv-muted); }
|
||||
.pv-pill { align-self: flex-start; padding: 3px 9px; font-size: 0.78em; font-weight: 800; text-transform: uppercase; letter-spacing: 0.06em; border: 1px solid currentColor; }
|
||||
|
||||
@@ -49,8 +49,6 @@
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
@else
|
||||
<h1>Rules</h1>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
{{-- Server copy of the main comment input: must match comments.js renderInput() + setupEmojiPicker() (users) or renderGuestInput() (guests) exactly, so the client re-render swaps in an identical box without any layout shift --}}
|
||||
@if(session)
|
||||
<div class="comment-input main-input">
|
||||
<textarea placeholder="{{ t('comments.write_comment') }}"@if(comment_max_length !== null && comment_max_length !== undefined) maxlength="{{ comment_max_length }}"@endif required></textarea>
|
||||
<div class="comment-file-preview"></div>
|
||||
<div class="input-actions">
|
||||
@if(comment_max_length !== null && comment_max_length !== undefined)<span class="char-counter" data-max="{{ comment_max_length }}">0 / {{ comment_max_length }}</span>@endif
|
||||
@if(allow_fileupload_comments && !session.is_anon || allow_fileupload_comments && anon_permissions.comment_attachments !== false)<button class="comment-attach-btn" title="{{ t('comments.attach_file') }}" type="button"><i class="fa-solid fa-paperclip"></i></button>@endif
|
||||
@if(enable_comment_polls)<button class="comment-poll-btn" title="{{ t('comments.poll_btn_title') || 'Create poll' }}" type="button"><i class="fa-solid fa-chart-bar"></i></button>@endif
|
||||
@if(is_mod_or_admin)<button id="lock-thread-btn" class="admin-lock-btn" title="{{ item.is_comments_locked ? 'Unlock Thread' : 'Lock Thread' }}">@if(item.is_comments_locked)<i class="fa-solid fa-lock"></i>@else<i class="fa-solid fa-lock-open"></i>@endif</button>@endif
|
||||
<button class="spoiler-trigger" title="Insert spoiler tag">[S]</button>
|
||||
<button class="emoji-trigger"><i class="fa-regular fa-face-smile"></i></button>
|
||||
<button class="submit-comment"><span class="submit-label">{{ t('comments.post') }}</span><i class="fa-solid fa-spinner fa-spin submit-spinner"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<div class="comment-input main-input is-guest" aria-disabled="true" title="Log in to comment" onclick="const b=document.getElementById('nav-login-btn'); if (b) b.click();">
|
||||
<textarea placeholder="Log in to comment" readonly tabindex="-1" aria-disabled="true"></textarea>
|
||||
<div class="comment-file-preview"></div>
|
||||
<div class="input-actions">
|
||||
<button class="submit-comment" type="button" tabindex="-1" aria-disabled="true"><span class="submit-label">{{ t('comments.post') }}</span><i class="fa-solid fa-spinner fa-spin submit-spinner"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@@ -2,6 +2,7 @@
|
||||
<div id="mod-action-modal" class="modal-overlay" style="display:none;">
|
||||
<div class="modal-content ma-modal-content">
|
||||
<div class="ma-header">
|
||||
<button type="button" class="modal-x" aria-label="Close" title="Close" onclick="document.getElementById('mod-action-cancel').click()">×</button>
|
||||
<h3 id="mod-action-title">{{ t('mod.confirm_action') }}</h3>
|
||||
</div>
|
||||
<div class="ma-body">
|
||||
@@ -36,7 +37,7 @@
|
||||
color: var(--ma-text);
|
||||
}
|
||||
#mod-action-modal .ma-modal-content * { border-radius: 0 !important; }
|
||||
#mod-action-modal .ma-header { flex-shrink: 0; padding: 18px 24px 14px; border-bottom: 1px solid var(--ma-border); }
|
||||
#mod-action-modal .ma-header { position: relative; flex-shrink: 0; padding: 18px 64px 14px 24px; border-bottom: 1px solid var(--ma-border); }
|
||||
#mod-action-modal .ma-header h3 { margin: 0; font-size: 1.2em; font-weight: 700; color: var(--ma-text); overflow-wrap: anywhere; }
|
||||
#mod-action-modal .ma-body {
|
||||
flex: 1 1 auto; min-height: 0; overflow-y: auto; overscroll-behavior: contain;
|
||||
@@ -143,6 +144,7 @@
|
||||
<div id="report-modal" class="modal-overlay" style="display:none;">
|
||||
<div class="modal-content report-modal-content">
|
||||
<div class="rm-header">
|
||||
<button type="button" class="modal-x" aria-label="Close" title="Close" onclick="document.getElementById('report-cancel').click()">×</button>
|
||||
<h3>{{ t('report.title') }}</h3>
|
||||
<p>Select one or more reasons and optionally add details below.</p>
|
||||
</div>
|
||||
@@ -217,7 +219,7 @@
|
||||
color: var(--rm-text);
|
||||
}
|
||||
#report-modal .report-modal-content * { border-radius: 0 !important; }
|
||||
#report-modal .rm-header { flex-shrink: 0; padding: 20px 24px 14px; border-bottom: 1px solid var(--rm-border); }
|
||||
#report-modal .rm-header { position: relative; flex-shrink: 0; padding: 20px 64px 14px 24px; border-bottom: 1px solid var(--rm-border); }
|
||||
#report-modal .rm-body { flex: 1 1 auto; min-height: 0; overflow-y: auto; overscroll-behavior: contain; padding: 16px 24px; }
|
||||
#report-modal .rm-body > :last-child { margin-bottom: 0; }
|
||||
#report-modal .rm-header h3 { margin: 0; font-size: 1.25em; font-weight: 700; color: var(--rm-text); }
|
||||
@@ -415,14 +417,15 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="global-sidebar-right-footer">
|
||||
<a href="/ranking">{{ t('footer.ranking') }}</a>
|
||||
<a href="/rules">{{ t('footer.rules') }}</a>
|
||||
<a href="/about">{{ t('footer.about') }}</a>
|
||||
<a href="/privacy">Privacy</a>
|
||||
<div id="help-button" style="color: var(--accent); font-weight: bold; opacity: 0.7;" title="Keyboard Shortcuts">?</div>
|
||||
<a href="/ranking" data-page-modal title="{{ t('footer.ranking') }}" aria-label="{{ t('footer.ranking') }}"><i class="fa-solid fa-users-between-lines"></i></a>
|
||||
<a href="/rules" data-page-modal title="{{ t('footer.rules') }}" aria-label="{{ t('footer.rules') }}"><i class="fa-solid fa-scale-balanced"></i></a>
|
||||
<a href="/about" data-page-modal title="{{ t('footer.about') }}" aria-label="{{ t('footer.about') }}"><i class="fa-solid fa-info"></i></a>
|
||||
<a href="/privacy" data-page-modal title="Privacy" aria-label="Privacy"><i class="fa-solid fa-shield-halved"></i></a>
|
||||
<div id="help-button" style="color: var(--accent); font-weight: bold; opacity: 0.7;" title="Keyboard Shortcuts" aria-label="Keyboard Shortcuts"><i class="fa-solid fa-question"></i></div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@include(snippets/page-modal)
|
||||
|
||||
@if(!private_society || session)
|
||||
<script src="/s/js/sanitizer.js?v={{ ts }}"></script>
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
@endif
|
||||
<link rel="stylesheet" href="/s/css/upload.css?v={{ ts }}">
|
||||
@endif
|
||||
<script>window.f0ckThemes = {{ themes_json }}; window.f0ckDefaultTheme = "{{ default_theme }}"; window.f0ckDefaultLayout = "{{ default_layout }}"; window.f0ckDomain = "{{ domain }}"; window.f0ckGitHash = "{{ git_hash }}"; window.f0ckAllowedImages = {{ allowed_comment_images_json }}; window.f0ckEmbedYoutubeInComments = {{ embed_youtube_in_comments ? 'true' : 'false' }}; window.f0ckEnableYoutubeUpload = {{ enable_youtube_upload ? 'true' : 'false' }}; window.f0ckBrandImages = {{ custom_brand_images_json }}; window.f0ckCustomBrandText = "{{ custom_navbar_brand_text || '' }}"; window.f0ckMediaBase = "{{ paths_images }}"; window.f0ckShitpostMode = {{ shitpost_mode ? 'true' : 'false' }}; window.f0ckShitpostRequireRating = {{ shitpost_require_rating ? 'true' : 'false' }}; window.f0ckShitpostMinTags = {{ shitpost_min_tags || 0 }}; window.f0ckEnableItemTitle = {{ enable_item_title ? 'true' : 'false' }}; window.f0ckCommentBannerEnabled = @if(comment_banner_enabled) true @else false @endif; window.f0ckServerAudioTuner = {{ audio_tuner_json || 'null' }}; window.f0ckEnableComments = {{ enable_comments ? 'true' : 'false' }};</script>
|
||||
<script>window.f0ckThemes = {{ themes_json }}; window.f0ckDefaultTheme = "{{ default_theme }}"; window.f0ckDefaultLayout = "{{ default_layout }}"; window.f0ckDomain = "{{ domain }}"; window.f0ckGitHash = "{{ git_hash }}"; window.f0ckAllowedImages = {{ allowed_comment_images_json }}; window.f0ckEmbedYoutubeInComments = {{ embed_youtube_in_comments ? 'true' : 'false' }}; window.f0ckEnableYoutubeUpload = {{ enable_youtube_upload ? 'true' : 'false' }}; window.f0ckBrandImages = {{ custom_brand_images_json }}; window.f0ckCustomBrandText = "{{ custom_navbar_brand_text || '' }}"; window.f0ckMediaBase = "{{ paths_images }}"; window.f0ckShitpostMode = {{ shitpost_mode ? 'true' : 'false' }}; window.f0ckShitpostRequireRating = {{ shitpost_require_rating ? 'true' : 'false' }}; window.f0ckShitpostMinTags = {{ shitpost_min_tags || 0 }}; window.f0ckEnableItemTitle = {{ enable_item_title ? 'true' : 'false' }}; window.f0ckCommentBannerEnabled = @if(comment_banner_enabled) true @else false @endif; window.f0ckServerAudioTuner = {{ audio_tuner_json || 'null' }}; window.f0ckEnableComments = {{ enable_comments ? 'true' : 'false' }}; window.f0ckHwFingerprint = {{ hw_fingerprint_enabled ? 'true' : 'false' }};</script>
|
||||
@if(!private_society || session)
|
||||
@if(enable_comments)
|
||||
<script src="/s/js/marked.min.js" defer></script>
|
||||
|
||||
+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
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
{{-- Fullscreen modal for info pages opened from the sidebar footer (see /s/js/page-modal.js) --}}
|
||||
<div id="page-modal" style="display: none;" role="dialog" aria-modal="true">
|
||||
<div class="pgm-header">
|
||||
<h3 class="pgm-title"></h3>
|
||||
<div class="pgm-actions">
|
||||
<button type="button" class="pgm-close" title="Close" aria-label="Close">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pgm-body"></div>
|
||||
</div>
|
||||
<style>
|
||||
#page-modal {
|
||||
/* Covers only the content area: below the navbar, between the sidebars (page-modal.js sets the insets).
|
||||
Just below the sidebar drag handles (899) and sidebars (900) so they keep working while it's open. */
|
||||
position: fixed; z-index: 895;
|
||||
top: var(--pgm-top, 0px); right: var(--pgm-right, 0px); bottom: 0; left: var(--pgm-left, 0px);
|
||||
flex-direction: column;
|
||||
background: var(--bg); color: var(--text-color, #fff);
|
||||
}
|
||||
#page-modal .pgm-header {
|
||||
flex-shrink: 0; display: flex; align-items: center; justify-content: space-between; gap: 12px;
|
||||
padding: 12px 20px; border-bottom: 1px solid var(--accent);
|
||||
}
|
||||
#page-modal .pgm-title {
|
||||
margin: 0; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
font-size: 1em; font-weight: 800; text-transform: uppercase; letter-spacing: 0.08em;
|
||||
}
|
||||
#page-modal .pgm-actions { display: flex; align-items: center; gap: 4px; flex-shrink: 0; }
|
||||
#page-modal .pgm-close {
|
||||
display: inline-flex; align-items: center; justify-content: center; width: 36px; height: 36px;
|
||||
margin: 0; padding: 0; background: none; border: 1px solid transparent; border-radius: 0;
|
||||
color: var(--text-color, #fff); font-size: 1em; line-height: 1; cursor: pointer; text-decoration: none;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
#page-modal .pgm-close { font-size: 1.6em; border-color: rgba(255, 255, 255, 0.25); }
|
||||
#page-modal .pgm-close:hover { color: var(--accent); border-color: var(--accent); }
|
||||
#page-modal .pgm-body {
|
||||
flex: 1 1 auto; min-height: 0; overflow-y: auto; overscroll-behavior: contain;
|
||||
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||||
}
|
||||
#page-modal .pgm-loading { padding: 60px 20px; text-align: center; color: var(--text-muted, #aaa); }
|
||||
#page-modal .pgm-loading a { color: var(--accent); }
|
||||
</style>
|
||||
<script src="/s/js/page-modal.js?v={{ ts }}"></script>
|
||||
@@ -2,6 +2,7 @@
|
||||
<div id="visibility-modal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal-content vis-modal-content">
|
||||
<div class="vis-header">
|
||||
<button type="button" class="modal-x" aria-label="Close" title="Close" onclick="document.getElementById('visibility-modal-cancel').click()">×</button>
|
||||
<h3><i class="fa-solid fa-eye"></i> Change visibility</h3>
|
||||
<p>Choose who can see this post.</p>
|
||||
</div>
|
||||
@@ -74,7 +75,7 @@
|
||||
}
|
||||
#visibility-modal .vis-modal-content * { border-radius: 0 !important; }
|
||||
|
||||
#visibility-modal .vis-header { flex-shrink: 0; padding: 20px 24px 14px; border-bottom: 1px solid var(--vm-border); }
|
||||
#visibility-modal .vis-header { position: relative; flex-shrink: 0; padding: 20px 64px 14px 24px; border-bottom: 1px solid var(--vm-border); }
|
||||
#visibility-modal .vis-header h3 { display: flex; align-items: center; gap: 10px; margin: 0; font-size: 1.2em; font-weight: 700; color: var(--vm-text); }
|
||||
#visibility-modal .vis-header h3 i { color: var(--vm-accent); font-size: 0.9em; }
|
||||
#visibility-modal .vis-header p { margin: 6px 0 0; font-size: 0.85em; color: var(--vm-muted); }
|
||||
|
||||
Reference in New Issue
Block a user