gfds
This commit is contained in:
+369
-190
@@ -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;
|
||||
}
|
||||
+27
-3
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
+174
-8
@@ -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 = '<div class="dmp-head"><span class="dmp-time"></span><span class="dmp-user"></span></div><div class="dmp-text"></div>';
|
||||
this.player.appendChild(tip);
|
||||
this._markerTip = tip;
|
||||
}
|
||||
const t = item.video_time;
|
||||
tip.querySelector('.dmp-time').textContent = Math.floor(t / 60) + ':' + String(Math.floor(t % 60)).padStart(2, '0');
|
||||
const user = tip.querySelector('.dmp-user');
|
||||
user.textContent = item.username;
|
||||
user.style.color = item.color || '';
|
||||
// Same renderer as the flying pills: emojis, images/media embeds, greentext; spoiler and blur
|
||||
// stay hidden. Builds DOM nodes (no HTML from the comment); size is capped by CSS.
|
||||
const body = tip.querySelector('.dmp-text');
|
||||
body.textContent = '';
|
||||
const liveCache = (this._emojiCache && Object.keys(this._emojiCache).length > 0)
|
||||
? this._emojiCache
|
||||
: ((typeof CommentSystem !== 'undefined' && CommentSystem.emojiCache) || null);
|
||||
if (liveCache && liveCache !== this._emojiCache) this._emojiCache = liveCache;
|
||||
body.appendChild(this._renderContent(item.raw || ''));
|
||||
if (!body.firstChild) body.textContent = '…';
|
||||
// Reposition once images have their size
|
||||
body.querySelectorAll('img').forEach(img => {
|
||||
if (!img.complete) img.addEventListener('load', () => { if (this._markerTip === tip && tip.classList.contains('is-visible')) this._showMarkerPreview(b); }, { once: true });
|
||||
});
|
||||
|
||||
// Position: centred over the marker, just above the progress bar, kept inside the player
|
||||
const pr = this.player.getBoundingClientRect();
|
||||
const mr = b.getBoundingClientRect();
|
||||
tip.style.visibility = 'hidden';
|
||||
tip.classList.add('is-visible');
|
||||
const w = tip.offsetWidth;
|
||||
let left = mr.left + mr.width / 2 - pr.left - w / 2;
|
||||
left = Math.max(6, Math.min(left, pr.width - w - 6));
|
||||
tip.style.left = left + 'px';
|
||||
tip.style.bottom = (pr.bottom - mr.top + 8) + 'px';
|
||||
tip.style.visibility = '';
|
||||
}
|
||||
|
||||
_hideMarkerPreview() {
|
||||
if (this._markerTip) this._markerTip.classList.remove('is-visible');
|
||||
}
|
||||
|
||||
_onMarkerClick(e) {
|
||||
const b = e.target.closest && e.target.closest('.danmaku-marker');
|
||||
if (!b) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const item = this.items[+b.dataset.idx];
|
||||
if (!item) return;
|
||||
// Land just past the comment: the seek reset then counts it as fired, so the timeline won't
|
||||
// spawn it a second time; it is shown right here instead (also while paused)
|
||||
const dur = this.media.duration;
|
||||
const t = Math.min(isFinite(dur) ? dur : Infinity, item.video_time + LOOKAHEAD_SEC + 0.05);
|
||||
try { this.media.currentTime = t; } catch (_) {}
|
||||
item.fired = true;
|
||||
this._hideMarkerPreview();
|
||||
this._spawnPill(item.text, item.username, item.color, true);
|
||||
}
|
||||
|
||||
_onPause() {
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+588
-86
@@ -6810,6 +6810,9 @@ window.cancelAnimFrame = (function () {
|
||||
<button type="button" class="f0ck-tuner-subtab-btn" data-subtab="background" title="Onara & Video Background Settings">
|
||||
<i class="fa-solid fa-film"></i> Background
|
||||
</button>
|
||||
<button type="button" class="f0ck-tuner-subtab-btn" data-subtab="flashyank" title="Flash Yank: retro low-res / low-fps / few-colour video filter">
|
||||
<i class="fa-solid fa-bolt-lightning"></i> Flash Yank
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" class="f0ck-tuner-close" id="f0ck-tuner-close" title="Close">✕</button>
|
||||
</div>
|
||||
@@ -6934,6 +6937,36 @@ window.cancelAnimFrame = (function () {
|
||||
<div id="f0ck-bg-advanced-rows" style="display:none;">${bgRowsAdvancedHtml}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pane 4: Flash Yank (video filter; replaces the old "SWF" button in the player menu) -->
|
||||
<div id="f0ck-tuner-pane-flashyank" class="f0ck-tuner-subtab-pane" style="display: none;">
|
||||
<div class="f0ck-tuner-body">
|
||||
<div class="f0ck-tuner-presets-bar">
|
||||
<div style="font-size: 11px; opacity: 0.85; font-weight: 700; display: flex; align-items: center; gap: 6px;">
|
||||
<i class="fa-solid fa-bolt-lightning"></i> Flash Yank
|
||||
</div>
|
||||
<div class="f0ck-tuner-presets-actions">
|
||||
<button type="button" id="fy-tuner-reset" class="f0ck-tuner-btn-factory-reset" title="Reset the yank strength to its default"><i class="fa-solid fa-rotate-left"></i> Reset</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="f0ck-tuner-mode-bar" style="display: flex; gap: 6px; padding: 6px 12px; background: rgba(255,255,255,0.04); border-bottom: 1px solid rgba(255,255,255,0.08);">
|
||||
<button type="button" id="fy-btn-off" class="f0ck-tuner-mode-btn" style="flex: 1; padding: 6px 10px; font-size: 11px; text-align: center;">Off</button>
|
||||
<button type="button" id="fy-btn-on" class="f0ck-tuner-mode-btn" style="flex: 1; padding: 6px 10px; font-size: 11px; text-align: center;"><i class="fa-solid fa-bolt-lightning"></i> On</button>
|
||||
</div>
|
||||
<div class="f0ck-tuner-row" data-key="flashyank-yank">
|
||||
<div class="f0ck-tuner-row-label">
|
||||
<span>Yank</span>
|
||||
<span id="fy-yank-value">0%</span>
|
||||
</div>
|
||||
<input type="range" id="fy-yank-slider" min="0" max="100" step="1" value="0" />
|
||||
</div>
|
||||
<div id="fy-info" style="padding: 4px 12px 8px; font-size: 11px; opacity: 0.7;"></div>
|
||||
<div style="padding: 0 12px 12px; font-size: 11px; opacity: 0.55; line-height: 1.4;">
|
||||
Degrades the video to a retro look: lower resolution, fewer frames and fewer colours the higher the yank.
|
||||
Toggle it on item pages with the <strong>S</strong> key.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// ── Sub-Tab Switching Logic ─────────────────────────────────────────────
|
||||
@@ -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 = `<div class="notif-empty">${window.f0ckI18n?.no_notifications || 'No new notifications'}</div>`;
|
||||
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 `
|
||||
<a href="/admin/tokens" class="notif-item ${n.is_read ? '' : 'unread'} notif-with-thumb" data-id="${n.id}" data-item-id="">
|
||||
<div class="notif-thumb" style="display:flex;align-items:center;justify-content:center;background:var(--bg-lighter);color:var(--accent);font-size:1.5em;"><i class="fa-solid fa-inbox"></i></div>
|
||||
<div class="notif-content">
|
||||
<div class="notif-user"><strong>${username}</strong></div>
|
||||
<div class="notif-msg">
|
||||
<strong>Invite request</strong>
|
||||
${reason ? '<div class="notif-reason">' + reason + '</div>' : ''}
|
||||
</div>
|
||||
<div class="notif-time">${new Date(n.created_at).toLocaleString()}</div>
|
||||
</div>
|
||||
</a>
|
||||
`;
|
||||
}
|
||||
|
||||
if (n.type === 'invite_approved') {
|
||||
return `
|
||||
<a href="/settings" class="notif-item ${n.is_read ? '' : 'unread'} notif-with-thumb" data-id="${n.id}" data-item-id="">
|
||||
<div class="notif-thumb" style="display:flex;align-items:center;justify-content:center;background:var(--bg-lighter);color:#51cf66;font-size:1.5em;"><i class="fa-solid fa-circle-check"></i></div>
|
||||
<div class="notif-content">
|
||||
<div class="notif-user"><strong style="color: #51cf66;">System</strong></div>
|
||||
<div class="notif-msg"><strong>Your invite request was approved!</strong><div style="font-size: 0.9em; margin-top: 2px;">Go to Settings to register with your token.</div></div>
|
||||
<div class="notif-time">${new Date(n.created_at).toLocaleString()}</div>
|
||||
</div>
|
||||
</a>
|
||||
`;
|
||||
}
|
||||
|
||||
if (n.type === 'invite_denied') {
|
||||
const isRevoked = n.data?.revoked === true;
|
||||
return `
|
||||
<a href="/settings" class="notif-item ${n.is_read ? '' : 'unread'} notif-with-thumb" data-id="${n.id}" data-item-id="">
|
||||
<div class="notif-thumb" style="display:flex;align-items:center;justify-content:center;background:var(--bg-lighter);color:${isRevoked ? '#e67700' : '#ff6b6b'};font-size:1.5em;"><i class="fa-solid ${isRevoked ? 'fa-ban' : 'fa-circle-xmark'}"></i></div>
|
||||
<div class="notif-content">
|
||||
<div class="notif-user"><strong style="color: ${isRevoked ? '#e67700' : '#ff6b6b'};">System</strong></div>
|
||||
<div class="notif-msg"><strong>${isRevoked ? 'Your invite token was revoked' : 'Your invite request was denied'}</strong><div style="font-size: 0.9em; margin-top: 2px;">You can submit a new request.</div></div>
|
||||
<div class="notif-time">${new Date(n.created_at).toLocaleString()}</div>
|
||||
</div>
|
||||
</a>
|
||||
`;
|
||||
}
|
||||
|
||||
if (n.type === 'deny' || n.type === 'item_deleted') {
|
||||
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 `
|
||||
<a href="/admin/tokens" class="notif-item ${n.is_read ? '' : 'unread'} notif-with-thumb" data-id="${n.id}" data-item-id="">
|
||||
<div class="notif-thumb" style="display:flex;align-items:center;justify-content:center;background:var(--bg-lighter);color:var(--accent);font-size:1.3em;"><i class="fa-solid fa-inbox"></i></div>
|
||||
<div class="notif-content">
|
||||
<div>
|
||||
<strong>Invite request</strong>
|
||||
<div style="color: var(--text-muted); margin-top: 2px;">from <span style="color: #5bc0be; font-weight: 600;">${username}</span>${reason ? ' — ' + reason : ''}</div>
|
||||
</div>
|
||||
<small class="notif-time">${new Date(n.created_at).toLocaleString()}</small>
|
||||
</div>
|
||||
</a>
|
||||
`;
|
||||
}
|
||||
|
||||
if (n.type === 'invite_approved') {
|
||||
return `
|
||||
<a href="/settings" class="notif-item ${n.is_read ? '' : 'unread'} notif-with-thumb" data-id="${n.id}" data-item-id="">
|
||||
<div class="notif-thumb" style="display:flex;align-items:center;justify-content:center;background:var(--bg-lighter);color:#51cf66;font-size:1.3em;"><i class="fa-solid fa-circle-check"></i></div>
|
||||
<div class="notif-content">
|
||||
<div>
|
||||
<strong style="color: #51cf66;">Invite request approved!</strong>
|
||||
<div style="font-size: 0.85em; color: var(--text-muted); margin-top: 2px;">Your invite token is ready. Go to Settings to register.</div>
|
||||
</div>
|
||||
<small class="notif-time">${new Date(n.created_at).toLocaleString()}</small>
|
||||
</div>
|
||||
</a>
|
||||
`;
|
||||
}
|
||||
|
||||
if (n.type === 'invite_denied') {
|
||||
const isRevoked = n.data?.revoked === true;
|
||||
return `
|
||||
<a href="/settings" class="notif-item ${n.is_read ? '' : 'unread'} notif-with-thumb" data-id="${n.id}" data-item-id="">
|
||||
<div class="notif-thumb" style="display:flex;align-items:center;justify-content:center;background:var(--bg-lighter);color:${isRevoked ? '#e67700' : '#ff6b6b'};font-size:1.3em;"><i class="fa-solid ${isRevoked ? 'fa-ban' : 'fa-circle-xmark'}"></i></div>
|
||||
<div class="notif-content">
|
||||
<div>
|
||||
<strong style="color: ${isRevoked ? '#e67700' : '#ff6b6b'};">${isRevoked ? 'Invite token revoked' : 'Invite request denied'}</strong>
|
||||
<div style="color: var(--text-muted); margin-top: 2px;">${isRevoked ? 'Your token was revoked. You can submit a new request.' : 'Your invite request was not approved. You can try again.'}</div>
|
||||
</div>
|
||||
<small class="notif-time">${new Date(n.created_at).toLocaleString()}</small>
|
||||
</div>
|
||||
</a>
|
||||
`;
|
||||
}
|
||||
|
||||
if (n.type === 'approve') {
|
||||
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 = `<div class="notif-empty">${window.f0ckI18n?.no_notifications || 'No new notifications'}</div>`;
|
||||
}
|
||||
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 = `<div class="notif-empty">${window.f0ckI18n?.no_notifications || 'No new notifications'}</div>`;
|
||||
}
|
||||
// Also update History page items if present
|
||||
document.querySelectorAll('.notifications-list-full .notif-item.unread').forEach(el => el.classList.remove('unread'));
|
||||
// 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 ─────────────────────────────────────────────
|
||||
|
||||
+64
-19
@@ -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) {
|
||||
|
||||
+155
-14
@@ -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 = '<div class="pgm-loading"><i class="fa-solid fa-spinner fa-spin"></i></div>';
|
||||
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; } };
|
||||
|
||||
@@ -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) {}
|
||||
});
|
||||
}
|
||||
|
||||
+114
-18
@@ -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 = '<i class="fa-solid fa-spinner fa-spin"></i>';
|
||||
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 = '<i class="fa-solid fa-xmark"></i>';
|
||||
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) => '<i class="fa-solid fa-image"></i> ' + inner;
|
||||
if (bannerProgressWrapper) { bannerProgressWrapper.style.display = 'block'; bannerProgressWrapper.setAttribute('aria-valuenow', '0'); }
|
||||
if (bannerProgressFill) bannerProgressFill.style.width = '0%';
|
||||
if (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 ? '<i class="fa-solid fa-spinner fa-spin"></i>' : 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 = `<i class="fa-solid ${icon}"></i> ${label}`;
|
||||
return b;
|
||||
};
|
||||
descControls.appendChild(mk('banner-edit-btn', 'button button-sm', 'fa-arrows-up-down-left-right', 'Reposition'));
|
||||
descControls.appendChild(mk('banner-remove-btn', 'button button-danger button-sm', 'fa-trash', 'Remove Banner'));
|
||||
} else if (!existingRemoveBtn) {
|
||||
const actionsDiv = bannerUploadBtn ? bannerUploadBtn.closest('.avatar-upload-actions') : null;
|
||||
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;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -31,7 +31,6 @@ const tpl_player = (svg, size) => `<div class="v0ck_player_controls">
|
||||
</svg>
|
||||
</button>
|
||||
<div class="v0ck_settings_menu v0ck_hidden">
|
||||
<button id="toggleswf" class="v0ck_menu_item" title="Flash Yank" tabindex="-1">SWF</button>
|
||||
<div class="v0ck_menu_item v0ck_bg_row">
|
||||
<span class="v0ck_switch_label">Background</span>
|
||||
<div id="togglebg" class="v0ck_cool_switch" title="Toggle Background"></div>
|
||||
|
||||
Reference in New Issue
Block a user