diff --git a/config_example.json b/config_example.json index 1e7f844..8bed30e 100644 --- a/config_example.json +++ b/config_example.json @@ -283,6 +283,7 @@ "audio/aac": "m4a", "video/x-m4v": "mp4", "video/x-matroska": "mkv", + "video/mpeg": "mp4", "application/x-shockwave-flash": "swf", "application/vnd.adobe.flash.movie": "swf", "application/pdf": "pdf", diff --git a/config_example.yaml b/config_example.yaml index cc76f82..c69b388 100644 --- a/config_example.yaml +++ b/config_example.yaml @@ -262,6 +262,7 @@ mimes: audio/aac: m4a video/x-m4v: mp4 video/x-matroska: mkv + video/mpeg: mp4 application/x-shockwave-flash: swf application/vnd.adobe.flash.movie: swf application/pdf: pdf diff --git a/migrations/add_deleted_user_ghost.sql b/migrations/add_deleted_user_ghost.sql new file mode 100644 index 0000000..9e7a718 --- /dev/null +++ b/migrations/add_deleted_user_ghost.sql @@ -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; diff --git a/migrations/add_invite_requests.sql b/migrations/add_invite_requests.sql new file mode 100644 index 0000000..352c0a8 --- /dev/null +++ b/migrations/add_invite_requests.sql @@ -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'); diff --git a/migrations/add_session_passkey_credential.sql b/migrations/add_session_passkey_credential.sql new file mode 100644 index 0000000..2d25212 --- /dev/null +++ b/migrations/add_session_passkey_credential.sql @@ -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; diff --git a/public/s/css/f0ckm.css b/public/s/css/f0ckm.css index 7f337df..3855e31 100644 --- a/public/s/css/f0ckm.css +++ b/public/s/css/f0ckm.css @@ -2665,10 +2665,14 @@ body.layout-modern .global-sidebar-right { display: flex; align-items: center; justify-content: flex-end; - overflow: hidden; + overflow: clip; top: var(--navbar-h, 50px) !important; - opacity: 0; - transition: opacity 0.2s ease; + /* Visibility lives on the pill (below): the strip can't be hovered anymore since it's click-through */ + 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 { @@ -2679,32 +2683,26 @@ body.layout-modern .global-sidebar-right { height: 40px; border-radius: 2px; background: var(--accent, #888); - /* Resting: only a sliver peeks past the edge */ - transform: translateX(3px); - transition: transform 0.28s cubic-bezier(0.22, 1, 0.36, 1), height 0.28s cubic-bezier(0.22, 1, 0.36, 1); + pointer-events: auto; + cursor: pointer; + opacity: 0.45; + transition: height 0.28s cubic-bezier(0.22, 1, 0.36, 1), opacity 0.2s ease; } -#sidebar-drag-zone:hover { - opacity: 1; -} - -body.sidebar-right-hidden #sidebar-drag-zone { +body.sidebar-right-hidden #sidebar-drag-zone::after { opacity: 0.7; } +/* Hovering the pill brightens and grows it — no lateral movement keeps the cursor stable */ #sidebar-drag-zone:hover::after { - transform: translateX(-3px); + opacity: 1; 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) { - #sidebar-drag-zone { - opacity: 0.7; - padding-right: 3px; - } #sidebar-drag-zone::after { - transform: none; + opacity: 0.7; } } @@ -3838,13 +3836,12 @@ body.sidebar-right-hidden #sidebar-drag-zone { overflow-x: auto; overflow-y: hidden; overscroll-behavior-x: contain; - scrollbar-width: thin; - scrollbar-color: rgba(255, 255, 255, 0.2) transparent; + /* No visible scrollbar: drag, swipe, or turn the mouse wheel (f0ckm.js maps it to sideways) */ + scrollbar-width: none !important; + -ms-overflow-style: none; } .f0ck-tuner-subtabs-nav::-webkit-scrollbar, -.f0ck-tuner-mode-bar::-webkit-scrollbar { height: 4px; } -.f0ck-tuner-subtabs-nav::-webkit-scrollbar-thumb, -.f0ck-tuner-mode-bar::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.2); } +.f0ck-tuner-mode-bar::-webkit-scrollbar { display: none; width: 0; height: 0; } /* 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-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 .location-link:hover { - color: #4ade80; + color: var(--accent); text-decoration: underline; } @@ -7669,6 +7666,18 @@ body:has(.index-container) > .pagewrapper > #main { 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) { .embed-responsive-image { bottom: unset !important; @@ -10580,7 +10589,6 @@ ul.navbar-nav-guests li.nav-item { .user_content_wrapper { display: grid; - grid-template-columns: 1fr 1fr; grid-column-gap: 5px; padding: 5px; } @@ -11453,6 +11461,33 @@ input#s_avatar { 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 { display: none; height: 1px; @@ -12648,10 +12683,6 @@ span.badge.badge-current { align-content: center; } -#help-button { - cursor: pointer; -} - .comment-content img { /*max-height: 200px;*/ max-width: 480px; @@ -12963,8 +12994,21 @@ html[theme='paper'] .steuerung.steuerung-icon button:hover, 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 { - 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; border: none; color: var(--white); @@ -12973,39 +13017,26 @@ html[theme='paper'] .steuerung.steuerung-icon button:hover, cursor: pointer; left: 50%; transform: translateX(-50%); +} + +.mobile-scroll-to-top .fa-solid { font-size: 0.85em; - font-weight: bold; - width: 100%; + pointer-events: none; } -@media (max-width: 999px) { - /* Only once the reader is far enough down for it to help (f0ckm.js toggles .is-visible) */ - .mobile-scroll-to-top { - display: block; - visibility: hidden; - opacity: 0; - transition: opacity 0.2s ease, visibility 0s linear 0.2s; - } - .mobile-scroll-to-top.is-visible { - visibility: visible; - opacity: 1; - transition: opacity 0.2s ease, visibility 0s; - } +.mobile-scroll-to-top.is-visible { + visibility: visible; + opacity: 1; + transition: opacity 0.15s ease, visibility 0s; } -/* At 600–999px: center the button in the free space left of the sidebar. - Shift left by half the sidebar width so transform:translateX(-50%) lands - at the center of the available area rather than the full viewport. */ -@media (min-width: 600px) and (max-width: 999px) { - .mobile-scroll-to-top { - left: calc(50% - min(140px, 40vw)); - transition: left 0.3s ease-in-out; - } +/* Hover / press feedback like the steuerung controls (is-clicked is set by the shared feedback) */ +.mobile-scroll-to-top.is-visible:hover { opacity: 0.8; } +.mobile-scroll-to-top.is-visible:active, +.mobile-scroll-to-top.is-visible.is-clicked { opacity: 0.6; } - body.sidebar-right-hidden .mobile-scroll-to-top { - left: 50%; - } -} +/* Horizontal centre next to the right sidebar is computed in f0ckm.js (inline `left`); + 50% above is only the fallback before the script has run */ .steuerung.steuerung-icon .fa-solid { font-size: 0.85em; @@ -13489,7 +13520,7 @@ body > nav.navbar { /* On mobile: right group stays in the single top row */ .nav-right-group { - gap: 5px; + gap: 15px; padding: 0 4px; margin-left: auto; } @@ -14589,99 +14620,100 @@ div.posts>a::after { background: #333; } -/* Video Player Settings Menu */ +/* Video Player Settings Menu: style guide (square, flat, theme colours, no glow) */ .v0ck_settings_container { position: relative; display: flex; - /* Fix alignment */ align-items: center; } .v0ck_settings_menu { position: absolute; bottom: 100%; - /* Position above the button */ right: 0; - margin-bottom: 5px; - background-color: rgba(0, 0, 0, 0.9); - border: 1px solid rgba(255, 255, 255, 0.1); - border-radius: 4px; - padding: 5px 0; + margin-bottom: 6px; display: flex; 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; - /* Ensure it's above other controls */ - box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3); } .v0ck_settings_menu.v0ck_hidden { display: none; } +.v0ck_settings_menu, +.v0ck_settings_menu * { + border-radius: 0 !important; +} + +/* Rows: label left, control right; one flat hover surface */ .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; align-items: center; justify-content: space-between; - /* Label on left, switch on right? Or center? User asked for "items centered". */ - /* 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; + gap: 14px; width: 100%; 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 { - margin-right: 10px; - font-size: 14px; - color: #fff; + margin: 0; + font-size: inherit; + color: inherit; } -/* Cool Switch Styling */ +/* Square switch: accent track with black knob when on */ .v0ck_cool_switch { - width: 36px; - height: 20px; - background-color: #555; - border-radius: 10px; 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; - transition: background-color 0.3s; + transition: background-color 0.15s, border-color 0.15s; } .v0ck_cool_switch::after { @@ -14689,39 +14721,24 @@ div.posts>a::after { position: absolute; top: 2px; left: 2px; - width: 16px; - height: 16px; - background-color: #fff; - border-radius: 50%; - transition: transform 0.3s; + width: 10px; + height: 10px; + background: #fff; + transition: transform 0.15s; } .v0ck_cool_switch.active { - background-color: var(--gray); + background: var(--accent); + border-color: var(--accent); } .v0ck_cool_switch.active::after { + background: #000; transform: translateX(16px); } .v0ck_cool_switch:hover { - opacity: 0.9; -} - -/* 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 */ + border-color: var(--accent); } /* Fix hover effect for settings button (viewBox scaling issue) */ @@ -15178,7 +15195,6 @@ html[theme='paper'] .badge.badge-untagged { #help-button:hover { opacity: 1 !important; - color: var(--white) !important; } #shortcut-theme { @@ -15416,52 +15432,150 @@ body.layout-modern .tag-ac-input { 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 { position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - background: rgba(0, 0, 0, 0.8); - backdrop-filter: blur(5px); - z-index: 10001; + inset: 0; + /* Above the sticky navbar (10070) and so its MOTD row; below the mobile nav menu (10075) */ + z-index: 10072; display: none; - align-items: center; - justify-content: center; + background: none; + pointer-events: none; } #upload-drag-modal.show { - display: flex; + display: block; } #upload-drag-modal .modal-content { - background: var(--bg); - border: 1px solid var(--shade3, #333); - padding: 0; - width: 900px; - max-width: 95vw; - position: relative; - max-height: 90vh; + position: fixed; + top: calc(var(--navbar-h, 50px) + 6px); + left: 8px; + width: min(760px, calc(100vw - 16px)); + max-width: none; + max-height: calc(100vh - var(--navbar-h, 50px) - 18px); 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 { position: absolute; - top: 15px; - right: 15px; - background: none; - border: none; - color: var(--white); - font-size: 24px; - cursor: pointer; - line-height: 1; + top: 12px; + right: 12px; 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 { max-width: 100%; width: 100%; + box-shadow: none; } /* Private Society Gate */ @@ -15547,11 +15661,52 @@ body.layout-modern .tag-ac-input { 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 { 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 { @@ -17389,6 +17544,35 @@ body.layout-legacy .gapRight>.iconset#a_favo { 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 */ i.iconset#a_oc { color: inherit; @@ -17753,8 +17937,8 @@ textarea#profile_description { .user-infobox-avatar img { - width: auto; - height: 22px; + width: 60px; + height: 60px; object-fit: cover; } @@ -17809,18 +17993,8 @@ textarea#profile_description { body.layout-legacy .user-infobox-block .gapRight > #a_favo.fa-regular, body.layout-modern .user-infobox-block .gapRight > #a_favo.fa-regular { 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 .gapRight #a_favo.fa-regular::before, @@ -21641,16 +21815,17 @@ body.layout-modern #onara-modal .item-layout-container .item-main-content { cursor: default; } -#onara-modal a, -#onara-modal button, -#onara-modal .btn, -#onara-modal [role="button"], -#onara-modal label, -#onara-modal .rating-tag, -#onara-modal .sidebar-tag { +/* Clickables keep the pointer. :is() takes its most specific argument (a[href]), so this outranks the + `cursor: default` block above, including for clickables that are direct children of .item-main-content */ +#onara-modal :is(a, a[href], button, .btn, [role="button"], label, .rating-tag, .sidebar-tag, + .iconset, .mobile-scroll-to-top, .danmaku-marker, summary, select) { cursor: pointer; } +#onara-modal :is(button, .btn, [role="button"]):disabled { + cursor: not-allowed; +} + #onara-modal input, #onara-modal textarea, #onara-modal [contenteditable="true"] { @@ -23012,3 +23187,7 @@ html.range-tap-lock input[type="range"].range-unlocked { pointer-events: none; user-select: none; } + +a#nav-upload-link { + user-select: none; +} \ No newline at end of file diff --git a/public/s/css/upload.css b/public/s/css/upload.css index 02524d2..d833189 100644 --- a/public/s/css/upload.css +++ b/public/s/css/upload.css @@ -5,7 +5,7 @@ padding: 0; animation: uploadReveal 0.5s cubic-bezier(0.4, 0, 0.2, 1) forwards; opacity: 0; - margin: 0 auto; + margin: 0; } @@ -25,17 +25,29 @@ text-align: center; } +/* Title line: title left, upload limit right (wraps under it on narrow screens) */ .upload-title { + display: flex; + align-items: baseline; + justify-content: space-between; + flex-wrap: wrap; + gap: 4px 14px; font-size: x-large; } -/* Upload Limit Info */ +/* Upload Limit Info (inside the title line; keeps its own small, normal-case text) */ .upload-limit-info { - text-align: center; font-size: 0.9rem; + font-weight: 400; + letter-spacing: normal; + text-transform: none; opacity: 0.7; } +.upload-title .upload-limit-info { + font-size: 0.8rem; +} + .upload-limit-info i { margin-right: 0.3rem; } @@ -1011,6 +1023,18 @@ 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 { color: #ff6b6b; } diff --git a/public/s/css/v0ck.css b/public/s/css/v0ck.css index d8a0c10..ac6a6dd 100644 --- a/public/s/css/v0ck.css +++ b/public/s/css/v0ck.css @@ -326,6 +326,144 @@ 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 */ .v0ck_seek_marker { position: absolute; diff --git a/public/s/js/danmaku.js b/public/s/js/danmaku.js index 45bae55..84541aa 100644 --- a/public/s/js/danmaku.js +++ b/public/s/js/danmaku.js @@ -143,6 +143,12 @@ class Danmaku { this.media.addEventListener('seeked', this._bound_onSeek, { passive: true }); this.media.addEventListener('pause', this._bound_onPause, { 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 => ({ id: c.id || null, text: this._prepareText(c.content), + raw: c.content, username: c.display_name || c.username || '?', color: c.username_color || null, raw_time: (c.video_time != null) ? parseFloat(c.video_time) : null, @@ -257,6 +264,7 @@ class Danmaku { this._resetFiredState(this.media.currentTime); this._lastTime = this.media.currentTime; + this._renderMarkers(); } /** @@ -335,6 +343,8 @@ class Danmaku { id: comment.id || null, video_time: t, 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 || '?', color: comment.username_color || null, fired: true @@ -350,6 +360,7 @@ class Danmaku { const idx = this.items.findIndex(i => i.video_time > t); if (idx === -1) this.items.push(item); else this.items.splice(idx, 0, item); + this._renderMarkers(); } /** Toggle danmaku on/off. */ @@ -357,6 +368,9 @@ class Danmaku { this._enabled = !this._enabled; localStorage.setItem('danmaku', this._enabled ? 'true' : 'false'); 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'); if (sw) sw.classList.toggle('active', this._enabled); @@ -482,6 +496,12 @@ class Danmaku { this.media.removeEventListener('seeked', this._bound_onSeek); this.media.removeEventListener('pause', this._bound_onPause); 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_onTuningChange) window.removeEventListener('f0ck:danmaku_tuning_changed', this._bound_onTuningChange); 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 = '
'; + 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() { this._paused = true; if (this._synthClock) this._synthClock.pause(); @@ -664,6 +807,15 @@ class Danmaku { 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 to = now + LOOKAHEAD_SEC; @@ -715,8 +867,9 @@ class Danmaku { return best; } - _spawnPill(text, username, color) { - if (!this.overlay || !this._enabled || this._paused) return; + /** @param {boolean} [force] show even while paused (marker click on the progress bar) */ + _spawnPill(text, username, color, force = false) { + if (!this.overlay || !this._enabled || (this._paused && !force)) return; const cfg = window.danmakuTuning || DEFAULT_DANMAKU_TUNING; const limit = Number(cfg.densityLimit) || 35; @@ -932,11 +1085,20 @@ class Danmaku { _prepareText(text) { 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 = []; - let protected_ = text.replace(/:([a-z0-9_+\-]+):/gi, (match) => { - emojiTokens.push(match); - return `\x02${emojiTokens.length - 1}\x03`; - }); + 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); + return `\x02${emojiTokens.length - 1}\x03`; + }); const allowedHosts = Array.isArray(window.f0ckAllowedImages) ? window.f0ckAllowedImages : []; const siteHost = window.location.hostname; @@ -946,7 +1108,6 @@ class Danmaku { return h === siteHost || allowedHosts.some(a => h === a || h.endsWith('.' + a)); } catch { return false; } }; - const imgTokenUrls = []; 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) => { imgTokenUrls.push(url); @@ -967,9 +1128,14 @@ class Danmaku { .replace(/\n{3,}/g, '\n\n') .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_ + .replace(/[\x00-\x01\x06-\x08\x0B-\x1F\x7F]/g, '') .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'); } } diff --git a/public/s/js/f0ck_upload_init.js b/public/s/js/f0ck_upload_init.js index 4c6d8fb..410d93b 100644 --- a/public/s/js/f0ck_upload_init.js +++ b/public/s/js/f0ck_upload_init.js @@ -7,10 +7,47 @@ const dragModalClose = document.getElementById('drag-modal-close'); 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 = () => { if (!dragModal) return; - dragModal.classList.add('show'); + dragModal.classList.add('show', 'is-dropdown'); document.body.classList.add('modal-open'); + positionModal(); // Reset scroll position so it always starts at the top dragModal.scrollTop = 0; const modalContent = dragModal.querySelector('.modal-content'); @@ -19,14 +56,22 @@ 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. - // Opens the modal when available; falls back to /upload navigation otherwise. - const navUploadLink = document.getElementById('nav-upload-link'); + // Toggles the dropdown when available; falls back to /upload navigation otherwise. if (navUploadLink) { navUploadLink.addEventListener('click', (e) => { if (!dragModal) return; // no modal → fall back to href navigation e.preventDefault(); - showModal(); + if (dragModal.classList.contains('show')) hideModal(); + else showModal(); }); } diff --git a/public/s/js/f0ckm.js b/public/s/js/f0ckm.js index 8c34c9f..eca2e1b 100644 --- a/public/s/js/f0ckm.js +++ b/public/s/js/f0ckm.js @@ -6810,6 +6810,9 @@ window.cancelAnimFrame = (function () { + @@ -6934,6 +6937,36 @@ window.cancelAnimFrame = (function () { + + + `; // ── Sub-Tab Switching Logic ───────────────────────────────────────────── @@ -6960,10 +6993,50 @@ window.cancelAnimFrame = (function () { }); 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); } + // ── 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) ──────────────────────── const bgBasicRows = panel.querySelector('#f0ck-bg-basic-rows'); const bgAdvancedRows = panel.querySelector('#f0ck-bg-advanced-rows'); @@ -7260,6 +7333,155 @@ window.cancelAnimFrame = (function () { barOpacity: 0.85, barGlow: 10, 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(); toggleSearch(true); } - // Shift + S to toggle strict mode globally - if (e.shiftKey && (e.key === 'S' || e.key === 's') && e.target.tagName !== 'INPUT' && e.target.tagName !== 'TEXTAREA') { - e.preventDefault(); - if (strict) strict.click(); - } + // (Shift + S used to toggle strict mode here; it opens the settings now, see page-modal.js) }); input.addEventListener('keydown', (e) => { @@ -17205,29 +17423,48 @@ window.cancelAnimFrame = (function () { const sidebarWidth = sidebar.offsetWidth || 300; 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; - // All wrappers animate in sync with the sidebar so content and sidebar - // expand/collapse together rather than staggering. + // Smoothness: the sidebar and #bg canvas animate via CSS transitions (compositor, minimal layout). + // 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(); - 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 => { const isRightEdge = w.classList.contains('pagination-container-fluid') || w.classList.contains('admin-bar'); const isMessages = w.classList.contains('messages-convo-page'); - const prop = (isRightEdge || isMessages) ? 'right' : 'padding-right'; - const targetVal = isHidden ? sidebarWidth + 'px' : '0px'; - - w.style.setProperty('transition', `${prop} 0.3s ease-in-out`, 'important'); - w.style.setProperty(prop, targetVal, 'important'); + w.style.setProperty('transition', 'none', 'important'); + w.style.setProperty(prop, open ? sidebarWidth + 'px' : '0px', '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 - // glued to the sidebar's left edge throughout the transition. - edgeZone.style.setProperty('transition', 'right 0.3s ease-in-out', 'important'); + // Opening: make room first, then slide in over empty space + if (isHidden) applyLayout(true); + + // 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) { // Opening: pill moves from right-edge → left of sidebar edgeZone.style.right = sidebarWidth + 'px'; @@ -17240,28 +17477,24 @@ window.cancelAnimFrame = (function () { edgeZone.style.pointerEvents = 'all'; } - // Animate the Onara modal wrapper's right edge in sync with the sidebar - // 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'); + // Commit the starting width for #bg so the transition has a defined origin if (bgCanvas && animateWrappers) { - const targetBgWidth = isHidden ? `calc(100vw - ${sidebarWidth}px)` : '100vw'; - bgCanvas.style.setProperty('transition', 'width 0.3s ease-in-out', 'important'); - bgCanvas.style.setProperty('width', targetBgWidth, 'important'); + const bgStartWidth = isHidden ? '100vw' : `calc(100vw - ${sidebarWidth}px)`; + bgCanvas.style.setProperty('width', bgStartWidth, '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)`; + // #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; const settle = () => { if (settled) return; @@ -17273,12 +17506,15 @@ window.cancelAnimFrame = (function () { if (onaraModal) onaraModal.style.setProperty('transition', 'none', 'important'); if (bgCanvas) bgCanvas.style.setProperty('transition', 'none', 'important'); 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); localStorage.setItem('sidebarRightHidden', toHidden); void sidebar.offsetWidth; sidebar.style.transform = ''; requestAnimationFrame(() => { sidebar.style.removeProperty('transition'); + sidebar.style.removeProperty('will-change'); edgeZone.style.removeProperty('transition'); if (onaraModal) { onaraModal.style.removeProperty('transition'); @@ -17305,7 +17541,7 @@ window.cancelAnimFrame = (function () { settle(); }; sidebar.addEventListener('transitionend', onEnd); - setTimeout(settle, 350); + setTimeout(settle, DURATION + 50); }; // Click on edgeZone: only toggle when the click lands on the pill @@ -17685,7 +17921,8 @@ if (sbtForm) { class NotificationSystem { // Notification type categorization 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() { 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._cachedUser = []; this._cachedSystem = []; + this._cachedAdmin = []; // Generate/retrieve unique tab ID this.tabId = sessionStorage.getItem('f0ck_tab_id'); @@ -18889,10 +19127,12 @@ class NotificationSystem { // Split into user and system categories this._cachedUser = notifications.filter(n => NotificationSystem.USER_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 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) if (totalUnread > 0) { @@ -18905,6 +19145,7 @@ class NotificationSystem { // Update per-tab badges const userBadge = document.getElementById('notif-tab-badge-user'); const systemBadge = document.getElementById('notif-tab-badge-system'); + const adminBadge = document.getElementById('notif-tab-badge-admin'); if (userBadge) { userBadge.textContent = userUnread; userBadge.style.display = userUnread > 0 ? '' : 'none'; @@ -18913,6 +19154,10 @@ class NotificationSystem { systemBadge.textContent = systemUnread; 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 if (typeof window._scrollerNotifHook === 'function') { @@ -18964,7 +19209,7 @@ class NotificationSystem { const historyContainer = document.querySelector('.notifications-list-full'); if (historyContainer) { 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 => { const existing = historyContainer.querySelector(`.notif-item[data-id="${n.id}"]`); if (!existing) { @@ -18984,7 +19229,7 @@ class NotificationSystem { _renderActiveTab() { 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) { this.list.innerHTML = `
${window.f0ckI18n?.no_notifications || 'No new notifications'}
`; return; @@ -18999,6 +19244,51 @@ class NotificationSystem { let msg = ''; 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 ` + +
+
+
${username}
+
+ Invite request + ${reason ? '
' + reason + '
' : ''} +
+
${new Date(n.created_at).toLocaleString()}
+
+
+ `; + } + + if (n.type === 'invite_approved') { + return ` + +
+
+
System
+
Your invite request was approved!
Go to Settings to register with your token.
+
${new Date(n.created_at).toLocaleString()}
+
+
+ `; + } + + if (n.type === 'invite_denied') { + const isRevoked = n.data?.revoked === true; + return ` + +
+
+
System
+
${isRevoked ? 'Your invite token was revoked' : 'Your invite request was denied'}
You can submit a new request.
+
${new Date(n.created_at).toLocaleString()}
+
+
+ `; + } + if (n.type === 'deny' || n.type === 'item_deleted') { const i18n = window.f0ckI18n || {}; const isDeleted = n.type === 'item_deleted'; @@ -19089,6 +19379,55 @@ class NotificationSystem { renderItem(n) { 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 ` + +
+
+
+ Invite request +
from ${username}${reason ? ' — ' + reason : ''}
+
+ ${new Date(n.created_at).toLocaleString()} +
+
+ `; + } + + if (n.type === 'invite_approved') { + return ` + +
+
+
+ Invite request approved! +
Your invite token is ready. Go to Settings to register.
+
+ ${new Date(n.created_at).toLocaleString()} +
+
+ `; + } + + if (n.type === 'invite_denied') { + const isRevoked = n.data?.revoked === true; + return ` + +
+
+
+ ${isRevoked ? 'Invite token revoked' : 'Invite request denied'} +
${isRevoked ? 'Your token was revoked. You can submit a new request.' : 'Your invite request was not approved. You can try again.'}
+
+ ${new Date(n.created_at).toLocaleString()} +
+
+ `; + } + if (n.type === 'approve') { const link = `/${itemKey}`; return ` @@ -19238,7 +19577,7 @@ class NotificationSystem { return false; }; - [this._cachedUser, this._cachedSystem].forEach(list => { + [this._cachedUser, this._cachedSystem, this._cachedAdmin].forEach(list => { if (!Array.isArray(list)) return; list.forEach(n => { if (matchesItem(n) && !n.is_read) { @@ -19284,7 +19623,8 @@ class NotificationSystem { // Recalculate unread counts const userUnread = (this._cachedUser || []).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) { this.countBadge.textContent = totalUnread; @@ -19292,6 +19632,7 @@ class NotificationSystem { } const userBadge = document.getElementById('notif-tab-badge-user'); const systemBadge = document.getElementById('notif-tab-badge-system'); + const adminBadge = document.getElementById('notif-tab-badge-admin'); if (userBadge) { userBadge.textContent = userUnread; userBadge.style.display = userUnread > 0 ? '' : 'none'; @@ -19300,6 +19641,10 @@ class NotificationSystem { systemBadge.textContent = systemUnread; systemBadge.style.display = systemUnread > 0 ? '' : 'none'; } + if (adminBadge) { + adminBadge.textContent = adminUnread; + adminBadge.style.display = adminUnread > 0 ? '' : 'none'; + } if (typeof window._scrollerNotifHook === 'function') { window._scrollerNotifHook(totalUnread); } @@ -19336,43 +19681,83 @@ class NotificationSystem { async markAllRead() { 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', headers: { 'X-CSRF-Token': window.f0ckSession?.csrf_token } }); const data = await res.json(); if (data.success) { - this.markAllReadUI(); + this.markAllReadUI(tab); } } catch (e) { console.error('Failed to mark all read', e); } } - markAllReadUI() { - if (this.countBadge) this.countBadge.style.display = 'none'; - // Clear per-tab badges - const userBadge = document.getElementById('notif-tab-badge-user'); - const systemBadge = document.getElementById('notif-tab-badge-system'); - if (userBadge) userBadge.style.display = 'none'; - if (systemBadge) systemBadge.style.display = 'none'; - // Clear cached data - this._cachedUser = []; - this._cachedSystem = []; - if (this.list) { + markAllReadUI(tab) { + if (!tab) { + // No tab specified — clear everything (backward compat) + if (this.countBadge) this.countBadge.style.display = 'none'; + const userBadge = document.getElementById('notif-tab-badge-user'); + const systemBadge = document.getElementById('notif-tab-badge-system'); + const adminBadge = document.getElementById('notif-tab-badge-admin'); + if (userBadge) userBadge.style.display = 'none'; + if (systemBadge) systemBadge.style.display = 'none'; + if (adminBadge) adminBadge.style.display = 'none'; + this._cachedUser = []; + this._cachedSystem = []; + this._cachedAdmin = []; + if (this.list) { + this.list.innerHTML = `
${window.f0ckI18n?.no_notifications || 'No new notifications'}
`; + } + document.querySelectorAll('.notifications-list-full .notif-item.unread').forEach(el => el.classList.remove('unread')); + document.querySelectorAll('a.thumb.has-notif, a.lazy-thumb.has-notif').forEach(el => el.classList.remove('has-notif')); + if (typeof gridCacheMap !== 'undefined' && gridCacheMap) { + gridCacheMap.forEach(cacheEntry => { + if (cacheEntry?.node) cacheEntry.node.querySelectorAll('a.thumb.has-notif, a.lazy-thumb.has-notif').forEach(el => el.classList.remove('has-notif')); + }); + } + if (typeof window._scrollerNotifHook === 'function') { + 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 = `
${window.f0ckI18n?.no_notifications || 'No new notifications'}
`; } - // Also update History page items if present - 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')); - if (typeof gridCacheMap !== 'undefined' && gridCacheMap) { - gridCacheMap.forEach(cacheEntry => { - if (cacheEntry?.node) cacheEntry.node.querySelectorAll('a.thumb.has-notif, a.lazy-thumb.has-notif').forEach(el => el.classList.remove('has-notif')); - }); - } + // 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(0); + window._scrollerNotifHook(totalUnread); } } @@ -22199,6 +22584,9 @@ document.addEventListener('DOMContentLoaded', () => { if (!el) return null; const steuerungEl = el.closest && el.closest('.steuerung a, .steuerung button, .steuerung [role="button"]'); 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'))) { return document.querySelector('.steuerung .nav-prev') || el; } @@ -22291,18 +22679,129 @@ document.addEventListener('click', (e) => { } }); -// ── Mobile scroll-to-top button ─────────────────────────────────────────────── -// Visible only on mobile (≤999px), appears below #comments-container so users -// can easily get back to the top after reading comments. Only shown once the relevant scroller is -// scrolled far enough that getting back up by hand is a chore (1.5 screens, at least 600px). +// ── Mouse wheel scrolls horizontal bars sideways ──────────────────────────────── +// For single-line bars that overflow horizontally without a visible scrollbar. A vertical wheel turn +// moves the bar sideways while it can still move that way; at either end the page scrolls as usual. +// 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 = () => { document.querySelectorAll('.mobile-scroll-to-top').forEach((btn) => { + centreButton(btn); const sc = scrollerFor(btn); const top = sc ? sc.scrollTop : (window.scrollY || document.documentElement.scrollTop); 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; @@ -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 document.addEventListener('scroll', schedule, { capture: true, 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) => { - if (!e.target.closest('.mobile-scroll-to-top')) return; + const btn = e.target.closest('.mobile-scroll-to-top'); + if (!btn) return; e.preventDefault(); - const onaraModal = e.target.closest('#onara-modal') || (document.body.classList.contains('onara-modal-open') && document.getElementById('onara-modal')); - if (onaraModal) { - onaraModal.scrollTo({ top: 0, behavior: 'smooth' }); - } else { - const ic = document.querySelector('.index-container'); - if (ic) { - ic.scrollTo({ top: 0, behavior: 'smooth' }); - } else { - window.scrollTo({ top: 0, behavior: 'smooth' }); - } - } - if (navigator.vibrate) navigator.vibrate(20); + // Scroll the same container the visibility is measured on + const sc = window._f0ckScrollerFor ? window._f0ckScrollerFor(btn) : null; + if (sc) sc.scrollTo({ top: 0, behavior: 'smooth' }); + else window.scrollTo({ top: 0, behavior: 'smooth' }); + // Sound + haptics come from the shared steuerung feedback (resolveSteuerungTarget) }); // ── Spoiler Tags Event Delegation ───────────────────────────────────────────── diff --git a/public/s/js/flash_yank.js b/public/s/js/flash_yank.js index dada866..97bbf76 100644 --- a/public/s/js/flash_yank.js +++ b/public/s/js/flash_yank.js @@ -594,14 +594,7 @@ info }; - slider.addEventListener('input', (e) => { - 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(); - }); + slider.addEventListener('input', (e) => setYank(e.target.value)); function toggleEnabledFromUI(targetController) { settings.enabled = !settings.enabled; @@ -697,7 +690,9 @@ // Never show it just because sidebar .webm stickers exist. const primaryVideo = document.getElementById('my-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) [ui.floatingBadge, ...swfButtons].forEach(b => { @@ -739,19 +734,69 @@ } if (!currentController) return; - settings.enabled = !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'); - } + setEnabled(!settings.enabled); }); } + // ---------- 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 ---------- function onReady(fn) { diff --git a/public/s/js/page-modal.js b/public/s/js/page-modal.js index 9d8f19b..ee845c3 100644 --- a/public/s/js/page-modal.js +++ b/public/s/js/page-modal.js @@ -12,7 +12,7 @@ (() => { 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'); if (!modal) return; @@ -20,6 +20,26 @@ const titleEl = modal.querySelector('.pgm-title'); let currentUrl = null; 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) => { try { @@ -42,6 +62,8 @@ const load = async (url, title) => { const seq = ++loadSeq; currentUrl = url; + showUrl(url); + markActive(); titleEl.textContent = title || ''; body.innerHTML = '
'; try { @@ -57,10 +79,12 @@ body.innerHTML = ''; body.appendChild(wrap); 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) { const h = wrap.querySelector('h1, h2, h3'); titleEl.textContent = h ? h.textContent.trim() : ''; } + if (doc.title) document.title = doc.title; runScripts(wrap); } catch (e) { if (seq !== loadSeq) return; @@ -76,19 +100,50 @@ } 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 vw = window.innerWidth; const nav = visibleRect('body > nav.navbar, nav.navbar'); 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) - const rs = visibleRect('.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; + // Right sidebar: open unless body.sidebar-right-hidden; its layout width ignores the slide transform + const rsEl = document.querySelector('.global-sidebar-right, .item-sidebar-right, .index-sidebar-right'); + 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 const ls = visibleRect('.item-sidebar-left'); 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-right', `${right}px`); - modal.style.setProperty('--pgm-left', `${left}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-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 let followUntil = 0; @@ -103,7 +158,19 @@ if (!running) requestAnimationFrame(follow); }; 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): // follow every size change so the modal's edges stay glued to them @@ -126,18 +193,55 @@ }; 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 watchLayout(); place(); 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); + 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); }; - const close = () => { - modal.style.display = 'none'; - body.innerHTML = ''; + // restoreUrl = false when history already moved (back/forward): the URL bar is correct then + const close = (restoreUrl = true) => { + if (!isOpen) return; + isOpen = false; 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 @@ -147,13 +251,27 @@ if (!a) return; 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.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') || ''); 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 if (!modal.contains(a)) { @@ -164,6 +282,11 @@ 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)) { e.preventDefault(); e.stopPropagation(); @@ -174,11 +297,29 @@ close(); }, 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 - 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) => { - 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; } }; diff --git a/public/s/js/scroller.js b/public/s/js/scroller.js index c500917..e14abbb 100644 --- a/public/s/js/scroller.js +++ b/public/s/js/scroller.js @@ -3873,19 +3873,24 @@ } }); - // Mark all read + // Mark all read (current tab only) if (sMarkAll) { sMarkAll.addEventListener('click', async () => { try { + const tab = sActiveTab || 'user'; const csrfToken = window.f0ckSession?.csrf_token || window.scrollerCsrf || ''; - await fetch('/api/notifications/read', { + await fetch('/api/notifications/read?tab=' + encodeURIComponent(tab), { method: 'POST', headers: { ...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {}) } }); - updateScrollerNotifBadge(0); - sCachedNotifs = sCachedNotifs.map(n => ({ ...n, is_read: true })); + // Mark only the active tab's cached notifications as read + const tabCheck = tab === 'user' ? isUserType : (tab === 'system' ? isSystemType : () => false); + sCachedNotifs = sCachedNotifs.map(n => tabCheck(n.type) ? { ...n, is_read: true } : n); updateScrollerTabBadges(sCachedNotifs); renderScrollerNotifs(filterByTab(sCachedNotifs, sActiveTab)); + // Update overall badge + const totalUnread = sCachedNotifs.filter(n => !n.is_read).length; + updateScrollerNotifBadge(totalUnread); } catch (e) {} }); } diff --git a/public/s/js/settings.js b/public/s/js/settings.js index f01b182..f0fcfef 100644 --- a/public/s/js/settings.js +++ b/public/s/js/settings.js @@ -151,7 +151,10 @@ const allowedTypes = ['image/gif', 'image/jpeg', 'image/png', 'image/webp']; 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', () => { const file = fileInput.files[0]; @@ -194,8 +197,24 @@ progressWrapper.style.display = 'flex'; progressFill.style.width = '0%'; progressText.textContent = '0%'; + progressWrapper.setAttribute('aria-valuenow', '0'); 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(); formData.append('file', file); @@ -205,14 +224,19 @@ if (e.lengthComputable) { const percent = Math.round((e.loaded / e.total) * 100); 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 = ''; + else progressText.textContent = percent + '%'; } }); + let uploadOk = false; xhr.addEventListener('load', () => { try { const res = JSON.parse(xhr.responseText); if (xhr.status === 200 && res.success) { + uploadOk = true; showStatus(res.msg || 'Avatar uploaded!', 'success'); // Update preview @@ -236,7 +260,15 @@ // Show remove button if not present 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 = ''; + chooseBtn.appendChild(btn); + } else if (!existingRemoveBtn) { const actionsDiv = document.querySelector('.avatar-upload-actions'); if (actionsDiv) { const btn = document.createElement('button'); @@ -258,14 +290,14 @@ showStatus('Upload failed: Invalid response', 'error'); } - progressWrapper.style.display = 'none'; + finishUpload(uploadOk); uploadBtn.disabled = true; chooseBtn.disabled = false; }); xhr.addEventListener('error', () => { showStatus('Upload failed: Network error', 'error'); - progressWrapper.style.display = 'none'; + finishUpload(false); uploadBtn.disabled = true; chooseBtn.disabled = false; }); @@ -654,11 +686,43 @@ if (bannerUploadBtn) bannerUploadBtn.disabled = true; if (bannerChooseBtn) bannerChooseBtn.disabled = true; - if (bannerProgressWrapper) { bannerProgressWrapper.style.display = 'flex'; } + const bannerLabel = (inner) => ' ' + inner; + if (bannerProgressWrapper) { bannerProgressWrapper.style.display = 'block'; bannerProgressWrapper.setAttribute('aria-valuenow', '0'); } if (bannerProgressFill) bannerProgressFill.style.width = '0%'; - if (bannerProgressText) bannerProgressText.textContent = '0%'; + if (bannerProgressText) bannerProgressText.innerHTML = bannerLabel('0%'); 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(); formData.append('file', payload.file); if (payload.file_orig) formData.append('file_orig', payload.file_orig); @@ -671,7 +735,9 @@ if (e.lengthComputable) { const pct = Math.round((e.loaded / e.total) * 100); 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 ? '' : pct + '%'); } }); @@ -679,6 +745,7 @@ try { const res = JSON.parse(xhr.responseText); if (xhr.status === 200 && res.success) { + bannerOk = true; showBannerStatus(res.msg || 'Banner uploaded!', 'success'); const bannerPreview = document.getElementById('banner-preview'); @@ -710,7 +777,19 @@ } 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 = ` ${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; if (actionsDiv) { const editBtn = document.createElement('button'); @@ -762,14 +841,12 @@ showBannerStatus('Upload failed: Invalid response', 'error'); } - if (bannerProgressWrapper) bannerProgressWrapper.style.display = 'none'; - if (bannerChooseBtn) bannerChooseBtn.disabled = false; + finishBannerUpload(bannerOk); }); xhr.addEventListener('error', () => { showBannerStatus('Upload failed: Network error', 'error'); - if (bannerProgressWrapper) bannerProgressWrapper.style.display = 'none'; - if (bannerChooseBtn) bannerChooseBtn.disabled = false; + finishBannerUpload(false); }); xhr.open('POST', '/api/v2/settings/uploadBanner'); @@ -1604,9 +1681,12 @@ onaraEnabledToggle.checked = stored !== null ? stored === 'true' : sessionOnara; // Reflect current state into window.onara so isOnaraActive() sees it window.onara = onaraEnabledToggle.checked; - try { - document.cookie = `f0ck_onara=${onaraEnabledToggle.checked ? '1' : '0'}; path=/; max-age=31536000; SameSite=Lax`; - } catch {} + // Only persist an explicit choice; without one the config default keeps applying + if (stored !== null) { + try { + document.cookie = `f0ck_onara=${onaraEnabledToggle.checked ? '1' : '0'}; path=/; max-age=31536000; SameSite=Lax`; + } catch {} + } onaraEnabledToggle.addEventListener('change', () => { const enabled = onaraEnabledToggle.checked; @@ -1950,6 +2030,17 @@ const liveDesc = document.getElementById('live-preview-description'); 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) { liveDesc.addEventListener('focus', () => { if (descControls) descControls.style.display = 'flex'; @@ -2072,7 +2163,9 @@ if (passwordForm) { passwordForm.addEventListener('submit', async (e) => { 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_confirm = document.getElementById('new_password_confirm').value; @@ -2082,6 +2175,7 @@ } const btn = passwordForm.querySelector('button'); + const btnLabel = btn.textContent; btn.disabled = true; btn.textContent = 'Updating...'; @@ -2098,6 +2192,8 @@ if (data.success) { showAccountStatus(passwordStatus, data.msg || 'Password updated correctly', 'success'); passwordForm.reset(); + // Password login was off: reload so the account section shows the normal state again + if (!currentInput) setTimeout(() => location.reload(), 900); } else { showAccountStatus(passwordStatus, data.msg || 'Failed to update password', 'error'); } @@ -2105,7 +2201,7 @@ showAccountStatus(passwordStatus, 'Request failed', 'error'); } finally { btn.disabled = false; - btn.textContent = 'Update Password'; + btn.textContent = btnLabel; } }); } diff --git a/public/s/js/v0ck.js b/public/s/js/v0ck.js index 1cd8e93..ff6feb9 100644 --- a/public/s/js/v0ck.js +++ b/public/s/js/v0ck.js @@ -31,7 +31,6 @@ const tpl_player = (svg, size) => `
-
Background
diff --git a/src/inc/anon_auth.mjs b/src/inc/anon_auth.mjs index b2f99ea..59566da 100644 --- a/src/inc/anon_auth.mjs +++ b/src/inc/anon_auth.mjs @@ -118,7 +118,7 @@ export async function getOrCreateAnonUserByCredential(credentialId, req = null, * @param {string} [hwFingerprint] * @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); // Update anon_identities last_ip and hw_fingerprint @@ -130,8 +130,15 @@ export async function createAnonSession(userId, req, hwFingerprint = null) { WHERE user_id = ${userId} `.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 && req.session.id === userId && req.session.csrf_token && req.cookies?.session) { + await markCredential(lib.sha256(req.cookies.session)); await logAnonActivity(req, { action: 'handshake', hwFingerprint }); 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) { 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 }); 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` 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 }); diff --git a/src/inc/locales/de.json b/src/inc/locales/de.json index 35f96c1..b6684ba 100644 --- a/src/inc/locales/de.json +++ b/src/inc/locales/de.json @@ -299,6 +299,7 @@ "focus_comment": "Kommentarfeld fokussieren", "send_comment": "Kommentar senden", "flash_yank": "Flash-Yank ein-/ausschalten", + "open_settings": "Einstellungen öffnen/schließen", "tag_exclude": "Tag-Ausschluss öffnen", "tag_input": "Tag-Eingabe öffnen", "toggle_bg": "Hintergrund ein-/ausschalten", diff --git a/src/inc/locales/en.json b/src/inc/locales/en.json index aca0f68..684e8cc 100644 --- a/src/inc/locales/en.json +++ b/src/inc/locales/en.json @@ -299,6 +299,7 @@ "focus_comment": "focus comment input", "send_comment": "send comment", "flash_yank": "enable/disable flash yank", + "open_settings": "open/close settings", "tag_exclude": "open tag exclude", "tag_input": "open tag input", "toggle_bg": "turns on/off the background", diff --git a/src/inc/locales/nl.json b/src/inc/locales/nl.json index 0fd238b..4db79a5 100644 --- a/src/inc/locales/nl.json +++ b/src/inc/locales/nl.json @@ -297,6 +297,7 @@ "focus_comment": "focus op commentaarinvoer", "send_comment": "opmerking verzenden", "flash_yank": "flash yank in/uitschakelen", + "open_settings": "instellingen openen/sluiten", "tag_exclude": "open tag uitsluiten", "tag_input": "open tag invoer", "toggle_bg": "turns on/off the background", diff --git a/src/inc/locales/zange.json b/src/inc/locales/zange.json index 42c19b0..7896097 100644 --- a/src/inc/locales/zange.json +++ b/src/inc/locales/zange.json @@ -295,6 +295,7 @@ "focus_comment": "Kommentareingabe fokussieren", "send_comment": "Kommentar senden", "flash_yank": "Blitz-Rucken aktivieren/deaktivieren", + "open_settings": "Einstellungen auf-/zuklappen", "tag_exclude": "Etiketten-Ausschluss öffnen", "tag_input": "Etiketten-Eingabe öffnen", "toggle_bg": "Hintergrund ein-/ausschalten", diff --git a/src/inc/routes/admin.mjs b/src/inc/routes/admin.mjs index 94d37bf..752d256 100644 --- a/src/inc/routes/admin.mjs +++ b/src/inc/routes/admin.mjs @@ -168,7 +168,7 @@ export default (router, tpl) => { 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; // 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) 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."; 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 }) }); @@ -259,7 +260,7 @@ export default (router, tpl) => { 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) { const msg = "Invalid or expired reset token."; 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 // 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 { - 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(*) 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 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.invite_requests = ir?.n || 0; dash.open_reports = r?.n || 0; dash.users = u?.users || 0; dash.anon_users = u?.anon || 0; @@ -669,11 +672,154 @@ export default (router, tpl) => { if (res.json) return res.json({ 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}`; if (res.json) return res.json({ 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) => { try { 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) => { try { const { user_id } = req.post; @@ -1554,9 +1720,8 @@ export default (router, tpl) => { 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.'); - // Get deleted_user info - const ghost = await db`SELECT id FROM "user" WHERE login = 'deleted_user' LIMIT 1`; - if (!ghost.length) throw new Error('Ghost account "deleted_user" not found. Please run migration.'); + // Get deleted_user info (created on first use; see migrations/add_deleted_user_ghost.sql) + const ghost = await ensureGhostUser(); const targetId = target[0].id; const targetLogin = target[0].login; diff --git a/src/inc/routes/apiv2/anon.mjs b/src/inc/routes/apiv2/anon.mjs index 802a26a..ce5b3ed 100644 --- a/src/inc/routes/apiv2/anon.mjs +++ b/src/inc/routes/apiv2/anon.mjs @@ -12,7 +12,7 @@ import { generateChallenge, consumeChallenge, verifyRegistration, verifyAuthentication, buildRegistrationOptions, buildAuthenticationOptions, - base64url, fromBase64url, getRpIdFromHost + base64url, fromBase64url, getRpIdFromHost, aaguidProvider } from '../../webauthn.mjs'; import { getEnableAnonymousAccess, getHwFingerprintEnabled } from '../../settings.mjs'; @@ -247,7 +247,10 @@ export default router => { ); // 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) { const u = userRows[0]; setBanCookie(res, u.ban_reason || 'Banned', u.ban_expires); @@ -262,7 +265,7 @@ export default router => { 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')}`); return res.json({ @@ -402,7 +405,10 @@ export default router => { } // 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) { const u = userRows[0]; setBanCookie(res, u.ban_reason || 'Banned', u.ban_expires); @@ -424,7 +430,7 @@ export default router => { WHERE user_id = ${userId} AND credential_id = ${credentialId} `.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')}`); return res.json({ @@ -567,6 +573,38 @@ export default router => { // ─── 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 the current anonymous identity or registered user state. diff --git a/src/inc/routes/apiv2/settings.mjs b/src/inc/routes/apiv2/settings.mjs index 85a2b96..3b7bba3 100644 --- a/src/inc/routes/apiv2/settings.mjs +++ b/src/inc/routes/apiv2/settings.mjs @@ -10,9 +10,29 @@ import { generateChallenge, consumeChallenge, verifyRegistration, verifyAuthentication, buildRegistrationOptions, buildAuthenticationOptions, - base64url, getRpIdFromHost + base64url, getRpIdFromHost, aaguidProvider } 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 // 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 group.put(/\/password/, lib.registeredUser, async (req, res) => { 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]; 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) { return res.json({ success: false, msg: 'Current password is required' }, 400); } @@ -576,7 +626,7 @@ export default router => { }); // Update Font preference - group.put(/\/font/, lib.registeredUser, async (req, res) => { + group.put(/\/font/, lib.loggedin, async (req, res) => { const { font } = req.post; // F-023 Security: Validate font against actual files on disk @@ -1216,7 +1266,11 @@ export default router => { WHERE user_id = ${req.session.id} 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) { console.error('[PASSKEYS] List error:', err); return res.json({ success: false, msg: err.message }, 500); @@ -1328,6 +1382,8 @@ export default router => { const body = req.post || req.body || {}; const credentialId = body.credential_id; 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` DELETE FROM passkey_credentials 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) => { try { 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` DELETE FROM passkey_credentials 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 const credRows = await db` 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 JOIN "user" u ON u.id = pc.user_id 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); } + if (!row.activated) { + return res.json({ success: false, msg: 'This account is not activated. Please check your email.' }, 403); + } + // Verify the assertion try { await verifyAuthentication({ @@ -1457,9 +1519,10 @@ export default router => { last_used: stamp, last_action: '/passkey-login', 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')}`); 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; }); + // ═══════════════════ 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; }; diff --git a/src/inc/routes/apiv2/upload.mjs b/src/inc/routes/apiv2/upload.mjs index 8529a13..624a11e 100644 --- a/src/inc/routes/apiv2/upload.mjs +++ b/src/inc/routes/apiv2/upload.mjs @@ -9,6 +9,7 @@ import queue from '../../queue.mjs'; import path from "path"; import f0cklib from "../../routeinc/f0cklib.mjs"; import { addPrivateItem } from "../../private_items.mjs"; +import { TRANSCODE_TO_MP4, transcodeToMp4 } from "../../transcode.mjs"; // ────────────────────────────────────────────────────────────────────── // In-memory job progress map (keyed by jobId string) @@ -734,6 +735,13 @@ export default router => { source = source.replace(/\.mkv$/, '.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$/)) { await queue.spawn('ffmpeg', ['-i', source, '-codec', 'copy', source.replace(/\.opus$/, '.ogg')]); await fs.unlink(source).catch(() => {}); diff --git a/src/inc/routes/index.mjs b/src/inc/routes/index.mjs index 8c9c578..a7fd29a 100644 --- a/src/inc/routes/index.mjs +++ b/src/inc/routes/index.mjs @@ -654,8 +654,9 @@ export default (router, tpl) => { }); // Specific route for direct item links: /user/:user/:itemid - // This avoids ambiguity with the profile route - router.get(/^\/user\/(?[^/]+)\/(?(?!f0cks$|uploads$|favs$)[a-zA-Z0-9_-]+)$/, handleGenericRoute); + // This avoids ambiguity with the profile route. The other /user/:user/ routes (comments, tags, halls) + // are excluded too, or an item lookup for "comments" etc. would answer "post not visible" before they run. + router.get(/^\/user\/(?[^/]+)\/(?(?!(?:f0cks|uploads|favs|comments|tags|halls)$)[a-zA-Z0-9_-]+)$/, handleGenericRoute); // 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. diff --git a/src/inc/routes/notifications.mjs b/src/inc/routes/notifications.mjs index 4c8a75f..21d0ac6 100644 --- a/src/inc/routes/notifications.mjs +++ b/src/inc/routes/notifications.mjs @@ -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)); 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; async function getNotificationHistory(userId, page = 1, limit = 50, tab = null) { 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 ? 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, @@ -596,7 +597,7 @@ export default (router, tpl) => { LEFT JOIN items i ON n.item_id = i.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 - 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 ( ${req.session.do_not_disturb !== true} AND ( (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 != 'admin_pending' OR (i.active = false AND i.is_deleted = false)) 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) => { if (!req.session) return res.reply({ code: 401, body: JSON.stringify({ success: false }) }); try { - await db`UPDATE notifications SET is_read = true WHERE user_id = ${req.session.id}`; + 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}`; + } return res.reply({ headers: { 'Content-Type': 'application/json; charset=utf-8' }, - body: JSON.stringify({ success: true }) + body: JSON.stringify({ success: true, tab }) }); } catch (err) { return res.reply({ code: 500, body: JSON.stringify({ success: false }) }); diff --git a/src/inc/routes/register.mjs b/src/inc/routes/register.mjs index ab5a419..ef625b1 100644 --- a/src/inc/routes/register.mjs +++ b/src/inc/routes/register.mjs @@ -80,6 +80,11 @@ export default (router, tpl) => { 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) { 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) 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']; if (!rcToken) return renderError("Please complete the reCAPTCHA."); try { @@ -154,23 +159,55 @@ export default (router, tpl) => { const hash = await lib.hash(password); 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; try { - const newUser = await db` - 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}) - returning id - `; - userId = newUser[0].id; + 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` + 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}) + returning id + `; + userId = newUser[0].id; - // Assign default avatar file - const avatarId = null; - const avatarFile = 'default.png'; + // Assign default avatar file + const avatarId = null; + const avatarFile = 'default.png'; - await db` - 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}) - `; + await db` + 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}) + `; + } } catch (err) { console.error(`[REGISTER] DB Error during user creation:`, err); 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. } + 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."); return; } @@ -217,13 +259,15 @@ export default (router, tpl) => { 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'))) { - 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 - return res.writeHead(302, { "Location": "/?login=success" }).end(); + // Upgraded accounts keep their current session; otherwise redirect home with login success message + return res.writeHead(302, { "Location": upgradeAnonId ? "/settings" : "/?login=success" }).end(); }); return router; diff --git a/src/inc/routes/settings.mjs b/src/inc/routes/settings.mjs index 6bc6ddd..b3e299f 100644 --- a/src/inc/routes/settings.mjs +++ b/src/inc/routes/settings.mjs @@ -37,7 +37,7 @@ export default (router, tpl) => { // Get full user info 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]; 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', email: user?.email || '', 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, enable_swf: cfg.enable_swf, enable_data_export: !req.session?.is_anon && cfg.websrv.enable_data_export, diff --git a/src/inc/settings.mjs b/src/inc/settings.mjs index d990fec..0169468 100644 --- a/src/inc/settings.mjs +++ b/src/inc/settings.mjs @@ -126,11 +126,10 @@ export const isAnonSession = (session) => { }; // 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 -// without a stored preference. `forceOn` covers explicit requests like ?onara=1. +// config `onara` is only the default for users without a stored preference (true = on, false = off); +// users can always turn it on or off themselves. `forceOn` covers explicit requests like ?onara=1. export const isOnaraEnabledFor = (req, forceOn = false) => { const c = cfg.onara !== undefined ? cfg.onara : cfg.websrv?.onara; - if (c === false) return false; if (forceOn) return true; const ck = req?.cookies || {}; const raw = ck.f0ck_onara !== undefined ? ck.f0ck_onara : ck.onara; diff --git a/src/inc/transcode.mjs b/src/inc/transcode.mjs new file mode 100644 index 0000000..7f21934 --- /dev/null +++ b/src/inc/transcode.mjs @@ -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 + ]); +} diff --git a/src/inc/trigger/parser.mjs b/src/inc/trigger/parser.mjs index 3251d00..aae4a20 100644 --- a/src/inc/trigger/parser.mjs +++ b/src/inc/trigger/parser.mjs @@ -8,6 +8,7 @@ import autotagger from "../autotagger.mjs"; import fetch from "flumm-fetch"; import fs from "fs"; import path from "path"; +import { TRANSCODE_TO_MP4, transcodeToMp4 } from "../transcode.mjs"; const regex = { @@ -648,6 +649,13 @@ export default async bot => { source = source.replace(/\.mkv$/, '.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 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); diff --git a/src/inc/webauthn.mjs b/src/inc/webauthn.mjs index 06a9445..ad0f37d 100644 --- a/src/inc/webauthn.mjs +++ b/src/inc/webauthn.mjs @@ -472,3 +472,44 @@ export function buildAuthenticationOptions({ challenge, allowCredentials = [], r 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; +} diff --git a/src/index.mjs b/src/index.mjs index aa26d2d..7efbdc2 100644 --- a/src/index.mjs +++ b/src/index.mjs @@ -21,7 +21,7 @@ import { handleMetaExtract } from "./meta_extract_handler.mjs"; import { handleMetaStrip } from "./meta_strip_handler.mjs"; import { handleCommentUpload, handleCommentUploadCancel } from "./comment_upload_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 { createI18n } from "./inc/i18n.mjs"; import { safeDeleteMediaFile, purgeExpiredUploads } from "./inc/lib_delete.mjs"; @@ -861,8 +861,13 @@ process.on('uncaughtException', err => { const _ucSet = (username, count) => { _uploadCountCache.set(username, { count, exp: Date.now() + UPLOAD_COUNT_CACHE_TTL }); }; - // Allow upload handler to bust the cache after a successful upload - global._invalidateUploadCountCache = (username) => _uploadCountCache.delete(username); + // Own uploads awaiting approval (user menu badge), keyed like items.username; same TTL + 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) => { @@ -1183,6 +1188,20 @@ process.on('uncaughtException', err => { 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) @@ -1216,6 +1235,23 @@ process.on('uncaughtException', err => { 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) if (!req.session.admin && !req.session.is_moderator) { let cachedUploadCount = _ucGet(req.session.user); @@ -1983,12 +2019,8 @@ process.on('uncaughtException', err => { const c = cfg.onara !== undefined ? cfg.onara : cfg.websrv?.onara; return !!c; }, - // onara_cfg locks the per-user setting; only `onara: false` (feature disabled) does that. - // `onara: true` is just the default for users who haven't chosen. - get onara_cfg() { - const c = cfg.onara !== undefined ? cfg.onara : cfg.websrv?.onara; - return c === false ? false : null; - }, + // onara_cfg would lock the per-user setting; config `onara` is only a default, so it never does. + onara_cfg: null, is_onara_item: false, can_extract_meta: 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.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 - const cfgOnara = cfg.onara !== undefined ? cfg.onara : cfg.websrv?.onara; + // Onara is a per-user setting; config `onara` is only the default data.onara = isOnaraEnabledFor(activeReq); - data.onara_cfg = cfgOnara === false ? false : null; + data.onara_cfg = null; } else { data.recaptcha_enabled = perRequestRecaptcha; 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; 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); diff --git a/src/upload_handler.mjs b/src/upload_handler.mjs index 16cd27b..8911972 100644 --- a/src/upload_handler.mjs +++ b/src/upload_handler.mjs @@ -12,6 +12,7 @@ import f0cklib from "./inc/routeinc/f0cklib.mjs"; import { calculateExpiresAt } from "./inc/routes/apiv2/upload.mjs"; import { addPrivateItem } from "./inc/private_items.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. @@ -691,6 +692,22 @@ export const handleUpload = async (req, res, self) => { 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 ───────────────────────────── // sha256sum, PHash, and dimension probing all just read the file — // none depends on the other, so run them concurrently. diff --git a/views/admin.html b/views/admin.html index 6b9f87a..2d5b860 100644 --- a/views/admin.html +++ b/views/admin.html @@ -27,6 +27,7 @@ {{ totals.pending }}Pending approval {{ dash.trash }}Soft deleted {{ dash.open_reports }}Open reports + {{ dash.invite_requests }}Token requests {{ dash.users }}Users +{{ dash.anon_users }} anon
@endif @@ -72,7 +73,7 @@ - Invite TokensCreate and revoke invites + Invite Tokens @if(dash.invite_requests > 0){{ dash.invite_requests }}@endifCreate and revoke invites, review token requests
diff --git a/views/admin/tokens.html b/views/admin/tokens.html index 5efa87f..d31f36f 100644 --- a/views/admin/tokens.html +++ b/views/admin/tokens.html @@ -8,6 +8,34 @@
+ +
+

+ Invite Requests + +

+
+ + + + + + + + + + + + + + +
UserReasonStatusSubmittedReviewed ByActions
Loading...
+
+
+ +

+ Token List +

@@ -29,14 +57,137 @@ diff --git a/views/admin/users.html b/views/admin/users.html index eb31a8a..05cf0a6 100644 --- a/views/admin/users.html +++ b/views/admin/users.html @@ -43,6 +43,7 @@ .status-active { background: linear-gradient(135deg, #28a745, #20c997); color: #fff; } .status-pending { background: linear-gradient(135deg, #f08c00, #ffc107); color: #000; } .status-banned { background: linear-gradient(135deg, #e03131, #f03e3e); color: #fff; } + .status-system { background: #5c0a0a; color: #ffb3b3; border: 1px solid #8b1a1a; } .method-tag { font-size: 0.7rem; @@ -329,7 +330,8 @@ -
+ +

User Management

diff --git a/views/admin/users_list.html b/views/admin/users_list.html index bff5724..d93d429 100644 --- a/views/admin/users_list.html +++ b/views/admin/users_list.html @@ -34,6 +34,9 @@
@if(!u.id) Legacy + @elseif(u.login === 'deleted_user') + {{-- System ghost that deleted users' content is reassigned to; it is banned only so it can't log in --}} + System @else @if(u.banned) Banned diff --git a/views/notifications.html b/views/notifications.html index 7cc0d0b..a1487f1 100644 --- a/views/notifications.html +++ b/views/notifications.html @@ -13,6 +13,9 @@ @else @endif + @if(session && session.admin) + + @endif
@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'); if (btn) { 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(); if (data.success) { document.querySelectorAll('.notif-item.unread').forEach(el => el.classList.remove('unread')); if (window.NotificationSystemInstance) { - window.NotificationSystemInstance.markAllReadUI(); + window.NotificationSystemInstance.markAllReadUI(tab); } } }; diff --git a/views/pending.html b/views/pending.html index 5f9a56b..1f91bef 100644 --- a/views/pending.html +++ b/views/pending.html @@ -1,6 +1,11 @@ @include(snippets/header)
+
diff --git a/views/ranking.html b/views/ranking.html index 3103adc..67c092b 100644 --- a/views/ranking.html +++ b/views/ranking.html @@ -1,14 +1,100 @@ @include(snippets/header)
-
-

{{ t('ranking.title') }}

-
+
-

{{ t('ranking.top_contributors') }}

- @if(list.length >= 3)
@for(let i = 0; i < 3; i++) diff --git a/views/scroller.html b/views/scroller.html index a75ef7f..c795e47 100644 --- a/views/scroller.html +++ b/views/scroller.html @@ -1107,6 +1107,9 @@ @else @endif + @if(typeof session !== 'undefined' && session && session.admin) + + @endif
diff --git a/views/settings.html b/views/settings.html index 8e50fbb..9bbb9da 100644 --- a/views/settings.html +++ b/views/settings.html @@ -2,201 +2,471 @@
-

{{ t('settings.title') }}

- - @if(!session.is_anon) - -

{{ t('settings.profile') }}

- -

- Click directly on your profile card below to edit your avatar, banner, name, color, or bio. -

- -
- - -
- - - - -
-
-
- - -
- {!! session.display_name || session.user !!} - -
- - -
- - - -
- - - - - - -
- -
- -
- - -
-
- {!! session.description || '' !!} -
-
- - - @if(user_banner_enabled) - - @if(banner_file) - - - @endif - @endif -
-
- -
- -
-
-
-
- - - - - - - - - - - - - - - @if(enable_profile_description) - - @endif - -
- @endif - - -

{{ t('settings.preferences') }}

@@ -533,27 +803,430 @@
+ @if(!session.is_anon) + +

{{ t('settings.profile') }}

+ +

+ Click directly on your profile card below to edit your avatar, banner, name, color, or bio. +

+ +
+ + +
+ + + + + + + +
+
+
+ + +
+ {!! session.display_name || session.user !!} + +
+ + +
+ + + + +
+ + + + + + +
+ +
+ +
+ + +
+
+ {!! session.description || '' !!} +
+
+ + + @if(user_banner_enabled) + + @if(banner_file) + + + @endif + @endif +
+
+ +
+ +
+
+
+
+ + + + + + + + + + + + + @if(enable_profile_description) + + @endif + +
+ @endif + + + + + @if(session.is_anon) -

Anonymous Passkey Identity

+

Passkey