This commit is contained in:
2026-09-29 04:35:35 +02:00
parent 73c0659d4a
commit 6c813143eb
51 changed files with 3708 additions and 743 deletions
+1
View File
@@ -283,6 +283,7 @@
"audio/aac": "m4a", "audio/aac": "m4a",
"video/x-m4v": "mp4", "video/x-m4v": "mp4",
"video/x-matroska": "mkv", "video/x-matroska": "mkv",
"video/mpeg": "mp4",
"application/x-shockwave-flash": "swf", "application/x-shockwave-flash": "swf",
"application/vnd.adobe.flash.movie": "swf", "application/vnd.adobe.flash.movie": "swf",
"application/pdf": "pdf", "application/pdf": "pdf",
+1
View File
@@ -262,6 +262,7 @@ mimes:
audio/aac: m4a audio/aac: m4a
video/x-m4v: mp4 video/x-m4v: mp4
video/x-matroska: mkv video/x-matroska: mkv
video/mpeg: mp4
application/x-shockwave-flash: swf application/x-shockwave-flash: swf
application/vnd.adobe.flash.movie: swf application/vnd.adobe.flash.movie: swf
application/pdf: pdf application/pdf: pdf
+13
View File
@@ -0,0 +1,13 @@
-- Migration: deleted_user ghost account
-- Deleted users' comments, tags, halls, reports, etc. are reassigned to this system account
-- (see /api/v2/admin/users/delete, which also creates it on first use).
-- It can never log in: password '!' matches no hash, it is not activated and it is banned.
INSERT INTO public."user" (login, "user", password, admin, is_moderator, activated, banned, ban_reason, created_at)
VALUES ('deleted_user', 'deleted_user', '!', false, false, false, true, 'System account', now())
ON CONFLICT (login) DO NOTHING;
INSERT INTO public.user_options (user_id, mode, theme, fullscreen, avatar, avatar_file, display_name)
SELECT id, 0, 'amoled', 0, NULL, 'default.png', 'deleted user'
FROM public."user" WHERE login = 'deleted_user'
ON CONFLICT (user_id) DO NOTHING;
+19
View File
@@ -0,0 +1,19 @@
-- Migration: invite_requests
-- Tracks anonymous users requesting invite tokens from admins
CREATE TABLE IF NOT EXISTS public.invite_requests (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES public."user"(id) ON DELETE SET NULL,
fingerprint VARCHAR(128) NOT NULL,
ip_hash VARCHAR(128),
reason TEXT DEFAULT '',
status VARCHAR(16) DEFAULT 'pending' CHECK (status IN ('pending', 'approved', 'denied', 'revoked')),
token_id INTEGER REFERENCES public.invite_tokens(id) ON DELETE SET NULL,
reviewed_by INTEGER REFERENCES public."user"(id) ON DELETE SET NULL,
reviewed_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_invite_requests_fingerprint ON public.invite_requests(fingerprint);
CREATE INDEX IF NOT EXISTS idx_invite_requests_status ON public.invite_requests(status);
CREATE INDEX IF NOT EXISTS idx_invite_requests_pending ON public.invite_requests(created_at) WHERE (status = 'pending');
@@ -0,0 +1,6 @@
-- Migration: remember which passkey opened a session
-- Set on passkey sign-in (registered and anonymous). The passkey a session is using can't be deleted
-- from that session, and the settings page marks it as "This session". NULL = password login or a
-- session from before this column existed.
ALTER TABLE public.user_sessions ADD COLUMN IF NOT EXISTS passkey_credential_id TEXT;
+366 -187
View File
@@ -2665,10 +2665,14 @@ body.layout-modern .global-sidebar-right {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: flex-end; justify-content: flex-end;
overflow: hidden; overflow: clip;
top: var(--navbar-h, 50px) !important; top: var(--navbar-h, 50px) !important;
opacity: 0; /* Visibility lives on the pill (below): the strip can't be hovered anymore since it's click-through */
transition: opacity 0.2s ease; opacity: 1;
/* The strip itself is click-through (overrides the inline pointer-events JS sets), so it never blocks
what's underneath, e.g. the page modal's close button. Only the pill below takes pointer events;
events on a pseudo-element still target #sidebar-drag-zone, so the drag/click handlers keep working. */
pointer-events: none !important;
} }
#sidebar-drag-zone::after { #sidebar-drag-zone::after {
@@ -2679,32 +2683,26 @@ body.layout-modern .global-sidebar-right {
height: 40px; height: 40px;
border-radius: 2px; border-radius: 2px;
background: var(--accent, #888); background: var(--accent, #888);
/* Resting: only a sliver peeks past the edge */ pointer-events: auto;
transform: translateX(3px); cursor: pointer;
transition: transform 0.28s cubic-bezier(0.22, 1, 0.36, 1), height 0.28s cubic-bezier(0.22, 1, 0.36, 1); opacity: 0.45;
transition: height 0.28s cubic-bezier(0.22, 1, 0.36, 1), opacity 0.2s ease;
} }
#sidebar-drag-zone:hover { body.sidebar-right-hidden #sidebar-drag-zone::after {
opacity: 1;
}
body.sidebar-right-hidden #sidebar-drag-zone {
opacity: 0.7; opacity: 0.7;
} }
/* Hovering the pill brightens and grows it — no lateral movement keeps the cursor stable */
#sidebar-drag-zone:hover::after { #sidebar-drag-zone:hover::after {
transform: translateX(-3px); opacity: 1;
height: 56px; height: 56px;
} }
/* Touch devices: no hover, so the handle stays fully out */ /* Touch devices: no hover, so the handle stays fully visible */
@media (pointer: coarse) { @media (pointer: coarse) {
#sidebar-drag-zone {
opacity: 0.7;
padding-right: 3px;
}
#sidebar-drag-zone::after { #sidebar-drag-zone::after {
transform: none; opacity: 0.7;
} }
} }
@@ -3838,13 +3836,12 @@ body.sidebar-right-hidden #sidebar-drag-zone {
overflow-x: auto; overflow-x: auto;
overflow-y: hidden; overflow-y: hidden;
overscroll-behavior-x: contain; overscroll-behavior-x: contain;
scrollbar-width: thin; /* No visible scrollbar: drag, swipe, or turn the mouse wheel (f0ckm.js maps it to sideways) */
scrollbar-color: rgba(255, 255, 255, 0.2) transparent; scrollbar-width: none !important;
-ms-overflow-style: none;
} }
.f0ck-tuner-subtabs-nav::-webkit-scrollbar, .f0ck-tuner-subtabs-nav::-webkit-scrollbar,
.f0ck-tuner-mode-bar::-webkit-scrollbar { height: 4px; } .f0ck-tuner-mode-bar::-webkit-scrollbar { display: none; width: 0; height: 0; }
.f0ck-tuner-subtabs-nav::-webkit-scrollbar-thumb,
.f0ck-tuner-mode-bar::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.2); }
/* Buttons keep their natural width (grow to fill when there is room); mode-bar buttons set flex:1 inline */ /* Buttons keep their natural width (grow to fill when there is room); mode-bar buttons set flex:1 inline */
.f0ck-tuner-subtabs-nav > .f0ck-tuner-subtab-btn { flex: 1 0 auto; } .f0ck-tuner-subtabs-nav > .f0ck-tuner-subtab-btn { flex: 1 0 auto; }
.f0ck-tuner-mode-bar > .f0ck-tuner-mode-btn { flex: 1 0 auto !important; } .f0ck-tuner-mode-bar > .f0ck-tuner-mode-btn { flex: 1 0 auto !important; }
@@ -7465,7 +7462,7 @@ body.layout-modern.sidebar-right-hidden .pagination-container-fluid {
.location a:hover, .location a:hover,
.location .location-link:hover { .location .location-link:hover {
color: #4ade80; color: var(--accent);
text-decoration: underline; text-decoration: underline;
} }
@@ -7669,6 +7666,18 @@ body:has(.index-container) > .pagewrapper > #main {
margin: 0; margin: 0;
} }
/* Main page scroller: scrolls as before (wheel, touch, keyboard, infinite scroll) but draws no scrollbar */
.index-container {
scrollbar-width: none !important;
-ms-overflow-style: none;
}
.index-container::-webkit-scrollbar {
display: none;
width: 0;
height: 0;
}
@media (min-width: 361px) { @media (min-width: 361px) {
.embed-responsive-image { .embed-responsive-image {
bottom: unset !important; bottom: unset !important;
@@ -10580,7 +10589,6 @@ ul.navbar-nav-guests li.nav-item {
.user_content_wrapper { .user_content_wrapper {
display: grid; display: grid;
grid-template-columns: 1fr 1fr;
grid-column-gap: 5px; grid-column-gap: 5px;
padding: 5px; padding: 5px;
} }
@@ -11453,6 +11461,33 @@ input#s_avatar {
background: rgba(255, 255, 255, 0.1); background: rgba(255, 255, 255, 0.1);
} }
/* Own uploads awaiting approval: count on the right of the "Pending" entry */
.nav-user-menu a.nav-menu-pending {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.nav-menu-count {
min-width: 18px;
padding: 1px 5px;
box-sizing: border-box;
background: var(--accent);
color: #000;
font-size: 0.75em;
font-weight: 800;
line-height: 1.4;
text-align: center;
font-variant-numeric: tabular-nums;
}
/* Nothing waiting: still shown, but quiet */
.nav-menu-count.is-zero {
background: rgba(255, 255, 255, 0.1);
color: var(--text-muted, #8a8f98);
}
.nav-user-divider { .nav-user-divider {
display: none; display: none;
height: 1px; height: 1px;
@@ -12648,10 +12683,6 @@ span.badge.badge-current {
align-content: center; align-content: center;
} }
#help-button {
cursor: pointer;
}
.comment-content img { .comment-content img {
/*max-height: 200px;*/ /*max-height: 200px;*/
max-width: 480px; max-width: 480px;
@@ -12963,8 +12994,21 @@ html[theme='paper'] .steuerung.steuerung-icon button:hover,
opacity: 0.75; opacity: 0.75;
} }
/* "Back to top" below the comments, all screen sizes; f0ckm.js toggles .is-visible once the
container it sits in is scrolled far enough (class name kept from when it was mobile-only) */
.mobile-scroll-to-top { .mobile-scroll-to-top {
display: none; /* Same size as the steuerung chevrons (.steuerung-icon: x-large, 1.6em wide, icon at 0.85em) */
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.6em;
height: 1.6em;
padding: 0;
font-size: x-large;
line-height: 1;
visibility: hidden;
opacity: 0;
transition: opacity 0.2s ease, visibility 0s linear 0.2s;
background: none; background: none;
border: none; border: none;
color: var(--white); color: var(--white);
@@ -12973,39 +13017,26 @@ html[theme='paper'] .steuerung.steuerung-icon button:hover,
cursor: pointer; cursor: pointer;
left: 50%; left: 50%;
transform: translateX(-50%); transform: translateX(-50%);
font-size: 0.85em;
font-weight: bold;
width: 100%;
} }
@media (max-width: 999px) { .mobile-scroll-to-top .fa-solid {
/* Only once the reader is far enough down for it to help (f0ckm.js toggles .is-visible) */ font-size: 0.85em;
.mobile-scroll-to-top { pointer-events: none;
display: block;
visibility: hidden;
opacity: 0;
transition: opacity 0.2s ease, visibility 0s linear 0.2s;
} }
.mobile-scroll-to-top.is-visible { .mobile-scroll-to-top.is-visible {
visibility: visible; visibility: visible;
opacity: 1; opacity: 1;
transition: opacity 0.2s ease, visibility 0s; transition: opacity 0.15s ease, visibility 0s;
}
} }
/* At 600–999px: center the button in the free space left of the sidebar. /* Hover / press feedback like the steuerung controls (is-clicked is set by the shared feedback) */
Shift left by half the sidebar width so transform:translateX(-50%) lands .mobile-scroll-to-top.is-visible:hover { opacity: 0.8; }
at the center of the available area rather than the full viewport. */ .mobile-scroll-to-top.is-visible:active,
@media (min-width: 600px) and (max-width: 999px) { .mobile-scroll-to-top.is-visible.is-clicked { opacity: 0.6; }
.mobile-scroll-to-top {
left: calc(50% - min(140px, 40vw));
transition: left 0.3s ease-in-out;
}
body.sidebar-right-hidden .mobile-scroll-to-top { /* Horizontal centre next to the right sidebar is computed in f0ckm.js (inline `left`);
left: 50%; 50% above is only the fallback before the script has run */
}
}
.steuerung.steuerung-icon .fa-solid { .steuerung.steuerung-icon .fa-solid {
font-size: 0.85em; font-size: 0.85em;
@@ -13489,7 +13520,7 @@ body > nav.navbar {
/* On mobile: right group stays in the single top row */ /* On mobile: right group stays in the single top row */
.nav-right-group { .nav-right-group {
gap: 5px; gap: 15px;
padding: 0 4px; padding: 0 4px;
margin-left: auto; margin-left: auto;
} }
@@ -14589,99 +14620,100 @@ div.posts>a::after {
background: #333; background: #333;
} }
/* Video Player Settings Menu */ /* Video Player Settings Menu: style guide (square, flat, theme colours, no glow) */
.v0ck_settings_container { .v0ck_settings_container {
position: relative; position: relative;
display: flex; display: flex;
/* Fix alignment */
align-items: center; align-items: center;
} }
.v0ck_settings_menu { .v0ck_settings_menu {
position: absolute; position: absolute;
bottom: 100%; bottom: 100%;
/* Position above the button */
right: 0; right: 0;
margin-bottom: 5px; margin-bottom: 6px;
background-color: rgba(0, 0, 0, 0.9);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 4px;
padding: 5px 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-width: 100px; align-items: stretch;
min-width: 190px;
padding: 4px 0;
box-sizing: border-box;
background: var(--bg, #111);
border: 1px solid var(--accent);
border-radius: 0;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.5);
z-index: 20; z-index: 20;
/* Ensure it's above other controls */
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
} }
.v0ck_settings_menu.v0ck_hidden { .v0ck_settings_menu.v0ck_hidden {
display: none; display: none;
} }
.v0ck_settings_menu,
.v0ck_settings_menu * {
border-radius: 0 !important;
}
/* Rows: label left, control right; one flat hover surface */
.v0ck_menu_item { .v0ck_menu_item {
background: transparent;
border: none;
color: #fff;
padding: 8px 15px;
text-align: left;
cursor: pointer;
font-size: 14px;
font-family: inherit;
white-space: nowrap;
transition: background-color 0.2s;
width: 100%;
}
.v0ck_menu_item:hover {
background-color: rgba(255, 255, 255, 0.1);
}
/* ... (rest of CSS) */
/* Specific styling for SWF/BG buttons now in menu */
#toggleswf {
display: block;
width: 100%;
text-align: center;
/* Center text */
}
/* Background Row Styling */
.v0ck_bg_row {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
/* Label on left, switch on right? Or center? User asked for "items centered". */ gap: 14px;
/* If items centered, maybe justify-content: center with a gap? */
/* Let's try space-between for the row content (Label ... Switch) looks better usually,
but if the menu is wide, it might look odd.
Let's stick to space-between for the row, but ensure the menu itself centers the row?
The row IS the menu item.
Let's use space-between for label and switch.
*/
justify-content: space-between;
padding: 8px 15px;
width: 100%; width: 100%;
box-sizing: border-box; box-sizing: border-box;
margin: 0;
padding: 8px 12px;
background: transparent;
border: 0;
color: var(--text-color, #fff);
font-family: inherit;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
line-height: 1.2;
text-transform: uppercase;
text-align: left;
white-space: nowrap;
cursor: pointer;
transition: background-color 0.15s, color 0.15s;
}
.v0ck_menu_item:hover {
background: rgba(255, 255, 255, 0.06);
}
.v0ck_bg_row {
cursor: default;
}
/* Plain action rows (Download): accent on hover, separated from the toggles above */
button.v0ck_menu_item {
margin-top: 4px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
button.v0ck_menu_item:hover {
color: var(--accent);
} }
.v0ck_switch_label { .v0ck_switch_label {
margin-right: 10px; margin: 0;
font-size: 14px; font-size: inherit;
color: #fff; color: inherit;
} }
/* Cool Switch Styling */ /* Square switch: accent track with black knob when on */
.v0ck_cool_switch { .v0ck_cool_switch {
width: 36px;
height: 20px;
background-color: #555;
border-radius: 10px;
position: relative; position: relative;
flex-shrink: 0;
width: 32px;
height: 16px;
box-sizing: border-box;
background: rgba(255, 255, 255, 0.12);
border: 1px solid rgba(255, 255, 255, 0.25);
cursor: pointer; cursor: pointer;
transition: background-color 0.3s; transition: background-color 0.15s, border-color 0.15s;
} }
.v0ck_cool_switch::after { .v0ck_cool_switch::after {
@@ -14689,39 +14721,24 @@ div.posts>a::after {
position: absolute; position: absolute;
top: 2px; top: 2px;
left: 2px; left: 2px;
width: 16px; width: 10px;
height: 16px; height: 10px;
background-color: #fff; background: #fff;
border-radius: 50%; transition: transform 0.15s;
transition: transform 0.3s;
} }
.v0ck_cool_switch.active { .v0ck_cool_switch.active {
background-color: var(--gray); background: var(--accent);
border-color: var(--accent);
} }
.v0ck_cool_switch.active::after { .v0ck_cool_switch.active::after {
background: #000;
transform: translateX(16px); transform: translateX(16px);
} }
.v0ck_cool_switch:hover { .v0ck_cool_switch:hover {
opacity: 0.9; border-color: var(--accent);
}
/* Update Menu Item Styling for Centering */
.v0ck_menu_item {
/* ... existing styles ... */
/* We want to center the SWF button text */
text-align: center;
}
/* Ensure menu itself centers items? */
.v0ck_settings_menu {
/* ... existing styles ... */
align-items: center;
/* Center items horizontally */
min-width: 160px;
/* Ensure enough width */
} }
/* Fix hover effect for settings button (viewBox scaling issue) */ /* Fix hover effect for settings button (viewBox scaling issue) */
@@ -15178,7 +15195,6 @@ html[theme='paper'] .badge.badge-untagged {
#help-button:hover { #help-button:hover {
opacity: 1 !important; opacity: 1 !important;
color: var(--white) !important;
} }
#shortcut-theme { #shortcut-theme {
@@ -15416,52 +15432,150 @@ body.layout-modern .tag-ac-input {
filter: drop-shadow(0 0 15px var(--accent)); filter: drop-shadow(0 0 15px var(--accent));
} }
/* Upload Drag Modal */ /* Upload modal: a dropdown right under the navbar "Upload" link (f0ck_upload_init.js positions it),
style guide (square, flat, theme colours). The rest of the page stays visible: no dim, no blur,
the full-screen layer lets clicks through, only the panel takes them. */
#upload-drag-modal { #upload-drag-modal {
position: fixed; position: fixed;
top: 0; inset: 0;
left: 0; /* Above the sticky navbar (10070) and so its MOTD row; below the mobile nav menu (10075) */
width: 100%; z-index: 10072;
height: 100%;
background: rgba(0, 0, 0, 0.8);
backdrop-filter: blur(5px);
z-index: 10001;
display: none; display: none;
align-items: center; background: none;
justify-content: center; pointer-events: none;
} }
#upload-drag-modal.show { #upload-drag-modal.show {
display: flex; display: block;
} }
#upload-drag-modal .modal-content { #upload-drag-modal .modal-content {
background: var(--bg); position: fixed;
border: 1px solid var(--shade3, #333); top: calc(var(--navbar-h, 50px) + 6px);
padding: 0; left: 8px;
width: 900px; width: min(760px, calc(100vw - 16px));
max-width: 95vw; max-width: none;
position: relative; max-height: calc(100vh - var(--navbar-h, 50px) - 18px);
max-height: 90vh;
overflow-y: auto; overflow-y: auto;
overscroll-behavior: contain;
padding: 0;
box-sizing: border-box;
background: var(--bg, #111);
border: 1px solid var(--accent);
box-shadow: 0 0 20px rgba(0, 0, 0, 0.5);
pointer-events: auto;
scrollbar-width: none;
animation: upload-dropdown-in 0.16s ease-out;
}
#upload-drag-modal .modal-content::-webkit-scrollbar {
display: none;
}
@keyframes upload-dropdown-in {
from { opacity: 0; transform: translateY(-6px); }
to { opacity: 1; transform: none; }
}
@media (prefers-reduced-motion: reduce) {
#upload-drag-modal .modal-content { animation: none; }
}
#upload-drag-modal .modal-content,
#upload-drag-modal .modal-content *,
#upload-drag-modal .modal-content *::before,
#upload-drag-modal .modal-content *::after {
border-radius: 0 !important;
} }
#upload-drag-modal .modal-close { #upload-drag-modal .modal-close {
position: absolute; position: absolute;
top: 15px; top: 12px;
right: 15px; right: 12px;
background: none;
border: none;
color: var(--white);
font-size: 24px;
cursor: pointer;
line-height: 1;
z-index: 10; z-index: 10;
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
background: none;
border: 1px solid rgba(255, 255, 255, 0.25);
color: var(--text-color, var(--white, #fff));
font-size: 22px;
line-height: 1;
cursor: pointer;
transition: color 0.15s, border-color 0.15s;
}
#upload-drag-modal .modal-close:hover {
color: var(--accent);
border-color: var(--accent);
}
/* Header / scrolling body / footer: the panel itself doesn't scroll (so the close button stays put),
.modal-body does; the title sticks to its top, the upload button + progress + status to its bottom */
#upload-drag-modal .modal-content {
display: flex;
flex-direction: column;
overflow: hidden;
}
#upload-drag-modal .modal-body {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
padding: 0 18px;
scrollbar-width: none;
}
#upload-drag-modal .modal-body::-webkit-scrollbar {
display: none;
}
#upload-drag-modal .upload-title {
position: sticky;
top: 0;
/* Above everything the form layers inside the panel (badges 5-100, tag input 9998, emoji picker 10000);
all of it lives in the panel's own stacking context, so these numbers don't leak outside */
z-index: 10001;
margin: 0 -18px 12px;
padding: 16px 62px 12px 18px;
background: var(--bg, #111);
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
font-size: 1.05em;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.08em;
text-align: left;
}
#upload-drag-modal .modal-close {
z-index: 10002;
}
#upload-drag-modal .upload-footer {
position: sticky;
bottom: 0;
z-index: 10001;
display: flex;
flex-direction: column;
gap: 8px;
margin: 4px -1rem -1rem;
padding: 12px 1rem 14px;
background: var(--bg, #111);
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
#upload-drag-modal .upload-footer .upload-status {
padding: 0;
} }
#upload-drag-modal .upload-container { #upload-drag-modal .upload-container {
max-width: 100%; max-width: 100%;
width: 100%; width: 100%;
box-shadow: none;
} }
/* Private Society Gate */ /* Private Society Gate */
@@ -15547,11 +15661,52 @@ body.layout-modern .tag-ac-input {
opacity: 1; opacity: 1;
} }
/* Navbar "Upload" while its dropdown is open (f0ck_upload_init.js) */
nav.navbar #nav-upload-link.is-active,
nav.navbar #nav-upload-link.is-active i {
color: var(--accent) !important;
opacity: 1;
}
nav.navbar #nav-upload-link.is-active {
box-shadow: inset 0 -2px 0 var(--accent);
}
nav.navbar #nav-upload-link.is-active i {
display: inline-block;
transform: rotate(180deg);
transition: transform 0.15s;
}
/* Navbar entries (settings gear, user/anon menu "Settings") whose page is open in the page modal */
nav.navbar a[data-page-modal].is-active,
nav.navbar a[data-page-modal].is-active i {
color: var(--accent) !important;
opacity: 1;
}
nav.navbar a.desktop-only[data-page-modal].is-active {
box-shadow: inset 0 -2px 0 var(--accent);
}
/* Entry whose page is open in the page modal (clicking it again closes the page) */
.global-sidebar-right-footer>a.is-active {
opacity: 1;
color: var(--accent);
box-shadow: inset 0 -2px 0 var(--accent);
}
#help-button { #help-button {
cursor: pointer; cursor: pointer;
padding: 0 10px; }
font-size: 16px;
line-height: 1; /* Divider between every entry; the last one (random, far right) closes the row */
.global-sidebar-right-footer>#help-button:not(:last-child) {
border-right: 1px solid var(--gray);
}
.global-sidebar-right-footer>:last-child {
border-right: 0;
} }
.git-hash { .git-hash {
@@ -17389,6 +17544,35 @@ body.layout-legacy .gapRight>.iconset#a_favo {
fill: inherit; fill: inherit;
} }
/* ── .gapRight action row: style guide (square, flat, theme colours, no glow) ──
Forced because some actions set rounded corners / shadows inline (chan rehost pills) or via
infobox rules (text-shadow, hover scale). The favourite heart keeps its pink "favourited" colour. */
.gapRight,
.gapRight *,
.gapRight *::before,
.gapRight *::after {
border-radius: 0 !important;
}
.gapRight :is(button, a, i, svg, .iconset) {
box-shadow: none !important;
text-shadow: none !important;
}
/* No hover scale; not !important, so the favourite "pop" animation still plays */
.gapRight :is(button, a, i, svg, .iconset):hover {
transform: none;
}
.gapRight :is(button, a, i, svg, .iconset):active {
opacity: 0.75;
}
.gapRight :is(button, a, i, svg, .iconset):focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
/* OC Toggle Icon - inherits from .gapRight i.iconset */ /* OC Toggle Icon - inherits from .gapRight i.iconset */
i.iconset#a_oc { i.iconset#a_oc {
color: inherit; color: inherit;
@@ -17753,8 +17937,8 @@ textarea#profile_description {
.user-infobox-avatar img { .user-infobox-avatar img {
width: auto; width: 60px;
height: 22px; height: 60px;
object-fit: cover; object-fit: cover;
} }
@@ -17809,18 +17993,8 @@ textarea#profile_description {
body.layout-legacy .user-infobox-block .gapRight > #a_favo.fa-regular, body.layout-legacy .user-infobox-block .gapRight > #a_favo.fa-regular,
body.layout-modern .user-infobox-block .gapRight > #a_favo.fa-regular { body.layout-modern .user-infobox-block .gapRight > #a_favo.fa-regular {
font-weight: 400 !important; font-weight: 400 !important;
background: transparent !important;
background-color: transparent !important;
} }
.user-infobox-block #a_favo.fa-regular:hover,
.user-infobox-block .gapRight #a_favo.fa-regular:hover,
.user-infobox-block .user-infobox-actions #a_favo.fa-regular:hover,
body.layout-legacy .user-infobox-block .gapRight > #a_favo.fa-regular:hover,
body.layout-modern .user-infobox-block .gapRight > #a_favo.fa-regular:hover {
background: transparent !important;
background-color: transparent !important;
}
.user-infobox-block #a_favo.fa-regular::before, .user-infobox-block #a_favo.fa-regular::before,
.user-infobox-block .gapRight #a_favo.fa-regular::before, .user-infobox-block .gapRight #a_favo.fa-regular::before,
@@ -21641,16 +21815,17 @@ body.layout-modern #onara-modal .item-layout-container .item-main-content {
cursor: default; cursor: default;
} }
#onara-modal a, /* Clickables keep the pointer. :is() takes its most specific argument (a[href]), so this outranks the
#onara-modal button, `cursor: default` block above, including for clickables that are direct children of .item-main-content */
#onara-modal .btn, #onara-modal :is(a, a[href], button, .btn, [role="button"], label, .rating-tag, .sidebar-tag,
#onara-modal [role="button"], .iconset, .mobile-scroll-to-top, .danmaku-marker, summary, select) {
#onara-modal label,
#onara-modal .rating-tag,
#onara-modal .sidebar-tag {
cursor: pointer; cursor: pointer;
} }
#onara-modal :is(button, .btn, [role="button"]):disabled {
cursor: not-allowed;
}
#onara-modal input, #onara-modal input,
#onara-modal textarea, #onara-modal textarea,
#onara-modal [contenteditable="true"] { #onara-modal [contenteditable="true"] {
@@ -23012,3 +23187,7 @@ html.range-tap-lock input[type="range"].range-unlocked {
pointer-events: none; pointer-events: none;
user-select: none; user-select: none;
} }
a#nav-upload-link {
user-select: none;
}
+27 -3
View File
@@ -5,7 +5,7 @@
padding: 0; padding: 0;
animation: uploadReveal 0.5s cubic-bezier(0.4, 0, 0.2, 1) forwards; animation: uploadReveal 0.5s cubic-bezier(0.4, 0, 0.2, 1) forwards;
opacity: 0; opacity: 0;
margin: 0 auto; margin: 0;
} }
@@ -25,17 +25,29 @@
text-align: center; text-align: center;
} }
/* Title line: title left, upload limit right (wraps under it on narrow screens) */
.upload-title { .upload-title {
display: flex;
align-items: baseline;
justify-content: space-between;
flex-wrap: wrap;
gap: 4px 14px;
font-size: x-large; font-size: x-large;
} }
/* Upload Limit Info */ /* Upload Limit Info (inside the title line; keeps its own small, normal-case text) */
.upload-limit-info { .upload-limit-info {
text-align: center;
font-size: 0.9rem; font-size: 0.9rem;
font-weight: 400;
letter-spacing: normal;
text-transform: none;
opacity: 0.7; opacity: 0.7;
} }
.upload-title .upload-limit-info {
font-size: 0.8rem;
}
.upload-limit-info i { .upload-limit-info i {
margin-right: 0.3rem; margin-right: 0.3rem;
} }
@@ -1011,6 +1023,18 @@
font-weight: 600; font-weight: 600;
} }
/* Groups button + progress + status (pinned in the upload dropdown, see f0ckm.css); same spacing as the form */
.upload-footer {
display: flex;
flex-direction: column;
gap: 5px;
}
/* Only takes space when there is a message; the progress bar is display:none until an upload runs */
.upload-status:empty {
display: none;
}
.upload-status.error { .upload-status.error {
color: #ff6b6b; color: #ff6b6b;
} }
+138
View File
@@ -326,6 +326,144 @@
transition: none !important; transition: none !important;
} }
/* Comment markers (danmaku.js): one tick per timeline comment; click = jump there + show the pill.
The layer passes clicks through (normal scrubbing); each tick has a 10px hit area around a 2px line. */
.danmaku-markers {
position: absolute;
inset: 0;
pointer-events: none;
z-index: 4;
}
.danmaku-marker {
position: absolute;
top: 0;
height: 100%;
width: 10px;
margin: 0;
padding: 0;
border: 0;
background: none;
transform: translateX(-50%);
pointer-events: auto;
cursor: pointer;
}
.danmaku-marker::before {
content: '';
position: absolute;
top: 0;
bottom: 0;
left: 50%;
width: 2px;
transform: translateX(-50%);
background: #fff;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.35);
transition: width 0.12s, background 0.12s;
}
/* No own timestamp: placed at a random time on load */
.danmaku-marker.is-random::before {
background: rgba(255, 255, 255, 0.45);
box-shadow: none;
}
.danmaku-marker:hover::before {
width: 4px;
background: #ffd844;
}
/* Hover preview of a marker's comment (plain text), above the progress bar */
.danmaku-marker-preview {
position: absolute;
z-index: 30;
max-width: min(320px, 80%);
padding: 6px 9px;
background: rgba(0, 0, 0, 0.88);
border-left: 2px solid var(--accent);
color: #fff;
font-size: 12px;
line-height: 1.35;
pointer-events: none;
opacity: 0;
transform: translateY(4px);
transition: opacity 0.12s, transform 0.12s;
}
.danmaku-marker-preview.is-visible {
opacity: 1;
transform: none;
}
.danmaku-marker-preview .dmp-head {
display: flex;
align-items: baseline;
gap: 6px;
margin-bottom: 2px;
white-space: nowrap;
overflow: hidden;
}
.danmaku-marker-preview .dmp-time {
color: rgba(255, 255, 255, 0.55);
font-variant-numeric: tabular-nums;
font-size: 11px;
}
.danmaku-marker-preview .dmp-user {
font-weight: 700;
overflow: hidden;
text-overflow: ellipsis;
}
.danmaku-marker-preview .dmp-text {
overflow-wrap: anywhere;
max-height: 140px;
overflow: hidden;
}
/* Rendered like the flying pills, sized for a small preview */
.danmaku-marker-preview .dmp-text .dpill-line,
.danmaku-marker-preview .dmp-text .dpill-greentext {
display: block;
}
.danmaku-marker-preview .dmp-text .dpill-greentext {
color: #789922;
}
.danmaku-marker-preview .dmp-text .dpill-emoji {
display: inline-block;
height: 1.6em;
width: auto;
max-width: 4em;
vertical-align: middle;
object-fit: contain;
}
.danmaku-marker-preview .dmp-text :is(.dpill-img, .dpill-video) {
display: block;
max-width: 100%;
max-height: 90px;
width: auto;
height: auto;
margin-top: 4px;
object-fit: contain;
}
.danmaku-marker-preview .dmp-text .dpill-spoiler:not(.revealed) {
background: #000;
color: transparent;
}
.danmaku-marker-preview .dmp-text .dpill-spoiler:not(.revealed) * {
visibility: hidden;
}
.danmaku-marker-preview .dmp-text .dpill-blur:not(.revealed) {
filter: blur(6px);
}
/* Seek Marker Ripple */ /* Seek Marker Ripple */
.v0ck_seek_marker { .v0ck_seek_marker {
position: absolute; position: absolute;
+171 -5
View File
@@ -143,6 +143,12 @@ class Danmaku {
this.media.addEventListener('seeked', this._bound_onSeek, { passive: true }); this.media.addEventListener('seeked', this._bound_onSeek, { passive: true });
this.media.addEventListener('pause', this._bound_onPause, { passive: true }); this.media.addEventListener('pause', this._bound_onPause, { passive: true });
this.media.addEventListener('play', this._bound_onPlay, { passive: true }); this.media.addEventListener('play', this._bound_onPlay, { passive: true });
// Comment markers on the progress bar need the duration
this._bound_onDuration = () => this._renderMarkers();
this._bound_onMarker = this._onMarkerClick.bind(this);
this.media.addEventListener('loadedmetadata', this._bound_onDuration, { passive: true });
this.media.addEventListener('durationchange', this._bound_onDuration, { passive: true });
} }
/** /**
@@ -230,6 +236,7 @@ class Danmaku {
.map(c => ({ .map(c => ({
id: c.id || null, id: c.id || null,
text: this._prepareText(c.content), text: this._prepareText(c.content),
raw: c.content,
username: c.display_name || c.username || '?', username: c.display_name || c.username || '?',
color: c.username_color || null, color: c.username_color || null,
raw_time: (c.video_time != null) ? parseFloat(c.video_time) : null, raw_time: (c.video_time != null) ? parseFloat(c.video_time) : null,
@@ -257,6 +264,7 @@ class Danmaku {
this._resetFiredState(this.media.currentTime); this._resetFiredState(this.media.currentTime);
this._lastTime = this.media.currentTime; this._lastTime = this.media.currentTime;
this._renderMarkers();
} }
/** /**
@@ -335,6 +343,8 @@ class Danmaku {
id: comment.id || null, id: comment.id || null,
video_time: t, video_time: t,
text: this._prepareText(comment.content), text: this._prepareText(comment.content),
raw: comment.content,
raw_time: (comment.video_time != null) ? parseFloat(comment.video_time) : null,
username: comment.display_name || comment.username || '?', username: comment.display_name || comment.username || '?',
color: comment.username_color || null, color: comment.username_color || null,
fired: true fired: true
@@ -350,6 +360,7 @@ class Danmaku {
const idx = this.items.findIndex(i => i.video_time > t); const idx = this.items.findIndex(i => i.video_time > t);
if (idx === -1) this.items.push(item); if (idx === -1) this.items.push(item);
else this.items.splice(idx, 0, item); else this.items.splice(idx, 0, item);
this._renderMarkers();
} }
/** Toggle danmaku on/off. */ /** Toggle danmaku on/off. */
@@ -357,6 +368,9 @@ class Danmaku {
this._enabled = !this._enabled; this._enabled = !this._enabled;
localStorage.setItem('danmaku', this._enabled ? 'true' : 'false'); localStorage.setItem('danmaku', this._enabled ? 'true' : 'false');
if (this.overlay) this.overlay.style.display = this._enabled ? '' : 'none'; if (this.overlay) this.overlay.style.display = this._enabled ? '' : 'none';
// Comment markers on the progress bar belong to danmaku: hidden while it is off
if (this._markerLayer) this._markerLayer.style.display = this._enabled ? '' : 'none';
if (!this._enabled) this._hideMarkerPreview();
const sw = this.player.querySelector('#toggledanmaku'); const sw = this.player.querySelector('#toggledanmaku');
if (sw) sw.classList.toggle('active', this._enabled); if (sw) sw.classList.toggle('active', this._enabled);
@@ -482,6 +496,12 @@ class Danmaku {
this.media.removeEventListener('seeked', this._bound_onSeek); this.media.removeEventListener('seeked', this._bound_onSeek);
this.media.removeEventListener('pause', this._bound_onPause); this.media.removeEventListener('pause', this._bound_onPause);
this.media.removeEventListener('play', this._bound_onPlay); this.media.removeEventListener('play', this._bound_onPlay);
this.media.removeEventListener('loadedmetadata', this._bound_onDuration);
this.media.removeEventListener('durationchange', this._bound_onDuration);
if (this._markerLayer && this._markerLayer.parentNode) this._markerLayer.parentNode.removeChild(this._markerLayer);
this._markerLayer = null;
if (this._markerTip && this._markerTip.parentNode) this._markerTip.parentNode.removeChild(this._markerTip);
this._markerTip = null;
if (this._bound_onEmojis) window.removeEventListener('f0ck:emojis_ready', this._bound_onEmojis); if (this._bound_onEmojis) window.removeEventListener('f0ck:emojis_ready', this._bound_onEmojis);
if (this._bound_onTuningChange) window.removeEventListener('f0ck:danmaku_tuning_changed', this._bound_onTuningChange); if (this._bound_onTuningChange) window.removeEventListener('f0ck:danmaku_tuning_changed', this._bound_onTuningChange);
if (this._laneGuidesTimer) clearInterval(this._laneGuidesTimer); if (this._laneGuidesTimer) clearInterval(this._laneGuidesTimer);
@@ -633,6 +653,129 @@ class Danmaku {
} }
} }
// ── Progress-bar comment markers ─────────────────────────────────────────
// One marker per timeline comment on .v0ck_progress. Comments with their own timestamp are solid;
// the others got a random time on load and are drawn fainter. Clicking jumps there and shows the pill.
_renderMarkers() {
if (this._destroyed || this._synthClock) return;
const track = this.player.querySelector('.v0ck_progress');
if (!track) return;
const dur = this.media.duration;
const ok = isFinite(dur) && dur > 0 && this.items.length > 0;
let layer = this._markerLayer;
if (!ok) { if (layer) layer.textContent = ''; return; }
if (!layer || !layer.isConnected) {
layer = document.createElement('div');
layer.className = 'danmaku-markers';
if (!this._enabled) layer.style.display = 'none';
// Keep the bar's own scrub handlers (mousedown/pointerdown/click on the track) out of marker clicks
const stop = (e) => e.stopPropagation();
layer.addEventListener('pointerdown', stop);
layer.addEventListener('mousedown', stop);
layer.addEventListener('touchstart', stop, { passive: true });
layer.addEventListener('click', this._bound_onMarker);
layer.addEventListener('mouseover', (e) => {
const b = e.target.closest && e.target.closest('.danmaku-marker');
if (b) this._showMarkerPreview(b);
});
layer.addEventListener('mouseout', (e) => {
const to = e.relatedTarget;
if (!to || !to.closest || !to.closest('.danmaku-marker')) this._hideMarkerPreview();
});
track.appendChild(layer);
this._markerLayer = layer;
}
const fmt = (t) => {
const m = Math.floor(t / 60), sec = Math.floor(t % 60);
return m + ':' + String(sec).padStart(2, '0');
};
const frag = document.createDocumentFragment();
this.items.forEach((item, i) => {
if (!(item.video_time >= 0) || item.video_time > dur) return;
const b = document.createElement('button');
b.type = 'button';
b.tabIndex = -1;
b.className = 'danmaku-marker' + (item.raw_time == null ? ' is-random' : '');
b.style.left = (item.video_time / dur * 100) + '%';
b.dataset.idx = String(i);
const txt = String(item.raw || '').replace(/\s+/g, ' ').trim();
b.setAttribute('aria-label', fmt(item.video_time) + ' · ' + item.username + ': ' + (txt.length > 80 ? txt.slice(0, 80) + '…' : txt));
frag.appendChild(b);
});
this._hideMarkerPreview();
layer.textContent = '';
layer.appendChild(frag);
}
// Plain-text preview of a comment above its marker. Built with textContent only (no HTML from comments).
_showMarkerPreview(b) {
const item = this.items[+b.dataset.idx];
if (!item) return;
let tip = this._markerTip;
if (!tip || !tip.isConnected) {
tip = document.createElement('div');
tip.className = 'danmaku-marker-preview';
tip.innerHTML = '<div class="dmp-head"><span class="dmp-time"></span><span class="dmp-user"></span></div><div class="dmp-text"></div>';
this.player.appendChild(tip);
this._markerTip = tip;
}
const t = item.video_time;
tip.querySelector('.dmp-time').textContent = Math.floor(t / 60) + ':' + String(Math.floor(t % 60)).padStart(2, '0');
const user = tip.querySelector('.dmp-user');
user.textContent = item.username;
user.style.color = item.color || '';
// Same renderer as the flying pills: emojis, images/media embeds, greentext; spoiler and blur
// stay hidden. Builds DOM nodes (no HTML from the comment); size is capped by CSS.
const body = tip.querySelector('.dmp-text');
body.textContent = '';
const liveCache = (this._emojiCache && Object.keys(this._emojiCache).length > 0)
? this._emojiCache
: ((typeof CommentSystem !== 'undefined' && CommentSystem.emojiCache) || null);
if (liveCache && liveCache !== this._emojiCache) this._emojiCache = liveCache;
body.appendChild(this._renderContent(item.raw || ''));
if (!body.firstChild) body.textContent = '…';
// Reposition once images have their size
body.querySelectorAll('img').forEach(img => {
if (!img.complete) img.addEventListener('load', () => { if (this._markerTip === tip && tip.classList.contains('is-visible')) this._showMarkerPreview(b); }, { once: true });
});
// Position: centred over the marker, just above the progress bar, kept inside the player
const pr = this.player.getBoundingClientRect();
const mr = b.getBoundingClientRect();
tip.style.visibility = 'hidden';
tip.classList.add('is-visible');
const w = tip.offsetWidth;
let left = mr.left + mr.width / 2 - pr.left - w / 2;
left = Math.max(6, Math.min(left, pr.width - w - 6));
tip.style.left = left + 'px';
tip.style.bottom = (pr.bottom - mr.top + 8) + 'px';
tip.style.visibility = '';
}
_hideMarkerPreview() {
if (this._markerTip) this._markerTip.classList.remove('is-visible');
}
_onMarkerClick(e) {
const b = e.target.closest && e.target.closest('.danmaku-marker');
if (!b) return;
e.preventDefault();
e.stopPropagation();
const item = this.items[+b.dataset.idx];
if (!item) return;
// Land just past the comment: the seek reset then counts it as fired, so the timeline won't
// spawn it a second time; it is shown right here instead (also while paused)
const dur = this.media.duration;
const t = Math.min(isFinite(dur) ? dur : Infinity, item.video_time + LOOKAHEAD_SEC + 0.05);
try { this.media.currentTime = t; } catch (_) {}
item.fired = true;
this._hideMarkerPreview();
this._spawnPill(item.text, item.username, item.color, true);
}
_onPause() { _onPause() {
this._paused = true; this._paused = true;
if (this._synthClock) this._synthClock.pause(); if (this._synthClock) this._synthClock.pause();
@@ -664,6 +807,15 @@ class Danmaku {
return; return;
} }
// Forward jump (seek, marker click, scrubbing): the timeupdate at the new position arrives before
// 'seeked', and treating the gap as played would fire every comment in between. Resync instead,
// so only comments from here on appear. Normal playback advances ~0.25s per event (x playbackRate).
const maxStep = Math.max(1.5, 1.5 * (this.media.playbackRate || 1));
if (this.media.seeking || now > prev + maxStep) {
this._resetFiredState(now);
return;
}
const from = prev; const from = prev;
const to = now + LOOKAHEAD_SEC; const to = now + LOOKAHEAD_SEC;
@@ -715,8 +867,9 @@ class Danmaku {
return best; return best;
} }
_spawnPill(text, username, color) { /** @param {boolean} [force] show even while paused (marker click on the progress bar) */
if (!this.overlay || !this._enabled || this._paused) return; _spawnPill(text, username, color, force = false) {
if (!this.overlay || !this._enabled || (this._paused && !force)) return;
const cfg = window.danmakuTuning || DEFAULT_DANMAKU_TUNING; const cfg = window.danmakuTuning || DEFAULT_DANMAKU_TUNING;
const limit = Number(cfg.densityLimit) || 35; const limit = Number(cfg.densityLimit) || 35;
@@ -932,8 +1085,17 @@ class Danmaku {
_prepareText(text) { _prepareText(text) {
if (!text) return ''; if (!text) return '';
// Idempotent: pills prepare the text on load and _renderContent prepares it again. Media already
// wrapped as \x04url\x05 is kept as one token; re-matching the URL inside it used to double-wrap
// it and leave a stray \x05 that browsers draw as a little control-character box behind the image.
const imgTokenUrls = [];
const emojiTokens = []; const emojiTokens = [];
let protected_ = text.replace(/:([a-z0-9_+\-]+):/gi, (match) => { let protected_ = text
.replace(/\x04([^\x04\x05]+)\x05/g, (_, url) => {
imgTokenUrls.push(url);
return `\x04${imgTokenUrls.length - 1}\x05`;
})
.replace(/:([a-z0-9_+\-]+):/gi, (match) => {
emojiTokens.push(match); emojiTokens.push(match);
return `\x02${emojiTokens.length - 1}\x03`; return `\x02${emojiTokens.length - 1}\x03`;
}); });
@@ -946,7 +1108,6 @@ class Danmaku {
return h === siteHost || allowedHosts.some(a => h === a || h.endsWith('.' + a)); return h === siteHost || allowedHosts.some(a => h === a || h.endsWith('.' + a));
} catch { return false; } } catch { return false; }
}; };
const imgTokenUrls = [];
protected_ = protected_ protected_ = protected_
.replace(/\/c\/[a-f0-9]+\.(?:png|jpg|jpeg|gif|webp|svg|avif|mp4|webm|ogv|mov|mp3|ogg|wav|flac|aac|opus|m4a)(?:#gif)?/gi, (url) => { .replace(/\/c\/[a-f0-9]+\.(?:png|jpg|jpeg|gif|webp|svg|avif|mp4|webm|ogv|mov|mp3|ogg|wav|flac|aac|opus|m4a)(?:#gif)?/gi, (url) => {
imgTokenUrls.push(url); imgTokenUrls.push(url);
@@ -967,9 +1128,14 @@ class Danmaku {
.replace(/\n{3,}/g, '\n\n') .replace(/\n{3,}/g, '\n\n')
.trim(); .trim();
// Any other control characters (from the comment itself) would render as visible boxes; strays
// of the token markers too. Valid media tokens are parked on \x0E/\x0F (already stripped) meanwhile.
return protected_ return protected_
.replace(/[\x00-\x01\x06-\x08\x0B-\x1F\x7F]/g, '')
.replace(/\x02(\d+)\x03/g, (_, i) => emojiTokens[+i] || '') .replace(/\x02(\d+)\x03/g, (_, i) => emojiTokens[+i] || '')
.replace(/\x04(\d+)\x05/g, (_, i) => imgTokenUrls[+i] ? `\x04${imgTokenUrls[+i]}\x05` : ''); .replace(/\x04(\d+)\x05/g, (_, i) => imgTokenUrls[+i] ? '\x0E' + i + '\x0F' : '')
.replace(/[\x02-\x05]/g, '')
.replace(/\x0E(\d+)\x0F/g, (_, i) => '\x04' + imgTokenUrls[+i] + '\x05');
} }
} }
+49 -4
View File
@@ -7,10 +7,47 @@
const dragModalClose = document.getElementById('drag-modal-close'); const dragModalClose = document.getElementById('drag-modal-close');
const dragForm = dragModal ? dragModal.querySelector('.upload-form') : null; const dragForm = dragModal ? dragModal.querySelector('.upload-form') : null;
const navUploadLink = document.getElementById('nav-upload-link');
// Dropdown placement: right under the navbar "Upload" link, left-aligned with it and kept inside the
// window. Without a visible link (phone: it sits in the collapsed menu) it drops from the navbar.
const positionModal = () => {
if (!dragModal || !dragModal.classList.contains('show')) return;
const content = dragModal.querySelector('.modal-content');
if (!content) return;
const vw = document.documentElement.clientWidth;
const linkRect = navUploadLink ? navUploadLink.getBoundingClientRect() : null;
const useLink = !!(linkRect && linkRect.width > 0 && linkRect.height > 0);
const nav = document.querySelector('body > nav.navbar, nav.navbar');
const top = Math.round((useLink ? linkRect.bottom : (nav ? nav.getBoundingClientRect().bottom : 0)) + 6);
content.style.top = top + 'px';
content.style.maxHeight = Math.max(200, window.innerHeight - top - 12) + 'px';
const w = content.offsetWidth;
let left = useLink ? linkRect.left : (vw - w) / 2;
left = Math.max(8, Math.min(left, vw - w - 8));
content.style.left = Math.round(left) + 'px';
};
window.addEventListener('resize', positionModal, { passive: true });
// The navbar link shows when the dropdown is open. Watched on the modal itself because it gets closed
// from several places (close button, Escape, toggle, finished upload, hideAllModals).
if (dragModal && navUploadLink) {
const syncLink = () => {
const open = dragModal.classList.contains('show');
navUploadLink.classList.toggle('is-active', open);
navUploadLink.setAttribute('aria-expanded', open ? 'true' : 'false');
};
new MutationObserver(syncLink).observe(dragModal, { attributes: true, attributeFilter: ['class'] });
syncLink();
}
const navEl = document.querySelector('body > nav.navbar, nav.navbar');
if (navEl && window.ResizeObserver) new ResizeObserver(positionModal).observe(navEl);
const showModal = () => { const showModal = () => {
if (!dragModal) return; if (!dragModal) return;
dragModal.classList.add('show'); dragModal.classList.add('show', 'is-dropdown');
document.body.classList.add('modal-open'); document.body.classList.add('modal-open');
positionModal();
// Reset scroll position so it always starts at the top // Reset scroll position so it always starts at the top
dragModal.scrollTop = 0; dragModal.scrollTop = 0;
const modalContent = dragModal.querySelector('.modal-content'); const modalContent = dragModal.querySelector('.modal-content');
@@ -19,14 +56,22 @@
if (modalBody) modalBody.scrollTop = 0; if (modalBody) modalBody.scrollTop = 0;
}; };
// Hide without resetting: a picked file / typed tags survive, reopening continues there.
// (The close button and Escape still close + reset, see below.)
const hideModal = () => {
if (!dragModal) return;
dragModal.classList.remove('show');
document.body.classList.remove('modal-open');
};
// Navbar upload link — always attached so it works even if drag-drop can't init. // Navbar upload link — always attached so it works even if drag-drop can't init.
// Opens the modal when available; falls back to /upload navigation otherwise. // Toggles the dropdown when available; falls back to /upload navigation otherwise.
const navUploadLink = document.getElementById('nav-upload-link');
if (navUploadLink) { if (navUploadLink) {
navUploadLink.addEventListener('click', (e) => { navUploadLink.addEventListener('click', (e) => {
if (!dragModal) return; // no modal → fall back to href navigation if (!dragModal) return; // no modal → fall back to href navigation
e.preventDefault(); e.preventDefault();
showModal(); if (dragModal.classList.contains('show')) hideModal();
else showModal();
}); });
} }
+571 -69
View File
@@ -6810,6 +6810,9 @@ window.cancelAnimFrame = (function () {
<button type="button" class="f0ck-tuner-subtab-btn" data-subtab="background" title="Onara & Video Background Settings"> <button type="button" class="f0ck-tuner-subtab-btn" data-subtab="background" title="Onara & Video Background Settings">
<i class="fa-solid fa-film"></i> Background <i class="fa-solid fa-film"></i> Background
</button> </button>
<button type="button" class="f0ck-tuner-subtab-btn" data-subtab="flashyank" title="Flash Yank: retro low-res / low-fps / few-colour video filter">
<i class="fa-solid fa-bolt-lightning"></i> Flash Yank
</button>
</div> </div>
<button type="button" class="f0ck-tuner-close" id="f0ck-tuner-close" title="Close">✕</button> <button type="button" class="f0ck-tuner-close" id="f0ck-tuner-close" title="Close">✕</button>
</div> </div>
@@ -6934,6 +6937,36 @@ window.cancelAnimFrame = (function () {
<div id="f0ck-bg-advanced-rows" style="display:none;">${bgRowsAdvancedHtml}</div> <div id="f0ck-bg-advanced-rows" style="display:none;">${bgRowsAdvancedHtml}</div>
</div> </div>
</div> </div>
<!-- Pane 4: Flash Yank (video filter; replaces the old "SWF" button in the player menu) -->
<div id="f0ck-tuner-pane-flashyank" class="f0ck-tuner-subtab-pane" style="display: none;">
<div class="f0ck-tuner-body">
<div class="f0ck-tuner-presets-bar">
<div style="font-size: 11px; opacity: 0.85; font-weight: 700; display: flex; align-items: center; gap: 6px;">
<i class="fa-solid fa-bolt-lightning"></i> Flash Yank
</div>
<div class="f0ck-tuner-presets-actions">
<button type="button" id="fy-tuner-reset" class="f0ck-tuner-btn-factory-reset" title="Reset the yank strength to its default"><i class="fa-solid fa-rotate-left"></i> Reset</button>
</div>
</div>
<div class="f0ck-tuner-mode-bar" style="display: flex; gap: 6px; padding: 6px 12px; background: rgba(255,255,255,0.04); border-bottom: 1px solid rgba(255,255,255,0.08);">
<button type="button" id="fy-btn-off" class="f0ck-tuner-mode-btn" style="flex: 1; padding: 6px 10px; font-size: 11px; text-align: center;">Off</button>
<button type="button" id="fy-btn-on" class="f0ck-tuner-mode-btn" style="flex: 1; padding: 6px 10px; font-size: 11px; text-align: center;"><i class="fa-solid fa-bolt-lightning"></i> On</button>
</div>
<div class="f0ck-tuner-row" data-key="flashyank-yank">
<div class="f0ck-tuner-row-label">
<span>Yank</span>
<span id="fy-yank-value">0%</span>
</div>
<input type="range" id="fy-yank-slider" min="0" max="100" step="1" value="0" />
</div>
<div id="fy-info" style="padding: 4px 12px 8px; font-size: 11px; opacity: 0.7;"></div>
<div style="padding: 0 12px 12px; font-size: 11px; opacity: 0.55; line-height: 1.4;">
Degrades the video to a retro look: lower resolution, fewer frames and fewer colours the higher the yank.
Toggle it on item pages with the <strong>S</strong> key.
</div>
</div>
</div>
`; `;
// ── Sub-Tab Switching Logic ───────────────────────────────────────────── // ── Sub-Tab Switching Logic ─────────────────────────────────────────────
@@ -6960,10 +6993,50 @@ window.cancelAnimFrame = (function () {
}); });
const savedSubtab = localStorage.getItem('f0ck_tuner_active_subtab'); const savedSubtab = localStorage.getItem('f0ck_tuner_active_subtab');
if (savedSubtab && (savedSubtab === 'audio' || savedSubtab === 'danmaku' || savedSubtab === 'background')) { if (savedSubtab && (savedSubtab === 'audio' || savedSubtab === 'danmaku' || savedSubtab === 'background' || savedSubtab === 'flashyank')) {
switchSubtab(savedSubtab); switchSubtab(savedSubtab);
} }
// ── Flash Yank pane (window.f0ckFlashYank from flash_yank.js) ──────────
(() => {
const fyOn = panel.querySelector('#fy-btn-on');
const fyOff = panel.querySelector('#fy-btn-off');
const fySlider = panel.querySelector('#fy-yank-slider');
const fyValue = panel.querySelector('#fy-yank-value');
const fyInfo = panel.querySelector('#fy-info');
const fyReset = panel.querySelector('#fy-tuner-reset');
if (!fyOn || !fySlider) return;
const api = () => window.f0ckFlashYank || null;
const render = (st) => {
st = st || (api() && api().getState());
const ready = !!st;
[fyOn, fyOff, fySlider, fyReset].forEach(el => { el.disabled = !ready; });
if (!ready) { fyInfo.textContent = 'Flash Yank is not available in this browser.'; return; }
fyOn.classList.toggle('active', st.enabled);
fyOff.classList.toggle('active', !st.enabled);
fySlider.value = st.yank;
fyValue.textContent = st.yank + '%';
fyInfo.textContent = st.width + 'px width, ' + st.fps + ' fps, ~' + st.colors + ' colours'
+ (st.available ? '' : ' (applies to the video on item pages)');
};
fyOn.addEventListener('click', () => { if (api()) api().setEnabled(true); });
fyOff.addEventListener('click', () => { if (api()) api().setEnabled(false); });
fySlider.addEventListener('input', () => {
fyValue.textContent = fySlider.value + '%';
if (api()) api().setYank(fySlider.value);
});
fyReset.addEventListener('click', () => { if (api()) api().setYank(api().defaults.yank); });
const onChange = (e) => {
if (!panel.isConnected) { window.removeEventListener('f0ck:flashyank_changed', onChange); return; }
render(e.detail);
};
window.addEventListener('f0ck:flashyank_changed', onChange);
// flash_yank.js may load after the tuner is built: re-read whenever the tab is opened
const fyTabBtn = panel.querySelector('.f0ck-tuner-subtab-btn[data-subtab="flashyank"]');
if (fyTabBtn) fyTabBtn.addEventListener('click', () => render());
render();
})();
// ── Einfach / Erweitert Toggle (Background Tab) ──────────────────────── // ── Einfach / Erweitert Toggle (Background Tab) ────────────────────────
const bgBasicRows = panel.querySelector('#f0ck-bg-basic-rows'); const bgBasicRows = panel.querySelector('#f0ck-bg-basic-rows');
const bgAdvancedRows = panel.querySelector('#f0ck-bg-advanced-rows'); const bgAdvancedRows = panel.querySelector('#f0ck-bg-advanced-rows');
@@ -7260,6 +7333,155 @@ window.cancelAnimFrame = (function () {
barOpacity: 0.85, barOpacity: 0.85,
barGlow: 10, barGlow: 10,
smoothing: 0.80 smoothing: 0.80
},
// Just the cover: no bars, rings, tunnels or glow; cover also on the blurred background
'Cover Only': {
useCustomColor: 0,
visualizerColor: "#241f31",
enableBeatHue: 0,
beatHueThreshold: 0.53,
beatHueStep: 45,
beatHueSmooth: 0.65,
beatHueIdleDrift: 0.2,
beatHueCooldown: 150,
coverSize: 145,
glowIntensity: 0,
coverGlowBase: 0,
glowBrightness: 0,
glowSensitivity: 1,
glowDynamism: 1.5,
glowSmoothness: 0.95,
showCoverInEye: 1,
showCoverInBg: 1,
showCoverArt: 1,
solidCover: 0,
coverPosX: 0,
coverPosY: 0,
coverImageScale: 80,
coverImagePosX: 0,
coverImagePosY: 0,
bgCoverOpacity: 0.48,
bgCoverBlur: 1,
bgCoverScale: 55,
bgCoverPosX: 50,
bgCoverPosY: 48,
coverColor: "#000000",
coverOpacity: 1,
bassGain: 0.6,
bassPower: 1.29,
scaleBounce: 0.05,
bounceBoost: 11.5,
attackSpeed: 0.16,
releaseSpeed: 0.47,
enableBars: 0,
enableInnerBars: 0,
useCustomInnerColor: 0,
innerBarColor: "#ff0000",
innerBarsMode: 1,
innerRadius: 30,
innerPupilRadius: 0,
innerPupilRingOpacity: 1,
innerPupilColor: "#000000",
enableVoidTunnel: 0,
voidTunnelRings: 10,
voidTunnelSpeed: 0.6,
voidTunnelReactivity: 3,
voidTunnelFadeWidth: 0.35,
voidTunnelUseAccent: 1,
voidTunnelColor: "#ffffff",
voidTunnelZone: 0,
voidTunnelRingWidth: 2.5,
voidTunnelEdgeFade: 0.35,
voidTunnelOpacity: 1,
voidTunnelDepth: 0.65,
voidTunnelZoomSpeed: 0.5,
enableSquareTunnel: 0,
squareTunnelSpeed: 0.4,
squareTunnelReactivity: 1.5,
squareTunnelLevels: 6,
squareTunnelOpacity: 1,
squareTunnelGridWidth: 1.5,
squareTunnelWallFillAlpha: 0,
squareTunnelFog: 0.75,
squareTunnelGlow: 12,
squareTunnelWarp: 2,
squareTunnelUseAccent: 1,
squareTunnelGridColor: "#ffffff",
squareTunnelRoomScale: 1.5,
squareTunnelFOV: 0.55,
squareTunnelHideHUD: 1,
squareTunnelWallMode: 0,
squareTunnelWallColor: "#1a1a2e",
squareTunnelWallColor2: "#000000",
squareTunnelBackdropColor: "#000000",
squareTunnelBackdropBrightness: 0.55,
squareTunnelPatternScale: 40,
squareTunnelPatTileAlpha: 1,
squareTunnelMaze: 0,
squareTunnelTurnStrength: 0.6,
squareTunnelTurnSpeed: 0.04,
squareTunnelStraightBias: 0.4,
squareTunnelDriftFreq: 1.1,
squareTunnelReactDrift: 0,
squareTunnelReactScale: 0.18,
squareTunnelReactBright: 0.4,
innerBarCount: 2,
innerBarWidth: 1,
innerBarHeight: 0.85,
innerBarOpacity: 0,
outerRingOpacity: 0,
eyeDisableGlow: 0,
eyeDisableShadow: 0,
eyeFOV: 650,
eyeZPos: 0,
innerHighBoost: 0.34,
innerRadialRotation: 105,
innerBarGlow: 0,
innerHideNote: 0,
barHeight: 0.2,
barWidth: 17,
barGap: 9,
barRadius: 0,
barOpacity: 0.42,
barGlow: 0,
smoothing: 0.73,
followMouse: 1,
followSpeed: 0.02,
followRadius: 10,
tiltEffect: 1,
glowAttack: 0.47,
glowDecay: 0.5,
enableBgGradient: 0,
bgGradientStyle: 1,
bgGradientOpacity: 0,
bgGradientReactivity: 0,
bgGradientSpread: 10,
useCustomBgColor: 0,
bgGradientColor: "#3a1c71",
enableBlink: 0,
blinkInterval: 12,
beatHueSpeed: 0.1,
onaraBgOpacity: 0.4,
onaraBackdropBlur: 5,
bgCanvasColor: "#000000",
bgCanvasColorOpacity: 0.53,
bgCanvasOpacity: 0.43,
bgBlurMethod: 1,
bgCanvasBlur: 60,
bgCanvasBrightness: 0.6,
bgCanvasSaturate: 1.9,
bgCanvasContrast: 1.15,
bgCanvasZoom: 1,
bgCanvasScale: 2.15,
onaraGridDim: 0.38,
feedAudioToBg: 0,
bgAmbientLighting: 0,
bgAmbientIntensity: 1.9,
bgAmbientSpread: 0.85,
bgAmbientSaturation: 2.1,
bgAmbientSmoothness: 0.11,
bgCanvasFps: 60,
bgAudioReactivity: 1.5
} }
}; };
@@ -15676,11 +15898,7 @@ window.cancelAnimFrame = (function () {
e.preventDefault(); e.preventDefault();
toggleSearch(true); toggleSearch(true);
} }
// Shift + S to toggle strict mode globally // (Shift + S used to toggle strict mode here; it opens the settings now, see page-modal.js)
if (e.shiftKey && (e.key === 'S' || e.key === 's') && e.target.tagName !== 'INPUT' && e.target.tagName !== 'TEXTAREA') {
e.preventDefault();
if (strict) strict.click();
}
}); });
input.addEventListener('keydown', (e) => { input.addEventListener('keydown', (e) => {
@@ -17205,29 +17423,48 @@ window.cancelAnimFrame = (function () {
const sidebarWidth = sidebar.offsetWidth || 300; const sidebarWidth = sidebar.offsetWidth || 300;
const targetTranslate = isHidden ? -sidebarWidth : sidebarWidth; const targetTranslate = isHidden ? -sidebarWidth : sidebarWidth;
// At ≤599px the sidebar is a pure overlay — never animate wrapper padding // At ≤599px the sidebar is a pure overlay — never touch wrapper padding
const animateWrappers = window.innerWidth > 599; const animateWrappers = window.innerWidth > 599;
// All wrappers animate in sync with the sidebar so content and sidebar // Smoothness: the sidebar and #bg canvas animate via CSS transitions (compositor, minimal layout).
// expand/collapse together rather than staggering. // The content wrappers and Onara modal change width ONCE instead of every frame: before the slide
// when opening, after it when closing. The strip the sidebar travels through is therefore always
// empty background, so no thumbnails reflow behind it; the single reflow is masked with an opacity fade.
const DURATION = 320;
const EASING = 'cubic-bezier(0.32, 0.72, 0, 1)';
const wrappers = getSidebarWrappers(); const wrappers = getSidebarWrappers();
if (animateWrappers) { const onaraModal = document.getElementById('onara-modal');
const bgCanvas = document.getElementById('bg');
const applyLayout = (open) => {
// Let overlays that mirror the content area (page modal) resize at exactly the same moment
window.dispatchEvent(new CustomEvent('f0ck:sidebar-right-layout', { detail: { open, width: sidebarWidth, overlay: !animateWrappers } }));
if (!animateWrappers) return;
wrappers.forEach(w => { wrappers.forEach(w => {
const isRightEdge = w.classList.contains('pagination-container-fluid') || w.classList.contains('admin-bar'); const isRightEdge = w.classList.contains('pagination-container-fluid') || w.classList.contains('admin-bar');
const isMessages = w.classList.contains('messages-convo-page'); const isMessages = w.classList.contains('messages-convo-page');
const prop = (isRightEdge || isMessages) ? 'right' : 'padding-right'; const prop = (isRightEdge || isMessages) ? 'right' : 'padding-right';
const targetVal = isHidden ? sidebarWidth + 'px' : '0px'; w.style.setProperty('transition', 'none', 'important');
w.style.setProperty(prop, open ? sidebarWidth + 'px' : '0px', 'important');
w.style.setProperty('transition', `${prop} 0.3s ease-in-out`, 'important');
w.style.setProperty(prop, targetVal, 'important');
}); });
void sidebar.offsetWidth; // settle layout before sidebar transform begins if (onaraModal) {
onaraModal.style.setProperty('transition', 'none', 'important');
onaraModal.style.setProperty('right', open ? sidebarWidth + 'px' : '0px', 'important');
} }
// #bg canvas is handled separately below — it transitions smoothly alongside the sidebar
// Soften the one-time reflow
wrappers.forEach(w => {
if (w.classList.contains('pagination-container-fluid') || w.classList.contains('admin-bar')) return;
if (typeof w.animate === 'function') w.animate([{ opacity: 0.55 }, { opacity: 1 }], { duration: 220, easing: 'ease-out' });
});
if (typeof window.redrawBgCanvas === 'function') requestAnimationFrame(() => window.redrawBgCanvas());
};
// Animate the drag-zone pill in sync with the sidebar so it stays // Opening: make room first, then slide in over empty space
// glued to the sidebar's left edge throughout the transition. if (isHidden) applyLayout(true);
edgeZone.style.setProperty('transition', 'right 0.3s ease-in-out', 'important');
// The drag-zone pill follows the sidebar edge (small fixed element: cheap to animate)
edgeZone.style.setProperty('transition', `right ${DURATION}ms ${EASING}`, 'important');
if (isHidden) { if (isHidden) {
// Opening: pill moves from right-edge → left of sidebar // Opening: pill moves from right-edge → left of sidebar
edgeZone.style.right = sidebarWidth + 'px'; edgeZone.style.right = sidebarWidth + 'px';
@@ -17240,28 +17477,24 @@ window.cancelAnimFrame = (function () {
edgeZone.style.pointerEvents = 'all'; edgeZone.style.pointerEvents = 'all';
} }
// Animate the Onara modal wrapper's right edge in sync with the sidebar // Commit the starting width for #bg so the transition has a defined origin
// so the modal resizes at the same time instead of waiting for settle.
const onaraModal = document.getElementById('onara-modal');
if (onaraModal && animateWrappers) {
const targetModalRight = isHidden ? sidebarWidth + 'px' : '0px';
onaraModal.style.setProperty('transition',
'opacity 0.28s cubic-bezier(0.16,1,0.3,1), transform 0.28s cubic-bezier(0.16,1,0.3,1), right 0.3s ease-in-out',
'important');
onaraModal.style.setProperty('right', targetModalRight, 'important');
}
// Animate #bg canvas width in sync with sidebar so background stays centered
const bgCanvas = document.getElementById('bg');
if (bgCanvas && animateWrappers) { if (bgCanvas && animateWrappers) {
const targetBgWidth = isHidden ? `calc(100vw - ${sidebarWidth}px)` : '100vw'; const bgStartWidth = isHidden ? '100vw' : `calc(100vw - ${sidebarWidth}px)`;
bgCanvas.style.setProperty('transition', 'width 0.3s ease-in-out', 'important'); bgCanvas.style.setProperty('width', bgStartWidth, 'important');
bgCanvas.style.setProperty('width', targetBgWidth, 'important');
} }
sidebar.style.setProperty('transition', 'transform 0.3s ease-in-out', 'important'); sidebar.style.willChange = 'transform';
void sidebar.offsetWidth; // commit the start state before transitions begin
sidebar.style.setProperty('transition', `transform ${DURATION}ms ${EASING}`, 'important');
sidebar.style.transform = `translateX(${targetTranslate}px)`; sidebar.style.transform = `translateX(${targetTranslate}px)`;
// #bg canvas width transitions in sync with the sidebar slide (position:fixed, no layout cost)
if (bgCanvas && animateWrappers) {
const bgTargetWidth = isHidden ? `calc(100vw - ${sidebarWidth}px)` : '100vw';
bgCanvas.style.setProperty('transition', `width ${DURATION}ms ${EASING}`, 'important');
bgCanvas.style.setProperty('width', bgTargetWidth, 'important');
}
let settled = false; let settled = false;
const settle = () => { const settle = () => {
if (settled) return; if (settled) return;
@@ -17273,12 +17506,15 @@ window.cancelAnimFrame = (function () {
if (onaraModal) onaraModal.style.setProperty('transition', 'none', 'important'); if (onaraModal) onaraModal.style.setProperty('transition', 'none', 'important');
if (bgCanvas) bgCanvas.style.setProperty('transition', 'none', 'important'); if (bgCanvas) bgCanvas.style.setProperty('transition', 'none', 'important');
const toHidden = !isHidden; const toHidden = !isHidden;
// Closing: the sidebar is off screen now, give the content its space back (single reflow)
if (toHidden) applyLayout(false);
document.body.classList.toggle('sidebar-right-hidden', toHidden); document.body.classList.toggle('sidebar-right-hidden', toHidden);
localStorage.setItem('sidebarRightHidden', toHidden); localStorage.setItem('sidebarRightHidden', toHidden);
void sidebar.offsetWidth; void sidebar.offsetWidth;
sidebar.style.transform = ''; sidebar.style.transform = '';
requestAnimationFrame(() => { requestAnimationFrame(() => {
sidebar.style.removeProperty('transition'); sidebar.style.removeProperty('transition');
sidebar.style.removeProperty('will-change');
edgeZone.style.removeProperty('transition'); edgeZone.style.removeProperty('transition');
if (onaraModal) { if (onaraModal) {
onaraModal.style.removeProperty('transition'); onaraModal.style.removeProperty('transition');
@@ -17305,7 +17541,7 @@ window.cancelAnimFrame = (function () {
settle(); settle();
}; };
sidebar.addEventListener('transitionend', onEnd); sidebar.addEventListener('transitionend', onEnd);
setTimeout(settle, 350); setTimeout(settle, DURATION + 50);
}; };
// Click on edgeZone: only toggle when the click lands on the pill // Click on edgeZone: only toggle when the click lands on the pill
@@ -17685,7 +17921,8 @@ if (sbtForm) {
class NotificationSystem { class NotificationSystem {
// Notification type categorization // Notification type categorization
static USER_TYPES = ['comment_reply', 'subscription', 'mention', 'upload_comment']; static USER_TYPES = ['comment_reply', 'subscription', 'mention', 'upload_comment'];
static SYSTEM_TYPES = ['approve', 'deny', 'item_deleted', 'upload_success', 'upload_error', 'admin_pending', 'report', 'warning']; static SYSTEM_TYPES = ['approve', 'deny', 'item_deleted', 'upload_success', 'upload_error', 'admin_pending', 'report', 'warning', 'invite_approved', 'invite_denied'];
static ADMIN_TYPES = ['invite_request'];
constructor() { constructor() {
this.bell = document.getElementById('nav-notif-btn'); this.bell = document.getElementById('nav-notif-btn');
@@ -17701,6 +17938,7 @@ class NotificationSystem {
this.activeTab = activeTabEl ? activeTabEl.dataset.tab : ((window.f0ckEnableComments === false) ? 'system' : 'user'); this.activeTab = activeTabEl ? activeTabEl.dataset.tab : ((window.f0ckEnableComments === false) ? 'system' : 'user');
this._cachedUser = []; this._cachedUser = [];
this._cachedSystem = []; this._cachedSystem = [];
this._cachedAdmin = [];
// Generate/retrieve unique tab ID // Generate/retrieve unique tab ID
this.tabId = sessionStorage.getItem('f0ck_tab_id'); this.tabId = sessionStorage.getItem('f0ck_tab_id');
@@ -18889,10 +19127,12 @@ class NotificationSystem {
// Split into user and system categories // Split into user and system categories
this._cachedUser = notifications.filter(n => NotificationSystem.USER_TYPES.includes(n.type)); this._cachedUser = notifications.filter(n => NotificationSystem.USER_TYPES.includes(n.type));
this._cachedSystem = notifications.filter(n => NotificationSystem.SYSTEM_TYPES.includes(n.type)); this._cachedSystem = notifications.filter(n => NotificationSystem.SYSTEM_TYPES.includes(n.type));
this._cachedAdmin = notifications.filter(n => NotificationSystem.ADMIN_TYPES.includes(n.type));
const userUnread = this._cachedUser.filter(n => !n.is_read).length; const userUnread = this._cachedUser.filter(n => !n.is_read).length;
const systemUnread = this._cachedSystem.filter(n => !n.is_read).length; const systemUnread = this._cachedSystem.filter(n => !n.is_read).length;
const totalUnread = userUnread + systemUnread; const adminUnread = this._cachedAdmin.filter(n => !n.is_read).length;
const totalUnread = userUnread + systemUnread + adminUnread;
// Update main bell badge (total unread) // Update main bell badge (total unread)
if (totalUnread > 0) { if (totalUnread > 0) {
@@ -18905,6 +19145,7 @@ class NotificationSystem {
// Update per-tab badges // Update per-tab badges
const userBadge = document.getElementById('notif-tab-badge-user'); const userBadge = document.getElementById('notif-tab-badge-user');
const systemBadge = document.getElementById('notif-tab-badge-system'); const systemBadge = document.getElementById('notif-tab-badge-system');
const adminBadge = document.getElementById('notif-tab-badge-admin');
if (userBadge) { if (userBadge) {
userBadge.textContent = userUnread; userBadge.textContent = userUnread;
userBadge.style.display = userUnread > 0 ? '' : 'none'; userBadge.style.display = userUnread > 0 ? '' : 'none';
@@ -18913,6 +19154,10 @@ class NotificationSystem {
systemBadge.textContent = systemUnread; systemBadge.textContent = systemUnread;
systemBadge.style.display = systemUnread > 0 ? '' : 'none'; systemBadge.style.display = systemUnread > 0 ? '' : 'none';
} }
if (adminBadge) {
adminBadge.textContent = adminUnread;
adminBadge.style.display = adminUnread > 0 ? '' : 'none';
}
// Forward count to Abyss scroller notification badge if active // Forward count to Abyss scroller notification badge if active
if (typeof window._scrollerNotifHook === 'function') { if (typeof window._scrollerNotifHook === 'function') {
@@ -18964,7 +19209,7 @@ class NotificationSystem {
const historyContainer = document.querySelector('.notifications-list-full'); const historyContainer = document.querySelector('.notifications-list-full');
if (historyContainer) { if (historyContainer) {
const historyTab = historyContainer.dataset.tab || 'user'; const historyTab = historyContainer.dataset.tab || 'user';
const tabNotifs = historyTab === 'system' ? this._cachedSystem : this._cachedUser; const tabNotifs = historyTab === 'admin' ? this._cachedAdmin : (historyTab === 'system' ? this._cachedSystem : this._cachedUser);
tabNotifs.forEach(n => { tabNotifs.forEach(n => {
const existing = historyContainer.querySelector(`.notif-item[data-id="${n.id}"]`); const existing = historyContainer.querySelector(`.notif-item[data-id="${n.id}"]`);
if (!existing) { if (!existing) {
@@ -18984,7 +19229,7 @@ class NotificationSystem {
_renderActiveTab() { _renderActiveTab() {
if (!this.list) return; if (!this.list) return;
const items = this.activeTab === 'system' ? this._cachedSystem : this._cachedUser; const items = this.activeTab === 'admin' ? this._cachedAdmin : (this.activeTab === 'system' ? this._cachedSystem : this._cachedUser);
if (items.length === 0) { if (items.length === 0) {
this.list.innerHTML = `<div class="notif-empty">${window.f0ckI18n?.no_notifications || 'No new notifications'}</div>`; this.list.innerHTML = `<div class="notif-empty">${window.f0ckI18n?.no_notifications || 'No new notifications'}</div>`;
return; return;
@@ -18999,6 +19244,51 @@ class NotificationSystem {
let msg = ''; let msg = '';
let user = n.from_display_name || n.from_user || 'System'; let user = n.from_display_name || n.from_user || 'System';
if (n.type === 'invite_request') {
const reason = n.data?.reason || '';
const username = n.data?.username || '???';
return `
<a href="/admin/tokens" class="notif-item ${n.is_read ? '' : 'unread'} notif-with-thumb" data-id="${n.id}" data-item-id="">
<div class="notif-thumb" style="display:flex;align-items:center;justify-content:center;background:var(--bg-lighter);color:var(--accent);font-size:1.5em;"><i class="fa-solid fa-inbox"></i></div>
<div class="notif-content">
<div class="notif-user"><strong>${username}</strong></div>
<div class="notif-msg">
<strong>Invite request</strong>
${reason ? '<div class="notif-reason">' + reason + '</div>' : ''}
</div>
<div class="notif-time">${new Date(n.created_at).toLocaleString()}</div>
</div>
</a>
`;
}
if (n.type === 'invite_approved') {
return `
<a href="/settings" class="notif-item ${n.is_read ? '' : 'unread'} notif-with-thumb" data-id="${n.id}" data-item-id="">
<div class="notif-thumb" style="display:flex;align-items:center;justify-content:center;background:var(--bg-lighter);color:#51cf66;font-size:1.5em;"><i class="fa-solid fa-circle-check"></i></div>
<div class="notif-content">
<div class="notif-user"><strong style="color: #51cf66;">System</strong></div>
<div class="notif-msg"><strong>Your invite request was approved!</strong><div style="font-size: 0.9em; margin-top: 2px;">Go to Settings to register with your token.</div></div>
<div class="notif-time">${new Date(n.created_at).toLocaleString()}</div>
</div>
</a>
`;
}
if (n.type === 'invite_denied') {
const isRevoked = n.data?.revoked === true;
return `
<a href="/settings" class="notif-item ${n.is_read ? '' : 'unread'} notif-with-thumb" data-id="${n.id}" data-item-id="">
<div class="notif-thumb" style="display:flex;align-items:center;justify-content:center;background:var(--bg-lighter);color:${isRevoked ? '#e67700' : '#ff6b6b'};font-size:1.5em;"><i class="fa-solid ${isRevoked ? 'fa-ban' : 'fa-circle-xmark'}"></i></div>
<div class="notif-content">
<div class="notif-user"><strong style="color: ${isRevoked ? '#e67700' : '#ff6b6b'};">System</strong></div>
<div class="notif-msg"><strong>${isRevoked ? 'Your invite token was revoked' : 'Your invite request was denied'}</strong><div style="font-size: 0.9em; margin-top: 2px;">You can submit a new request.</div></div>
<div class="notif-time">${new Date(n.created_at).toLocaleString()}</div>
</div>
</a>
`;
}
if (n.type === 'deny' || n.type === 'item_deleted') { if (n.type === 'deny' || n.type === 'item_deleted') {
const i18n = window.f0ckI18n || {}; const i18n = window.f0ckI18n || {};
const isDeleted = n.type === 'item_deleted'; const isDeleted = n.type === 'item_deleted';
@@ -19089,6 +19379,55 @@ class NotificationSystem {
renderItem(n) { renderItem(n) {
const itemKey = n.item_slug || n.slug || n.item_id; const itemKey = n.item_slug || n.slug || n.item_id;
if (n.type === 'invite_request') {
const reason = n.data?.reason || '';
const username = n.data?.username || '???';
return `
<a href="/admin/tokens" class="notif-item ${n.is_read ? '' : 'unread'} notif-with-thumb" data-id="${n.id}" data-item-id="">
<div class="notif-thumb" style="display:flex;align-items:center;justify-content:center;background:var(--bg-lighter);color:var(--accent);font-size:1.3em;"><i class="fa-solid fa-inbox"></i></div>
<div class="notif-content">
<div>
<strong>Invite request</strong>
<div style="color: var(--text-muted); margin-top: 2px;">from <span style="color: #5bc0be; font-weight: 600;">${username}</span>${reason ? ' — ' + reason : ''}</div>
</div>
<small class="notif-time">${new Date(n.created_at).toLocaleString()}</small>
</div>
</a>
`;
}
if (n.type === 'invite_approved') {
return `
<a href="/settings" class="notif-item ${n.is_read ? '' : 'unread'} notif-with-thumb" data-id="${n.id}" data-item-id="">
<div class="notif-thumb" style="display:flex;align-items:center;justify-content:center;background:var(--bg-lighter);color:#51cf66;font-size:1.3em;"><i class="fa-solid fa-circle-check"></i></div>
<div class="notif-content">
<div>
<strong style="color: #51cf66;">Invite request approved!</strong>
<div style="font-size: 0.85em; color: var(--text-muted); margin-top: 2px;">Your invite token is ready. Go to Settings to register.</div>
</div>
<small class="notif-time">${new Date(n.created_at).toLocaleString()}</small>
</div>
</a>
`;
}
if (n.type === 'invite_denied') {
const isRevoked = n.data?.revoked === true;
return `
<a href="/settings" class="notif-item ${n.is_read ? '' : 'unread'} notif-with-thumb" data-id="${n.id}" data-item-id="">
<div class="notif-thumb" style="display:flex;align-items:center;justify-content:center;background:var(--bg-lighter);color:${isRevoked ? '#e67700' : '#ff6b6b'};font-size:1.3em;"><i class="fa-solid ${isRevoked ? 'fa-ban' : 'fa-circle-xmark'}"></i></div>
<div class="notif-content">
<div>
<strong style="color: ${isRevoked ? '#e67700' : '#ff6b6b'};">${isRevoked ? 'Invite token revoked' : 'Invite request denied'}</strong>
<div style="color: var(--text-muted); margin-top: 2px;">${isRevoked ? 'Your token was revoked. You can submit a new request.' : 'Your invite request was not approved. You can try again.'}</div>
</div>
<small class="notif-time">${new Date(n.created_at).toLocaleString()}</small>
</div>
</a>
`;
}
if (n.type === 'approve') { if (n.type === 'approve') {
const link = `/${itemKey}`; const link = `/${itemKey}`;
return ` return `
@@ -19238,7 +19577,7 @@ class NotificationSystem {
return false; return false;
}; };
[this._cachedUser, this._cachedSystem].forEach(list => { [this._cachedUser, this._cachedSystem, this._cachedAdmin].forEach(list => {
if (!Array.isArray(list)) return; if (!Array.isArray(list)) return;
list.forEach(n => { list.forEach(n => {
if (matchesItem(n) && !n.is_read) { if (matchesItem(n) && !n.is_read) {
@@ -19284,7 +19623,8 @@ class NotificationSystem {
// Recalculate unread counts // Recalculate unread counts
const userUnread = (this._cachedUser || []).filter(n => !n.is_read).length; const userUnread = (this._cachedUser || []).filter(n => !n.is_read).length;
const systemUnread = (this._cachedSystem || []).filter(n => !n.is_read).length; const systemUnread = (this._cachedSystem || []).filter(n => !n.is_read).length;
const totalUnread = userUnread + systemUnread; const adminUnread = (this._cachedAdmin || []).filter(n => !n.is_read).length;
const totalUnread = userUnread + systemUnread + adminUnread;
if (this.countBadge) { if (this.countBadge) {
this.countBadge.textContent = totalUnread; this.countBadge.textContent = totalUnread;
@@ -19292,6 +19632,7 @@ class NotificationSystem {
} }
const userBadge = document.getElementById('notif-tab-badge-user'); const userBadge = document.getElementById('notif-tab-badge-user');
const systemBadge = document.getElementById('notif-tab-badge-system'); const systemBadge = document.getElementById('notif-tab-badge-system');
const adminBadge = document.getElementById('notif-tab-badge-admin');
if (userBadge) { if (userBadge) {
userBadge.textContent = userUnread; userBadge.textContent = userUnread;
userBadge.style.display = userUnread > 0 ? '' : 'none'; userBadge.style.display = userUnread > 0 ? '' : 'none';
@@ -19300,6 +19641,10 @@ class NotificationSystem {
systemBadge.textContent = systemUnread; systemBadge.textContent = systemUnread;
systemBadge.style.display = systemUnread > 0 ? '' : 'none'; systemBadge.style.display = systemUnread > 0 ? '' : 'none';
} }
if (adminBadge) {
adminBadge.textContent = adminUnread;
adminBadge.style.display = adminUnread > 0 ? '' : 'none';
}
if (typeof window._scrollerNotifHook === 'function') { if (typeof window._scrollerNotifHook === 'function') {
window._scrollerNotifHook(totalUnread); window._scrollerNotifHook(totalUnread);
} }
@@ -19336,35 +19681,37 @@ class NotificationSystem {
async markAllRead() { async markAllRead() {
try { try {
const res = await fetch('/api/notifications/read', { const tab = this.activeTab || 'user';
const res = await fetch('/api/notifications/read?tab=' + encodeURIComponent(tab), {
method: 'POST', method: 'POST',
headers: { 'X-CSRF-Token': window.f0ckSession?.csrf_token } headers: { 'X-CSRF-Token': window.f0ckSession?.csrf_token }
}); });
const data = await res.json(); const data = await res.json();
if (data.success) { if (data.success) {
this.markAllReadUI(); this.markAllReadUI(tab);
} }
} catch (e) { } catch (e) {
console.error('Failed to mark all read', e); console.error('Failed to mark all read', e);
} }
} }
markAllReadUI() { markAllReadUI(tab) {
if (!tab) {
// No tab specified — clear everything (backward compat)
if (this.countBadge) this.countBadge.style.display = 'none'; if (this.countBadge) this.countBadge.style.display = 'none';
// Clear per-tab badges
const userBadge = document.getElementById('notif-tab-badge-user'); const userBadge = document.getElementById('notif-tab-badge-user');
const systemBadge = document.getElementById('notif-tab-badge-system'); const systemBadge = document.getElementById('notif-tab-badge-system');
const adminBadge = document.getElementById('notif-tab-badge-admin');
if (userBadge) userBadge.style.display = 'none'; if (userBadge) userBadge.style.display = 'none';
if (systemBadge) systemBadge.style.display = 'none'; if (systemBadge) systemBadge.style.display = 'none';
// Clear cached data if (adminBadge) adminBadge.style.display = 'none';
this._cachedUser = []; this._cachedUser = [];
this._cachedSystem = []; this._cachedSystem = [];
this._cachedAdmin = [];
if (this.list) { if (this.list) {
this.list.innerHTML = `<div class="notif-empty">${window.f0ckI18n?.no_notifications || 'No new notifications'}</div>`; this.list.innerHTML = `<div class="notif-empty">${window.f0ckI18n?.no_notifications || 'No new notifications'}</div>`;
} }
// Also update History page items if present
document.querySelectorAll('.notifications-list-full .notif-item.unread').forEach(el => el.classList.remove('unread')); document.querySelectorAll('.notifications-list-full .notif-item.unread').forEach(el => el.classList.remove('unread'));
// Remove .has-notif highlights
document.querySelectorAll('a.thumb.has-notif, a.lazy-thumb.has-notif').forEach(el => el.classList.remove('has-notif')); document.querySelectorAll('a.thumb.has-notif, a.lazy-thumb.has-notif').forEach(el => el.classList.remove('has-notif'));
if (typeof gridCacheMap !== 'undefined' && gridCacheMap) { if (typeof gridCacheMap !== 'undefined' && gridCacheMap) {
gridCacheMap.forEach(cacheEntry => { gridCacheMap.forEach(cacheEntry => {
@@ -19374,6 +19721,44 @@ class NotificationSystem {
if (typeof window._scrollerNotifHook === 'function') { if (typeof window._scrollerNotifHook === 'function') {
window._scrollerNotifHook(0); window._scrollerNotifHook(0);
} }
return;
}
// Tab-specific clear
const badge = document.getElementById('notif-tab-badge-' + tab);
if (badge) badge.style.display = 'none';
if (tab === 'user') this._cachedUser = [];
else if (tab === 'system') this._cachedSystem = [];
else if (tab === 'admin') this._cachedAdmin = [];
// Recalculate total unread across remaining tabs
const userUnread = (this._cachedUser || []).filter(n => !n.is_read).length;
const systemUnread = (this._cachedSystem || []).filter(n => !n.is_read).length;
const adminUnread = (this._cachedAdmin || []).filter(n => !n.is_read).length;
const totalUnread = userUnread + systemUnread + adminUnread;
if (this.countBadge) {
if (totalUnread > 0) {
this.countBadge.textContent = totalUnread;
this.countBadge.style.display = '';
} else {
this.countBadge.style.display = 'none';
}
}
// Re-render the dropdown list for the active tab
if (this.list && this.activeTab === tab) {
this.list.innerHTML = `<div class="notif-empty">${window.f0ckI18n?.no_notifications || 'No new notifications'}</div>`;
}
// Update History page items if present — remove unread from items belonging to this tab's types
const tabTypes = tab === 'user' ? NotificationSystem.USER_TYPES
: tab === 'admin' ? NotificationSystem.ADMIN_TYPES
: NotificationSystem.SYSTEM_TYPES;
document.querySelectorAll('.notifications-list-full .notif-item.unread').forEach(el => {
if (tabTypes.includes(el.dataset.type)) el.classList.remove('unread');
});
if (typeof window._scrollerNotifHook === 'function') {
window._scrollerNotifHook(totalUnread);
}
} }
handleTagsUpdate(data) { handleTagsUpdate(data) {
@@ -22199,6 +22584,9 @@ document.addEventListener('DOMContentLoaded', () => {
if (!el) return null; if (!el) return null;
const steuerungEl = el.closest && el.closest('.steuerung a, .steuerung button, .steuerung [role="button"]'); const steuerungEl = el.closest && el.closest('.steuerung a, .steuerung button, .steuerung [role="button"]');
if (steuerungEl) return steuerungEl; if (steuerungEl) return steuerungEl;
// "Back to top" below the comments: same click sound / haptics / press state as the nav controls
const toTop = el.closest && el.closest('.mobile-scroll-to-top');
if (toTop) return toTop;
if (el.id === 'prev' || (el.classList && el.classList.contains('nav-prev')) || (el.closest && el.closest('.arrow-prev, .previous-post'))) { if (el.id === 'prev' || (el.classList && el.classList.contains('nav-prev')) || (el.closest && el.closest('.arrow-prev, .previous-post'))) {
return document.querySelector('.steuerung .nav-prev') || el; return document.querySelector('.steuerung .nav-prev') || el;
} }
@@ -22291,18 +22679,129 @@ document.addEventListener('click', (e) => {
} }
}); });
// ── Mobile scroll-to-top button ─────────────────────────────────────────────── // ── Mouse wheel scrolls horizontal bars sideways ────────────────────────────────
// Visible only on mobile (≤999px), appears below #comments-container so users // For single-line bars that overflow horizontally without a visible scrollbar. A vertical wheel turn
// can easily get back to the top after reading comments. Only shown once the relevant scroller is // moves the bar sideways while it can still move that way; at either end the page scrolls as usual.
// scrolled far enough that getting back up by hand is a chore (1.5 screens, at least 600px). // Trackpad sideways swipes (deltaX dominant) are left to the browser.
(() => { (() => {
const scrollerFor = (btn) => btn.closest('#onara-modal') || document.querySelector('.index-container') || null; const WHEEL_X_SEL = '.f0ck-tuner-subtabs-nav, .f0ck-tuner-mode-bar, #settings-quicknav';
document.addEventListener('wheel', (e) => {
if (e.ctrlKey || e.defaultPrevented) return; // ctrl+wheel = zoom
if (Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return;
const bar = e.target.closest && e.target.closest(WHEEL_X_SEL);
if (!bar || bar.scrollWidth <= bar.clientWidth + 1) return;
const max = bar.scrollWidth - bar.clientWidth;
// Wheel down moves the bar right, wheel up moves it left
const dir = Math.sign(e.deltaY);
// The wheel stays with the bar while the pointer is on it (no page scroll)
e.preventDefault();
if ((dir < 0 && bar.scrollLeft <= 0) || (dir > 0 && bar.scrollLeft >= max - 1)) {
// At the end: short rubber-band stretch past the edge, then ease back. A running stretch is not
// restarted, so a continuous wheel spin reads as one bounce instead of a jitter.
const items = Array.from(bar.children).filter(el => typeof el.animate === 'function' && el.offsetParent !== null);
if (!items.length || (bar._edgeAnim && bar._edgeAnim.playState === 'running')) return;
if (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
// Stretch the items, not the bar: the one at the edge being pulled moves the full distance, the
// far end barely moves, so the gaps open a little and close again. Pull out with a soft ease-out,
// settle back slowly (spring-like, no overshoot).
const n = items.length;
items.forEach((el, i) => {
const weight = dir > 0 ? (i + 1) / n : (n - i) / n;
const px = -dir * 22 * weight;
const anim = el.animate(
[
{ transform: 'translateX(0)', easing: 'cubic-bezier(0.25, 0.8, 0.4, 1)' },
{ transform: 'translateX(' + px.toFixed(2) + 'px)', offset: 0.3, easing: 'cubic-bezier(0.45, 0, 0.25, 1)' },
{ transform: 'translateX(0)' }
],
{ duration: 900 }
);
if (i === 0) bar._edgeAnim = anim;
});
return;
}
// deltaMode 1 = lines (Firefox mouse wheels), 2 = pages
const amount = Math.abs(e.deltaMode === 1 ? e.deltaY * 16 : e.deltaMode === 2 ? e.deltaY * bar.clientWidth : e.deltaY);
bar.scrollLeft = Math.max(0, Math.min(max, bar.scrollLeft + dir * amount));
}, { passive: false });
})();
// Clicking a chip in one of these bars brings it fully into view (smooth, sideways only). For tuner
// sub-tabs the tuner also scrolls so the opened pane starts in view.
(() => {
const revealInBar = (bar, el) => {
if (!bar || !el || bar.scrollWidth <= bar.clientWidth + 1) return;
const br = bar.getBoundingClientRect(), er = el.getBoundingClientRect(), pad = 12;
let target = bar.scrollLeft;
if (er.left < br.left + pad) target += er.left - br.left - pad;
else if (er.right > br.right - pad) target += er.right - br.right + pad;
else return;
bar.scrollTo({ left: Math.max(0, target), behavior: 'smooth' });
};
document.addEventListener('click', (e) => {
const chip = e.target.closest && e.target.closest('.f0ck-tuner-subtab-btn, .f0ck-tuner-mode-btn');
if (!chip) return;
const bar = chip.closest('.f0ck-tuner-subtabs-nav, .f0ck-tuner-mode-bar');
// After the tab switch has laid out the new pane
requestAnimationFrame(() => {
revealInBar(bar, chip);
if (!chip.dataset.subtab) return;
const pane = document.getElementById('f0ck-tuner-pane-' + chip.dataset.subtab);
const sc = pane && window._f0ckScrollerFor ? window._f0ckScrollerFor(pane) : null;
if (!pane || !sc) return;
const pr = pane.getBoundingClientRect(), sr = sc.getBoundingClientRect();
// Pane top above the visible area (scrolled away): bring it back so the section reads from its start
const header = pane.parentElement && pane.parentElement.querySelector('.f0ck-tuner-header');
const headerBottom = header && getComputedStyle(header).position === 'sticky' ? header.getBoundingClientRect().bottom : sr.top;
if (pr.top < headerBottom - 1) sc.scrollBy({ top: pr.top - headerBottom, behavior: 'smooth' });
});
});
})();
// ── Scroll-to-top button ─────────────────────────────────────────────────────
// All screen sizes (class name .mobile-scroll-to-top is historical); sits below the comments.
// Shown once the container it lives in is scrolled more than ~half a screen: measured on the element
// that really scrolls (Onara modal, .index-container or the window), found by walking up from the button.
(() => {
const isScroller = (el) => {
if (!el || el === document.body || el === document.documentElement) return false;
const oy = getComputedStyle(el).overflowY;
return (oy === 'auto' || oy === 'scroll' || oy === 'overlay') && el.scrollHeight > el.clientHeight + 1;
};
// null = the window scrolls
const scrollerFor = (btn) => {
for (let el = btn.parentElement; el; el = el.parentElement) if (isScroller(el)) return el;
return null;
};
window._f0ckScrollerFor = scrollerFor;
// Horizontal centre of the button's content column, minus whatever the right sidebar covers of it
// (the column can run underneath an open sidebar). Applied as `left` in px against its offsetParent.
const SIDEBAR_SEL = '.global-sidebar-right, .item-sidebar-right, .index-sidebar-right';
const centreButton = (btn) => {
const col = btn.parentElement;
const op = btn.offsetParent;
if (!col || !op) return;
const cr = col.getBoundingClientRect();
let L = Math.max(0, cr.left), R = Math.min(document.documentElement.clientWidth, cr.right);
const rs = document.querySelector(SIDEBAR_SEL);
if (rs && !btn.closest(SIDEBAR_SEL) && !document.body.classList.contains('sidebar-right-hidden')) {
const r = rs.getBoundingClientRect();
const cs = getComputedStyle(rs);
if (r.width > 0 && cs.display !== 'none' && cs.visibility !== 'hidden' && r.left < R && r.right > L) R = Math.min(R, r.left);
}
if (R - L < 10) return;
const or = op.getBoundingClientRect();
btn.style.left = Math.round((L + R) / 2 - or.left - op.clientLeft) + 'px';
};
const update = () => { const update = () => {
document.querySelectorAll('.mobile-scroll-to-top').forEach((btn) => { document.querySelectorAll('.mobile-scroll-to-top').forEach((btn) => {
centreButton(btn);
const sc = scrollerFor(btn); const sc = scrollerFor(btn);
const top = sc ? sc.scrollTop : (window.scrollY || document.documentElement.scrollTop); const top = sc ? sc.scrollTop : (window.scrollY || document.documentElement.scrollTop);
const viewH = sc ? sc.clientHeight : window.innerHeight; const viewH = sc ? sc.clientHeight : window.innerHeight;
btn.classList.toggle('is-visible', top > Math.max(600, viewH * 1.5)); btn.classList.toggle('is-visible', top > Math.max(250, viewH * 0.6));
}); });
}; };
let queued = false; let queued = false;
@@ -22314,23 +22813,26 @@ document.addEventListener('click', (e) => {
// Capture: scroll events don't bubble, and the scroller may be the window, .index-container or the Onara modal // Capture: scroll events don't bubble, and the scroller may be the window, .index-container or the Onara modal
document.addEventListener('scroll', schedule, { capture: true, passive: true }); document.addEventListener('scroll', schedule, { capture: true, passive: true });
window.addEventListener('resize', schedule, { passive: true }); window.addEventListener('resize', schedule, { passive: true });
// New page content (AJAX navigation, Onara) arrives already scrolled sometimes: re-check then too
document.addEventListener('f0ck:contentLoaded', schedule);
window.addEventListener('popstate', schedule);
// Sidebar open/close/resize moves the centre: the toggle announces it, the body class flips after the slide
window.addEventListener('f0ck:sidebar-right-layout', () => { schedule(); setTimeout(schedule, 350); });
new MutationObserver(schedule).observe(document.body, { attributes: true, attributeFilter: ['class'] });
document.addEventListener('transitionend', (e) => {
if (e.target && e.target.matches && e.target.matches(SIDEBAR_SEL)) schedule();
});
})(); })();
document.addEventListener('click', (e) => { document.addEventListener('click', (e) => {
if (!e.target.closest('.mobile-scroll-to-top')) return; const btn = e.target.closest('.mobile-scroll-to-top');
if (!btn) return;
e.preventDefault(); e.preventDefault();
const onaraModal = e.target.closest('#onara-modal') || (document.body.classList.contains('onara-modal-open') && document.getElementById('onara-modal')); // Scroll the same container the visibility is measured on
if (onaraModal) { const sc = window._f0ckScrollerFor ? window._f0ckScrollerFor(btn) : null;
onaraModal.scrollTo({ top: 0, behavior: 'smooth' }); if (sc) sc.scrollTo({ top: 0, behavior: 'smooth' });
} else { else window.scrollTo({ top: 0, behavior: 'smooth' });
const ic = document.querySelector('.index-container'); // Sound + haptics come from the shared steuerung feedback (resolveSteuerungTarget)
if (ic) {
ic.scrollTo({ top: 0, behavior: 'smooth' });
} else {
window.scrollTo({ top: 0, behavior: 'smooth' });
}
}
if (navigator.vibrate) navigator.vibrate(20);
}); });
// ── Spoiler Tags Event Delegation ───────────────────────────────────────────── // ── Spoiler Tags Event Delegation ─────────────────────────────────────────────
+64 -19
View File
@@ -594,14 +594,7 @@
info info
}; };
slider.addEventListener('input', (e) => { slider.addEventListener('input', (e) => setYank(e.target.value));
const val = parseInt(e.target.value, 10);
settings.yank = isNaN(val) ? 0 : Math.min(100, Math.max(0, val));
// Yank drives the underlying advanced parameters
updateAdvancedFromYank();
saveSettings();
applySettingsToRuntime();
});
function toggleEnabledFromUI(targetController) { function toggleEnabledFromUI(targetController) {
settings.enabled = !settings.enabled; settings.enabled = !settings.enabled;
@@ -697,7 +690,9 @@
// Never show it just because sidebar .webm stickers exist. // Never show it just because sidebar .webm stickers exist.
const primaryVideo = document.getElementById('my-video') || const primaryVideo = document.getElementById('my-video') ||
document.querySelector('video.viewer, video.v0ck_video'); document.querySelector('video.viewer, video.v0ck_video');
ui.floatingBadge.style.display = (swfButtons.length === 0 && !!primaryVideo) ? 'block' : 'none'; // Retired: the settings live in the sidebar Tuner (Flash Yank tab) now, so no floating fallback badge
void primaryVideo;
ui.floatingBadge.style.display = 'none';
// Style both (if they exist) // Style both (if they exist)
[ui.floatingBadge, ...swfButtons].forEach(b => { [ui.floatingBadge, ...swfButtons].forEach(b => {
@@ -739,19 +734,69 @@
} }
if (!currentController) return; if (!currentController) return;
settings.enabled = !settings.enabled; setEnabled(!settings.enabled);
if (settings.enabled) currentController.enable();
else currentController.disable();
saveSettings();
updateUIFromSettings();
if (typeof window.flashMessage === 'function') {
window.flashMessage(`Flash Yank ${settings.enabled ? 'enabled' : 'disabled'}`, 2000, settings.enabled ? 'success' : 'info');
}
}); });
} }
// ---------- Public API (sidebar Tuner → Flash Yank) ----------
// The in-player "SWF" button is gone; the Tuner pane drives the filter through this API and
// listens to 'f0ck:flashyank_changed' (also fired by the 's' hotkey) to stay in sync.
function findController() {
if (currentController) return currentController;
const mainVid = document.getElementById('my-video') || document.querySelector('video.v0ck_video, video.viewer, video');
if (!mainVid) return null;
if (!mainVid.__flashFilterController) setupVideo(mainVid);
return mainVid.__flashFilterController || null;
}
function getState() {
return {
enabled: !!settings.enabled,
yank: settings.yank,
width: currentConfig.internalWidth,
fps: currentConfig.fps,
colors: Math.pow(currentConfig.paletteLevels, 3),
available: isItemPage() && document.querySelectorAll('video').length > 0
};
}
function emitChange() {
window.dispatchEvent(new CustomEvent('f0ck:flashyank_changed', { detail: getState() }));
}
function setEnabled(on, { silent = false } = {}) {
settings.enabled = !!on;
saveSettings();
const controller = findController();
if (controller) {
if (settings.enabled) controller.enable();
else controller.disable();
}
updateUIFromSettings();
emitChange();
if (!silent && typeof window.flashMessage === 'function') {
window.flashMessage('Flash Yank ' + (settings.enabled ? 'enabled' : 'disabled'), 2000, settings.enabled ? 'success' : 'info');
}
}
function setYank(value) {
const v = parseInt(value, 10);
settings.yank = isNaN(v) ? 0 : Math.min(100, Math.max(0, v));
updateAdvancedFromYank();
saveSettings();
applySettingsToRuntime();
emitChange();
}
window.f0ckFlashYank = {
getState,
setEnabled: (on) => setEnabled(on),
toggle: () => setEnabled(!settings.enabled),
setYank,
defaults: { yank: DEFAULT_SETTINGS.yank }
};
// ---------- Bootstrapping ---------- // ---------- Bootstrapping ----------
function onReady(fn) { function onReady(fn) {
+153 -12
View File
@@ -12,7 +12,7 @@
(() => { (() => {
if (window.f0ckPageModal) return; if (window.f0ckPageModal) return;
const MODAL_PAGES = /^\/(ranking|rules|about|privacy|terms)\/?$/; const MODAL_PAGES = /^\/(ranking|rules|about|privacy|terms|settings)\/?$/;
const modal = document.getElementById('page-modal'); const modal = document.getElementById('page-modal');
if (!modal) return; if (!modal) return;
@@ -20,6 +20,26 @@
const titleEl = modal.querySelector('.pgm-title'); const titleEl = modal.querySelector('.pgm-title');
let currentUrl = null; let currentUrl = null;
let loadSeq = 0; let loadSeq = 0;
// Open state is tracked separately from display: while the close animation runs the modal is still shown
let isOpen = false;
let fx = null; // running open/close animation
const reduceMotion = window.matchMedia ? window.matchMedia('(prefers-reduced-motion: reduce)') : { matches: false };
const animate = (el, frames, opts) => (typeof el.animate === 'function' && !reduceMotion.matches) ? el.animate(frames, opts) : null;
const pathOf = (href) => { try { return new URL(href, location.origin).pathname.replace(/\/+$/, '') || '/'; } catch { return null; } };
// Sidebar/footer entries of the page that is open get .is-active (a second click on it closes the page)
const markActive = () => {
const cur = isOpen && currentUrl ? pathOf(currentUrl) : null;
document.querySelectorAll('a[data-page-modal]').forEach((a) => {
a.classList.toggle('is-active', !!cur && pathOf(a.getAttribute('href')) === cur);
});
};
// URL bar + title of the page underneath, restored on close. replaceState (never push/back): a popstate
// would make the site's AJAX router reload the page underneath, which this modal exists to avoid.
let baseUrl = null;
let baseTitle = null;
const showUrl = (url) => {
try { history.replaceState(history.state, '', url); } catch {}
};
const isModalPath = (href) => { const isModalPath = (href) => {
try { try {
@@ -42,6 +62,8 @@
const load = async (url, title) => { const load = async (url, title) => {
const seq = ++loadSeq; const seq = ++loadSeq;
currentUrl = url; currentUrl = url;
showUrl(url);
markActive();
titleEl.textContent = title || ''; titleEl.textContent = title || '';
body.innerHTML = '<div class="pgm-loading"><i class="fa-solid fa-spinner fa-spin"></i></div>'; body.innerHTML = '<div class="pgm-loading"><i class="fa-solid fa-spinner fa-spin"></i></div>';
try { try {
@@ -57,10 +79,12 @@
body.innerHTML = ''; body.innerHTML = '';
body.appendChild(wrap); body.appendChild(wrap);
body.scrollTop = 0; body.scrollTop = 0;
animate(wrap, [{ opacity: 0, transform: 'translateY(6px)' }, { opacity: 1, transform: 'none' }], { duration: 200, easing: 'cubic-bezier(0.2, 0, 0, 1)' });
if (!title) { if (!title) {
const h = wrap.querySelector('h1, h2, h3'); const h = wrap.querySelector('h1, h2, h3');
titleEl.textContent = h ? h.textContent.trim() : ''; titleEl.textContent = h ? h.textContent.trim() : '';
} }
if (doc.title) document.title = doc.title;
runScripts(wrap); runScripts(wrap);
} catch (e) { } catch (e) {
if (seq !== loadSeq) return; if (seq !== loadSeq) return;
@@ -76,19 +100,50 @@
} }
return null; return null;
}; };
// Top follows the navbar live (e.g. mobile burger menu collapsing). The sides follow the sidebars' *state*,
// not their animated position, so the content never re-lays out while a sidebar slides: it changes width
// once, when the toggle has finished, masked by a short fade (same as the main content).
let lastSides = null;
// Set by toggleSidebarRight at the moment the main content changes width (before the slide when
// opening, after it when closing); wins over the body class, which only flips once the slide ends.
let rightOverride = null;
const place = () => { const place = () => {
const vw = window.innerWidth; const vw = window.innerWidth;
const nav = visibleRect('body > nav.navbar, nav.navbar'); const nav = visibleRect('body > nav.navbar, nav.navbar');
const top = nav ? Math.max(0, Math.round(nav.bottom)) : 0; const top = nav ? Math.max(0, Math.round(nav.bottom)) : 0;
// Right sidebar: counts only while it is on screen (hidden = slid out past the right edge) // Right sidebar: open unless body.sidebar-right-hidden; its layout width ignores the slide transform
const rs = visibleRect('.global-sidebar-right, .item-sidebar-right, .index-sidebar-right'); const rsEl = document.querySelector('.global-sidebar-right, .item-sidebar-right, .index-sidebar-right');
const right = rs && rs.left < vw - 1 && rs.left > vw / 2 ? Math.round(vw - rs.left) : 0; const rsOpen = !!rsEl && !document.body.classList.contains('sidebar-right-hidden')
&& getComputedStyle(rsEl).display !== 'none' && rsEl.offsetWidth > 0;
let right = rsOpen ? Math.min(Math.round(rsEl.offsetWidth), Math.round(vw / 2)) : 0;
if (rightOverride && performance.now() < rightOverride.until) right = rightOverride.right;
// Small phones: the sidebar is a pure overlay there, the content never makes room for it
if (vw <= 599) right = 0;
// Left (comments) sidebar on item pages // Left (comments) sidebar on item pages
const ls = visibleRect('.item-sidebar-left'); const ls = visibleRect('.item-sidebar-left');
const left = ls && ls.right > 1 && ls.right < vw / 2 ? Math.round(ls.right) : 0; const left = ls && ls.right > 1 && ls.right < vw / 2 ? Math.round(ls.right) : 0;
modal.style.setProperty('--pgm-top', `${top}px`); modal.style.setProperty('--pgm-top', `${top}px`);
// Desktop, sidebar beside the content: the header takes the exact height of the sidebar's tab row,
// so the two form one continuous bar under the navbar
const tabs = right > 0 && rsEl ? rsEl.querySelector('.sidebar-tabs') : null;
const tabsH = tabs ? tabs.getBoundingClientRect().height : 0;
if (tabsH > 0) {
modal.style.setProperty('--pgm-header-h', `${tabsH}px`);
modal.classList.add('pgm-header-synced');
} else {
modal.style.removeProperty('--pgm-header-h');
modal.classList.remove('pgm-header-synced');
}
const sides = `${left}|${right}`;
if (sides !== lastSides) {
const changed = lastSides !== null && modal.style.display !== 'none';
lastSides = sides;
modal.style.setProperty('--pgm-right', `${right}px`); modal.style.setProperty('--pgm-right', `${right}px`);
modal.style.setProperty('--pgm-left', `${left}px`); modal.style.setProperty('--pgm-left', `${left}px`);
if (changed && typeof body.animate === 'function') {
body.animate([{ opacity: 0.55 }, { opacity: 1 }], { duration: 220, easing: 'ease-out' });
}
}
}; };
// Follow sidebar slide animations for a moment after a toggle // Follow sidebar slide animations for a moment after a toggle
let followUntil = 0; let followUntil = 0;
@@ -103,7 +158,19 @@
if (!running) requestAnimationFrame(follow); if (!running) requestAnimationFrame(follow);
}; };
window.addEventListener('resize', () => { if (modal.style.display !== 'none') place(); }, { passive: true }); window.addEventListener('resize', () => { if (modal.style.display !== 'none') place(); }, { passive: true });
new MutationObserver(() => followFor(600)).observe(document.body, { attributes: true, attributeFilter: ['class'] }); window.addEventListener('f0ck:sidebar-right-layout', (e) => {
const d = 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 };
if (modal.style.display !== 'none') place();
});
// Sidebar toggles commit their body class when the slide has finished: update once then. The left
// (comments) sidebar may still be transitioning, so check again after it settles.
new MutationObserver(() => {
if (modal.style.display === 'none') return;
place();
setTimeout(place, 400);
}).observe(document.body, { attributes: true, attributeFilter: ['class'] });
// The navbar and sidebars change size on their own (mobile burger menu expanding/collapsing, sidebar resize): // The navbar and sidebars change size on their own (mobile burger menu expanding/collapsing, sidebar resize):
// follow every size change so the modal's edges stay glued to them // follow every size change so the modal's edges stay glued to them
@@ -126,18 +193,55 @@
}; };
const open = (url, title) => { const open = (url, title) => {
if (!isOpen || baseUrl === null) {
baseUrl = location.pathname + location.search + location.hash;
baseTitle = document.title;
}
// Fade in on a fresh open, and when reopened while the close animation is still running
const fadeIn = !isOpen;
isOpen = true;
// Item pages bring their own sidebars after AJAX navigation, so (re)attach observers on each open // Item pages bring their own sidebars after AJAX navigation, so (re)attach observers on each open
watchLayout(); watchLayout();
place(); place();
modal.style.display = 'flex'; modal.style.display = 'flex';
// No visible scrollbar: focus the body so arrow keys / PageUp / PageDown / Space scroll it right away
try { body.focus({ preventScroll: true }); } catch {}
followFor(300); followFor(300);
if (fadeIn) {
// Mid-close: continue from where the fade-out is instead of flashing back to full opacity
const from = fx ? parseFloat(getComputedStyle(modal).opacity) || 0 : 0;
if (fx) { fx.cancel(); fx = null; }
fx = animate(modal, [{ opacity: from, transform: `translateY(${10 * (1 - from)}px)` }, { opacity: 1, transform: 'none' }], { duration: 220, easing: 'cubic-bezier(0.2, 0, 0, 1)' });
if (fx) fx.onfinish = () => { fx = null; };
}
load(url, title); load(url, title);
}; };
const close = () => { // restoreUrl = false when history already moved (back/forward): the URL bar is correct then
modal.style.display = 'none'; const close = (restoreUrl = true) => {
body.innerHTML = ''; if (!isOpen) return;
isOpen = false;
currentUrl = null; currentUrl = null;
++loadSeq; // drop a load still in flight so it can't rewrite the URL after closing
if (restoreUrl && baseUrl !== null) {
showUrl(baseUrl);
if (baseTitle !== null) document.title = baseTitle;
}
baseUrl = baseTitle = null;
markActive();
const hide = () => {
fx = null;
if (isOpen) return; // reopened meanwhile
modal.style.display = 'none';
lastSides = null;
body.innerHTML = '';
};
if (fx) { fx.cancel(); fx = null; }
const out = animate(modal, [{ opacity: 1, transform: 'none' }, { opacity: 0, transform: 'translateY(10px)' }], { duration: 160, easing: 'cubic-bezier(0.4, 0, 1, 1)', fill: 'forwards' });
if (!out) { hide(); return; }
fx = out;
out.onfinish = () => { if (fx === out) { out.cancel(); hide(); } };
}; };
// Capture phase: runs before the global AJAX link handler // Capture phase: runs before the global AJAX link handler
@@ -147,13 +251,27 @@
if (!a) return; if (!a) return;
if (a.hasAttribute('data-page-modal')) { if (a.hasAttribute('data-page-modal')) {
// Already on that page (e.g. the gear on /settings): plain link, never the same page twice
if (!isOpen && pathOf(a.getAttribute('href')) === pathOf(location.pathname)) return;
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
// Same page already open: the entry works as a toggle
if (isOpen && currentUrl && pathOf(currentUrl) === pathOf(a.getAttribute('href'))) {
close();
return;
}
// Mobile: the sidebar overlays the content, so close it to bring the opened page into focus
const fromSidebar = a.closest('.global-sidebar-right, .item-sidebar-right, .index-sidebar-right');
if (fromSidebar && window.matchMedia('(max-width: 999px)').matches
&& !document.body.classList.contains('sidebar-right-hidden')
&& typeof window.toggleSidebarRight === 'function') {
window.toggleSidebarRight();
}
open(a.getAttribute('href'), a.getAttribute('title') || ''); open(a.getAttribute('href'), a.getAttribute('title') || '');
return; return;
} }
if (modal.style.display === 'none' || a.target === '_blank') return; if (!isOpen || a.target === '_blank') return;
// Outside the modal (navbar brand, sidebar links, …): a real navigation closes it so the new page shows // Outside the modal (navbar brand, sidebar links, …): a real navigation closes it so the new page shows
if (!modal.contains(a)) { if (!modal.contains(a)) {
@@ -164,6 +282,11 @@
return; return;
} }
// In-page anchors (#section, e.g. the settings quicknav) belong to the loaded page's own scripts:
// never reload the page for them
const rawHref = a.getAttribute('href') || '';
if (rawHref.startsWith('#')) return;
if (isModalPath(a.href)) { if (isModalPath(a.href)) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
@@ -174,11 +297,29 @@
close(); close();
}, true); }, true);
modal.querySelector('.pgm-close').addEventListener('click', close); modal.querySelector('.pgm-close').addEventListener('click', () => close());
// Back/forward changes the page underneath: don't leave the modal covering it // Back/forward changes the page underneath: don't leave the modal covering it
window.addEventListener('popstate', () => { if (modal.style.display !== 'none') close(); }); window.addEventListener('popstate', () => { if (isOpen) close(false); });
// Capture phase + stop: the page sits on top (e.g. over the Onara viewer), so Escape closes only it
document.addEventListener('keydown', (e) => { document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && modal.style.display !== 'none') close(); if (e.key !== 'Escape' || !isOpen) return;
e.stopImmediatePropagation();
close();
}, true);
// Shift+S toggles the settings (signed-in users: a settings link is on the page). Not while typing,
// not on /settings itself. Plain "s" stays Flash Yank.
document.addEventListener('keydown', (e) => {
if (!e.shiftKey || e.ctrlKey || e.altKey || e.metaKey || e.repeat) return;
if (e.key !== 'S' && e.key !== 's') return;
const t = e.target;
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT' || t.isContentEditable)) return;
if (document.body.classList.contains('scroller-active')) return;
const link = document.querySelector('a[href="/settings"][data-page-modal]');
if (!link || pathOf(location.pathname) === '/settings' && !isOpen) return;
e.preventDefault();
if (isOpen && currentUrl && pathOf(currentUrl) === '/settings') close();
else open('/settings', link.getAttribute('title') || 'Settings');
}); });
window.f0ckPageModal = { open, close, get url() { return currentUrl; } }; window.f0ckPageModal = { open, close, get url() { return currentUrl; } };
+9 -4
View File
@@ -3873,19 +3873,24 @@
} }
}); });
// Mark all read // Mark all read (current tab only)
if (sMarkAll) { if (sMarkAll) {
sMarkAll.addEventListener('click', async () => { sMarkAll.addEventListener('click', async () => {
try { try {
const tab = sActiveTab || 'user';
const csrfToken = window.f0ckSession?.csrf_token || window.scrollerCsrf || ''; const csrfToken = window.f0ckSession?.csrf_token || window.scrollerCsrf || '';
await fetch('/api/notifications/read', { await fetch('/api/notifications/read?tab=' + encodeURIComponent(tab), {
method: 'POST', method: 'POST',
headers: { ...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {}) } headers: { ...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {}) }
}); });
updateScrollerNotifBadge(0); // Mark only the active tab's cached notifications as read
sCachedNotifs = sCachedNotifs.map(n => ({ ...n, is_read: true })); const tabCheck = tab === 'user' ? isUserType : (tab === 'system' ? isSystemType : () => false);
sCachedNotifs = sCachedNotifs.map(n => tabCheck(n.type) ? { ...n, is_read: true } : n);
updateScrollerTabBadges(sCachedNotifs); updateScrollerTabBadges(sCachedNotifs);
renderScrollerNotifs(filterByTab(sCachedNotifs, sActiveTab)); renderScrollerNotifs(filterByTab(sCachedNotifs, sActiveTab));
// Update overall badge
const totalUnread = sCachedNotifs.filter(n => !n.is_read).length;
updateScrollerNotifBadge(totalUnread);
} catch (e) {} } catch (e) {}
}); });
} }
+111 -15
View File
@@ -151,7 +151,10 @@
const allowedTypes = ['image/gif', 'image/jpeg', 'image/png', 'image/webp']; const allowedTypes = ['image/gif', 'image/jpeg', 'image/png', 'image/webp'];
if (chooseBtn && fileInput) { if (chooseBtn && fileInput) {
chooseBtn.addEventListener('click', () => fileInput.click()); chooseBtn.addEventListener('click', (e) => {
if (e.target.closest('#avatar-remove-btn')) return; // removing, not choosing
fileInput.click();
});
fileInput.addEventListener('change', () => { fileInput.addEventListener('change', () => {
const file = fileInput.files[0]; const file = fileInput.files[0];
@@ -194,8 +197,24 @@
progressWrapper.style.display = 'flex'; progressWrapper.style.display = 'flex';
progressFill.style.width = '0%'; progressFill.style.width = '0%';
progressText.textContent = '0%'; progressText.textContent = '0%';
progressWrapper.setAttribute('aria-valuenow', '0');
showStatus(i18n.uploading || 'Uploading...', ''); showStatus(i18n.uploading || 'Uploading...', '');
// Show the chosen image in the live preview right away; the progress overlay sits on top of it
chooseBtn.classList.add('is-uploading');
const liveAvatar = document.getElementById('live-preview-avatar');
const prevAvatarSrc = liveAvatar ? liveAvatar.src : null;
const localUrl = URL.createObjectURL(file);
if (liveAvatar) liveAvatar.src = localUrl;
const finishUpload = (ok) => {
progressWrapper.style.display = 'none';
chooseBtn.classList.remove('is-uploading');
if (!ok && liveAvatar && prevAvatarSrc) liveAvatar.src = prevAvatarSrc;
// Keep the local image until the server copy has loaded, then free it
if (ok && liveAvatar) liveAvatar.addEventListener('load', () => URL.revokeObjectURL(localUrl), { once: true });
else URL.revokeObjectURL(localUrl);
};
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); formData.append('file', file);
@@ -205,14 +224,19 @@
if (e.lengthComputable) { if (e.lengthComputable) {
const percent = Math.round((e.loaded / e.total) * 100); const percent = Math.round((e.loaded / e.total) * 100);
progressFill.style.width = percent + '%'; progressFill.style.width = percent + '%';
progressText.textContent = percent + '%'; progressWrapper.setAttribute('aria-valuenow', String(percent));
// At 100% the bytes are sent but the server is still processing
if (percent >= 100) progressText.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i>';
else progressText.textContent = percent + '%';
} }
}); });
let uploadOk = false;
xhr.addEventListener('load', () => { xhr.addEventListener('load', () => {
try { try {
const res = JSON.parse(xhr.responseText); const res = JSON.parse(xhr.responseText);
if (xhr.status === 200 && res.success) { if (xhr.status === 200 && res.success) {
uploadOk = true;
showStatus(res.msg || 'Avatar uploaded!', 'success'); showStatus(res.msg || 'Avatar uploaded!', 'success');
// Update preview // Update preview
@@ -236,7 +260,15 @@
// Show remove button if not present // Show remove button if not present
const existingRemoveBtn = document.getElementById('avatar-remove-btn'); const existingRemoveBtn = document.getElementById('avatar-remove-btn');
if (!existingRemoveBtn) { if (!existingRemoveBtn && chooseBtn.classList.contains('editable-avatar-container')) {
const btn = document.createElement('button');
btn.type = 'button';
btn.id = 'avatar-remove-btn';
btn.className = 'avatar-quick-remove';
btn.title = 'Remove custom avatar';
btn.innerHTML = '<i class="fa-solid fa-xmark"></i>';
chooseBtn.appendChild(btn);
} else if (!existingRemoveBtn) {
const actionsDiv = document.querySelector('.avatar-upload-actions'); const actionsDiv = document.querySelector('.avatar-upload-actions');
if (actionsDiv) { if (actionsDiv) {
const btn = document.createElement('button'); const btn = document.createElement('button');
@@ -258,14 +290,14 @@
showStatus('Upload failed: Invalid response', 'error'); showStatus('Upload failed: Invalid response', 'error');
} }
progressWrapper.style.display = 'none'; finishUpload(uploadOk);
uploadBtn.disabled = true; uploadBtn.disabled = true;
chooseBtn.disabled = false; chooseBtn.disabled = false;
}); });
xhr.addEventListener('error', () => { xhr.addEventListener('error', () => {
showStatus('Upload failed: Network error', 'error'); showStatus('Upload failed: Network error', 'error');
progressWrapper.style.display = 'none'; finishUpload(false);
uploadBtn.disabled = true; uploadBtn.disabled = true;
chooseBtn.disabled = false; chooseBtn.disabled = false;
}); });
@@ -654,11 +686,43 @@
if (bannerUploadBtn) bannerUploadBtn.disabled = true; if (bannerUploadBtn) bannerUploadBtn.disabled = true;
if (bannerChooseBtn) bannerChooseBtn.disabled = true; if (bannerChooseBtn) bannerChooseBtn.disabled = true;
if (bannerProgressWrapper) { bannerProgressWrapper.style.display = 'flex'; } const bannerLabel = (inner) => '<i class="fa-solid fa-image"></i> ' + inner;
if (bannerProgressWrapper) { bannerProgressWrapper.style.display = 'block'; bannerProgressWrapper.setAttribute('aria-valuenow', '0'); }
if (bannerProgressFill) bannerProgressFill.style.width = '0%'; if (bannerProgressFill) bannerProgressFill.style.width = '0%';
if (bannerProgressText) bannerProgressText.textContent = '0%'; if (bannerProgressText) bannerProgressText.innerHTML = bannerLabel('0%');
showBannerStatus('Uploading...', ''); showBannerStatus('Uploading...', '');
// Show the cropped banner on the live card right away; roll back if the upload fails
const liveCard = document.getElementById('live-profile-preview-box');
const prevBanner = liveCard ? {
img: liveCard.style.getPropertyValue('--author-banner'),
size: liveCard.style.getPropertyValue('--author-banner-size'),
pos: liveCard.style.getPropertyValue('--author-banner-position')
} : null;
const localBannerUrl = URL.createObjectURL(payload.file);
if (liveCard) {
liveCard.classList.add('is-banner-uploading');
liveCard.style.setProperty('--author-banner', `url('${localBannerUrl}')`);
liveCard.style.setProperty('--author-banner-size', payload.banner_size);
liveCard.style.setProperty('--author-banner-position', payload.banner_position);
}
const finishBannerUpload = (ok) => {
if (bannerProgressWrapper) bannerProgressWrapper.style.display = 'none';
if (bannerChooseBtn) bannerChooseBtn.disabled = false;
if (liveCard) {
liveCard.classList.remove('is-banner-uploading');
if (!ok && prevBanner) {
['--author-banner', '--author-banner-size', '--author-banner-position'].forEach((prop, i) => {
const v = [prevBanner.img, prevBanner.size, prevBanner.pos][i];
if (v) liveCard.style.setProperty(prop, v); else liveCard.style.removeProperty(prop);
});
}
}
// The server URL replaced the local one on success; give the browser time to swap before freeing it
setTimeout(() => URL.revokeObjectURL(localBannerUrl), ok ? 5000 : 0);
};
let bannerOk = false;
const formData = new FormData(); const formData = new FormData();
formData.append('file', payload.file); formData.append('file', payload.file);
if (payload.file_orig) formData.append('file_orig', payload.file_orig); if (payload.file_orig) formData.append('file_orig', payload.file_orig);
@@ -671,7 +735,9 @@
if (e.lengthComputable) { if (e.lengthComputable) {
const pct = Math.round((e.loaded / e.total) * 100); const pct = Math.round((e.loaded / e.total) * 100);
if (bannerProgressFill) bannerProgressFill.style.width = pct + '%'; if (bannerProgressFill) bannerProgressFill.style.width = pct + '%';
if (bannerProgressText) bannerProgressText.textContent = pct + '%'; if (bannerProgressWrapper) bannerProgressWrapper.setAttribute('aria-valuenow', String(pct));
// At 100% the bytes are sent but the server is still processing
if (bannerProgressText) bannerProgressText.innerHTML = bannerLabel(pct >= 100 ? '<i class="fa-solid fa-spinner fa-spin"></i>' : pct + '%');
} }
}); });
@@ -679,6 +745,7 @@
try { try {
const res = JSON.parse(xhr.responseText); const res = JSON.parse(xhr.responseText);
if (xhr.status === 200 && res.success) { if (xhr.status === 200 && res.success) {
bannerOk = true;
showBannerStatus(res.msg || 'Banner uploaded!', 'success'); showBannerStatus(res.msg || 'Banner uploaded!', 'success');
const bannerPreview = document.getElementById('banner-preview'); const bannerPreview = document.getElementById('banner-preview');
@@ -710,7 +777,19 @@
} }
const existingRemoveBtn = document.getElementById('banner-remove-btn'); const existingRemoveBtn = document.getElementById('banner-remove-btn');
if (!existingRemoveBtn) { const descControls = document.getElementById('desc-edit-controls');
if (!existingRemoveBtn && descControls) {
// Live card: add Reposition / Remove next to Change Banner, same as the server render
const mk = (id, cls, icon, label) => {
const b = document.createElement('button');
b.type = 'button'; b.id = id; b.className = cls;
b.style.cssText = 'padding: 4px 10px; font-size: 0.8em;';
b.innerHTML = `<i class="fa-solid ${icon}"></i> ${label}`;
return b;
};
descControls.appendChild(mk('banner-edit-btn', 'button button-sm', 'fa-arrows-up-down-left-right', 'Reposition'));
descControls.appendChild(mk('banner-remove-btn', 'button button-danger button-sm', 'fa-trash', 'Remove Banner'));
} else if (!existingRemoveBtn) {
const actionsDiv = bannerUploadBtn ? bannerUploadBtn.closest('.avatar-upload-actions') : null; const actionsDiv = bannerUploadBtn ? bannerUploadBtn.closest('.avatar-upload-actions') : null;
if (actionsDiv) { if (actionsDiv) {
const editBtn = document.createElement('button'); const editBtn = document.createElement('button');
@@ -762,14 +841,12 @@
showBannerStatus('Upload failed: Invalid response', 'error'); showBannerStatus('Upload failed: Invalid response', 'error');
} }
if (bannerProgressWrapper) bannerProgressWrapper.style.display = 'none'; finishBannerUpload(bannerOk);
if (bannerChooseBtn) bannerChooseBtn.disabled = false;
}); });
xhr.addEventListener('error', () => { xhr.addEventListener('error', () => {
showBannerStatus('Upload failed: Network error', 'error'); showBannerStatus('Upload failed: Network error', 'error');
if (bannerProgressWrapper) bannerProgressWrapper.style.display = 'none'; finishBannerUpload(false);
if (bannerChooseBtn) bannerChooseBtn.disabled = false;
}); });
xhr.open('POST', '/api/v2/settings/uploadBanner'); xhr.open('POST', '/api/v2/settings/uploadBanner');
@@ -1604,9 +1681,12 @@
onaraEnabledToggle.checked = stored !== null ? stored === 'true' : sessionOnara; onaraEnabledToggle.checked = stored !== null ? stored === 'true' : sessionOnara;
// Reflect current state into window.onara so isOnaraActive() sees it // Reflect current state into window.onara so isOnaraActive() sees it
window.onara = onaraEnabledToggle.checked; window.onara = onaraEnabledToggle.checked;
// Only persist an explicit choice; without one the config default keeps applying
if (stored !== null) {
try { try {
document.cookie = `f0ck_onara=${onaraEnabledToggle.checked ? '1' : '0'}; path=/; max-age=31536000; SameSite=Lax`; document.cookie = `f0ck_onara=${onaraEnabledToggle.checked ? '1' : '0'}; path=/; max-age=31536000; SameSite=Lax`;
} catch {} } catch {}
}
onaraEnabledToggle.addEventListener('change', () => { onaraEnabledToggle.addEventListener('change', () => {
const enabled = onaraEnabledToggle.checked; const enabled = onaraEnabledToggle.checked;
@@ -1950,6 +2030,17 @@
const liveDesc = document.getElementById('live-preview-description'); const liveDesc = document.getElementById('live-preview-description');
const descControls = document.getElementById('desc-edit-controls'); const descControls = document.getElementById('desc-edit-controls');
// Inline-editable profile fields are text inputs: keep their key events away from the site-wide
// shortcut handlers (bubble-phase on document/window), not all of which skip contenteditable.
// The fields' own listeners (Enter / Ctrl+Enter to save, Escape) sit on the element and still run.
document.querySelectorAll('.settings [contenteditable="true"]').forEach((el) => {
if (el.dataset.keysIsolated) return;
el.dataset.keysIsolated = '1';
['keydown', 'keyup', 'keypress'].forEach((type) => {
el.addEventListener(type, (e) => e.stopPropagation());
});
});
if (liveDesc && descriptionTextarea) { if (liveDesc && descriptionTextarea) {
liveDesc.addEventListener('focus', () => { liveDesc.addEventListener('focus', () => {
if (descControls) descControls.style.display = 'flex'; if (descControls) descControls.style.display = 'flex';
@@ -2072,7 +2163,9 @@
if (passwordForm) { if (passwordForm) {
passwordForm.addEventListener('submit', async (e) => { passwordForm.addEventListener('submit', async (e) => {
e.preventDefault(); e.preventDefault();
const current_password = document.getElementById('current_password').value; // Absent on passkey-only accounts (no current password; setting one re-enables password login)
const currentInput = document.getElementById('current_password');
const current_password = currentInput ? currentInput.value : '';
const new_password = document.getElementById('new_password').value; const new_password = document.getElementById('new_password').value;
const new_password_confirm = document.getElementById('new_password_confirm').value; const new_password_confirm = document.getElementById('new_password_confirm').value;
@@ -2082,6 +2175,7 @@
} }
const btn = passwordForm.querySelector('button'); const btn = passwordForm.querySelector('button');
const btnLabel = btn.textContent;
btn.disabled = true; btn.disabled = true;
btn.textContent = 'Updating...'; btn.textContent = 'Updating...';
@@ -2098,6 +2192,8 @@
if (data.success) { if (data.success) {
showAccountStatus(passwordStatus, data.msg || 'Password updated correctly', 'success'); showAccountStatus(passwordStatus, data.msg || 'Password updated correctly', 'success');
passwordForm.reset(); passwordForm.reset();
// Password login was off: reload so the account section shows the normal state again
if (!currentInput) setTimeout(() => location.reload(), 900);
} else { } else {
showAccountStatus(passwordStatus, data.msg || 'Failed to update password', 'error'); showAccountStatus(passwordStatus, data.msg || 'Failed to update password', 'error');
} }
@@ -2105,7 +2201,7 @@
showAccountStatus(passwordStatus, 'Request failed', 'error'); showAccountStatus(passwordStatus, 'Request failed', 'error');
} finally { } finally {
btn.disabled = false; btn.disabled = false;
btn.textContent = 'Update Password'; btn.textContent = btnLabel;
} }
}); });
} }
-1
View File
@@ -31,7 +31,6 @@ const tpl_player = (svg, size) => `<div class="v0ck_player_controls">
</svg> </svg>
</button> </button>
<div class="v0ck_settings_menu v0ck_hidden"> <div class="v0ck_settings_menu v0ck_hidden">
<button id="toggleswf" class="v0ck_menu_item" title="Flash Yank" tabindex="-1">SWF</button>
<div class="v0ck_menu_item v0ck_bg_row"> <div class="v0ck_menu_item v0ck_bg_row">
<span class="v0ck_switch_label">Background</span> <span class="v0ck_switch_label">Background</span>
<div id="togglebg" class="v0ck_cool_switch" title="Toggle Background"></div> <div id="togglebg" class="v0ck_cool_switch" title="Toggle Background"></div>
+10 -1
View File
@@ -118,7 +118,7 @@ export async function getOrCreateAnonUserByCredential(credentialId, req = null,
* @param {string} [hwFingerprint] * @param {string} [hwFingerprint]
* @returns {Promise<{ session: string, csrf_token: string }>} * @returns {Promise<{ session: string, csrf_token: string }>}
*/ */
export async function createAnonSession(userId, req, hwFingerprint = null) { export async function createAnonSession(userId, req, hwFingerprint = null, credentialId = null) {
const auditIp = resolveAuditIP(req); const auditIp = resolveAuditIP(req);
// Update anon_identities last_ip and hw_fingerprint // Update anon_identities last_ip and hw_fingerprint
@@ -130,8 +130,15 @@ export async function createAnonSession(userId, req, hwFingerprint = null) {
WHERE user_id = ${userId} WHERE user_id = ${userId}
`.catch(() => {}); `.catch(() => {});
// Remember which passkey this session runs on (settings: can't delete the one in use)
const markCredential = async (sessionHash) => {
if (!credentialId) return;
await db`UPDATE user_sessions SET passkey_credential_id = ${credentialId} WHERE session = ${sessionHash}`.catch(() => {});
};
// If req.session is already active for this exact userId, reuse it // If req.session is already active for this exact userId, reuse it
if (req?.session && req.session.id === userId && req.session.csrf_token && req.cookies?.session) { if (req?.session && req.session.id === userId && req.session.csrf_token && req.cookies?.session) {
await markCredential(lib.sha256(req.cookies.session));
await logAnonActivity(req, { action: 'handshake', hwFingerprint }); await logAnonActivity(req, { action: 'handshake', hwFingerprint });
return { session: req.cookies.session, csrf_token: req.session.csrf_token }; return { session: req.cookies.session, csrf_token: req.session.csrf_token };
} }
@@ -146,6 +153,7 @@ export async function createAnonSession(userId, req, hwFingerprint = null) {
`; `;
if (existing.length > 0) { if (existing.length > 0) {
await db`UPDATE user_sessions SET last_used = ${~~(Date.now() / 1e3)} WHERE session = ${existingHash}`; await db`UPDATE user_sessions SET last_used = ${~~(Date.now() / 1e3)} WHERE session = ${existingHash}`;
await markCredential(existingHash);
await logAnonActivity({ ...req, session: { id: userId, is_anon: true } }, { action: 'handshake', hwFingerprint }); await logAnonActivity({ ...req, session: { id: userId, is_anon: true } }, { action: 'handshake', hwFingerprint });
return { session: req.cookies.session, csrf_token: existing[0].csrf_token }; return { session: req.cookies.session, csrf_token: existing[0].csrf_token };
} }
@@ -173,6 +181,7 @@ export async function createAnonSession(userId, req, hwFingerprint = null) {
await db` await db`
INSERT INTO "user_sessions" ${db(sessRecord, 'user_id', 'session', 'csrf_token', 'browser', 'created_at', 'last_used', 'last_action', 'kmsi', 'ip')} INSERT INTO "user_sessions" ${db(sessRecord, 'user_id', 'session', 'csrf_token', 'browser', 'created_at', 'last_used', 'last_action', 'kmsi', 'ip')}
`; `;
await markCredential(sessionHash);
await logAnonActivity({ ...req, session: { id: userId, is_anon: true } }, { action: 'handshake', hwFingerprint }); await logAnonActivity({ ...req, session: { id: userId, is_anon: true } }, { action: 'handshake', hwFingerprint });
+1
View File
@@ -299,6 +299,7 @@
"focus_comment": "Kommentarfeld fokussieren", "focus_comment": "Kommentarfeld fokussieren",
"send_comment": "Kommentar senden", "send_comment": "Kommentar senden",
"flash_yank": "Flash-Yank ein-/ausschalten", "flash_yank": "Flash-Yank ein-/ausschalten",
"open_settings": "Einstellungen öffnen/schließen",
"tag_exclude": "Tag-Ausschluss öffnen", "tag_exclude": "Tag-Ausschluss öffnen",
"tag_input": "Tag-Eingabe öffnen", "tag_input": "Tag-Eingabe öffnen",
"toggle_bg": "Hintergrund ein-/ausschalten", "toggle_bg": "Hintergrund ein-/ausschalten",
+1
View File
@@ -299,6 +299,7 @@
"focus_comment": "focus comment input", "focus_comment": "focus comment input",
"send_comment": "send comment", "send_comment": "send comment",
"flash_yank": "enable/disable flash yank", "flash_yank": "enable/disable flash yank",
"open_settings": "open/close settings",
"tag_exclude": "open tag exclude", "tag_exclude": "open tag exclude",
"tag_input": "open tag input", "tag_input": "open tag input",
"toggle_bg": "turns on/off the background", "toggle_bg": "turns on/off the background",
+1
View File
@@ -297,6 +297,7 @@
"focus_comment": "focus op commentaarinvoer", "focus_comment": "focus op commentaarinvoer",
"send_comment": "opmerking verzenden", "send_comment": "opmerking verzenden",
"flash_yank": "flash yank in/uitschakelen", "flash_yank": "flash yank in/uitschakelen",
"open_settings": "instellingen openen/sluiten",
"tag_exclude": "open tag uitsluiten", "tag_exclude": "open tag uitsluiten",
"tag_input": "open tag invoer", "tag_input": "open tag invoer",
"toggle_bg": "turns on/off the background", "toggle_bg": "turns on/off the background",
+1
View File
@@ -295,6 +295,7 @@
"focus_comment": "Kommentareingabe fokussieren", "focus_comment": "Kommentareingabe fokussieren",
"send_comment": "Kommentar senden", "send_comment": "Kommentar senden",
"flash_yank": "Blitz-Rucken aktivieren/deaktivieren", "flash_yank": "Blitz-Rucken aktivieren/deaktivieren",
"open_settings": "Einstellungen auf-/zuklappen",
"tag_exclude": "Etiketten-Ausschluss öffnen", "tag_exclude": "Etiketten-Ausschluss öffnen",
"tag_input": "Etiketten-Eingabe öffnen", "tag_input": "Etiketten-Eingabe öffnen",
"toggle_bg": "Hintergrund ein-/ausschalten", "toggle_bg": "Hintergrund ein-/ausschalten",
+174 -9
View File
@@ -168,7 +168,7 @@ export default (router, tpl) => {
return res.reply({ code: 429, body: tpl.render("forgot-password", { error: msg }) }); return res.reply({ code: 429, body: tpl.render("forgot-password", { error: msg }) });
} }
const user = (await db`select id, login from "user" where lower(email) = lower(${email.trim()}) limit 1`)[0]; const user = (await db`select id, login, password from "user" where lower(email) = lower(${email.trim()}) limit 1`)[0];
const targetIdentity = user ? user.login : email; const targetIdentity = user ? user.login : email;
// 2. Identity-based check // 2. Identity-based check
@@ -182,7 +182,8 @@ export default (router, tpl) => {
// but the user wants "maximum tries per day is 1", so we record it regardless of email existence) // but the user wants "maximum tries per day is 1", so we record it regardless of email existence)
await security.recordAttempt(ip, targetIdentity, 'password_reset_request', true); await security.recordAttempt(ip, targetIdentity, 'password_reset_request', true);
if (!user) { // Passkey-only accounts (password '!') can't get a password by mail: same neutral answer, no mail
if (!user || user.password === '!') {
const msg = "If an account with that email exists, we have sent a reset link."; const msg = "If an account with that email exists, we have sent a reset link.";
if (isAJAX) return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: true, msg })); if (isAJAX) return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: true, msg }));
return res.reply({ body: tpl.render("forgot-password", { success: msg }) }); return res.reply({ body: tpl.render("forgot-password", { success: msg }) });
@@ -259,7 +260,7 @@ export default (router, tpl) => {
return res.reply({ code: 429, body: tpl.render("forgot-password", { error: msg }) }); return res.reply({ code: 429, body: tpl.render("forgot-password", { error: msg }) });
} }
const user = (await db`select id from "user" where reset_token = ${token} and reset_expires > now() limit 1`)[0]; const user = (await db`select id from "user" where reset_token = ${token} and reset_expires > now() and password <> '!' limit 1`)[0];
if (!user) { if (!user) {
const msg = "Invalid or expired reset token."; const msg = "Invalid or expired reset token.";
if (isAJAX) return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg })); if (isAJAX) return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg }));
@@ -287,16 +288,18 @@ export default (router, tpl) => {
router.get(/^\/admin(\/)?$/, lib.auth, async (req, res) => { // frontpage router.get(/^\/admin(\/)?$/, lib.auth, async (req, res) => { // frontpage
// Dashboard counters (cheap aggregate queries; failures just show 0) // Dashboard counters (cheap aggregate queries; failures just show 0)
const dash = { open_reports: 0, users: 0, anon_users: 0, trash: 0 }; const dash = { open_reports: 0, users: 0, anon_users: 0, trash: 0, invite_requests: 0 };
try { try {
const [[r], [u], [t]] = await Promise.all([ const [[r], [u], [t], [ir]] = await Promise.all([
db`SELECT count(*)::int AS n FROM reports WHERE status = 'pending'`, db`SELECT count(*)::int AS n FROM reports WHERE status = 'pending'`,
db`SELECT count(*) FILTER (WHERE NOT EXISTS (SELECT 1 FROM anon_identities ai WHERE ai.user_id = "user".id))::int AS users, db`SELECT count(*) FILTER (WHERE NOT EXISTS (SELECT 1 FROM anon_identities ai WHERE ai.user_id = "user".id))::int AS users,
count(*) FILTER (WHERE EXISTS (SELECT 1 FROM anon_identities ai WHERE ai.user_id = "user".id))::int AS anon count(*) FILTER (WHERE EXISTS (SELECT 1 FROM anon_identities ai WHERE ai.user_id = "user".id))::int AS anon
FROM "user"`, FROM "user"`,
db`SELECT count(*)::int AS n FROM items WHERE active = false AND is_deleted = true AND is_purged = false` db`SELECT count(*)::int AS n FROM items WHERE active = false AND is_deleted = true AND is_purged = false`,
db`SELECT count(*)::int AS n FROM invite_requests WHERE status = 'pending'`
]); ]);
dash.trash = t?.n || 0; dash.trash = t?.n || 0;
dash.invite_requests = ir?.n || 0;
dash.open_reports = r?.n || 0; dash.open_reports = r?.n || 0;
dash.users = u?.users || 0; dash.users = u?.users || 0;
dash.anon_users = u?.anon || 0; dash.anon_users = u?.anon || 0;
@@ -669,11 +672,154 @@ export default (router, tpl) => {
if (res.json) return res.json({ success: false }); if (res.json) return res.json({ success: false });
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false })); return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false }));
} }
// Revoke any linked invite request and notify the user
const [linkedRequest] = await db`
SELECT ir.id, ir.user_id FROM invite_requests ir
WHERE ir.token_id = ${req.post.id} AND ir.status = 'approved'
`;
if (linkedRequest) {
await db`
UPDATE invite_requests
SET status = 'revoked', reviewed_at = NOW(), reviewed_by = ${req.session.id}
WHERE id = ${linkedRequest.id}
`;
if (linkedRequest.user_id) {
await db`
INSERT INTO notifications (user_id, type, reference_id, data)
VALUES (${linkedRequest.user_id}, 'invite_denied', 0, ${db.json({ revoked: true })})
`;
}
}
await db`delete from invite_tokens where id = ${req.post.id}`; await db`delete from invite_tokens where id = ${req.post.id}`;
if (res.json) return res.json({ success: true }); if (res.json) return res.json({ success: true });
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: true })); return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: true }));
}); });
// ═══════════════════ Invite Requests (admin) ═══════════════════
// List all invite requests
router.get(/^\/api\/v2\/admin\/invite-requests\/?$/, lib.auth, async (req, res) => {
try {
const requests = await db`
SELECT ir.*,
u_reviewer.user as reviewed_by_name,
u_requester.login as requester_login,
u_requester.user as requester_name
FROM invite_requests ir
LEFT JOIN "user" u_reviewer ON u_reviewer.id = ir.reviewed_by
LEFT JOIN "user" u_requester ON u_requester.id = ir.user_id
ORDER BY
CASE WHEN ir.status = 'pending' THEN 0 ELSE 1 END,
ir.created_at DESC
LIMIT 200
`;
if (res.json) return res.json({ success: true, requests });
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: true, requests }));
} catch (err) {
const msg = lib.logError(err, 'Failed to fetch invite requests');
if (res.json) return res.json({ success: false, msg });
return res.writeHead(500, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg }));
}
});
// Approve an invite request — generates a token and notifies the anon user
router.post(/^\/api\/v2\/admin\/invite-requests\/approve\/?$/, lib.auth, async (req, res) => {
try {
const { id } = req.post;
if (!id) {
if (res.json) return res.json({ success: false, msg: 'Missing request id' });
return res.writeHead(400, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: 'Missing request id' }));
}
// Check request exists and is pending
const [request] = await db`SELECT * FROM invite_requests WHERE id = ${+id} AND status = 'pending'`;
if (!request) {
if (res.json) return res.json({ success: false, msg: 'Request not found or already processed' });
return res.writeHead(404, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: 'Request not found or already processed' }));
}
// Generate invite token
const token = crypto.randomBytes(16).toString('hex').toUpperCase();
const [tokenRow] = await db`
INSERT INTO invite_tokens (token, created_at, created_by)
VALUES (${token}, ${~~(Date.now() / 1e3)}, ${req.session.id})
RETURNING id
`;
// Update request status
await db`
UPDATE invite_requests
SET status = 'approved', token_id = ${tokenRow.id}, reviewed_by = ${req.session.id}, reviewed_at = NOW()
WHERE id = ${+id}
`;
// Find the anon user's real user_id via their fingerprint, and send them a notification
const anonUser = await db`
SELECT user_id FROM anon_identities WHERE fingerprint = ${request.fingerprint} LIMIT 1
`;
if (anonUser.length > 0) {
await db`
INSERT INTO notifications (user_id, type, reference_id, data)
VALUES (${anonUser[0].user_id}, 'invite_approved', 0, ${db.json({ token, request_id: +id })})
`;
}
console.log(`[INVITE-REQ] Approved request #${id} (fp: ${request.fingerprint.slice(0, 12)}…) → token ${token.slice(0, 8)}…`);
if (res.json) return res.json({ success: true, token });
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: true, token }));
} catch (err) {
const msg = lib.logError(err, 'Failed to approve invite request');
if (res.json) return res.json({ success: false, msg });
return res.writeHead(500, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg }));
}
});
// Deny an invite request
router.post(/^\/api\/v2\/admin\/invite-requests\/deny\/?$/, lib.auth, async (req, res) => {
try {
const { id } = req.post;
if (!id) {
if (res.json) return res.json({ success: false, msg: 'Missing request id' });
return res.writeHead(400, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: 'Missing request id' }));
}
const [request] = await db`SELECT * FROM invite_requests WHERE id = ${+id} AND status = 'pending'`;
if (!request) {
if (res.json) return res.json({ success: false, msg: 'Request not found or already processed' });
return res.writeHead(404, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: 'Request not found or already processed' }));
}
await db`
UPDATE invite_requests
SET status = 'denied', reviewed_by = ${req.session.id}, reviewed_at = NOW()
WHERE id = ${+id}
`;
// Notify the anon user their request was denied
const anonUser = await db`
SELECT user_id FROM anon_identities WHERE fingerprint = ${request.fingerprint} LIMIT 1
`;
if (anonUser.length > 0) {
await db`
INSERT INTO notifications (user_id, type, reference_id, data)
VALUES (${anonUser[0].user_id}, 'invite_denied', 0, ${db.json({ request_id: +id })})
`;
}
console.log(`[INVITE-REQ] Denied request #${id} (fp: ${request.fingerprint.slice(0, 12)}…)`);
if (res.json) return res.json({ success: true });
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: true }));
} catch (err) {
const msg = lib.logError(err, 'Failed to deny invite request');
if (res.json) return res.json({ success: false, msg });
return res.writeHead(500, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg }));
}
});
router.post(/^\/api\/v2\/admin\/ban\/?$/, lib.modAuth, async (req, res) => { router.post(/^\/api\/v2\/admin\/ban\/?$/, lib.modAuth, async (req, res) => {
try { try {
const { user_id, reason, duration } = req.post; const { user_id, reason, duration } = req.post;
@@ -1544,6 +1690,26 @@ export default (router, tpl) => {
} }
}); });
// The system ghost that deleted users' content is reassigned to. It can never log in: password '!'
// matches no hash, it is not activated and it is banned (the unban route refuses to touch it).
const ensureGhostUser = async () => {
let ghost = await db`SELECT id FROM "user" WHERE login = 'deleted_user' LIMIT 1`;
if (ghost.length) return ghost;
await db`
INSERT INTO "user" (login, "user", password, admin, is_moderator, activated, banned, ban_reason, created_at)
VALUES ('deleted_user', 'deleted_user', '!', false, false, false, true, 'System account', now())
ON CONFLICT (login) DO NOTHING
`;
ghost = await db`SELECT id FROM "user" WHERE login = 'deleted_user' LIMIT 1`;
if (!ghost.length) throw new Error('Could not create the "deleted_user" ghost account.');
await db`
INSERT INTO user_options (user_id, mode, theme, fullscreen, avatar, avatar_file, display_name)
VALUES (${ghost[0].id}, 0, 'amoled', 0, null, 'default.png', 'deleted user')
ON CONFLICT (user_id) DO NOTHING
`;
return ghost;
};
router.post(/^\/api\/v2\/admin\/users\/delete\/?$/, lib.auth, async (req, res) => { router.post(/^\/api\/v2\/admin\/users\/delete\/?$/, lib.auth, async (req, res) => {
try { try {
const { user_id } = req.post; const { user_id } = req.post;
@@ -1554,9 +1720,8 @@ export default (router, tpl) => {
if (!target.length) throw new Error('User not found'); if (!target.length) throw new Error('User not found');
if (target[0].login === 'deleted_user') throw new Error('The deleted_user account is protected and cannot be deleted.'); if (target[0].login === 'deleted_user') throw new Error('The deleted_user account is protected and cannot be deleted.');
// Get deleted_user info // Get deleted_user info (created on first use; see migrations/add_deleted_user_ghost.sql)
const ghost = await db`SELECT id FROM "user" WHERE login = 'deleted_user' LIMIT 1`; const ghost = await ensureGhostUser();
if (!ghost.length) throw new Error('Ghost account "deleted_user" not found. Please run migration.');
const targetId = target[0].id; const targetId = target[0].id;
const targetLogin = target[0].login; const targetLogin = target[0].login;
+43 -5
View File
@@ -12,7 +12,7 @@ import {
generateChallenge, consumeChallenge, generateChallenge, consumeChallenge,
verifyRegistration, verifyAuthentication, verifyRegistration, verifyAuthentication,
buildRegistrationOptions, buildAuthenticationOptions, buildRegistrationOptions, buildAuthenticationOptions,
base64url, fromBase64url, getRpIdFromHost base64url, fromBase64url, getRpIdFromHost, aaguidProvider
} from '../../webauthn.mjs'; } from '../../webauthn.mjs';
import { getEnableAnonymousAccess, getHwFingerprintEnabled } from '../../settings.mjs'; import { getEnableAnonymousAccess, getHwFingerprintEnabled } from '../../settings.mjs';
@@ -247,7 +247,10 @@ export default router => {
); );
// Check user table ban // Check user table ban
const userRows = await db`SELECT banned, ban_reason, ban_expires FROM "user" WHERE id = ${userId} LIMIT 1`; const userRows = await db`SELECT banned, ban_reason, ban_expires, activated FROM "user" WHERE id = ${userId} LIMIT 1`;
if (userRows.length > 0 && userRows[0].activated === false) {
return res.json({ success: false, msg: 'This account is not activated. Please check your email.' }, 403);
}
if (userRows.length > 0 && userRows[0].banned) { if (userRows.length > 0 && userRows[0].banned) {
const u = userRows[0]; const u = userRows[0];
setBanCookie(res, u.ban_reason || 'Banned', u.ban_expires); setBanCookie(res, u.ban_reason || 'Banned', u.ban_expires);
@@ -262,7 +265,7 @@ export default router => {
SET sign_count = ${regResult.signCount}, last_used = NOW() SET sign_count = ${regResult.signCount}, last_used = NOW()
`; `;
const { session, csrf_token } = await createAnonSession(userId, req, hwFingerprint || null); const { session, csrf_token } = await createAnonSession(userId, req, hwFingerprint || null, credentialId);
res.setHeader('Set-Cookie', `session=${session}; ${lib.getCookieOptions('Fri, 31 Dec 9999 23:59:59 GMT')}`); res.setHeader('Set-Cookie', `session=${session}; ${lib.getCookieOptions('Fri, 31 Dec 9999 23:59:59 GMT')}`);
return res.json({ return res.json({
@@ -402,7 +405,10 @@ export default router => {
} }
// Check user table ban // Check user table ban
const userRows = await db`SELECT banned, ban_reason, ban_expires FROM "user" WHERE id = ${userId} LIMIT 1`; const userRows = await db`SELECT banned, ban_reason, ban_expires, activated FROM "user" WHERE id = ${userId} LIMIT 1`;
if (userRows.length > 0 && userRows[0].activated === false) {
return res.json({ success: false, msg: 'This account is not activated. Please check your email.' }, 403);
}
if (userRows.length > 0 && userRows[0].banned) { if (userRows.length > 0 && userRows[0].banned) {
const u = userRows[0]; const u = userRows[0];
setBanCookie(res, u.ban_reason || 'Banned', u.ban_expires); setBanCookie(res, u.ban_reason || 'Banned', u.ban_expires);
@@ -424,7 +430,7 @@ export default router => {
WHERE user_id = ${userId} AND credential_id = ${credentialId} WHERE user_id = ${userId} AND credential_id = ${credentialId}
`.catch(() => {}); `.catch(() => {});
const { session, csrf_token } = await createAnonSession(userId, req, hwFingerprint || null); const { session, csrf_token } = await createAnonSession(userId, req, hwFingerprint || null, credentialId);
res.setHeader('Set-Cookie', `session=${session}; ${lib.getCookieOptions('Fri, 31 Dec 9999 23:59:59 GMT')}`); res.setHeader('Set-Cookie', `session=${session}; ${lib.getCookieOptions('Fri, 31 Dec 9999 23:59:59 GMT')}`);
return res.json({ return res.json({
@@ -567,6 +573,38 @@ export default router => {
// ─── Identity ───────────────────────────────────────────────────────────── // ─── Identity ─────────────────────────────────────────────────────────────
/**
* GET /api/v2/anon/passkeys
* The current anonymous identity's passkeys (settings page). `primary` marks the one the
* identity's fingerprint is derived from.
*/
group.get(/\/passkeys$/, async (req, res) => {
try {
const userId = await getAnonSessionUserId(req);
if (!userId) return res.json({ success: false, msg: 'Not logged in as anonymous' }, 401);
const rows = await db`
SELECT pc.id, pc.credential_id, pc.name, pc.aaguid, pc.created_at, pc.last_used,
(ai.credential_id IS NOT NULL) AS primary
FROM passkey_credentials pc
LEFT JOIN anon_identities ai ON ai.user_id = pc.user_id AND ai.credential_id = pc.credential_id
WHERE pc.user_id = ${userId}
ORDER BY pc.created_at ASC
`;
const [sess] = req.session.sess_id
? await db`SELECT passkey_credential_id FROM user_sessions WHERE id = ${+req.session.sess_id}`
: [];
const inUse = sess?.passkey_credential_id || null;
return res.json({
success: true,
max: MAX_ANON_PASSKEYS,
passkeys: rows.map(r => ({ ...r, provider: aaguidProvider(r.aaguid), current: !!inUse && r.credential_id === inUse }))
});
} catch (err) {
console.error('[ANON_PASSKEY] list error:', err);
return res.json({ success: false, msg: 'Failed to load passkeys' }, 500);
}
});
/** /**
* GET /api/v2/anon/identity * GET /api/v2/anon/identity
* Get the current anonymous identity or registered user state. * Get the current anonymous identity or registered user state.
+167 -7
View File
@@ -10,9 +10,29 @@ import {
generateChallenge, consumeChallenge, generateChallenge, consumeChallenge,
verifyRegistration, verifyAuthentication, verifyRegistration, verifyAuthentication,
buildRegistrationOptions, buildAuthenticationOptions, buildRegistrationOptions, buildAuthenticationOptions,
base64url, getRpIdFromHost base64url, getRpIdFromHost, aaguidProvider
} from '../../webauthn.mjs'; } from '../../webauthn.mjs';
// Passkey-only accounts store this instead of a hash; lib.verify never matches it
const PASSWORD_DISABLED = '!';
const LAST_PASSKEY_MSG = 'This is your last passkey and password login is disabled. Set a password first, or add another passkey.';
const IN_USE_PASSKEY_MSG = 'This passkey is the one you are signed in with right now and cannot be removed from this session.';
// The passkey the current session was opened with (null: password login or an older session)
const sessionPasskey = async (req) => {
if (!req.session?.sess_id) return null;
const [row] = await db`select passkey_credential_id from user_sessions where id = ${+req.session.sess_id}`;
return row?.passkey_credential_id || null;
};
// True when removing a passkey would leave a passkey-only account with no way to log in
const isLastPasskeyOfPasskeyOnly = async (userId) => {
const [row] = await db`
select (u.password = ${PASSWORD_DISABLED}) as disabled,
(select count(*)::int from passkey_credentials pc where pc.user_id = u.id) as n
from "user" u where u.id = ${+userId}
`;
return !!row && row.disabled && row.n <= 1;
};
// Note: Avatar upload/delete is handled by middleware in index.mjs via avatar_handler.mjs // Note: Avatar upload/delete is handled by middleware in index.mjs via avatar_handler.mjs
// These routes remain for other settings API endpoints // These routes remain for other settings API endpoints
@@ -442,6 +462,34 @@ export default router => {
} }
}); });
/**
* POST /api/v2/settings/password/disable
* Passkey-only account: switch off password login. Needs the current password (a hijacked session
* alone can't lock the owner out) and at least one passkey. Setting a new password re-enables it.
*/
group.post(/\/password\/disable$/, lib.registeredUser, async (req, res) => {
try {
const { current_password } = req.post || {};
const user = (await db`select password from "user" where id = ${+req.session.id}`)[0];
if (!user) return res.json({ success: false, msg: 'User not found' }, 404);
if (user.password === PASSWORD_DISABLED) return res.json({ success: false, msg: 'Password login is already disabled' }, 400);
if (!current_password || !(await lib.verify(current_password, user.password))) {
return res.json({ success: false, msg: 'Incorrect current password' }, 401);
}
const [{ n }] = await db`select count(*)::int as n from passkey_credentials where user_id = ${+req.session.id}`;
if (n < 1) return res.json({ success: false, msg: 'Add a passkey first, otherwise you could not log in anymore' }, 400);
await db`update "user" set password = ${PASSWORD_DISABLED}, force_password_change = false where id = ${+req.session.id}`;
// Sessions elsewhere may have been opened with the password: end them, keep this one
await db`delete from "user_sessions" where user_id = ${+req.session.id} and id != ${+req.session.sess_id}`;
await db`delete from login_attempts where username = ${req.session.login}`.catch(() => {});
return res.json({ success: true, msg: 'Password login disabled. Use your passkey to sign in.' });
} catch (err) {
console.error('[SETTINGS] Disable password error:', err);
return res.json({ success: false, msg: 'Failed to disable password' }, 500);
}
});
// Update password // Update password
group.put(/\/password/, lib.registeredUser, async (req, res) => { group.put(/\/password/, lib.registeredUser, async (req, res) => {
const { current_password, new_password, new_password_confirm } = req.post; const { current_password, new_password, new_password_confirm } = req.post;
@@ -453,7 +501,9 @@ export default router => {
const user = (await db`select password, force_password_change from "user" where id = ${+req.session.id}`)[0]; const user = (await db`select password, force_password_change from "user" where id = ${+req.session.id}`)[0];
if (!user) return res.json({ success: false, msg: 'User not found' }, 404); if (!user) return res.json({ success: false, msg: 'User not found' }, 404);
if (!user.force_password_change) { // Passkey-only accounts have no current password: setting one turns password login back on
const passwordDisabled = user.password === PASSWORD_DISABLED;
if (!user.force_password_change && !passwordDisabled) {
if (!current_password) { if (!current_password) {
return res.json({ success: false, msg: 'Current password is required' }, 400); return res.json({ success: false, msg: 'Current password is required' }, 400);
} }
@@ -576,7 +626,7 @@ export default router => {
}); });
// Update Font preference // Update Font preference
group.put(/\/font/, lib.registeredUser, async (req, res) => { group.put(/\/font/, lib.loggedin, async (req, res) => {
const { font } = req.post; const { font } = req.post;
// F-023 Security: Validate font against actual files on disk // F-023 Security: Validate font against actual files on disk
@@ -1216,7 +1266,11 @@ export default router => {
WHERE user_id = ${req.session.id} WHERE user_id = ${req.session.id}
ORDER BY created_at DESC ORDER BY created_at DESC
`; `;
return res.json({ success: true, passkeys: rows }); const inUse = await sessionPasskey(req);
return res.json({
success: true,
passkeys: rows.map(r => ({ ...r, provider: aaguidProvider(r.aaguid), current: !!inUse && r.credential_id === inUse }))
});
} catch (err) { } catch (err) {
console.error('[PASSKEYS] List error:', err); console.error('[PASSKEYS] List error:', err);
return res.json({ success: false, msg: err.message }, 500); return res.json({ success: false, msg: err.message }, 500);
@@ -1328,6 +1382,8 @@ export default router => {
const body = req.post || req.body || {}; const body = req.post || req.body || {};
const credentialId = body.credential_id; const credentialId = body.credential_id;
if (!credentialId) return res.json({ success: false, msg: 'Missing credential_id' }, 400); if (!credentialId) return res.json({ success: false, msg: 'Missing credential_id' }, 400);
if (credentialId === await sessionPasskey(req)) return res.json({ success: false, msg: IN_USE_PASSKEY_MSG }, 400);
if (await isLastPasskeyOfPasskeyOnly(req.session.id)) return res.json({ success: false, msg: LAST_PASSKEY_MSG }, 400);
const result = await db` const result = await db`
DELETE FROM passkey_credentials DELETE FROM passkey_credentials
WHERE credential_id = ${credentialId} AND user_id = ${req.session.id} WHERE credential_id = ${credentialId} AND user_id = ${req.session.id}
@@ -1348,6 +1404,8 @@ export default router => {
group.delete(/\/passkeys\/([^/]+)$/, lib.registeredUser, async (req, res) => { group.delete(/\/passkeys\/([^/]+)$/, lib.registeredUser, async (req, res) => {
try { try {
const credentialId = decodeURIComponent(req.url.pathname.split('/').pop()); const credentialId = decodeURIComponent(req.url.pathname.split('/').pop());
if (credentialId === await sessionPasskey(req)) return res.json({ success: false, msg: IN_USE_PASSKEY_MSG }, 400);
if (await isLastPasskeyOfPasskeyOnly(req.session.id)) return res.json({ success: false, msg: LAST_PASSKEY_MSG }, 400);
const result = await db` const result = await db`
DELETE FROM passkey_credentials DELETE FROM passkey_credentials
WHERE credential_id = ${credentialId} AND user_id = ${req.session.id} WHERE credential_id = ${credentialId} AND user_id = ${req.session.id}
@@ -1403,7 +1461,7 @@ export default router => {
// Look up credential — must belong to a registered (non-anon) user // Look up credential — must belong to a registered (non-anon) user
const credRows = await db` const credRows = await db`
SELECT pc.user_id, pc.public_key_spki, pc.sign_count, SELECT pc.user_id, pc.public_key_spki, pc.sign_count,
u.login, u.user, u.banned, u.ban_reason, u.ban_expires, u.force_password_change u.login, u.user, u.activated, u.banned, u.ban_reason, u.ban_expires, u.force_password_change
FROM passkey_credentials pc FROM passkey_credentials pc
JOIN "user" u ON u.id = pc.user_id JOIN "user" u ON u.id = pc.user_id
WHERE pc.credential_id = ${credentialId} WHERE pc.credential_id = ${credentialId}
@@ -1421,6 +1479,10 @@ export default router => {
return res.json({ success: false, msg: 'Account is banned: ' + (row.ban_reason || '') }, 403); return res.json({ success: false, msg: 'Account is banned: ' + (row.ban_reason || '') }, 403);
} }
if (!row.activated) {
return res.json({ success: false, msg: 'This account is not activated. Please check your email.' }, 403);
}
// Verify the assertion // Verify the assertion
try { try {
await verifyAuthentication({ await verifyAuthentication({
@@ -1457,9 +1519,10 @@ export default router => {
last_used: stamp, last_used: stamp,
last_action: '/passkey-login', last_action: '/passkey-login',
kmsi: 1, kmsi: 1,
ip ip,
passkey_credential_id: credentialId
}; };
await db`INSERT INTO "user_sessions" ${db(sessRecord, 'user_id', 'session', 'csrf_token', 'browser', 'created_at', 'last_used', 'last_action', 'kmsi', 'ip')}`; await db`INSERT INTO "user_sessions" ${db(sessRecord, 'user_id', 'session', 'csrf_token', 'browser', 'created_at', 'last_used', 'last_action', 'kmsi', 'ip', 'passkey_credential_id')}`;
res.setHeader('Set-Cookie', `session=${sessionToken}; ${lib.getCookieOptions('Fri, 31 Dec 9999 23:59:59 GMT')}`); res.setHeader('Set-Cookie', `session=${sessionToken}; ${lib.getCookieOptions('Fri, 31 Dec 9999 23:59:59 GMT')}`);
return res.json({ success: true, user: row.user, login: row.login, force_password_change: row.force_password_change || false }); return res.json({ success: true, user: row.user, login: row.login, force_password_change: row.force_password_change || false });
@@ -1472,5 +1535,102 @@ export default router => {
return group; return group;
}); });
// ═══════════════════ Invite Request (anon user) ═══════════════════
// Submit an invite request (requires a session — anon or registered, but mainly for anon)
router.post(/^\/api\/v2\/invite-request\/?$/, lib.loggedin, async (req, res) => {
try {
if (!req.session.is_anon) {
return res.json({ success: false, msg: 'You already have a registered account' }, 400);
}
const fingerprint = req.session.fingerprint;
if (!fingerprint) {
return res.json({ success: false, msg: 'No passkey identity found' }, 400);
}
// Check if there's already a pending request for this fingerprint
const [existing] = await db`
SELECT id, status FROM invite_requests
WHERE fingerprint = ${fingerprint} AND status = 'pending'
LIMIT 1
`;
if (existing) {
return res.json({ success: false, msg: 'You already have a pending invite request' }, 409);
}
const reason = (req.post.reason || '').trim().slice(0, 500);
const ip = security.getRealIP(req);
const ipHash = ip ? crypto.createHash('sha256').update(ip).digest('hex').slice(0, 16) : null;
await db`
INSERT INTO invite_requests (user_id, fingerprint, ip_hash, reason)
VALUES (${req.session.id}, ${fingerprint}, ${ipHash}, ${reason})
`;
// Notify all admin users
const anonLogin = req.session.login || req.session.user || 'anonymous';
const admins = await db`SELECT id FROM "user" WHERE admin = true`;
if (admins.length > 0) {
const notifications = admins.map(a => ({
user_id: a.id,
type: 'invite_request',
reference_id: 0,
data: db.json({ username: anonLogin, reason: reason.slice(0, 100) })
}));
await db`INSERT INTO notifications ${db(notifications)}`;
}
console.log(`[INVITE-REQ] New request from fp: ${fingerprint.slice(0, 12)}…`);
return res.json({ success: true, msg: 'Invite request submitted! An admin will review it shortly.' });
} catch (err) {
console.error('[INVITE-REQ] Submit error:', err);
return res.json({ success: false, msg: 'Failed to submit request' }, 500);
}
});
// Check status of current invite request
router.get(/^\/api\/v2\/invite-request\/status\/?$/, lib.loggedin, async (req, res) => {
try {
const fingerprint = req.session.fingerprint;
if (!fingerprint) {
return res.json({ success: true, status: null });
}
const [request] = await db`
SELECT ir.id, ir.status, ir.created_at, ir.reviewed_at,
it.token, it.is_used
FROM invite_requests ir
LEFT JOIN invite_tokens it ON it.id = ir.token_id
WHERE ir.fingerprint = ${fingerprint}
ORDER BY ir.created_at DESC
LIMIT 1
`;
if (!request) {
return res.json({ success: true, status: null });
}
// Auto-revoke if token was deleted or already used but status still says approved
let status = request.status;
if (status === 'approved' && (!request.token || request.is_used)) {
await db`UPDATE invite_requests SET status = 'revoked' WHERE id = ${request.id}`;
status = 'revoked';
}
return res.json({
success: true,
status,
created_at: request.created_at,
reviewed_at: request.reviewed_at,
token: status === 'approved' ? request.token : undefined
});
} catch (err) {
console.error('[INVITE-REQ] Status check error:', err);
return res.json({ success: false, msg: 'Failed to check status' }, 500);
}
});
return router; return router;
}; };
+8
View File
@@ -9,6 +9,7 @@ import queue from '../../queue.mjs';
import path from "path"; import path from "path";
import f0cklib from "../../routeinc/f0cklib.mjs"; import f0cklib from "../../routeinc/f0cklib.mjs";
import { addPrivateItem } from "../../private_items.mjs"; import { addPrivateItem } from "../../private_items.mjs";
import { TRANSCODE_TO_MP4, transcodeToMp4 } from "../../transcode.mjs";
// ────────────────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────────────────
// In-memory job progress map (keyed by jobId string) // In-memory job progress map (keyed by jobId string)
@@ -734,6 +735,13 @@ export default router => {
source = source.replace(/\.mkv$/, '.mp4'); source = source.replace(/\.mkv$/, '.mp4');
mime = 'video/mp4'; mime = 'video/mp4';
} }
if (TRANSCODE_TO_MP4.has(mime)) { // .mpg etc.: browsers can't play it, re-encode
const converted = source.replace(/\.[^./]+$/, '') + '.conv.mp4';
await transcodeToMp4(queue, source, converted);
await fs.unlink(source).catch(() => {});
source = converted;
mime = 'video/mp4';
}
if (source.match(/\.opus$/)) { if (source.match(/\.opus$/)) {
await queue.spawn('ffmpeg', ['-i', source, '-codec', 'copy', source.replace(/\.opus$/, '.ogg')]); await queue.spawn('ffmpeg', ['-i', source, '-codec', 'copy', source.replace(/\.opus$/, '.ogg')]);
await fs.unlink(source).catch(() => {}); await fs.unlink(source).catch(() => {});
+3 -2
View File
@@ -654,8 +654,9 @@ export default (router, tpl) => {
}); });
// Specific route for direct item links: /user/:user/:itemid // Specific route for direct item links: /user/:user/:itemid
// This avoids ambiguity with the profile route // This avoids ambiguity with the profile route. The other /user/:user/<page> routes (comments, tags, halls)
router.get(/^\/user\/(?<user>[^/]+)\/(?<itemid>(?!f0cks$|uploads$|favs$)[a-zA-Z0-9_-]+)$/, handleGenericRoute); // are excluded too, or an item lookup for "comments" etc. would answer "post not visible" before they run.
router.get(/^\/user\/(?<user>[^/]+)\/(?<itemid>(?!(?:f0cks|uploads|favs|comments|tags|halls)$)[a-zA-Z0-9_-]+)$/, handleGenericRoute);
// Generic router for everything else (Index, Tags, standard User Grids) // Generic router for everything else (Index, Tags, standard User Grids)
// We exclude static paths (/s/, /b/, /t/, /ca/, /a/, system routes) to prevent the greedy regex from intercepting them. // We exclude static paths (/s/, /b/, /t/, /ca/, /a/, system routes) to prevent the greedy regex from intercepting them.
+13 -6
View File
@@ -492,13 +492,14 @@ export default (router, tpl) => {
db`UPDATE notifications SET is_read = true WHERE type = 'admin_pending' AND item_id IN (SELECT id FROM items WHERE active = true OR is_deleted = true) AND is_read = false`.catch(err => console.error('[NOTIF CLEANUP] Failed to cleanup admin_pending notifications:', err)); db`UPDATE notifications SET is_read = true WHERE type = 'admin_pending' AND item_id IN (SELECT id FROM items WHERE active = true OR is_deleted = true) AND is_read = false`.catch(err => console.error('[NOTIF CLEANUP] Failed to cleanup admin_pending notifications:', err));
const USER_TYPES = ['comment_reply', 'subscription', 'mention', 'upload_comment']; const USER_TYPES = ['comment_reply', 'subscription', 'mention', 'upload_comment'];
const SYSTEM_TYPES = ['approve', 'deny', 'item_deleted', 'upload_success', 'upload_error', 'admin_pending', 'report', 'warning']; const SYSTEM_TYPES = ['approve', 'deny', 'item_deleted', 'upload_success', 'upload_error', 'admin_pending', 'report', 'warning', 'invite_approved', 'invite_denied'];
const ADMIN_TYPES = ['invite_request'];
const nsflTagId = cfg.nsfl_tag_id || 3; const nsflTagId = cfg.nsfl_tag_id || 3;
async function getNotificationHistory(userId, page = 1, limit = 50, tab = null) { async function getNotificationHistory(userId, page = 1, limit = 50, tab = null) {
const offset = (page - 1) * limit; const offset = (page - 1) * limit;
const typeFilter = tab === 'system' ? SYSTEM_TYPES : (tab === 'user' ? USER_TYPES : null); const typeFilter = tab === 'admin' ? ADMIN_TYPES : (tab === 'system' ? SYSTEM_TYPES : (tab === 'user' ? USER_TYPES : null));
const notifications = typeFilter const notifications = typeFilter
? await db` ? await db`
SELECT n.id, n.type, n.item_id, i.slug as item_slug, n.reference_id, n.created_at, n.is_read, n.data, SELECT n.id, n.type, n.item_id, i.slug as item_slug, n.reference_id, n.created_at, n.is_read, n.data,
@@ -596,7 +597,7 @@ export default (router, tpl) => {
LEFT JOIN items i ON n.item_id = i.id LEFT JOIN items i ON n.item_id = i.id
LEFT JOIN reports r ON n.type = 'report' AND n.reference_id = r.id LEFT JOIN reports r ON n.type = 'report' AND n.reference_id = r.id
WHERE n.user_id = ${req.session.id} AND n.is_read = false WHERE n.user_id = ${req.session.id} AND n.is_read = false
AND (n.type IN ('admin_pending', 'deny', 'item_deleted', 'report', 'approve', 'warning') AND (n.type IN ('admin_pending', 'deny', 'item_deleted', 'report', 'approve', 'warning', 'invite_request', 'invite_approved', 'invite_denied')
OR ( OR (
${req.session.do_not_disturb !== true} AND ( ${req.session.do_not_disturb !== true} AND (
(n.type IN ('upload_success', 'upload_error') AND ${req.session.receive_system_notifications !== false}) (n.type IN ('upload_success', 'upload_error') AND ${req.session.receive_system_notifications !== false})
@@ -604,7 +605,7 @@ export default (router, tpl) => {
) )
) )
) )
AND (n.item_id IS NULL OR (i.active = true AND i.is_deleted = false) OR n.type IN ('admin_pending', 'deny', 'item_deleted', 'report', 'warning')) AND (n.item_id IS NULL OR (i.active = true AND i.is_deleted = false) OR n.type IN ('admin_pending', 'deny', 'item_deleted', 'report', 'warning', 'invite_request', 'invite_approved', 'invite_denied'))
AND (n.type != 'report' OR r.status = 'pending') AND (n.type != 'report' OR r.status = 'pending')
AND (n.type != 'admin_pending' OR (i.active = false AND i.is_deleted = false)) AND (n.type != 'admin_pending' OR (i.active = false AND i.is_deleted = false))
ORDER BY n.created_at DESC ORDER BY n.created_at DESC
@@ -630,15 +631,21 @@ export default (router, tpl) => {
} }
}); });
// Mark all as read // Mark all as read (optionally filtered by tab)
router.post('/api/notifications/read', async (req, res) => { router.post('/api/notifications/read', async (req, res) => {
if (!req.session) return res.reply({ code: 401, body: JSON.stringify({ success: false }) }); if (!req.session) return res.reply({ code: 401, body: JSON.stringify({ success: false }) });
try { try {
const tab = req.url.qs?.tab || null;
const typeFilter = tab === 'admin' ? ADMIN_TYPES : (tab === 'system' ? SYSTEM_TYPES : (tab === 'user' ? USER_TYPES : null));
if (typeFilter) {
await db`UPDATE notifications SET is_read = true WHERE user_id = ${req.session.id} AND type = ANY(${typeFilter})`;
} else {
await db`UPDATE notifications SET is_read = true WHERE user_id = ${req.session.id}`; await db`UPDATE notifications SET is_read = true WHERE user_id = ${req.session.id}`;
}
return res.reply({ return res.reply({
headers: { 'Content-Type': 'application/json; charset=utf-8' }, headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify({ success: true }) body: JSON.stringify({ success: true, tab })
}); });
} catch (err) { } catch (err) {
return res.reply({ code: 500, body: JSON.stringify({ success: false }) }); return res.reply({ code: 500, body: JSON.stringify({ success: false }) });
+49 -5
View File
@@ -80,6 +80,11 @@ export default (router, tpl) => {
return renderError("Username contains invalid characters. Only A-Z, 0-9, _, -, and . are allowed."); return renderError("Username contains invalid characters. Only A-Z, 0-9, _, -, and . are allowed.");
} }
// anon_* logins are reserved for anonymous shadow users (treated as anon throughout the app)
if (/^anon_/i.test(username) || username.toLowerCase() === 'anonymous') {
return renderError("Username taken");
}
if (!password || password.length < 20) { if (!password || password.length < 20) {
return renderError("Password must be at least 20 characters long."); return renderError("Password must be at least 20 characters long.");
} }
@@ -90,7 +95,7 @@ export default (router, tpl) => {
// reCAPTCHA verification (bypassed for .onion requests as Google reCAPTCHA cannot validate .onion domains) // reCAPTCHA verification (bypassed for .onion requests as Google reCAPTCHA cannot validate .onion domains)
const isOnion = lib.isOnionRequest(req); const isOnion = lib.isOnionRequest(req);
if (!isOnion && cfg.recaptcha?.enabled && cfg.recaptcha?.secret_key) { if (!isOnion && cfg.recaptcha?.enabled && cfg.recaptcha?.site_key && cfg.recaptcha?.secret_key) {
const rcToken = req.post['g-recaptcha-response']; const rcToken = req.post['g-recaptcha-response'];
if (!rcToken) return renderError("Please complete the reCAPTCHA."); if (!rcToken) return renderError("Please complete the reCAPTCHA.");
try { try {
@@ -154,8 +159,39 @@ export default (router, tpl) => {
const hash = await lib.hash(password); const hash = await lib.hash(password);
const ts = ~~(Date.now() / 1e3); const ts = ~~(Date.now() / 1e3);
// Anonymous passkey identity registering: upgrade the existing shadow user in place
// instead of creating a new one. Favs, comments, uploads and passkeys stay attached;
// dropping the anon_identities row is what turns the account into a regular one.
const upgradeAnonId = req.session?.is_anon ? req.session.id : null;
let userId; let userId;
try { try {
if (upgradeAnonId) {
await db.begin(async sql => {
// Uploads reference their owner by name (items.username = the anon shadow login),
// so they move to the new username, the name regular uploads are stored under
const [old] = await sql`select login from "user" where id = ${upgradeAnonId} for update`;
if (old?.login) {
await sql`update items set username = ${username} where lower(username) = lower(${old.login})`;
}
await sql`
update "user"
set "login" = ${username.toLowerCase()}, "password" = ${hash}, "user" = ${username},
"email" = ${email || null}, "activated" = ${activated}, "activation_token" = ${activationToken}
where id = ${upgradeAnonId}
`;
await sql`
update user_options
set display_name = null, avatar_file = coalesce(avatar_file, 'default.png')
where user_id = ${upgradeAnonId}
`;
await sql`delete from anon_identities where user_id = ${upgradeAnonId}`;
});
userId = upgradeAnonId;
if (global._invalidateSessionCache && req.cookies?.session) {
global._invalidateSessionCache(lib.sha256(req.cookies.session));
}
} else {
const newUser = await db` const newUser = await db`
insert into "user" ("login", "password", "user", "created_at", "admin", "is_moderator", "email", "activated", "activation_token") insert into "user" ("login", "password", "user", "created_at", "admin", "is_moderator", "email", "activated", "activation_token")
values (${username.toLowerCase()}, ${hash}, ${username}, to_timestamp(${ts}), false, false, ${email || null}, ${activated}, ${activationToken}) values (${username.toLowerCase()}, ${hash}, ${username}, to_timestamp(${ts}), false, false, ${email || null}, ${activated}, ${activationToken})
@@ -171,6 +207,7 @@ export default (router, tpl) => {
insert into user_options (user_id, mode, theme, fullscreen, avatar, avatar_file, use_new_layout, disable_autoplay, disable_swiping, use_alternative_infobox) insert into user_options (user_id, mode, theme, fullscreen, avatar, avatar_file, use_new_layout, disable_autoplay, disable_swiping, use_alternative_infobox)
values (${userId}, 3, 'amoled', 0, ${avatarId}, ${avatarFile}, ${getDefaultLayout() === 'modern'}, ${cfg.websrv.enable_autoplay === false}, ${cfg.websrv.enable_swiping === false}, ${cfg.websrv.user_alternative_infobox !== false}) values (${userId}, 3, 'amoled', 0, ${avatarId}, ${avatarFile}, ${getDefaultLayout() === 'modern'}, ${cfg.websrv.enable_autoplay === false}, ${cfg.websrv.enable_swiping === false}, ${cfg.websrv.user_alternative_infobox !== false})
`; `;
}
} catch (err) { } catch (err) {
console.error(`[REGISTER] DB Error during user creation:`, err); console.error(`[REGISTER] DB Error during user creation:`, err);
if (err.code === '23505') { // Unique constraint violation if (err.code === '23505') { // Unique constraint violation
@@ -200,6 +237,11 @@ export default (router, tpl) => {
// In production they should see an error, but let's keep it simple for now. // In production they should see an error, but let's keep it simple for now.
} }
if (upgradeAnonId) {
// Unactivated accounts may not stay logged in — end the anon sessions until the email link is used
await db`delete from user_sessions where user_id = ${upgradeAnonId}`;
res.setHeader('Set-Cookie', `session=; ${lib.getCookieOptions('Thu, 01 Jan 1970 00:00:00 GMT')}`);
}
await renderSuccess("Registration successful! Please check your email to activate your account."); await renderSuccess("Registration successful! Please check your email to activate your account.");
return; return;
} }
@@ -217,13 +259,15 @@ export default (router, tpl) => {
await security.recordAttempt(ip, username, 'register', true); await security.recordAttempt(ip, username, 'register', true);
const successMsg = "Registration successful! You can now login."; const successMsg = upgradeAnonId
? "Username claimed, you can still use your passkey."
: "Registration successful! You can now login.";
if (req.headers['x-requested-with'] === 'XMLHttpRequest' || (req.headers.accept && req.headers.accept.includes('application/json'))) { if (req.headers['x-requested-with'] === 'XMLHttpRequest' || (req.headers.accept && req.headers.accept.includes('application/json'))) {
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: true, msg: successMsg })); return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: true, msg: successMsg, upgraded: !!upgradeAnonId }));
} }
// Redirect to home with login success message // Upgraded accounts keep their current session; otherwise redirect home with login success message
return res.writeHead(302, { "Location": "/?login=success" }).end(); return res.writeHead(302, { "Location": upgradeAnonId ? "/settings" : "/?login=success" }).end();
}); });
return router; return router;
+3 -1
View File
@@ -37,7 +37,7 @@ export default (router, tpl) => {
// Get full user info // Get full user info
const user = (await db` const user = (await db`
select email, created_at from "user" where id = ${+req.session.id} select email, created_at, (password = '!') as password_disabled from "user" where id = ${+req.session.id}
`)[0]; `)[0];
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate'); res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
@@ -58,6 +58,8 @@ export default (router, tpl) => {
banner_size: userOptions?.banner_size || 'cover', banner_size: userOptions?.banner_size || 'cover',
email: user?.email || '', email: user?.email || '',
joined: user?.created_at || null, joined: user?.created_at || null,
// Passkey-only account: password login switched off (password = '!')
password_disabled: !!user?.password_disabled,
user_banner_enabled: cfg.websrv.user_banner_enabled !== false, user_banner_enabled: cfg.websrv.user_banner_enabled !== false,
enable_swf: cfg.enable_swf, enable_swf: cfg.enable_swf,
enable_data_export: !req.session?.is_anon && cfg.websrv.enable_data_export, enable_data_export: !req.session?.is_anon && cfg.websrv.enable_data_export,
+2 -3
View File
@@ -126,11 +126,10 @@ export const isAnonSession = (session) => {
}; };
// Onara viewer is a per-user setting (cookie f0ck_onara, legacy cookie onara, or session value). // Onara viewer is a per-user setting (cookie f0ck_onara, legacy cookie onara, or session value).
// config `onara: false` disables it for everyone; `onara: true` is only the default for users // config `onara` is only the default for users without a stored preference (true = on, false = off);
// without a stored preference. `forceOn` covers explicit requests like ?onara=1. // users can always turn it on or off themselves. `forceOn` covers explicit requests like ?onara=1.
export const isOnaraEnabledFor = (req, forceOn = false) => { export const isOnaraEnabledFor = (req, forceOn = false) => {
const c = cfg.onara !== undefined ? cfg.onara : cfg.websrv?.onara; const c = cfg.onara !== undefined ? cfg.onara : cfg.websrv?.onara;
if (c === false) return false;
if (forceOn) return true; if (forceOn) return true;
const ck = req?.cookies || {}; const ck = req?.cookies || {};
const raw = ck.f0ck_onara !== undefined ? ck.f0ck_onara : ck.onara; const raw = ck.f0ck_onara !== undefined ? ck.f0ck_onara : ck.onara;
+26
View File
@@ -0,0 +1,26 @@
// Re-encode video that browsers can't play (MPEG-1/2 program streams, .mpg/.mpeg) to web MP4.
// A plain remux (-codec copy) is not enough for these: MPEG-1/2 video has to become H.264.
// file --mime-type results that get converted
export const TRANSCODE_TO_MP4 = new Set(['video/mpeg']);
/**
* Re-encode src to H.264/AAC MP4 at dest.
* - yadif only touches frames flagged as interlaced (DVD MPEG-2), progressive video is left alone
* - even dimensions + yuv420p for broad decoder support, faststart so playback starts before the full download
* @param {{ spawn: Function }} queue
* @param {string} src
* @param {string} dest
*/
export async function transcodeToMp4(queue, src, dest) {
await queue.spawn('ffmpeg', [
'-y', '-hide_banner', '-loglevel', 'error',
'-i', src,
'-map', '0:v:0', '-map', '0:a?',
'-vf', 'yadif=deint=interlaced,scale=trunc(iw/2)*2:trunc(ih/2)*2',
'-c:v', 'libx264', '-preset', 'veryfast', '-crf', '22', '-pix_fmt', 'yuv420p',
'-c:a', 'aac', '-b:a', '160k',
'-movflags', '+faststart',
dest
]);
}
+8
View File
@@ -8,6 +8,7 @@ import autotagger from "../autotagger.mjs";
import fetch from "flumm-fetch"; import fetch from "flumm-fetch";
import fs from "fs"; import fs from "fs";
import path from "path"; import path from "path";
import { TRANSCODE_TO_MP4, transcodeToMp4 } from "../transcode.mjs";
const regex = { const regex = {
@@ -648,6 +649,13 @@ export default async bot => {
source = source.replace(/\.mkv$/, '.mp4'); source = source.replace(/\.mkv$/, '.mp4');
mime = 'video/mp4'; mime = 'video/mp4';
} }
if (TRANSCODE_TO_MP4.has(mime)) { // .mpg etc.: browsers can't play it, re-encode
const converted = path.join(cfg.paths.tmp, `${uuid}.conv.mp4`);
await transcodeToMp4(queue, source, converted);
await fs.promises.unlink(source).catch(_ => { });
source = converted;
mime = 'video/mp4';
}
if (source.match(/\.opus$/)) { // opus failsafe if (source.match(/\.opus$/)) { // opus failsafe
await queue.spawn('ffmpeg', ['-i', path.join(cfg.paths.tmp, `${uuid}.opus`), '-codec', 'copy', path.join(cfg.paths.tmp, `${uuid}.ogg`)]); await queue.spawn('ffmpeg', ['-i', path.join(cfg.paths.tmp, `${uuid}.opus`), '-codec', 'copy', path.join(cfg.paths.tmp, `${uuid}.ogg`)]);
await fs.promises.unlink(source); await fs.promises.unlink(source);
+41
View File
@@ -472,3 +472,44 @@ export function buildAuthenticationOptions({ challenge, allowCredentials = [], r
timeout: 60000 timeout: 60000
}; };
} }
// ─── Authenticator names ─────────────────────────────────────────────────────
// AAGUIDs (stored as 32 hex chars) of common passkey providers, from the community list at
// github.com/passkeydeveloper/passkey-authenticator-aaguids. All zeros = the browser/authenticator
// did not disclose it (common with Firefox and "none" attestation).
const AAGUID_PROVIDERS = {
'd548826e79b4db40a3d811116f7e8349': 'Bitwarden',
'fbfc3007154e4ecc8c0b6e020557d7bd': 'iCloud Keychain',
'dd4ec289e01d41c9bb8970fa845d4bf2': 'iCloud Keychain (Managed)',
'ea9b8d664d011d213ce4b6b48cb575d4': 'Google Password Manager',
'adce000235bcc60a648b0b25f1f05503': 'Chrome on Mac',
'08987058cadc4b81b6e130de50dcbe96': 'Windows Hello',
'9ddd1817af5a4672a2b93e3dd95000a9': 'Windows Hello',
'6028b017b1d44c02b4b3afcdafc96bb2': 'Windows Hello',
'bada5566a7aa401fbd9645619a55120d': '1Password',
'531126d6e717415c93203d9aa6981239': 'Dashlane',
'fdb141b25d84443e8a354698c205a502': 'KeePassXC',
'50726f746f6e5061737350726f746f6e': 'Proton Pass',
'53414d53554e47000000000000000000': 'Samsung Pass',
'cb69481e8ff7403993ec0a2729a154a8': 'YubiKey',
'ee882879721c491397753dfcce97072a': 'YubiKey',
'fa2b99dc9e3942578f924a30d23c4118': 'YubiKey',
'2fc0579f811347eab116bb5a8db9202a': 'YubiKey',
'c5ef55ffad9a4b9fb580adebafe026d0': 'YubiKey',
'73bb0cd4e50249b89c6fb59445bf720b': 'YubiKey',
'a4e9fc6d4cbe4758b8ba37598bb5bbaa': 'Yubico Security Key',
'b92c3f9ac0144056887f140a2501163b': 'Yubico Security Key',
'0bb43545fd2c418587ddfeb0b2916ace': 'Yubico Security Key'
};
/**
* Human name of the authenticator that holds a passkey, from its stored AAGUID.
* @param {string|null} aaguidHex
* @returns {string|null} provider name, 'Undisclosed' for the all-zero AAGUID, or null if unknown
*/
export function aaguidProvider(aaguidHex) {
if (!aaguidHex) return null;
const hex = String(aaguidHex).toLowerCase().replace(/[^0-9a-f]/g, '');
if (/^0+$/.test(hex)) return 'Undisclosed';
return AAGUID_PROVIDERS[hex] || null;
}
+44 -13
View File
@@ -21,7 +21,7 @@ import { handleMetaExtract } from "./meta_extract_handler.mjs";
import { handleMetaStrip } from "./meta_strip_handler.mjs"; import { handleMetaStrip } from "./meta_strip_handler.mjs";
import { handleCommentUpload, handleCommentUploadCancel } from "./comment_upload_handler.mjs"; import { handleCommentUpload, handleCommentUploadCancel } from "./comment_upload_handler.mjs";
import { handleDmAttachmentUpload, handleDmAttachmentDownload, handleDmAttachmentDelete } from "./dm_attachment_handler.mjs"; import { handleDmAttachmentUpload, handleDmAttachmentDownload, handleDmAttachmentDelete } from "./dm_attachment_handler.mjs";
import { getManualApproval, setManualApproval, getMinTags, setMinTags, getRegistrationOpen, setRegistrationOpen, getTrustedUploads, setTrustedUploads, getBypassDuplicateCheck, setBypassDuplicateCheck, getProtectFiles, setProtectFiles, getPrivateMessages, setPrivateMessages, getDmAttachments, setDmAttachments, getDmUnencrypted, setDmUnencrypted, getDefaultLayout, setDefaultLayout, getEnablePdf, setEnablePdf, getEnableCleanup, setEnableCleanup, getCleanupStartDate, setCleanupStartDate, getCleanupEndDate, setCleanupEndDate, getCleanupIncludeEngaged, setCleanupIncludeEngaged, getLogUserIps, setLogUserIps, getHashUserIps, setHashUserIps, getShitpostMode, setShitpostMode, getAllowCommentDeletion, setAllowCommentDeletion, getNsfpIds, setNsfpIds, getEnableExpiringUploads, getEnableItemSlugs, getEnableAnonymousAccess, getAnonPermissions, getAnonAnonymize, isAnonymizeSession, ensureAllItemsHaveSlugs, ensureAllAlbumItemsHaveSlugs, isAnonSession, canAnonDo, canUseChan, isOnaraEnabledFor, getAnonAllowedModes, getAnonAllowedMimes, getBrandImageUrl, setBrandImageUrl, getHwFingerprintEnabled } from "./inc/settings.mjs"; import { getManualApproval, setManualApproval, getMinTags, setMinTags, getRegistrationOpen, setRegistrationOpen, getTrustedUploads, setTrustedUploads, getBypassDuplicateCheck, setBypassDuplicateCheck, getProtectFiles, setProtectFiles, getPrivateMessages, setPrivateMessages, getDmAttachments, setDmAttachments, getDmUnencrypted, setDmUnencrypted, getDefaultLayout, setDefaultLayout, getEnablePdf, setEnablePdf, getEnableCleanup, setEnableCleanup, getCleanupStartDate, setCleanupStartDate, getCleanupEndDate, setCleanupEndDate, getCleanupIncludeEngaged, setCleanupIncludeEngaged, getLogUserIps, setLogUserIps, getHashUserIps, setHashUserIps, getShitpostMode, setShitpostMode, getAllowCommentDeletion, setAllowCommentDeletion, getNsfpIds, setNsfpIds, getEnableExpiringUploads, getEnableItemSlugs, getEnableAnonymousAccess, getAnonPermissions, getAnonAnonymize, isAnonymizeSession, ensureAllItemsHaveSlugs, ensureAllAlbumItemsHaveSlugs, isAnonSession, canAnonDo, canUseChan, isOnaraEnabledFor, getSessionOwnerName, getAnonAllowedModes, getAnonAllowedMimes, getBrandImageUrl, setBrandImageUrl, getHwFingerprintEnabled } from "./inc/settings.mjs";
import { updateHallsCache, getHalls } from "./inc/halls_cache.mjs"; import { updateHallsCache, getHalls } from "./inc/halls_cache.mjs";
import { createI18n } from "./inc/i18n.mjs"; import { createI18n } from "./inc/i18n.mjs";
import { safeDeleteMediaFile, purgeExpiredUploads } from "./inc/lib_delete.mjs"; import { safeDeleteMediaFile, purgeExpiredUploads } from "./inc/lib_delete.mjs";
@@ -861,8 +861,13 @@ process.on('uncaughtException', err => {
const _ucSet = (username, count) => { const _ucSet = (username, count) => {
_uploadCountCache.set(username, { count, exp: Date.now() + UPLOAD_COUNT_CACHE_TTL }); _uploadCountCache.set(username, { count, exp: Date.now() + UPLOAD_COUNT_CACHE_TTL });
}; };
// Allow upload handler to bust the cache after a successful upload // Own uploads awaiting approval (user menu badge), keyed like items.username; same TTL
global._invalidateUploadCountCache = (username) => _uploadCountCache.delete(username); const _pendingCountCache = new Map(); // lower(owner) -> { count: number, exp: number }
// Allow upload handler to bust the caches after a successful upload
global._invalidateUploadCountCache = (username) => {
_uploadCountCache.delete(username);
if (username) _pendingCountCache.delete(String(username).toLowerCase());
};
// ────────────────────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────────────────────
app.use(async (req, res) => { app.use(async (req, res) => {
@@ -1183,6 +1188,20 @@ process.on('uncaughtException', err => {
console.error('[IMPERSONATE] Cookie parse error:', _impErr.message); console.error('[IMPERSONATE] Cookie parse error:', _impErr.message);
} }
} }
// ── Admin role preview ("Preview: Mod / User" in the admin bar) ─────────────
// `preview_role` cookie: a real admin sees and acts with the rights of that role (templates,
// f0ckSession, admin/mod-guarded routes). It can only lower rights and is ignored for everyone
// else. A copy is made so the cached session row keeps the real flags.
const _previewRole = req.cookies?.preview_role;
if ((_previewRole === 'mod' || _previewRole === 'user') && req.session.admin && !req.session._is_impersonating) {
req.session = {
...req.session,
admin: false,
is_moderator: _previewRole === 'mod',
_preview_role: _previewRole
};
}
// ───────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────
// log last action (Fire-and-Forget) // log last action (Fire-and-Forget)
@@ -1216,6 +1235,23 @@ process.on('uncaughtException', err => {
req.session.pending_count = pending[0].c; req.session.pending_count = pending[0].c;
} }
// Own pending uploads for the user menu badge, same rule as /pending (not live, not denied/purged)
const _pendingOwner = getSessionOwnerName(req.session);
if (_pendingOwner) {
const key = _pendingOwner.toLowerCase();
const hit = _pendingCountCache.get(key);
if (hit && hit.exp > Date.now()) {
req.session.own_pending_count = hit.count;
} else {
const [row] = await db`
select count(*)::int as c from items
where lower(username) = ${key} and active = false and is_deleted = false and is_purged = false
`.catch(() => [{ c: 0 }]);
req.session.own_pending_count = row?.c || 0;
_pendingCountCache.set(key, { count: req.session.own_pending_count, exp: Date.now() + UPLOAD_COUNT_CACHE_TTL });
}
}
// Calculate uploads remaining globally for the modal (cached 30s per user) // Calculate uploads remaining globally for the modal (cached 30s per user)
if (!req.session.admin && !req.session.is_moderator) { if (!req.session.admin && !req.session.is_moderator) {
let cachedUploadCount = _ucGet(req.session.user); let cachedUploadCount = _ucGet(req.session.user);
@@ -1983,12 +2019,8 @@ process.on('uncaughtException', err => {
const c = cfg.onara !== undefined ? cfg.onara : cfg.websrv?.onara; const c = cfg.onara !== undefined ? cfg.onara : cfg.websrv?.onara;
return !!c; return !!c;
}, },
// onara_cfg locks the per-user setting; only `onara: false` (feature disabled) does that. // onara_cfg would lock the per-user setting; config `onara` is only a default, so it never does.
// `onara: true` is just the default for users who haven't chosen. onara_cfg: null,
get onara_cfg() {
const c = cfg.onara !== undefined ? cfg.onara : cfg.websrv?.onara;
return c === false ? false : null;
},
is_onara_item: false, is_onara_item: false,
can_extract_meta: false, can_extract_meta: false,
enable_private_uploads: cfg.enable_private_uploads !== false, enable_private_uploads: cfg.enable_private_uploads !== false,
@@ -2206,10 +2238,9 @@ process.on('uncaughtException', err => {
data.max_file_size_bytes = Math.floor(cfg.main.maxfilesize * (activeReq.session?.admin ? cfg.main.adminmultiplier : 1)); data.max_file_size_bytes = Math.floor(cfg.main.maxfilesize * (activeReq.session?.admin ? cfg.main.adminmultiplier : 1));
data.web_url_upload = data.web_url_upload !== undefined ? data.web_url_upload : !!cfg.websrv.web_url_upload; data.web_url_upload = data.web_url_upload !== undefined ? data.web_url_upload : !!cfg.websrv.web_url_upload;
// Onara is a per-user setting; config `onara: false` disables it, `onara: true` is only the default // Onara is a per-user setting; config `onara` is only the default
const cfgOnara = cfg.onara !== undefined ? cfg.onara : cfg.websrv?.onara;
data.onara = isOnaraEnabledFor(activeReq); data.onara = isOnaraEnabledFor(activeReq);
data.onara_cfg = cfgOnara === false ? false : null; data.onara_cfg = null;
} else { } else {
data.recaptcha_enabled = perRequestRecaptcha; data.recaptcha_enabled = perRequestRecaptcha;
data.theme = data.theme || cfg.websrv.theme || 'f0ck'; data.theme = data.theme || cfg.websrv.theme || 'f0ck';
@@ -2219,7 +2250,7 @@ process.on('uncaughtException', err => {
const cfgOnara = cfg.onara !== undefined ? cfg.onara : cfg.websrv?.onara; const cfgOnara = cfg.onara !== undefined ? cfg.onara : cfg.websrv?.onara;
data.onara = cfgOnara === true; data.onara = cfgOnara === true;
data.onara_cfg = cfgOnara === false ? false : null; data.onara_cfg = null;
} }
return originalRender.call(tpl, view, data, (data.url && data.url !== req?.url) ? undefined : req); return originalRender.call(tpl, view, data, (data.url && data.url !== req?.url) ? undefined : req);
+17
View File
@@ -12,6 +12,7 @@ import f0cklib from "./inc/routeinc/f0cklib.mjs";
import { calculateExpiresAt } from "./inc/routes/apiv2/upload.mjs"; import { calculateExpiresAt } from "./inc/routes/apiv2/upload.mjs";
import { addPrivateItem } from "./inc/private_items.mjs"; import { addPrivateItem } from "./inc/private_items.mjs";
import { resolveAuditIP, logAnonActivity } from "./inc/anon_auth.mjs"; import { resolveAuditIP, logAnonActivity } from "./inc/anon_auth.mjs";
import { TRANSCODE_TO_MP4, transcodeToMp4 } from "./inc/transcode.mjs";
// Derive archive MIME types from cfg.mimes — any application/* that isn't swf or pdf. // Derive archive MIME types from cfg.mimes — any application/* that isn't swf or pdf.
@@ -691,6 +692,22 @@ export const handleUpload = async (req, res, self) => {
return sendJson(res, { success: false, msg: 'Archive uploads are currently disabled.' }, 403); return sendJson(res, { success: false, msg: 'Archive uploads are currently disabled.' }, 403);
} }
// Video browsers can't play (MPEG-1/2 .mpg): re-encode to H.264 MP4 in place, before hashing,
// duplicate checks and probing, so the rest of the pipeline sees a normal mp4
if (TRANSCODE_TO_MP4.has(actualMime)) {
const converted = tmpPath + '.mp4';
try {
await transcodeToMp4(queue, tmpPath, converted);
await fs.rename(converted, tmpPath);
actualMime = 'video/mp4';
} catch (convErr) {
console.error('[UPLOAD] Transcode to mp4 failed:', convErr.message);
await fs.unlink(converted).catch(() => { });
await fs.unlink(tmpPath).catch(() => { });
return sendJson(res, { success: false, msg: 'Could not convert this video to a playable format.' }, 400);
}
}
// ── Phase A: parallel analysis of tmpPath ───────────────────────────── // ── Phase A: parallel analysis of tmpPath ─────────────────────────────
// sha256sum, PHash, and dimension probing all just read the file — // sha256sum, PHash, and dimension probing all just read the file —
// none depends on the other, so run them concurrently. // none depends on the other, so run them concurrently.
+2 -1
View File
@@ -27,6 +27,7 @@
<a href="/admin/approve" class="adm-stat adm-stat-link @if(totals.pending > 0) is-hot @endif"><span class="adm-stat-num">{{ totals.pending }}</span><span class="adm-stat-label">Pending approval</span></a> <a href="/admin/approve" class="adm-stat adm-stat-link @if(totals.pending > 0) is-hot @endif"><span class="adm-stat-num">{{ totals.pending }}</span><span class="adm-stat-label">Pending approval</span></a>
<a href="/mod/trash" class="adm-stat adm-stat-link"><span class="adm-stat-num">{{ dash.trash }}</span><span class="adm-stat-label">Soft deleted</span></a> <a href="/mod/trash" class="adm-stat adm-stat-link"><span class="adm-stat-num">{{ dash.trash }}</span><span class="adm-stat-label">Soft deleted</span></a>
<a href="/mod/reports" class="adm-stat adm-stat-link @if(dash.open_reports > 0) is-hot @endif"><span class="adm-stat-num">{{ dash.open_reports }}</span><span class="adm-stat-label">Open reports</span></a> <a href="/mod/reports" class="adm-stat adm-stat-link @if(dash.open_reports > 0) is-hot @endif"><span class="adm-stat-num">{{ dash.open_reports }}</span><span class="adm-stat-label">Open reports</span></a>
<a href="/admin/tokens#invite-requests" class="adm-stat adm-stat-link @if(dash.invite_requests > 0) is-hot @endif"><span class="adm-stat-num">{{ dash.invite_requests }}</span><span class="adm-stat-label">Token requests</span></a>
<a href="/admin/users" class="adm-stat adm-stat-link"><span class="adm-stat-num">{{ dash.users }}</span><span class="adm-stat-label">Users <small>+{{ dash.anon_users }} anon</small></span></a> <a href="/admin/users" class="adm-stat adm-stat-link"><span class="adm-stat-num">{{ dash.users }}</span><span class="adm-stat-label">Users <small>+{{ dash.anon_users }} anon</small></span></a>
</div> </div>
@endif @endif
@@ -72,7 +73,7 @@
</a> </a>
<a href="/admin/tokens" class="adm-tool"> <a href="/admin/tokens" class="adm-tool">
<i class="fa-solid fa-ticket"></i> <i class="fa-solid fa-ticket"></i>
<span class="adm-tool-text"><strong>Invite Tokens</strong><span>Create and revoke invites</span></span> <span class="adm-tool-text"><strong>Invite Tokens @if(dash.invite_requests > 0)<span class="adm-count">{{ dash.invite_requests }}</span>@endif</strong><span>Create and revoke invites, review token requests</span></span>
</a> </a>
</div> </div>
+166 -9
View File
@@ -8,6 +8,34 @@
<button id="generate-token" class="btn-upload" style="width: auto; padding: 10px 20px;">Generate New Token</button> <button id="generate-token" class="btn-upload" style="width: auto; padding: 10px 20px;">Generate New Token</button>
</div> </div>
<!-- ═══════════════════ Invite Requests ═══════════════════ -->
<div id="invite-requests" style="margin-bottom: 30px; scroll-margin-top: calc(var(--navbar-h, 50px) + 16px);">
<h3 style="display: flex; align-items: center; gap: 8px; margin-bottom: 12px;">
<i class="fa-solid fa-inbox" style="color: var(--accent);"></i> Invite Requests
<span id="invite-req-count" style="display: none; background: #ff6b6b; color: white; font-size: 0.7em; padding: 2px 8px; border-radius: 10px; font-weight: 600;"></span>
</h3>
<div class="upload-form">
<table class="responsive-table">
<thead>
<tr>
<th>User</th>
<th>Reason</th>
<th>Status</th>
<th>Submitted</th>
<th>Reviewed By</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="invite-req-list">
<tr><td colspan="6" style="text-align: center; color: var(--text-muted);">Loading...</td></tr>
</tbody>
</table>
</div>
</div>
<h3 style="display: flex; align-items: center; gap: 8px; margin-bottom: 12px;">
<i class="fa-solid fa-ticket" style="color: var(--accent);"></i> Token List
</h3>
<div class="upload-form"> <div class="upload-form">
<table class="responsive-table"> <table class="responsive-table">
<thead> <thead>
@@ -29,14 +57,137 @@
<script> <script>
window.f0ckDebug = window.f0ckDebug || (() => {}); window.f0ckDebug = window.f0ckDebug || (() => {});
const loadTokens = async () => {
// ═══════════════════ Invite Requests ═══════════════════
window.loadInviteRequests = async () => {
const tbody = document.getElementById('invite-req-list');
try {
const res = await fetch('/api/v2/admin/invite-requests');
const data = await res.json();
if (data.success) {
const countBadge = document.getElementById('invite-req-count');
if (!tbody) return;
const pendingCount = data.requests.filter(r => r.status === 'pending').length;
if (countBadge) {
if (pendingCount > 0) {
countBadge.textContent = pendingCount;
countBadge.style.display = '';
} else {
countBadge.style.display = 'none';
}
}
if (data.requests.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" style="text-align: center; color: var(--text-muted);">No invite requests yet.</td></tr>';
return;
}
tbody.innerHTML = data.requests.map(r => {
const reason = r.reason ? '<span title="' + r.reason.replace(/"/g, '&quot;') + '">' + (r.reason.length > 60 ? r.reason.slice(0, 60) + '…' : r.reason) + '</span>' : '<span style="color: var(--text-muted); font-style: italic;">No reason given</span>';
let statusBadge;
if (r.status === 'pending') {
statusBadge = '<span style="color: #ffc107; font-weight: 600;"><i class="fa-solid fa-clock"></i> Pending</span>';
} else if (r.status === 'approved') {
statusBadge = '<span style="color: #51cf66; font-weight: 600;"><i class="fa-solid fa-circle-check"></i> Approved</span>';
} else if (r.status === 'revoked') {
statusBadge = '<span style="color: #e67700; font-weight: 600;"><i class="fa-solid fa-ban"></i> Revoked</span>';
} else {
statusBadge = '<span style="color: #ff6b6b; font-weight: 600;"><i class="fa-solid fa-circle-xmark"></i> Denied</span>';
}
let actions = '';
if (r.status === 'pending') {
actions =
'<div style="display: flex; gap: 6px; flex-wrap: wrap;">' +
'<button onclick="approveInviteReq(' + r.id + ')" class="btn-upload" style="padding: 5px 12px; font-size: 0.8em; background: #51cf66; border-color: #51cf66;"><i class="fa-solid fa-check"></i> Approve</button>' +
'<button onclick="denyInviteReq(' + r.id + ')" class="btn-remove" style="padding: 5px 12px; font-size: 0.8em;"><i class="fa-solid fa-xmark"></i> Deny</button>' +
'</div>';
} else {
actions = '<span style="color: var(--text-muted); font-size: 0.85em;">' + (r.reviewed_at ? new Date(r.reviewed_at).toLocaleString() : '') + '</span>';
}
const username = r.requester_login || r.requester_name || (r.fingerprint ? r.fingerprint.slice(0, 12) + '…' : '—');
const userCell = r.requester_login
? '<a href="/user/' + r.requester_login + '" style="color: #5bc0be; text-decoration: none; font-weight: 600;">' + username + '</a>'
: '<span style="color: var(--text-muted);">' + username + '</span>';
return '<tr' + (r.status === 'pending' ? ' style="background: rgba(255,193,7,0.04);"' : '') + '>' +
'<td data-label="User">' + userCell + '</td>' +
'<td data-label="Reason" style="max-width: 250px;">' + reason + '</td>' +
'<td data-label="Status">' + statusBadge + '</td>' +
'<td data-label="Submitted">' + new Date(r.created_at).toLocaleString() + '</td>' +
'<td data-label="Reviewed By">' + (r.reviewed_by_name || '—') + '</td>' +
'<td data-label="Actions">' + actions + '</td>' +
'</tr>';
}).join('');
} else {
if (tbody) tbody.innerHTML = '<tr><td colspan="6" style="text-align: center; color: var(--text-muted);">Failed to load invite requests.</td></tr>';
}
} catch (e) {
console.error('Failed to load invite requests:', e);
if (tbody) tbody.innerHTML = '<tr><td colspan="6" style="text-align: center; color: var(--text-muted);">Failed to load invite requests.</td></tr>';
}
};
window.approveInviteReq = async (id) => {
if (!confirm('Approve this invite request? A token will be generated and sent to the user.')) return;
try {
const res = await fetch('/api/v2/admin/invite-requests/approve', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': window.f0ckSession?.csrf_token
},
body: JSON.stringify({ id })
});
const data = await res.json();
if (data.success) {
window.loadInviteRequests();
window.loadTokens();
} else {
alert('Failed: ' + (data.msg || 'Unknown error'));
}
} catch (e) {
console.error(e);
alert('Error: ' + e.message);
}
};
window.denyInviteReq = async (id) => {
if (!confirm('Deny this invite request?')) return;
try {
const res = await fetch('/api/v2/admin/invite-requests/deny', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': window.f0ckSession?.csrf_token
},
body: JSON.stringify({ id })
});
const data = await res.json();
if (data.success) {
window.loadInviteRequests();
} else {
alert('Failed: ' + (data.msg || 'Unknown error'));
}
} catch (e) {
console.error(e);
alert('Error: ' + e.message);
}
};
// ═══════════════════ Tokens ═══════════════════
window.loadTokens = async () => {
const tbody = document.getElementById('token-list');
try { try {
window.f0ckDebug('Loading tokens...'); window.f0ckDebug('Loading tokens...');
const res = await fetch('/api/v2/admin/tokens'); const res = await fetch('/api/v2/admin/tokens');
const data = await res.json(); const data = await res.json();
window.f0ckDebug('Tokens data:', data); window.f0ckDebug('Tokens data:', data);
if (data.success) { if (data.success) {
const tbody = document.getElementById('token-list');
if (!tbody) return; if (!tbody) return;
tbody.innerHTML = data.tokens.map(t => tbody.innerHTML = data.tokens.map(t =>
'<tr>' + '<tr>' +
@@ -57,11 +208,16 @@
'</td>' + '</td>' +
'</tr>' '</tr>'
).join(''); ).join('');
} else {
if (tbody) tbody.innerHTML = '<tr><td colspan="7" style="text-align: center; color: var(--text-muted);">Failed to load tokens.</td></tr>';
}
} catch (e) {
console.error(e);
if (tbody) tbody.innerHTML = '<tr><td colspan="7" style="text-align: center; color: var(--text-muted);">Failed to load tokens.</td></tr>';
} }
} catch (e) { console.error(e); }
}; };
const generateToken = async () => { window.generateToken = async () => {
window.f0ckDebug('Generating...'); window.f0ckDebug('Generating...');
try { try {
const res = await fetch('/api/v2/admin/tokens/create', { const res = await fetch('/api/v2/admin/tokens/create', {
@@ -71,7 +227,7 @@
const data = await res.json(); const data = await res.json();
window.f0ckDebug('Gen result:', data); window.f0ckDebug('Gen result:', data);
if (data.success) { if (data.success) {
loadTokens(); window.loadTokens();
} else { } else {
alert('Failed: ' + data.msg); alert('Failed: ' + data.msg);
} }
@@ -81,7 +237,7 @@
} }
}; };
const deleteToken = async (id) => { window.deleteToken = async (id) => {
if (!confirm('Delete this token?')) return; if (!confirm('Delete this token?')) return;
const res = await fetch('/api/v2/admin/tokens/delete', { const res = await fetch('/api/v2/admin/tokens/delete', {
method: 'POST', method: 'POST',
@@ -93,13 +249,14 @@
}); });
const data = await res.json(); const data = await res.json();
if (data.success) { if (data.success) {
loadTokens(); window.loadTokens();
} }
}; };
const btnGenToken = document.getElementById('generate-token'); const btnGenToken = document.getElementById('generate-token');
if (btnGenToken) btnGenToken.addEventListener('click', generateToken); if (btnGenToken) btnGenToken.addEventListener('click', window.generateToken);
loadTokens(); window.loadTokens();
window.loadInviteRequests();
</script> </script>
</div> </div>
</div> </div>
+3 -1
View File
@@ -43,6 +43,7 @@
.status-active { background: linear-gradient(135deg, #28a745, #20c997); color: #fff; } .status-active { background: linear-gradient(135deg, #28a745, #20c997); color: #fff; }
.status-pending { background: linear-gradient(135deg, #f08c00, #ffc107); color: #000; } .status-pending { background: linear-gradient(135deg, #f08c00, #ffc107); color: #000; }
.status-banned { background: linear-gradient(135deg, #e03131, #f03e3e); color: #fff; } .status-banned { background: linear-gradient(135deg, #e03131, #f03e3e); color: #fff; }
.status-system { background: #5c0a0a; color: #ffb3b3; border: 1px solid #8b1a1a; }
.method-tag { .method-tag {
font-size: 0.7rem; font-size: 0.7rem;
@@ -329,7 +330,8 @@
</div> </div>
</div> </div>
<div style="padding: 0 15px;"> <!-- .container: the admin skin's 1200px column, same width and gutters as the back bar above -->
<div class="container">
<div style="display: flex; justify-content: space-between; align-items: flex-end; margin-bottom: 25px; gap: 20px; flex-wrap: wrap;"> <div style="display: flex; justify-content: space-between; align-items: flex-end; margin-bottom: 25px; gap: 20px; flex-wrap: wrap;">
<div> <div>
<h2 style="margin: 0; font-weight: 800; letter-spacing: -0.5px;">User Management</h2> <h2 style="margin: 0; font-weight: 800; letter-spacing: -0.5px;">User Management</h2>
+3
View File
@@ -34,6 +34,9 @@
<td data-label="Status" id="status-cell-{{ u.id || 'ghost-' + u.login }}"> <td data-label="Status" id="status-cell-{{ u.id || 'ghost-' + u.login }}">
@if(!u.id) @if(!u.id)
<span class="status-badge" style="background: rgba(255,255,255,0.05); color: #888; border: 1px dashed rgba(255,255,255,0.1);">Legacy</span> <span class="status-badge" style="background: rgba(255,255,255,0.05); color: #888; border: 1px dashed rgba(255,255,255,0.1);">Legacy</span>
@elseif(u.login === 'deleted_user')
{{-- System ghost that deleted users' content is reassigned to; it is banned only so it can't log in --}}
<span class="status-badge status-system" title="System account: holds content of deleted users">System</span>
@else @else
@if(u.banned) @if(u.banned)
<span class="status-badge status-banned">Banned</span> <span class="status-badge status-banned">Banned</span>
+8 -3
View File
@@ -13,6 +13,9 @@
@else @else
<button class="notif-page-tab active" data-tab="system">{{ t('nav.notif_tab_system') }}</button> <button class="notif-page-tab active" data-tab="system">{{ t('nav.notif_tab_system') }}</button>
@endif @endif
@if(session && session.admin)
<button class="notif-page-tab @if(activeTab === 'admin') active @endif" data-tab="admin">Admin</button>
@endif
</div> </div>
<div id="notifications-container" class="posts notifications-list-full" data-page="{{ pagination.page }}" data-tab="{{ activeTab }}"> <div id="notifications-container" class="posts notifications-list-full" data-page="{{ pagination.page }}" data-tab="{{ activeTab }}">
@include(snippets/notifications-list) @include(snippets/notifications-list)
@@ -55,16 +58,18 @@
}); });
}); });
// Mark all read // Mark all read (current tab only)
const btn = document.getElementById('mark-all-read-page'); const btn = document.getElementById('mark-all-read-page');
if (btn) { if (btn) {
btn.onclick = async () => { btn.onclick = async () => {
const res = await fetch('/api/notifications/read', { method: 'POST' }); const activeTabEl = document.querySelector('.notif-page-tab.active');
const tab = activeTabEl ? activeTabEl.dataset.tab : container.dataset.tab;
const res = await fetch('/api/notifications/read?tab=' + encodeURIComponent(tab), { method: 'POST' });
const data = await res.json(); const data = await res.json();
if (data.success) { if (data.success) {
document.querySelectorAll('.notif-item.unread').forEach(el => el.classList.remove('unread')); document.querySelectorAll('.notif-item.unread').forEach(el => el.classList.remove('unread'));
if (window.NotificationSystemInstance) { if (window.NotificationSystemInstance) {
window.NotificationSystemInstance.markAllReadUI(); window.NotificationSystemInstance.markAllReadUI(tab);
} }
} }
}; };
+5
View File
@@ -1,6 +1,11 @@
@include(snippets/header) @include(snippets/header)
<div class="pagewrapper"> <div class="pagewrapper">
<div id="main"> <div id="main">
<style>
/* 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 > .container.adm.up) #bg { opacity: 0 !important; visibility: hidden !important; }
</style>
<div class="container adm up"> <div class="container adm up">
<header class="adm-header"> <header class="adm-header">
+91 -5
View File
@@ -1,14 +1,100 @@
@include(snippets/header) @include(snippets/header)
<div class="pagewrapper"> <div class="pagewrapper">
<div id="main" class="ranking-page"> <div id="main" class="ranking-page">
<div class="topf0ckers"> <style>
<h3>{{ t('ranking.title') }}</h3> /* Ranking: style guide (square, flat, theme colours, no glow). Scoped to .ranking-page so it also
</div> applies inside the page modal, which lifts this #main (styles included) over the current page. */
.ranking-page {
--rk-accent: var(--accent, #0096ff);
--rk-text: var(--text-color, var(--white, #fff));
--rk-muted: var(--text-muted, #8a8f98);
--rk-surface: rgba(255, 255, 255, 0.04);
--rk-border: rgba(255, 255, 255, 0.1);
--rk-gold: #ffd700; --rk-silver: #c0c0c0; --rk-bronze: #cd7f32;
}
.ranking-page *, .ranking-page *::before, .ranking-page *::after { border-radius: 0 !important; }
/* Podium: flat tiles, medal colour as frame + label, no bounce, no glow */
.ranking-page .ranking-podium { background: none; gap: 16px; margin: 0 0 20px; padding: 0; align-items: stretch; }
.ranking-page .podium-item {
flex: 1 1 0; max-width: 240px; padding: 22px 14px 16px; box-sizing: border-box;
background: var(--rk-surface); border: 1px solid var(--rk-border); border-top: 3px solid var(--medal, var(--rk-accent));
justify-content: flex-start; transition: border-color 0.15s, background 0.15s;
}
.ranking-page .podium-item:hover { transform: none; background: rgba(255, 255, 255, 0.06); border-color: var(--medal, var(--rk-accent)); }
.ranking-page .podium-item.rank-1 { --medal: var(--rk-gold); transform: none; padding-top: 40px; }
.ranking-page .podium-item.rank-1:hover { transform: none; }
.ranking-page .podium-item.rank-2 { --medal: var(--rk-silver); }
.ranking-page .podium-item.rank-3 { --medal: var(--rk-bronze); }
.ranking-page .podium-avatar,
.ranking-page :is(.rank-1, .rank-2, .rank-3) .podium-avatar {
width: 96px; height: 96px; border: 2px solid var(--medal, var(--rk-accent)); box-shadow: none; background: #000;
}
.ranking-page .rank-1 .podium-avatar { width: 120px; height: 120px; }
.ranking-page .podium-crown { top: -34px; filter: none; transform: translateX(-50%); font-size: 1.6em; }
.ranking-page .podium-avatar-wrapper { margin-bottom: 18px; }
.ranking-page .podium-rank-label,
.ranking-page :is(.rank-1, .rank-2, .rank-3) .podium-rank-label {
bottom: -11px; padding: 2px 10px; font-size: 0.85em; font-weight: 800; letter-spacing: 0.06em;
background: var(--medal, var(--rk-accent)); color: #000; box-shadow: none;
}
.ranking-page .podium-name { font-size: 1.15em; text-shadow: none; color: var(--rk-text); overflow-wrap: anywhere; }
.ranking-page .podium-count,
.ranking-page :is(.rank-1, .rank-2, .rank-3) .podium-count {
color: var(--rk-muted); font-size: 0.72em; font-weight: 800; text-transform: uppercase; letter-spacing: 0.08em;
}
/* Tables */
.ranking-page .clean-table { background: transparent; border: 1px solid var(--rk-border); border-collapse: collapse; }
.ranking-page .clean-table th {
padding: 10px 12px; font-size: 0.72em; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em;
color: var(--rk-muted); background: var(--rk-surface); border-bottom: 1px solid var(--rk-border);
}
.ranking-page .clean-table td { padding: 10px 12px; border-bottom: 1px solid var(--rk-border); color: var(--rk-text); line-height: 1.4; }
.ranking-page .clean-table tbody tr { transition: background 0.12s; }
.ranking-page .clean-table tbody tr:hover { background: var(--rk-surface); }
.ranking-page .rank-cell { color: var(--rk-accent) !important; font-variant-numeric: tabular-nums; }
.ranking-page .count-cell, .ranking-page .stats-table td:last-child, .ranking-page .f0cks-table td:last-child { font-variant-numeric: tabular-nums; }
.ranking-page .rank-avatar { border: 1px solid var(--rk-border); }
/* Side panels */
.ranking-page .section-small { gap: 20px; }
.ranking-page .stats-box-simple {
background: var(--rk-surface); border: 1px solid var(--rk-border); box-shadow: none; padding: 16px; box-sizing: border-box;
}
.ranking-page .stats-box-simple h3 {
display: flex; align-items: center; gap: 8px; margin: 0 0 4px; padding: 0 0 10px;
font-size: 0.78em; font-weight: 800; letter-spacing: 0.12em; color: var(--rk-muted);
border-bottom: 1px solid var(--rk-border);
}
.ranking-page .stats-box-simple h3::before { content: ''; width: 3px; height: 10px; background: var(--rk-accent); flex-shrink: 0; }
.ranking-page .stats-box-simple .clean-table { border: 0; margin-top: 0; }
.ranking-page .stats-box-simple .clean-table td { padding: 8px 0; }
.ranking-page .stats-box-simple .clean-table tbody tr:hover { background: transparent; }
.ranking-page .stats-box-simple .clean-table tbody tr:last-child td { border-bottom: 0; }
/* Both columns start on the same top line */
.ranking-page .ranking-wrapper { gap: 28px; margin-top: 0; align-items: flex-start; }
.ranking-page .section-big > .clean-table:first-child { margin-top: 0; }
@media (max-width: 992px) {
.ranking-page .ranking-podium { flex-direction: row; align-items: stretch; gap: 10px; padding-top: 0; }
.ranking-page .podium-item { padding: 18px 8px 12px; }
.ranking-page .podium-item.rank-1 { padding-top: 34px; }
.ranking-page .podium-avatar, .ranking-page :is(.rank-1, .rank-2, .rank-3) .podium-avatar { width: 64px; height: 64px; }
.ranking-page .rank-1 .podium-avatar { width: 80px; height: 80px; }
.ranking-page .podium-item.rank-1 { order: 2 !important; }
.ranking-page .podium-item.rank-2 { order: 1 !important; }
.ranking-page .podium-item.rank-3 { order: 3 !important; }
.ranking-page .podium-name { font-size: 0.95em; }
}
@media (max-width: 420px) {
.ranking-page .ranking-podium { flex-direction: column; align-items: stretch; gap: 26px; }
.ranking-page .podium-item { max-width: none; order: unset !important; }
}
</style>
<div class="ranking-wrapper"> <div class="ranking-wrapper">
<div class="section-big"> <div class="section-big">
<h3>{{ t('ranking.top_contributors') }}</h3>
@if(list.length >= 3) @if(list.length >= 3)
<div class="ranking-podium"> <div class="ranking-podium">
@for(let i = 0; i < 3; i++) @for(let i = 0; i < 3; i++)
+3
View File
@@ -1107,6 +1107,9 @@
@else @else
<button class="notif-tab active" data-tab="system">{{ t('nav.notif_tab_system') }} <span class="notif-tab-badge" id="scroller-notif-tab-badge-system" style="display:none">0</span></button> <button class="notif-tab active" data-tab="system">{{ t('nav.notif_tab_system') }} <span class="notif-tab-badge" id="scroller-notif-tab-badge-system" style="display:none">0</span></button>
@endif @endif
@if(typeof session !== 'undefined' && session && session.admin)
<button class="notif-tab" data-tab="admin"><i class="fa-solid fa-shield-halved" style="font-size: 0.85em;"></i> Admin <span class="notif-tab-badge" id="scroller-notif-tab-badge-admin" style="display:none">0</span></button>
@endif
</div> </div>
<button id="scroller-mark-all-read" title="{{ t('nav.mark_all_read') }}"><i class="fa-solid fa-check-double"></i></button> <button id="scroller-mark-all-read" title="{{ t('nav.mark_all_read') }}"><i class="fa-solid fa-check-double"></i></button>
</div> </div>
+982 -253
View File
File diff suppressed because it is too large Load Diff
+11 -3
View File
@@ -220,7 +220,8 @@
} }
#report-modal .report-modal-content * { border-radius: 0 !important; } #report-modal .report-modal-content * { border-radius: 0 !important; }
#report-modal .rm-header { position: relative; flex-shrink: 0; padding: 20px 64px 14px 24px; 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 { flex: 1 1 auto; min-height: 0; overflow-y: auto; overscroll-behavior: contain; padding: 16px 24px; scrollbar-width: none !important; -ms-overflow-style: none; }
#report-modal .rm-body::-webkit-scrollbar { display: none; width: 0; height: 0; }
#report-modal .rm-body > :last-child { margin-bottom: 0; } #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); } #report-modal .rm-header h3 { margin: 0; font-size: 1.25em; font-weight: 700; color: var(--rm-text); }
#report-modal .rm-header p { margin: 6px 0 0; font-size: 0.85em; line-height: 1.5; color: var(--rm-muted); } #report-modal .rm-header p { margin: 6px 0 0; font-size: 0.85em; line-height: 1.5; color: var(--rm-muted); }
@@ -417,11 +418,17 @@
</div> </div>
</div> </div>
<div class="global-sidebar-right-footer"> <div class="global-sidebar-right-footer">
@if(session)
<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="/ranking" data-page-modal title="{{ t('footer.ranking') }}" aria-label="{{ t('footer.ranking') }}"><i class="fa-solid fa-users-between-lines"></i></a>
@endif
<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="/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="/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> <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 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-keyboard"></i></div>
@if(!/^\/\d$/.test(url.pathname))
{{-- id kept: f0ckm.js / sidebar-activity.js hook the AJAX random transition on #nav-random --}}
<a href="/random" id="nav-random" title="{{ t('nav.random') }}" aria-label="{{ t('nav.random') }}"><i class="fa-solid fa-shuffle"></i></a>
@endif
</div> </div>
</div> </div>
@endif @endif
@@ -653,7 +660,8 @@
}); });
const json = await resp.json(); const json = await resp.json();
if (status) { status.textContent = json.msg || ''; status.className = json.success ? 'flash-success' : 'flash-error'; } if (status) { status.textContent = json.msg || ''; status.className = json.success ? 'flash-success' : 'flash-error'; }
if (json.success) setTimeout(() => { close(registerModal); open(loginModal); }, 2000); if (json.success && json.upgraded) setTimeout(() => { location.reload(); }, 1500);
else if (json.success) setTimeout(() => { close(registerModal); open(loginModal); }, 2000);
} catch (err) { } catch (err) {
if (status) { status.textContent = 'Network error.'; status.className = 'flash-error'; } if (status) { status.textContent = 'Network error.'; status.className = 'flash-error'; }
} finally { btn.disabled = false; btn.textContent = 'Create Account'; } } finally { btn.disabled = false; btn.textContent = 'Create Account'; }
+5
View File
@@ -347,7 +347,12 @@
#info-modal .infobox-modal-content .infobox-modal-footer { flex-shrink: 0; } #info-modal .infobox-modal-content .infobox-modal-footer { flex-shrink: 0; }
#info-modal .infobox-modal-content .infobox-modal-body { #info-modal .infobox-modal-content .infobox-modal-body {
flex: 1 1 auto; min-height: 0; max-height: none; overflow-y: auto; overscroll-behavior: contain; flex: 1 1 auto; min-height: 0; max-height: none; overflow-y: auto; overscroll-behavior: contain;
/* Scrolls (wheel, touch, keyboard) but shows no scrollbar in any browser.
NOTE: no backticks or dollar-brace in views, the template engine compiles them as JS template literals */
scrollbar-width: none !important;
-ms-overflow-style: none;
} }
#info-modal .infobox-modal-content .infobox-modal-body::-webkit-scrollbar { display: none; width: 0; height: 0; }
@media (max-width: 600px) { @media (max-width: 600px) {
#info-modal { padding: 0 !important; align-items: stretch !important; } #info-modal { padding: 0 !important; align-items: stretch !important; }
#info-modal .infobox-modal-content { #info-modal .infobox-modal-content {
+39 -37
View File
@@ -35,9 +35,6 @@
<a href="/abyss" title="{{ t('nav.abyss') }}"><i class="fa-solid fa-dice-d6"></i></a> <a href="/abyss" title="{{ t('nav.abyss') }}"><i class="fa-solid fa-dice-d6"></i></a>
@endif @endif
<a href="#" id="nav-search-btn" title="{{ t('nav.search') }}"><i class="fa-solid fa-magnifying-glass"></i></a> <a href="#" id="nav-search-btn" title="{{ t('nav.search') }}"><i class="fa-solid fa-magnifying-glass"></i></a>
@if(!/^\/\d$/.test(url.pathname))
<a href="/random" id="nav-random" title="{{ t('nav.random') }}"><i class="fa-solid fa-shuffle"></i></a>
@endif
</div> </div>
</div> </div>
@@ -48,7 +45,7 @@
<div class="nav-user-dropdown"> <div class="nav-user-dropdown">
<button class="nav-user-btn nav-avatar-btn" id="nav-user-toggle"> <button class="nav-user-btn nav-avatar-btn" id="nav-user-toggle">
@if(session.is_anon) @if(session.is_anon)
<span class="nav-avatar-icon" style="font-size: 1.2em; margin-right: 6px; display: inline-flex; align-items: center; cursor: pointer;" onclick="event.stopPropagation(); const u = '{{ session.login.toLowerCase() }}'; if(typeof window.loadPageAjax==='function'){ window.loadPageAjax('/user/' + u, true); } else { window.location.href='/user/' + u; }" title="{{ session.login }}"><i class="fa-solid fa-user-secret"></i></span> <img class="nav-avatar-img" src="/a/default.png" onclick="event.stopPropagation(); const u = '{{ session.login.toLowerCase() }}'; if(typeof window.loadPageAjax==='function'){ window.loadPageAjax('/user/' + u, true); } else { window.location.href='/user/' + u; }" title="{{ session.login }}">
@elseif(session.avatar_file) @elseif(session.avatar_file)
<img class="nav-avatar-img" src="/a/{{ session.avatar_file }}" onerror="this.src='/a/default.png'" onclick="event.stopPropagation(); if(typeof window.loadPageAjax==='function'){ window.loadPageAjax('/user/{!! session.user.toLowerCase() !!}', true); } else { window.location.href='/user/{!! session.user.toLowerCase() !!}'; }" title="{{ session.user }}"> <img class="nav-avatar-img" src="/a/{{ session.avatar_file }}" onerror="this.src='/a/default.png'" onclick="event.stopPropagation(); if(typeof window.loadPageAjax==='function'){ window.loadPageAjax('/user/{!! session.user.toLowerCase() !!}', true); } else { window.location.href='/user/{!! session.user.toLowerCase() !!}'; }" title="{{ session.user }}">
@elseif(session.avatar && session.avatar > 0) @elseif(session.avatar && session.avatar > 0)
@@ -68,12 +65,12 @@
<a href="/user/{!! session.user.toLowerCase() !!}/halls">{{ t('nav.my_halls') }}</a> <a href="/user/{!! session.user.toLowerCase() !!}/halls">{{ t('nav.my_halls') }}</a>
@endif @endif
@endif @endif
<a href="/pending">Pending Uploads</a> <a href="/pending" class="nav-menu-pending">Pending@if(typeof session.own_pending_count === 'number')<span class="nav-menu-count @if(session.own_pending_count === 0)is-zero@endif" title="{{ session.own_pending_count }} upload(s) awaiting approval">{{ session.own_pending_count }}</span>@endif</a>
@if(enable_anonymous_access && session.is_anon) @if(enable_anonymous_access && session.is_anon)
<a href="#" id="nav-user-anon-identity-btn" style="white-space: nowrap;" onclick="event.preventDefault(); if(window.f0ckAnonPasskey) { window.f0ckAnonPasskey.openModal(); } else { const m = document.getElementById('anon-passkey-modal'); if (m) m.style.display='flex'; }">Passkey Identity</a> <a href="#" id="nav-user-anon-identity-btn" style="white-space: nowrap;" onclick="event.preventDefault(); if(window.f0ckAnonPasskey) { window.f0ckAnonPasskey.openModal(); } else { const m = document.getElementById('anon-passkey-modal'); if (m) m.style.display='flex'; }">Passkey Identity</a>
@endif @endif
<a href="/user/{{ (session.is_anon && session.login ? session.login : session.user).toLowerCase() }}/favs" class="mobile-only">{{ t('nav.favs') }}</a> <a href="/user/{{ (session.is_anon && session.login ? session.login : session.user).toLowerCase() }}/favs" class="mobile-only">{{ t('nav.favs') }}</a>
<a href="/settings" class="mobile-only">{{ t('nav.settings') }}</a> <a href="/settings" class="mobile-only" data-page-modal title="{{ t('nav.settings') }}">{{ t('nav.settings') }}</a>
<div class="nav-user-divider"></div> <div class="nav-user-divider"></div>
<a href="/logout" class="mobile-only">{{ t('nav.logout') }}</a> <a href="/logout" class="mobile-only">{{ t('nav.logout') }}</a>
</div> </div>
@@ -94,6 +91,9 @@
@else @else
<button class="notif-tab active" data-tab="system">{{ t('nav.notif_tab_system') }} <span class="notif-tab-badge" id="notif-tab-badge-system" style="display:none">0</span></button> <button class="notif-tab active" data-tab="system">{{ t('nav.notif_tab_system') }} <span class="notif-tab-badge" id="notif-tab-badge-system" style="display:none">0</span></button>
@endif @endif
@if(session && session.admin)
<button class="notif-tab" data-tab="admin">Admin <span class="notif-tab-badge" id="notif-tab-badge-admin" style="display:none">0</span></button>
@endif
</div> </div>
<button id="mark-all-read" title="{{ t('nav.mark_all_read') }}"><i class="fa-solid fa-check-double"></i></button> <button id="mark-all-read" title="{{ t('nav.mark_all_read') }}"><i class="fa-solid fa-check-double"></i></button>
</div> </div>
@@ -129,7 +129,7 @@
@endif @endif
<!-- Quick Settings --> <!-- Quick Settings -->
<a href="/settings" title="Settings" class="desktop-only"><i class="fa-solid fa-gear"></i></a> <a href="/settings" title="Settings" class="desktop-only" data-page-modal><i class="fa-solid fa-gear"></i></a>
<!-- Filter --> <!-- Filter -->
@if(!session.is_anon || (enable_anonymous_access && anon_permissions.filter)) @if(!session.is_anon || (enable_anonymous_access && anon_permissions.filter))
@@ -171,9 +171,9 @@
</nav> </nav>
@if(session.admin || session._is_impersonating) @if(session.admin || session._is_impersonating || session._preview_role)
<!-- Admin Bar (Bottom, hotkey-activated) --> <!-- Admin Bar (Bottom, hotkey-activated) -->
<div class="admin-bar" id="admin-bar"> <div class="admin-bar" id="admin-bar" data-preview-role="{{ session._preview_role || 'admin' }}">
@if(session._is_impersonating) @if(session._is_impersonating)
<!-- Impersonation Banner --> <!-- Impersonation Banner -->
<div class="admin-bar-impersonate-notice"> <div class="admin-bar-impersonate-notice">
@@ -190,10 +190,6 @@
@else @else
<!-- Normal Admin Bar --> <!-- Normal Admin Bar -->
<div class="admin-bar-inner"> <div class="admin-bar-inner">
<div class="admin-bar-brand">
<i class="fa-solid fa-shield-halved"></i>
<span>Admin</span>
</div>
<div class="admin-bar-links"> <div class="admin-bar-links">
<a href="/admin" class="admin-bar-link" title="Admin panel"> <a href="/admin" class="admin-bar-link" title="Admin panel">
@@ -235,7 +231,7 @@
@endif @endif
<!-- Floating exit-preview pill (visible only during role preview) --> <!-- Floating exit-preview pill (visible only during role preview) -->
@if(session.admin) @if(session.admin || session._preview_role)
<div id="admin-preview-exit-pill" style="display:none;"> <div id="admin-preview-exit-pill" style="display:none;">
<i class="fa-solid fa-eye"></i> <i class="fa-solid fa-eye"></i>
<span id="admin-preview-exit-label">Previewing as User</span> <span id="admin-preview-exit-label">Previewing as User</span>
@@ -279,9 +275,6 @@
@if(abyss_enabled) @if(abyss_enabled)
<a href="/abyss" title="Abyss"><i class="fa-solid fa-dice-d6"></i></a> <a href="/abyss" title="Abyss"><i class="fa-solid fa-dice-d6"></i></a>
@endif @endif
@if(!/^\/\d$/.test(url.pathname))
<a href="/random" id="nav-random" title="Random"><i class="fa-solid fa-shuffle"></i></a>
@endif
<a href="#" id="nav-search-btn" title="Search"><i class="fa-solid fa-magnifying-glass"></i></a> <a href="#" id="nav-search-btn" title="Search"><i class="fa-solid fa-magnifying-glass"></i></a>
</div> </div>
@@ -301,7 +294,7 @@
@if(enable_anonymous_access) @if(enable_anonymous_access)
<a href="#" id="nav-anon-identity-btn" @if(!session || !session.is_anon) style="display:none;" @endif onclick="event.preventDefault(); if(window.f0ckAnonPasskey) { window.f0ckAnonPasskey.openModal(); } else { const m = document.getElementById('anon-passkey-modal'); if (m) m.style.display='flex'; }"><i class="fa-solid fa-fingerprint"></i> Passkey Identity</a> <a href="#" id="nav-anon-identity-btn" @if(!session || !session.is_anon) style="display:none;" @endif onclick="event.preventDefault(); if(window.f0ckAnonPasskey) { window.f0ckAnonPasskey.openModal(); } else { const m = document.getElementById('anon-passkey-modal'); if (m) m.style.display='flex'; }"><i class="fa-solid fa-fingerprint"></i> Passkey Identity</a>
<a href="/settings" id="nav-anon-settings-btn" @if(!session || !session.is_anon) style="display:none;" @endif><i class="fa-solid fa-gear"></i> Settings</a> <a href="/settings" id="nav-anon-settings-btn" data-page-modal title="Settings" @if(!session || !session.is_anon) style="display:none;" @endif><i class="fa-solid fa-gear"></i> Settings</a>
@endif @endif
<a href="#" id="nav-login-btn">{{ t('nav.login') }}</a> <a href="#" id="nav-login-btn">{{ t('nav.login') }}</a>
@if(registration_open) @if(registration_open)
@@ -1082,6 +1075,9 @@ async function loginWithPasskey() {
<div class="shortcut-item"><span><kbd>Ctrl</kbd> + <kbd>Enter</kbd></span><span>{{ t('shortcuts.send_comment') }}</span></div> <div class="shortcut-item"><span><kbd>Ctrl</kbd> + <kbd>Enter</kbd></span><span>{{ t('shortcuts.send_comment') }}</span></div>
<div class="shortcut-item"><span><kbd>s</kbd></span><span>{{ t('shortcuts.flash_yank') }}</span></div> <div class="shortcut-item"><span><kbd>s</kbd></span><span>{{ t('shortcuts.flash_yank') }}</span></div>
@if(session) @if(session)
<div class="shortcut-item"><span><kbd>Shift</kbd> + <kbd>S</kbd></span><span>{{ t('shortcuts.open_settings') }}</span></div>
@endif
@if(session)
<div class="shortcut-item"><span><kbd>e</kbd></span><span>{{ t('shortcuts.tag_exclude') }}</span></div> <div class="shortcut-item"><span><kbd>e</kbd></span><span>{{ t('shortcuts.tag_exclude') }}</span></div>
<div class="shortcut-item"><span><kbd>i</kbd></span><span>{{ t('shortcuts.tag_input') }}</span></div> <div class="shortcut-item"><span><kbd>i</kbd></span><span>{{ t('shortcuts.tag_input') }}</span></div>
@endif @endif
@@ -1241,8 +1237,8 @@ async function loginWithPasskey() {
<button class="modal-close" id="drag-modal-close" title="Cancel Upload">&times;</button> <button class="modal-close" id="drag-modal-close" title="Cancel Upload">&times;</button>
<div class="modal-body"> <div class="modal-body">
<div class="upload-container" style="padding: 0; animation: none; opacity: 1;"> <div class="upload-container" style="padding: 0; animation: none; opacity: 1;">
<div class="upload-title">{{ t('upload.title') }}</div> <div class="upload-title">
<span class="upload-title-text">{{ t('upload.title') }}</span>
<div class="upload-limit-info"> <div class="upload-limit-info">
@if(session.uploads_remaining === undefined) @if(session.uploads_remaining === undefined)
<span class="limit-unlimited"><i class="fa-solid fa-infinity"></i> {{ t('upload.unlimited') }}</span> <span class="limit-unlimited"><i class="fa-solid fa-infinity"></i> {{ t('upload.unlimited') }}</span>
@@ -1252,6 +1248,7 @@ async function loginWithPasskey() {
<span class="limit-remaining"><i class="fa-solid fa-upload"></i> {{ session.uploads_remaining }}/{{ upload_limit }} {{ t('upload.remaining') }}</span> <span class="limit-remaining"><i class="fa-solid fa-upload"></i> {{ session.uploads_remaining }}/{{ upload_limit }} {{ t('upload.remaining') }}</span>
@endif @endif
</div> </div>
</div>
@include(snippets/upload-form) @include(snippets/upload-form)
</div> </div>
@@ -1261,7 +1258,7 @@ async function loginWithPasskey() {
@endif @endif
@if(recaptcha_enabled && !session) @if(recaptcha_enabled && (!session || session.is_anon))
<script> <script>
(function() { (function() {
var _rcModalWidgetId = null; var _rcModalWidgetId = null;
@@ -1300,7 +1297,7 @@ async function loginWithPasskey() {
<script src="https://www.google.com/recaptcha/api.js?onload=onRecaptchaModalReady&render=explicit" async defer></script> <script src="https://www.google.com/recaptcha/api.js?onload=onRecaptchaModalReady&render=explicit" async defer></script>
@endif @endif
@if(session && (session.admin || session._is_impersonating)) @if(session && (session.admin || session._is_impersonating || session._preview_role))
<style> <style>
/* ── Admin Bar ──────────────────────────────────────────────────────────────── */ /* ── Admin Bar ──────────────────────────────────────────────────────────────── */
.admin-bar { .admin-bar {
@@ -1730,7 +1727,8 @@ body.preview-as-user .admin-delete-btn,
body.preview-as-user .admin-edit-btn, body.preview-as-user .admin-edit-btn,
body.preview-as-user .admin-pin-btn, body.preview-as-user .admin-pin-btn,
body.preview-as-user .delete-btn, body.preview-as-user .delete-btn,
body.preview-as-user .poll-delete-btn, body.preview-as-user .poll-delete-btn { display: none !important; }
/* Title stays visible but loses its click-to-edit */
body.preview-as-user .item_title_text { display: inline !important; cursor: default !important; pointer-events: none !important; } body.preview-as-user .item_title_text { display: inline !important; cursor: default !important; pointer-events: none !important; }
body.preview-as-user .item_title_text.no-title { display: none !important; } body.preview-as-user .item_title_text.no-title { display: none !important; }
@@ -2012,8 +2010,10 @@ body.preview-as-mod .btn-infobox-action { display: none !important; }
} }
/* ── Admin bar: Preview As role toggle ──────────────────────────────────── */ /* ── Admin bar: Preview As role toggle ──────────────────────────────────── */
// The role is applied server-side (cookie preview_role, see src/index.mjs): templates, f0ckSession and
// admin/mod-guarded routes all behave as that role. Switching sets the cookie and reloads.
(function() { (function() {
const STORAGE_KEY = 'f0ck_preview_role'; const COOKIE = 'preview_role';
const pills = document.querySelectorAll('.admin-bar-preview-pill'); const pills = document.querySelectorAll('.admin-bar-preview-pill');
const exitPill = document.getElementById('admin-preview-exit-pill'); const exitPill = document.getElementById('admin-preview-exit-pill');
const exitLabel = document.getElementById('admin-preview-exit-label'); const exitLabel = document.getElementById('admin-preview-exit-label');
@@ -2040,26 +2040,28 @@ body.preview-as-mod .btn-infobox-action { display: none !important; }
} }
} }
// Init from localStorage function switchRole(role) {
const saved = localStorage.getItem(STORAGE_KEY) || 'admin'; const secure = location.protocol === 'https:' ? '; Secure' : '';
applyRole(saved); // No template literals here: the view engine would evaluate the placeholders server-side
document.cookie = role === 'admin'
? COOKIE + '=; path=/; max-age=0; SameSite=Lax' + secure
: COOKIE + '=' + role + '; path=/; max-age=86400; SameSite=Lax' + secure;
location.reload();
}
// The server tells which role this page was rendered as
try { localStorage.removeItem('f0ck_preview_role'); } catch {} // old client-only preview
const current = (adminBar && adminBar.dataset.previewRole) || 'admin';
applyRole(roleLabels[current] ? current : 'admin');
// Pill click
pills.forEach(pill => { pills.forEach(pill => {
pill.addEventListener('click', () => { pill.addEventListener('click', () => {
const role = pill.dataset.role; const role = pill.dataset.role;
localStorage.setItem(STORAGE_KEY, role); if (role !== current) switchRole(role);
applyRole(role);
}); });
}); });
// Exit button if (exitBtn) exitBtn.addEventListener('click', () => switchRole('admin'));
if (exitBtn) {
exitBtn.addEventListener('click', () => {
localStorage.setItem(STORAGE_KEY, 'admin');
applyRole('admin');
});
}
})(); })();
})(); })();
+10 -10
View File
@@ -1,6 +1,6 @@
@each(notifications as n) @each(notifications as n)
@if(n.type === 'approve') @if(n.type === 'approve')
<a href="/{{ n.item_slug || n.item_id }}" class="notif-item {{ n.is_read ? '' : 'unread' }} notif-with-thumb" data-id="{{ n.id }}" data-item-id="{{ n.item_id || '' }}" data-item-slug="{{ n.item_slug || '' }}"> <a href="/{{ n.item_slug || n.item_id }}" class="notif-item {{ n.is_read ? '' : 'unread' }} notif-with-thumb" data-id="{{ n.id }}" data-type="{{ n.type }}" data-item-id="{{ n.item_id || '' }}" data-item-slug="{{ n.item_slug || '' }}">
<div class="notif-thumb" data-mode="{{ n.item_mode || '' }}"> <div class="notif-thumb" data-mode="{{ n.item_mode || '' }}">
<img src="/t/{{ n.item_id }}{{ ((active_mode===0&&(n.item_mode==='nsfw'||n.item_mode==='nsfl'))||(active_mode===1&&n.item_mode==='nsfl')||(active_mode===4&&(n.item_mode==='sfw'||n.item_mode==='nsfw'))) ? '_blur' : '' }}.webp" data-orig-src="/t/{{ n.item_id }}.webp" alt="thumb" onerror="this.onerror=null;this.src='/pending/t/{{ n.item_id }}.webp';this.onerror=function(){this.onerror=null;this.src='/mod/deleted/t/{{ n.item_id }}.webp';this.onerror=function(){this.style.display='none';};};"/> <img src="/t/{{ n.item_id }}{{ ((active_mode===0&&(n.item_mode==='nsfw'||n.item_mode==='nsfl'))||(active_mode===1&&n.item_mode==='nsfl')||(active_mode===4&&(n.item_mode==='sfw'||n.item_mode==='nsfw'))) ? '_blur' : '' }}.webp" data-orig-src="/t/{{ n.item_id }}.webp" alt="thumb" onerror="this.onerror=null;this.src='/pending/t/{{ n.item_id }}.webp';this.onerror=function(){this.onerror=null;this.src='/mod/deleted/t/{{ n.item_id }}.webp';this.onerror=function(){this.style.display='none';};};"/>
</div> </div>
@@ -11,7 +11,7 @@
</div> </div>
</a> </a>
@elseif(n.type === 'admin_pending') @elseif(n.type === 'admin_pending')
<a href="/mod/approve" class="notif-item {{ n.is_read ? '' : 'unread' }} notif-with-thumb" data-id="{{ n.id }}" data-item-id="{{ n.item_id || '' }}" data-item-slug="{{ n.item_slug || '' }}"> <a href="/mod/approve" class="notif-item {{ n.is_read ? '' : 'unread' }} notif-with-thumb" data-id="{{ n.id }}" data-type="{{ n.type }}" data-item-id="{{ n.item_id || '' }}" data-item-slug="{{ n.item_slug || '' }}">
<div class="notif-thumb" data-mode="{{ n.item_mode || '' }}"> <div class="notif-thumb" data-mode="{{ n.item_mode || '' }}">
<img src="/t/{{ n.item_id }}{{ ((active_mode===0&&(n.item_mode==='nsfw'||n.item_mode==='nsfl'))||(active_mode===1&&n.item_mode==='nsfl')||(active_mode===4&&(n.item_mode==='sfw'||n.item_mode==='nsfw'))) ? '_blur' : '' }}.webp" data-orig-src="/t/{{ n.item_id }}.webp" alt="thumb" onerror="this.onerror=null;this.src='/t/{{ n.item_id }}.webp';this.onerror=function(){this.style.display='none';};"/> <img src="/t/{{ n.item_id }}{{ ((active_mode===0&&(n.item_mode==='nsfw'||n.item_mode==='nsfl'))||(active_mode===1&&n.item_mode==='nsfl')||(active_mode===4&&(n.item_mode==='sfw'||n.item_mode==='nsfw'))) ? '_blur' : '' }}.webp" data-orig-src="/t/{{ n.item_id }}.webp" alt="thumb" onerror="this.onerror=null;this.src='/t/{{ n.item_id }}.webp';this.onerror=function(){this.style.display='none';};"/>
</div> </div>
@@ -22,7 +22,7 @@
</div> </div>
</a> </a>
@elseif(n.type === 'report') @elseif(n.type === 'report')
<a href="/mod/reports" class="notif-item {{ n.is_read ? '' : 'unread' }} notif-with-thumb" data-id="{{ n.id }}" data-item-id="{{ n.item_id || '' }}" data-item-slug="{{ n.item_slug || '' }}"> <a href="/mod/reports" class="notif-item {{ n.is_read ? '' : 'unread' }} notif-with-thumb" data-id="{{ n.id }}" data-type="{{ n.type }}" data-item-id="{{ n.item_id || '' }}" data-item-slug="{{ n.item_slug || '' }}">
<div class="notif-thumb" data-mode="{{ n.item_mode || '' }}"> <div class="notif-thumb" data-mode="{{ n.item_mode || '' }}">
<img src="/t/{{ n.item_id }}{{ ((active_mode===0&&(n.item_mode==='nsfw'||n.item_mode==='nsfl'))||(active_mode===1&&n.item_mode==='nsfl')||(active_mode===4&&(n.item_mode==='sfw'||n.item_mode==='nsfw'))) ? '_blur' : '' }}.webp" data-orig-src="/t/{{ n.item_id }}.webp" alt="thumb" onerror="this.onerror=null;this.src='/pending/t/{{ n.item_id }}.webp';this.onerror=function(){this.onerror=null;this.src='/mod/deleted/t/{{ n.item_id }}.webp';this.onerror=function(){this.style.display='none';};};"/> <img src="/t/{{ n.item_id }}{{ ((active_mode===0&&(n.item_mode==='nsfw'||n.item_mode==='nsfl'))||(active_mode===1&&n.item_mode==='nsfl')||(active_mode===4&&(n.item_mode==='sfw'||n.item_mode==='nsfw'))) ? '_blur' : '' }}.webp" data-orig-src="/t/{{ n.item_id }}.webp" alt="thumb" onerror="this.onerror=null;this.src='/pending/t/{{ n.item_id }}.webp';this.onerror=function(){this.onerror=null;this.src='/mod/deleted/t/{{ n.item_id }}.webp';this.onerror=function(){this.style.display='none';};};"/>
</div> </div>
@@ -33,7 +33,7 @@
</div> </div>
</a> </a>
@elseif(n.type === 'deny') @elseif(n.type === 'deny')
<a href="/{{ n.item_slug || n.item_id }}" class="notif-item {{ n.is_read ? '' : 'unread' }} notif-with-thumb" data-id="{{ n.id }}" data-item-id="{{ n.item_id || '' }}" data-item-slug="{{ n.item_slug || '' }}"> <a href="/{{ n.item_slug || n.item_id }}" class="notif-item {{ n.is_read ? '' : 'unread' }} notif-with-thumb" data-id="{{ n.id }}" data-type="{{ n.type }}" data-item-id="{{ n.item_id || '' }}" data-item-slug="{{ n.item_slug || '' }}">
<div class="notif-thumb" data-mode="{{ n.item_mode || '' }}"> <div class="notif-thumb" data-mode="{{ n.item_mode || '' }}">
<img src="/t/{{ n.item_id }}{{ ((active_mode===0&&(n.item_mode==='nsfw'||n.item_mode==='nsfl'))||(active_mode===1&&n.item_mode==='nsfl')||(active_mode===4&&(n.item_mode==='sfw'||n.item_mode==='nsfw'))) ? '_blur' : '' }}.webp" data-orig-src="/t/{{ n.item_id }}.webp" alt="thumb" onerror="this.onerror=null;this.src='/t/{{ n.item_id }}.webp';this.onerror=function(){this.style.display='none'};" /> <img src="/t/{{ n.item_id }}{{ ((active_mode===0&&(n.item_mode==='nsfw'||n.item_mode==='nsfl'))||(active_mode===1&&n.item_mode==='nsfl')||(active_mode===4&&(n.item_mode==='sfw'||n.item_mode==='nsfw'))) ? '_blur' : '' }}.webp" data-orig-src="/t/{{ n.item_id }}.webp" alt="thumb" onerror="this.onerror=null;this.src='/t/{{ n.item_id }}.webp';this.onerror=function(){this.style.display='none'};" />
</div> </div>
@@ -47,7 +47,7 @@
</div> </div>
</a> </a>
@elseif(n.type === 'item_deleted') @elseif(n.type === 'item_deleted')
<a href="/{{ n.item_slug || n.item_id }}" class="notif-item {{ n.is_read ? '' : 'unread' }} notif-with-thumb" data-id="{{ n.id }}" data-item-id="{{ n.item_id || '' }}" data-item-slug="{{ n.item_slug || '' }}"> <a href="/{{ n.item_slug || n.item_id }}" class="notif-item {{ n.is_read ? '' : 'unread' }} notif-with-thumb" data-id="{{ n.id }}" data-type="{{ n.type }}" data-item-id="{{ n.item_id || '' }}" data-item-slug="{{ n.item_slug || '' }}">
<div class="notif-thumb" data-mode="{{ n.item_mode || '' }}"> <div class="notif-thumb" data-mode="{{ n.item_mode || '' }}">
<img src="/t/{{ n.item_id }}{{ ((active_mode===0&&(n.item_mode==='nsfw'||n.item_mode==='nsfl'))||(active_mode===1&&n.item_mode==='nsfl')||(active_mode===4&&(n.item_mode==='sfw'||n.item_mode==='nsfw'))) ? '_blur' : '' }}.webp" data-orig-src="/t/{{ n.item_id }}.webp" alt="thumb" onerror="this.onerror=null;this.src='/t/{{ n.item_id }}.webp';this.onerror=function(){this.style.display='none'};" /> <img src="/t/{{ n.item_id }}{{ ((active_mode===0&&(n.item_mode==='nsfw'||n.item_mode==='nsfl'))||(active_mode===1&&n.item_mode==='nsfl')||(active_mode===4&&(n.item_mode==='sfw'||n.item_mode==='nsfw'))) ? '_blur' : '' }}.webp" data-orig-src="/t/{{ n.item_id }}.webp" alt="thumb" onerror="this.onerror=null;this.src='/t/{{ n.item_id }}.webp';this.onerror=function(){this.style.display='none'};" />
</div> </div>
@@ -61,7 +61,7 @@
</div> </div>
</a> </a>
@elseif(n.type === 'upload_comment') @elseif(n.type === 'upload_comment')
<a href="/{{ n.item_slug || n.item_id }}#c{{ n.reference_id }}" class="notif-item {{ n.is_read ? '' : 'unread' }} notif-with-thumb" data-id="{{ n.id }}" data-item-id="{{ n.item_id || '' }}" data-item-slug="{{ n.item_slug || '' }}"> <a href="/{{ n.item_slug || n.item_id }}#c{{ n.reference_id }}" class="notif-item {{ n.is_read ? '' : 'unread' }} notif-with-thumb" data-id="{{ n.id }}" data-type="{{ n.type }}" data-item-id="{{ n.item_id || '' }}" data-item-slug="{{ n.item_slug || '' }}">
<div class="notif-thumb" data-mode="{{ n.item_mode || '' }}"> <div class="notif-thumb" data-mode="{{ n.item_mode || '' }}">
<img src="/t/{{ n.item_id }}{{ ((active_mode===0&&(n.item_mode==='nsfw'||n.item_mode==='nsfl'))||(active_mode===1&&n.item_mode==='nsfl')||(active_mode===4&&(n.item_mode==='sfw'||n.item_mode==='nsfw'))) ? '_blur' : '' }}.webp" data-orig-src="/t/{{ n.item_id }}.webp" alt="thumbnail" onerror="this.style.display='none'"> <img src="/t/{{ n.item_id }}{{ ((active_mode===0&&(n.item_mode==='nsfw'||n.item_mode==='nsfl'))||(active_mode===1&&n.item_mode==='nsfl')||(active_mode===4&&(n.item_mode==='sfw'||n.item_mode==='nsfw'))) ? '_blur' : '' }}.webp" data-orig-src="/t/{{ n.item_id }}.webp" alt="thumbnail" onerror="this.style.display='none'">
</div> </div>
@@ -72,7 +72,7 @@
</div> </div>
</a> </a>
@elseif(n.type === 'upload_success') @elseif(n.type === 'upload_success')
<a href="/{{ n.item_slug || n.item_id }}" class="notif-item {{ n.is_read ? '' : 'unread' }} notif-with-thumb" data-id="{{ n.id }}" data-item-id="{{ n.item_id || '' }}" data-item-slug="{{ n.item_slug || '' }}"> <a href="/{{ n.item_slug || n.item_id }}" class="notif-item {{ n.is_read ? '' : 'unread' }} notif-with-thumb" data-id="{{ n.id }}" data-type="{{ n.type }}" data-item-id="{{ n.item_id || '' }}" data-item-slug="{{ n.item_slug || '' }}">
<div class="notif-thumb" data-mode="{{ n.item_mode || '' }}"> <div class="notif-thumb" data-mode="{{ n.item_mode || '' }}">
<img src="/t/{{ n.item_id }}{{ ((active_mode===0&&(n.item_mode==='nsfw'||n.item_mode==='nsfl'))||(active_mode===1&&n.item_mode==='nsfl')||(active_mode===4&&(n.item_mode==='sfw'||n.item_mode==='nsfw'))) ? '_blur' : '' }}.webp" data-orig-src="/t/{{ n.item_id }}.webp" alt="thumb" onerror="this.style.display='none'" /> <img src="/t/{{ n.item_id }}{{ ((active_mode===0&&(n.item_mode==='nsfw'||n.item_mode==='nsfl'))||(active_mode===1&&n.item_mode==='nsfl')||(active_mode===4&&(n.item_mode==='sfw'||n.item_mode==='nsfw'))) ? '_blur' : '' }}.webp" data-orig-src="/t/{{ n.item_id }}.webp" alt="thumb" onerror="this.style.display='none'" />
</div> </div>
@@ -83,7 +83,7 @@
</div> </div>
</a> </a>
@elseif(n.type === 'upload_error') @elseif(n.type === 'upload_error')
<a href="{{ n.item_id ? '/' + (n.item_slug || n.item_id) : '#' }}" class="notif-item {{ n.is_read ? '' : 'unread' }} {{ n.item_id ? 'notif-with-thumb' : '' }}" data-id="{{ n.id }}" data-item-id="{{ n.item_id || '' }}" data-item-slug="{{ n.item_slug || '' }}"> <a href="{{ n.item_id ? '/' + (n.item_slug || n.item_id) : '#' }}" class="notif-item {{ n.is_read ? '' : 'unread' }} {{ n.item_id ? 'notif-with-thumb' : '' }}" data-id="{{ n.id }}" data-type="{{ n.type }}" data-item-id="{{ n.item_id || '' }}" data-item-slug="{{ n.item_slug || '' }}">
@if(n.item_id) @if(n.item_id)
<div class="notif-thumb" data-mode="{{ n.item_mode || '' }}"> <div class="notif-thumb" data-mode="{{ n.item_mode || '' }}">
<img src="/t/{{ n.item_id }}{{ ((active_mode===0&&(n.item_mode==='nsfw'||n.item_mode==='nsfl'))||(active_mode===1&&n.item_mode==='nsfl')||(active_mode===4&&(n.item_mode==='sfw'||n.item_mode==='nsfw'))) ? '_blur' : '' }}.webp" data-orig-src="/t/{{ n.item_id }}.webp" alt="thumb" onerror="this.onerror=null;this.src='/pending/t/{{ n.item_id }}.webp';this.onerror=function(){this.style.display='none'};" /> <img src="/t/{{ n.item_id }}{{ ((active_mode===0&&(n.item_mode==='nsfw'||n.item_mode==='nsfl'))||(active_mode===1&&n.item_mode==='nsfl')||(active_mode===4&&(n.item_mode==='sfw'||n.item_mode==='nsfw'))) ? '_blur' : '' }}.webp" data-orig-src="/t/{{ n.item_id }}.webp" alt="thumb" onerror="this.onerror=null;this.src='/pending/t/{{ n.item_id }}.webp';this.onerror=function(){this.style.display='none'};" />
@@ -104,7 +104,7 @@
</div> </div>
</a> </a>
@elseif(n.type === 'warning') @elseif(n.type === 'warning')
<div class="notif-item {{ n.is_read ? '' : 'unread' }} notif-with-thumb" data-id="{{ n.id }}" data-item-id="{{ n.item_id || '' }}" data-item-slug="{{ n.item_slug || '' }}"> <div class="notif-item {{ n.is_read ? '' : 'unread' }} notif-with-thumb" data-id="{{ n.id }}" data-type="{{ n.type }}" data-item-id="{{ n.item_id || '' }}" data-item-slug="{{ n.item_slug || '' }}">
<div class="notif-thumb" style="display:flex;align-items:center;justify-content:center;background:var(--bg-lighter);color:var(--danger);font-size:1.5em;"><i class="fa-solid fa-triangle-exclamation"></i></div> <div class="notif-thumb" style="display:flex;align-items:center;justify-content:center;background:var(--bg-lighter);color:var(--danger);font-size:1.5em;"><i class="fa-solid fa-triangle-exclamation"></i></div>
<div class="notif-content"> <div class="notif-content">
<div class="notif-user"><strong>{{ t('notifications.system') }}</strong></div> <div class="notif-user"><strong>{{ t('notifications.system') }}</strong></div>
@@ -116,7 +116,7 @@
</div> </div>
</div> </div>
@else @else
<a href="/{{ n.item_slug || n.item_id }}#c{{ n.comment_id || n.reference_id }}" class="notif-item {{ n.is_read ? '' : 'unread' }} notif-with-thumb" data-id="{{ n.id }}" data-item-id="{{ n.item_id || '' }}" data-item-slug="{{ n.item_slug || '' }}"> <a href="/{{ n.item_slug || n.item_id }}#c{{ n.comment_id || n.reference_id }}" class="notif-item {{ n.is_read ? '' : 'unread' }} notif-with-thumb" data-id="{{ n.id }}" data-type="{{ n.type }}" data-item-id="{{ n.item_id || '' }}" data-item-slug="{{ n.item_slug || '' }}">
@if(n.item_id) @if(n.item_id)
<div class="notif-thumb" data-mode="{{ n.item_mode || '' }}"> <div class="notif-thumb" data-mode="{{ n.item_mode || '' }}">
<img src="/t/{{ n.item_id }}{{ ((active_mode===0&&(n.item_mode==='nsfw'||n.item_mode==='nsfl'))||(active_mode===1&&n.item_mode==='nsfl')||(active_mode===4&&(n.item_mode==='sfw'||n.item_mode==='nsfw'))) ? '_blur' : '' }}.webp" data-orig-src="/t/{{ n.item_id }}.webp" alt="thumb" onerror="this.style.display='none'" /> <img src="/t/{{ n.item_id }}{{ ((active_mode===0&&(n.item_mode==='nsfw'||n.item_mode==='nsfl'))||(active_mode===1&&n.item_mode==='nsfl')||(active_mode===4&&(n.item_mode==='sfw'||n.item_mode==='nsfw'))) ? '_blur' : '' }}.webp" data-orig-src="/t/{{ n.item_id }}.webp" alt="thumb" onerror="this.style.display='none'" />
+19 -1
View File
@@ -6,7 +6,7 @@
<button type="button" class="pgm-close" title="Close" aria-label="Close">&times;</button> <button type="button" class="pgm-close" title="Close" aria-label="Close">&times;</button>
</div> </div>
</div> </div>
<div class="pgm-body"></div> <div class="pgm-body" tabindex="-1"></div>
</div> </div>
<style> <style>
#page-modal { #page-modal {
@@ -17,10 +17,21 @@
flex-direction: column; flex-direction: column;
background: var(--bg); color: var(--text-color, #fff); background: var(--bg); color: var(--text-color, #fff);
} }
/* The Onara viewer (10050) lifts the sidebar (10060) and its drag zone (10059) above itself; keep the same
order: page over Onara, still under the sidebar controls and the navbar (10070) */
body.onara-modal-open #page-modal { z-index: 10055; }
#page-modal .pgm-header { #page-modal .pgm-header {
flex-shrink: 0; display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-shrink: 0; display: flex; align-items: center; justify-content: space-between; gap: 12px;
padding: 12px 20px; border-bottom: 1px solid var(--accent); padding: 12px 20px; border-bottom: 1px solid var(--accent);
} }
/* Height matched to the sidebar tab row (page-modal.js measures it); the close button scales with it */
#page-modal.pgm-header-synced .pgm-header {
box-sizing: border-box; height: var(--pgm-header-h); padding-top: 0; padding-bottom: 0;
}
#page-modal.pgm-header-synced .pgm-close {
width: calc(var(--pgm-header-h) - 12px); height: calc(var(--pgm-header-h) - 12px);
font-size: 1.3em;
}
#page-modal .pgm-title { #page-modal .pgm-title {
margin: 0; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; 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; font-size: 1em; font-weight: 800; text-transform: uppercase; letter-spacing: 0.08em;
@@ -37,7 +48,14 @@
#page-modal .pgm-body { #page-modal .pgm-body {
flex: 1 1 auto; min-height: 0; overflow-y: auto; overscroll-behavior: contain; flex: 1 1 auto; min-height: 0; overflow-y: auto; overscroll-behavior: contain;
padding-bottom: env(safe-area-inset-bottom, 0px); padding-bottom: env(safe-area-inset-bottom, 0px);
/* Scrollbar hidden, scrolling kept (wheel, touch, keyboard). Firefox only honours scrollbar-width;
!important so a site-wide scrollbar style can't bring it back */
scrollbar-width: none !important;
-ms-overflow-style: none;
} }
#page-modal .pgm-body::-webkit-scrollbar { display: none; width: 0; height: 0; }
/* Focusable so arrow keys / PageUp / PageDown / Space scroll it once clicked into, without an outline */
#page-modal .pgm-body:focus { outline: none; }
#page-modal .pgm-loading { padding: 60px 20px; text-align: center; color: var(--text-muted, #aaa); } #page-modal .pgm-loading { padding: 60px 20px; text-align: center; color: var(--text-muted, #aaa); }
#page-modal .pgm-loading a { color: var(--accent); } #page-modal .pgm-loading a { color: var(--accent); }
</style> </style>
+5 -5
View File
@@ -22,11 +22,7 @@
<div class="drop-zone" id="upload-form-drop-zone"> <div class="drop-zone" id="upload-form-drop-zone">
<input type="file" class="file-input" name="file" accept="{{ allowed_mimes }}" multiple> <input type="file" class="file-input" name="file" accept="{{ allowed_mimes }}" multiple>
<div class="drop-zone-prompt"> <div class="drop-zone-prompt">
<p class="drop-zone-title" style="font-size: 1.1rem; font-weight: 500;">{{ t('upload.drop_here') }}</p> <p class="drop-zone-title" style="font-size: 1.1rem; font-weight: 500;">{{ t('upload.drop_here') }} <span class="drop-zone-max" style="font-size: 0.8em; font-weight: 400; opacity: 0.6;">(max {{ max_file_size }})</span>@if(session.admin) <span style="font-size: 0.8em; font-weight: 400; color: var(--accent);">{{ t('upload.admin_boost') }}</span>@endif</p>
<p style="font-size: 0.9rem; opacity: 0.6;">(max {{ max_file_size }})@if(session.admin) <span style="color: var(--accent);">{{ t('upload.admin_boost') }}</span>@endif</p>
<p class="drop-album-hint" style="margin-top: 6px; font-size: 0.85rem; color: var(--accent); opacity: 0.95;">
<i class="fa-solid fa-layer-group"></i> {{ t('album.drop_hint') || 'Select or drop multiple files (subf0cks) to create an Album' }}
</p>
</div> </div>
<!-- Preview Container --> <!-- Preview Container -->
<div class="file-preview" style="display: none;"> <div class="file-preview" style="display: none;">
@@ -180,6 +176,9 @@
</label> </label>
</div> </div>
<!-- Footer: upload button, progress and status. Pinned to the bottom of the upload dropdown
while the form above it scrolls (f0ckm.css #upload-drag-modal .upload-footer) -->
<div class="upload-footer">
<div class="form-actions"> <div class="form-actions">
<button type="submit" class="btn-upload" disabled> <button type="submit" class="btn-upload" disabled>
<span class="btn-text">{{ t('upload.select_file') }}</span> <span class="btn-text">{{ t('upload.select_file') }}</span>
@@ -195,4 +194,5 @@
</div> </div>
<div class="upload-status"></div> <div class="upload-status"></div>
</div>
</form> </form>
+3 -2
View File
@@ -3,8 +3,8 @@
<div id="main"> <div id="main">
<link rel="stylesheet" href="/s/css/upload.css"> <link rel="stylesheet" href="/s/css/upload.css">
<div class="upload-container" style="opacity: 0;" data-mimes='{!! mimes_json !!}'> <div class="upload-container" style="opacity: 0;" data-mimes='{!! mimes_json !!}'>
<div class="upload-title">{{ t('upload.title') }}</div> <div class="upload-title">
<span class="upload-title-text">{{ t('upload.title') }}</span>
<div class="upload-limit-info"> <div class="upload-limit-info">
@if(uploads_remaining === null) @if(uploads_remaining === null)
<span class="limit-unlimited"><i class="fa-solid fa-infinity"></i> {{ t('upload_page.limit_unlimited') }}</span> <span class="limit-unlimited"><i class="fa-solid fa-infinity"></i> {{ t('upload_page.limit_unlimited') }}</span>
@@ -14,6 +14,7 @@
<span class="limit-remaining"><i class="fa-solid fa-upload"></i> {{ t('upload_page.limit_remaining').replace('{remaining}', uploads_remaining).replace('{limit}', upload_limit) }}</span> <span class="limit-remaining"><i class="fa-solid fa-upload"></i> {{ t('upload_page.limit_remaining').replace('{remaining}', uploads_remaining).replace('{limit}', upload_limit) }}</span>
@endif @endif
</div> </div>
</div>
@if(session) @if(session)
@include(snippets/upload-form) @include(snippets/upload-form)