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 () {
${bgRowsAdvancedHtml}
+
+
+
+
+
+
+ Flash Yank
+
+
+
+
+
+
+
+
+
+
+
+ Yank
+ 0%
+
+
+
+
+
+ Degrades the video to a retro look: lower resolution, fewer frames and fewer colours the higher the yank.
+ Toggle it on item pages with the S key.
+
+
+
`;
// ── 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 `
+
+
- You are browsing anonymously. Your identity is protected by a passkey stored in your OS or password manager. No private key ever touches this browser's storage.
-
-
- Want full features? Registered accounts can upload items, customize avatars, create API keys, and invite friends. You can register an account at any time.
+
+
+
+ Want full features?
+
+
+ Registered accounts can upload items, customize avatars, create API keys, and invite friends.
+ Registration requires an invite token — request one below and an admin will review it.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@endif
@@ -561,11 +1234,11 @@
Passkeys
-
+
Passkeys let you sign in without a password using your OS, Bitwarden, or any compatible password manager. They are phishing-resistant and device-bound.
{{ t('settings.export_data_desc') || 'Download a copy of your data. This process happens entirely in your browser to protect your privacy and save server resources.' }}
{{ t('settings.export_data_desc') || 'Download a copy of your data. This process happens entirely in your browser to protect your privacy and save server resources.' }}