Update base
This commit is contained in:
@@ -966,23 +966,114 @@ html[theme="4d"] {
|
||||
}
|
||||
|
||||
.notif-header {
|
||||
padding: 10px;
|
||||
padding: 8px 10px 6px;
|
||||
border-bottom: 1px solid var(--nav-border-color);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 0.9rem;
|
||||
color: var(--white);
|
||||
background: var(--nav-bg);
|
||||
}
|
||||
|
||||
.notif-header button {
|
||||
.notif-header button#mark-all-read {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
font-size: 0.7rem;
|
||||
padding: 0;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Notification Tabs (dropdown) */
|
||||
.notif-tabs {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.notif-tab {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #888;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
padding: 4px 10px;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: all 0.15s ease;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
white-space: nowrap;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.notif-tab:hover {
|
||||
color: #ccc;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.notif-tab.active {
|
||||
color: var(--accent);
|
||||
background: rgba(var(--accent-rgb, 31, 178, 176), 0.1);
|
||||
}
|
||||
|
||||
.notif-tab-badge {
|
||||
display: inline-block;
|
||||
background: var(--badge-nsfw);
|
||||
color: #fff;
|
||||
font-size: 9px;
|
||||
min-width: 14px;
|
||||
height: 14px;
|
||||
line-height: 14px;
|
||||
text-align: center;
|
||||
border-radius: 7px;
|
||||
padding: 0 3px;
|
||||
margin-left: 3px;
|
||||
font-weight: 700;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* Notification Page Tabs */
|
||||
.notif-page-tabs {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
margin-bottom: 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
border-radius: 4px 4px 0 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.notif-page-tab {
|
||||
flex: 1;
|
||||
background: none;
|
||||
border: none;
|
||||
color: #888;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
padding: 10px 20px;
|
||||
cursor: pointer;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
transition: all 0.2s ease;
|
||||
border-bottom: 2px solid transparent;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.notif-page-tab:hover {
|
||||
color: #ccc;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.notif-page-tab.active {
|
||||
color: var(--accent);
|
||||
border-bottom-color: var(--accent);
|
||||
background: rgba(var(--accent-rgb, 31, 178, 176), 0.05);
|
||||
}
|
||||
|
||||
.notif-list {
|
||||
@@ -3420,6 +3511,69 @@ span.placeholder {
|
||||
}
|
||||
}
|
||||
|
||||
/* Danmaku toggle button on Ruffle embeds — appears on hover */
|
||||
.ruffle-danmaku-toggle {
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
left: 10px;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 8px;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.25s ease, background 0.2s ease, color 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.embed-responsive:hover .ruffle-danmaku-toggle,
|
||||
#ruffle-container:hover .ruffle-danmaku-toggle {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.ruffle-danmaku-toggle:hover {
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
.ruffle-danmaku-toggle.active {
|
||||
color: var(--accent, #9f0);
|
||||
border-color: var(--accent, #9f0);
|
||||
background: rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
|
||||
.ruffle-danmaku-toggle.active:hover {
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
/* Strikethrough line when inactive */
|
||||
.ruffle-danmaku-toggle:not(.active)::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 15%;
|
||||
width: 70%;
|
||||
height: 2px;
|
||||
background: rgba(255, 80, 80, 0.8);
|
||||
transform: rotate(-45deg);
|
||||
border-radius: 1px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
.embed-responsive-image {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
@@ -10110,6 +10264,223 @@ body.layout-modern .tag-controls {
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
/* ── DM Post Preview Card ─────────────────────────────────── */
|
||||
a.dm-post-card,
|
||||
span.dm-post-card {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 0;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
text-decoration: none;
|
||||
border: 1px solid rgba(255,255,255,0.12);
|
||||
background: rgba(0,0,0,0.3);
|
||||
margin: 6px 0 2px;
|
||||
max-width: 320px;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
a.dm-post-card:hover {
|
||||
border-color: var(--accent);
|
||||
background: rgba(0,0,0,0.4);
|
||||
}
|
||||
|
||||
/* Loading state */
|
||||
span.dm-post-card--loading {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.dm-post-card__thumb-wrap {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
background: #111;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dm-post-card__thumb {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.dm-post-card__thumb-placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
color: #555;
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
/* Media type badge (top-right of thumb) */
|
||||
.dm-post-card__type-badge {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
background: rgba(0,0,0,0.7);
|
||||
color: var(--accent, #aaa);
|
||||
font-size: 0.65em;
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
line-height: 1.2;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.dm-post-card__info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
padding: 8px 12px;
|
||||
min-width: 160px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.dm-post-card__id {
|
||||
font-weight: 700;
|
||||
font-size: 0.88em;
|
||||
color: var(--accent, #aaa);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.dm-post-card__uploader,
|
||||
.dm-post-card__comments {
|
||||
font-size: 0.75em;
|
||||
color: rgba(255,255,255,0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.dm-post-card__uploader i,
|
||||
.dm-post-card__comments i {
|
||||
font-size: 0.85em;
|
||||
opacity: 0.7;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Invert text colors inside a "mine" bubble so card still reads well */
|
||||
.dm-msg-mine .dm-post-card__uploader,
|
||||
.dm-msg-mine .dm-post-card__comments {
|
||||
color: rgba(255,255,255,0.65);
|
||||
}
|
||||
|
||||
.dm-msg-mine a.dm-post-card {
|
||||
background: rgba(0,0,0,0.2);
|
||||
border-color: rgba(255,255,255,0.15);
|
||||
}
|
||||
|
||||
/* ── Global Chat Post Preview Card ──────────────────────────── */
|
||||
a.gchat-post-card,
|
||||
span.gchat-post-card {
|
||||
display: inline-flex;
|
||||
align-items: stretch;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
text-decoration: none;
|
||||
border: 1px solid rgba(255,255,255,0.12);
|
||||
background: rgba(0,0,0,0.3);
|
||||
margin: 4px 0 2px;
|
||||
max-width: 280px;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
vertical-align: middle;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
a.gchat-post-card:hover {
|
||||
border-color: var(--accent);
|
||||
background: rgba(0,0,0,0.45);
|
||||
}
|
||||
|
||||
span.gchat-post-card--loading {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.gchat-post-card__thumb-wrap {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
background: #111;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.gchat-post-card__thumb {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.gchat-post-card__thumb-placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
color: #555;
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
.gchat-post-card__type-badge {
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
right: 3px;
|
||||
background: rgba(0,0,0,0.75);
|
||||
color: var(--accent, #aaa);
|
||||
font-size: 0.6em;
|
||||
padding: 2px 3px;
|
||||
border-radius: 3px;
|
||||
line-height: 1.2;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.gchat-post-card__info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 3px;
|
||||
padding: 6px 10px;
|
||||
min-width: 120px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.gchat-post-card__id {
|
||||
font-weight: 700;
|
||||
font-size: 0.82em;
|
||||
color: var(--accent, #aaa);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.gchat-post-card__uploader,
|
||||
.gchat-post-card__comments {
|
||||
font-size: 0.72em;
|
||||
color: rgba(255,255,255,0.45);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.gchat-post-card__uploader i,
|
||||
.gchat-post-card__comments i {
|
||||
font-size: 0.85em;
|
||||
opacity: 0.7;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Köpfe — random corner image */
|
||||
#koepfe-img {
|
||||
position: fixed;
|
||||
@@ -10909,7 +11280,7 @@ textarea#profile_description {
|
||||
}
|
||||
|
||||
.meta-suggestion:hover {
|
||||
background: var(--accent);
|
||||
background: var(--bg);
|
||||
border-color: var(--accent);
|
||||
color: #000 !important;
|
||||
transform: translateY(-1px);
|
||||
@@ -11101,6 +11472,23 @@ body.scroller-active #gchat-widget {
|
||||
opacity: 1;
|
||||
transform: scale(1.08);
|
||||
}
|
||||
.gchat-bubble-badge {
|
||||
position: absolute;
|
||||
top: -5px;
|
||||
right: -5px;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 4px;
|
||||
border-radius: 999px;
|
||||
background: #e53935;
|
||||
color: #fff;
|
||||
font-size: 0.68em;
|
||||
font-weight: 700;
|
||||
line-height: 18px;
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
animation: gchat-badge-pop 0.2s ease;
|
||||
}
|
||||
body.sidebar-right-hidden #gchat-reopen-bubble {
|
||||
right: 18px;
|
||||
}
|
||||
@@ -11226,6 +11614,68 @@ body.scroller-active #gchat-reopen-bubble {
|
||||
|
||||
/* Messages area */
|
||||
/* Pinned topic bar */
|
||||
/* Online users bar */
|
||||
#gchat-online {
|
||||
display: none;
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid var(--nav-border-color, rgba(255,255,255,0.07));
|
||||
background: rgba(0,0,0,0.15);
|
||||
}
|
||||
.gchat-online-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 4px 10px;
|
||||
gap: 8px;
|
||||
}
|
||||
.gchat-online-count {
|
||||
font-size: 0.68em;
|
||||
color: rgba(255,255,255,0.45);
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.gchat-online-count::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: #4caf50;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.gchat-online-avatars {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: row-reverse; /* stack right-to-left */
|
||||
}
|
||||
.gchat-online-avatar {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 2px solid var(--bg, #1a1a1a);
|
||||
margin-left: -6px;
|
||||
flex-shrink: 0;
|
||||
transition: transform 0.1s;
|
||||
}
|
||||
.gchat-online-avatar:last-child {
|
||||
margin-left: 0;
|
||||
}
|
||||
.gchat-online-avatars:hover .gchat-online-avatar {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
.gchat-online-extra {
|
||||
font-size: 0.65em;
|
||||
color: rgba(255,255,255,0.5);
|
||||
background: rgba(255,255,255,0.08);
|
||||
border-radius: 999px;
|
||||
padding: 1px 5px;
|
||||
margin-left: -2px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
#gchat-topic {
|
||||
padding: 5px 10px;
|
||||
font-size: 0.78em;
|
||||
@@ -11691,7 +12141,7 @@ body.scroller-active #gchat-reopen-bubble {
|
||||
|
||||
.gchat-embed-video video {
|
||||
max-width: 100%;
|
||||
max-height: 180px;
|
||||
max-height: 350px;
|
||||
border-radius: 6px;
|
||||
display: block;
|
||||
}
|
||||
@@ -11831,3 +12281,245 @@ body.scroller-active #gchat-reopen-bubble {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
|
||||
/* ── Same-site item preview card ───────────────────────────────────────────── */
|
||||
.gchat-item-card {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 6px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: rgba(255,255,255,0.05);
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
max-width: 260px;
|
||||
width: 100%;
|
||||
}
|
||||
.gchat-item-card:hover {
|
||||
background: rgba(255,255,255,0.1);
|
||||
border-color: var(--accent, rgba(255,255,255,0.2));
|
||||
text-decoration: none;
|
||||
}
|
||||
.gchat-item-card-thumb {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
width: 80px;
|
||||
height: 60px;
|
||||
overflow: hidden;
|
||||
background: rgba(0,0,0,0.3);
|
||||
}
|
||||
.gchat-item-card-thumb img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
.gchat-item-card-icon {
|
||||
position: absolute;
|
||||
bottom: 4px;
|
||||
right: 4px;
|
||||
background: rgba(0,0,0,0.6);
|
||||
border-radius: 4px;
|
||||
padding: 2px 5px;
|
||||
font-size: 0.72rem;
|
||||
color: #fff;
|
||||
line-height: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
.gchat-item-card-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 6px 8px 6px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
.gchat-item-card-id {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
color: var(--accent, #f2ef0b);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.gchat-item-card-meta {
|
||||
font-size: 0.7rem;
|
||||
opacity: 0.5;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* ── Same-site item preview card ───────────────────────────────────────────── */
|
||||
.gchat-item-card {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 6px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: rgba(255,255,255,0.05);
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
max-width: 260px;
|
||||
width: 100%;
|
||||
}
|
||||
.gchat-item-card:hover {
|
||||
background: rgba(255,255,255,0.1);
|
||||
border-color: var(--accent, rgba(255,255,255,0.2));
|
||||
text-decoration: none;
|
||||
}
|
||||
.gchat-item-card-thumb {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
width: 80px;
|
||||
height: 60px;
|
||||
overflow: hidden;
|
||||
background: rgba(0,0,0,0.3);
|
||||
}
|
||||
.gchat-item-card-thumb img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
.gchat-item-card-icon {
|
||||
position: absolute;
|
||||
bottom: 4px;
|
||||
right: 4px;
|
||||
background: rgba(0,0,0,0.6);
|
||||
border-radius: 4px;
|
||||
padding: 2px 5px;
|
||||
font-size: 0.72rem;
|
||||
color: #fff;
|
||||
line-height: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
.gchat-item-card-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 6px 8px 6px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
.gchat-item-card-id {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
color: var(--accent, #f2ef0b);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.gchat-item-card-meta {
|
||||
font-size: 0.7rem;
|
||||
opacity: 0.5;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* =============================================
|
||||
SETTINGS PAGE – MOBILE OVERFLOW FIXES
|
||||
Prevents long i18n strings (Deutsch / Zange)
|
||||
from expanding past the viewport on mobile.
|
||||
============================================= */
|
||||
|
||||
.settings {
|
||||
max-width: 100%;
|
||||
overflow-x: hidden;
|
||||
box-sizing: border-box;
|
||||
word-break: break-word;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.settings h1,
|
||||
.settings h2,
|
||||
.settings h4,
|
||||
.settings label,
|
||||
.settings span,
|
||||
.settings small,
|
||||
.settings p,
|
||||
.settings legend {
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.settings fieldset {
|
||||
min-width: 0; /* prevent fieldsets from blowing out grid */
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.settings {
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
/* Account info table: stack label and value */
|
||||
.settings .account-info-table td {
|
||||
display: block;
|
||||
width: 100% !important;
|
||||
padding: 2px 0;
|
||||
}
|
||||
.settings .account-info-table tr {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* Display name row – stack input & button vertically */
|
||||
.settings .account-info-table td[style*="display: flex"] {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Account actions grid – single column on mobile */
|
||||
.settings .account-actions-grid {
|
||||
grid-template-columns: 1fr !important;
|
||||
}
|
||||
|
||||
/* Prevent flex rows from overflowing */
|
||||
.settings .setting-item label[style*="display: flex"] {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.settings .setting-item label span {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Hint text under checkboxes */
|
||||
.settings small.text-muted {
|
||||
display: block;
|
||||
margin-left: 0 !important;
|
||||
margin-top: 2px;
|
||||
word-break: break-word;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* Username color row */
|
||||
.settings .setting-item div[style*="display: flex"] {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Linked accounts wrapper */
|
||||
.settings .linked-accounts-wrapper,
|
||||
.settings .preferences-settings-wrapper,
|
||||
.settings .account-settings-wrapper,
|
||||
.settings .profile-settings-wrapper {
|
||||
max-width: 100%;
|
||||
overflow-x: hidden;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.settings .account-settings-wrapper {
|
||||
padding: 12px !important;
|
||||
}
|
||||
|
||||
/* Fieldset legend text */
|
||||
.settings fieldset legend {
|
||||
font-size: 1em !important;
|
||||
max-width: 100%;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -444,5 +444,33 @@
|
||||
}
|
||||
};
|
||||
|
||||
window.adminReassignUploads = async (btn) => {
|
||||
const id = btn.dataset.id;
|
||||
const name = btn.dataset.name;
|
||||
const username = btn.dataset.username;
|
||||
|
||||
if (typeof ModAction === 'undefined') return alert('Error: ModAction module not loaded');
|
||||
|
||||
ModAction.confirm(
|
||||
'Reassign Uploads',
|
||||
'Enter the <strong>target username</strong> to transfer all uploads from <strong style="color:var(--accent)">' + escHTML(name) + '</strong> to:',
|
||||
async (targetUsername) => {
|
||||
const payload = { target_username: targetUsername };
|
||||
if (id) {
|
||||
payload.source_user_id = id;
|
||||
} else {
|
||||
payload.source_username = username;
|
||||
}
|
||||
const res = await post('/api/v2/admin/users/reassign-uploads', payload);
|
||||
if (res.success) {
|
||||
showFlash(res.msg, 'success');
|
||||
} else {
|
||||
throw new Error(res.msg || 'Reassignment failed');
|
||||
}
|
||||
},
|
||||
{ hideReason: false, confirmText: 'Reassign', placeholder: 'target username' }
|
||||
);
|
||||
};
|
||||
|
||||
})();
|
||||
|
||||
|
||||
@@ -1183,9 +1183,15 @@ window.cancelAnimFrame = (function () {
|
||||
});
|
||||
|
||||
// --- Gesture Support (Mobile & Desktop) ---
|
||||
// Inject HUD and Overlay
|
||||
// Inject HUD, Overlay, and Danmaku toggle
|
||||
const existingHUD = container.querySelector('.v0ck_hud');
|
||||
if (!existingHUD) {
|
||||
// Determine initial danmaku state
|
||||
const dmConfigDefault = (window.f0ckSession && window.f0ckSession.enable_danmaku !== undefined)
|
||||
? !!window.f0ckSession.enable_danmaku : true;
|
||||
const dmSaved = localStorage.getItem('danmaku');
|
||||
const dmOn = (dmSaved !== null) ? (dmSaved !== 'false') : dmConfigDefault;
|
||||
|
||||
container.insertAdjacentHTML('beforeend', `
|
||||
<div class="v0ck_hud v0ck_hidden" style="z-index: 10000;">
|
||||
<svg viewBox="0 0 24 24"><use class="v0ck_hud_icon" href="/s/img/v0ck.svg#volume_full"></use></svg>
|
||||
@@ -1194,7 +1200,51 @@ window.cancelAnimFrame = (function () {
|
||||
</div>
|
||||
</div>
|
||||
<div class="ruffle-gesture-overlay"></div>
|
||||
<button class="ruffle-danmaku-toggle${dmOn ? ' active' : ''}" title="Toggle Danmaku">
|
||||
<i class="fa-solid fa-bars-staggered"></i>
|
||||
</button>
|
||||
`);
|
||||
|
||||
// Wire up danmaku toggle
|
||||
const dmBtn = container.querySelector('.ruffle-danmaku-toggle');
|
||||
if (dmBtn) {
|
||||
dmBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
if (window.danmakuInstance) {
|
||||
window.danmakuInstance.toggle();
|
||||
const on = window.danmakuInstance.isEnabled();
|
||||
dmBtn.classList.toggle('active', on);
|
||||
localStorage.setItem('danmaku', on ? 'true' : 'false');
|
||||
if (window.flashMessage) window.flashMessage(on ? 'Danmaku ON' : 'Danmaku OFF', 1800);
|
||||
} else {
|
||||
dmBtn.classList.toggle('active');
|
||||
const newVal = dmBtn.classList.contains('active');
|
||||
localStorage.setItem('danmaku', newVal ? 'true' : 'false');
|
||||
if (window.flashMessage) window.flashMessage(newVal ? 'Danmaku ON' : 'Danmaku OFF', 1800);
|
||||
}
|
||||
});
|
||||
|
||||
// Mobile: show button briefly on tap, then auto-hide
|
||||
const isMobileDevice = /Mobi/i.test(navigator.userAgent) || ('ontouchstart' in window);
|
||||
if (isMobileDevice) {
|
||||
let dmHideTimer = null;
|
||||
const showDmBtn = () => {
|
||||
dmBtn.style.opacity = '1';
|
||||
dmBtn.style.pointerEvents = 'auto';
|
||||
clearTimeout(dmHideTimer);
|
||||
dmHideTimer = setTimeout(() => {
|
||||
dmBtn.style.opacity = '';
|
||||
dmBtn.style.pointerEvents = '';
|
||||
}, 3000);
|
||||
};
|
||||
container.addEventListener('touchstart', showDmBtn, { passive: true });
|
||||
// Keep visible while interacting with the button itself
|
||||
dmBtn.addEventListener('touchstart', (e) => {
|
||||
e.stopPropagation();
|
||||
showDmBtn();
|
||||
}, { passive: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const hud = container.querySelector('.v0ck_hud');
|
||||
@@ -1379,7 +1429,7 @@ window.cancelAnimFrame = (function () {
|
||||
// that were applied so the main-site layout is fully restored.
|
||||
if (document.body.classList.contains('scroller-active')) {
|
||||
document.head.querySelectorAll('[data-pjax-abyss]').forEach(el => el.remove());
|
||||
document.body.classList.remove('scroller-active');
|
||||
document.body.classList.remove('scroller-active', 'gallery-open');
|
||||
|
||||
// Restore body
|
||||
['overflow', 'background', 'height'].forEach(p => document.body.style.removeProperty(p));
|
||||
@@ -1403,6 +1453,10 @@ window.cancelAnimFrame = (function () {
|
||||
// Restore #main (element persists across PJAX, its inline styles must be cleared)
|
||||
const _m = document.getElementById('main');
|
||||
if (_m) ['height', 'overflow'].forEach(p => _m.style.removeProperty(p));
|
||||
|
||||
// Stop all media
|
||||
document.querySelectorAll('video, audio').forEach(el => { try { el.pause(); el.removeAttribute('src'); el.load(); } catch {} });
|
||||
document.querySelectorAll('ruffle-player').forEach(el => { try { el.remove(); } catch {} });
|
||||
}
|
||||
|
||||
// Immediately close image modal on any navigation
|
||||
@@ -2224,7 +2278,7 @@ window.cancelAnimFrame = (function () {
|
||||
if (visualizerRafId) window.cancelAnimFrame(visualizerRafId);
|
||||
|
||||
|
||||
const media = document.querySelectorAll('video, audio');
|
||||
const media = document.querySelectorAll('video:not(#gchat-messages video), audio:not(#gchat-messages audio)');
|
||||
|
||||
media.forEach(m => {
|
||||
try {
|
||||
@@ -2301,6 +2355,26 @@ window.cancelAnimFrame = (function () {
|
||||
if (isNavigating) return;
|
||||
isNavigating = true;
|
||||
|
||||
// ── Scroller-active cleanup (same as loadPageAjax) ───────────────────
|
||||
if (document.body.classList.contains('scroller-active')) {
|
||||
document.head.querySelectorAll('[data-pjax-abyss]').forEach(el => el.remove());
|
||||
document.body.classList.remove('scroller-active', 'gallery-open');
|
||||
['overflow', 'background', 'height'].forEach(p => document.body.style.removeProperty(p));
|
||||
const _nav = document.querySelector('nav.navbar');
|
||||
if (_nav) _nav.style.removeProperty('display');
|
||||
const _sb = document.querySelector('.global-sidebar-right');
|
||||
if (_sb) _sb.style.removeProperty('display');
|
||||
const _dz = document.getElementById('sidebar-drag-zone');
|
||||
if (_dz) _dz.style.removeProperty('display');
|
||||
const _pw = document.querySelector('.pagewrapper');
|
||||
if (_pw) ['height', 'padding', 'margin', 'overflow'].forEach(p => _pw.style.removeProperty(p));
|
||||
const _m = document.getElementById('main');
|
||||
if (_m) ['height', 'overflow'].forEach(p => _m.style.removeProperty(p));
|
||||
// Stop all media
|
||||
document.querySelectorAll('video:not(#gchat-messages video), audio:not(#gchat-messages audio)').forEach(el => { try { el.pause(); el.removeAttribute('src'); el.load(); } catch {} });
|
||||
document.querySelectorAll('ruffle-player').forEach(el => { try { el.remove(); } catch {} });
|
||||
}
|
||||
|
||||
// Dispatch pjax:start so navigation-aware listeners (e.g. metadata modal, image modal) can react
|
||||
if (!options.keepMedia) {
|
||||
window.dispatchEvent(new Event('pjax:start'));
|
||||
@@ -3059,6 +3133,25 @@ window.cancelAnimFrame = (function () {
|
||||
const url = window.location.href;
|
||||
const p = window.location.pathname;
|
||||
|
||||
// ── Abyss handling ───────────────────────────────────────────────────
|
||||
const wasOnAbyss = document.body.classList.contains('scroller-active');
|
||||
|
||||
if (p.startsWith('/abyss')) {
|
||||
if (wasOnAbyss) {
|
||||
// Within-abyss back/forward — let the scroller's own popstate handler manage it
|
||||
return;
|
||||
}
|
||||
// Coming BACK to abyss from a different page — full reload to reinitialize
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
|
||||
// Leaving abyss — stop all media
|
||||
if (wasOnAbyss) {
|
||||
document.querySelectorAll('video, audio').forEach(el => { try { el.pause(); el.removeAttribute('src'); el.load(); } catch {} });
|
||||
document.querySelectorAll('ruffle-player').forEach(el => { try { el.remove(); } catch {} });
|
||||
}
|
||||
|
||||
// Item detection logic MUST match loadPageAjax/loadItemAjax analysis
|
||||
// Priorities: Item first, then Special/Grid
|
||||
const parts = p.split('/').filter(Boolean);
|
||||
@@ -3458,6 +3551,11 @@ window.cancelAnimFrame = (function () {
|
||||
params.append('strict', '1');
|
||||
}
|
||||
|
||||
if (ctx.notif) {
|
||||
const notifTab = document.getElementById('notifications-container')?.dataset?.tab;
|
||||
if (notifTab) params.append('tab', notifTab);
|
||||
}
|
||||
|
||||
const fetchUrl = ctx.notif ? `/ajax/notifications?${params.toString()}` :
|
||||
ctx.tags ? `/api/tags?ajax=true&${params.toString()}` :
|
||||
ctx.subs ? `/ajax/subscriptions?${params.toString()}` :
|
||||
@@ -3551,6 +3649,11 @@ window.cancelAnimFrame = (function () {
|
||||
params.append('strict', '1');
|
||||
}
|
||||
|
||||
if (ctx.notif) {
|
||||
const notifTab = document.getElementById('notifications-container')?.dataset?.tab;
|
||||
if (notifTab) params.append('tab', notifTab);
|
||||
}
|
||||
|
||||
const fetchUrl = ctx.notif ? `/ajax/notifications?${params.toString()}` :
|
||||
ctx.tags ? `/api/tags?ajax=true&${params.toString()}` :
|
||||
ctx.subs ? `/ajax/subscriptions?${params.toString()}` :
|
||||
@@ -4971,6 +5074,10 @@ if (sbtForm) {
|
||||
|
||||
// Notification System
|
||||
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'];
|
||||
|
||||
constructor() {
|
||||
this.bell = document.getElementById('nav-notif-btn');
|
||||
this.dropdown = document.getElementById('notif-dropdown');
|
||||
@@ -4981,6 +5088,9 @@ class NotificationSystem {
|
||||
this.retryCount = 0;
|
||||
this.maxRetries = 20; // Increased retries
|
||||
this.pendingNotifIds = new Set(); // item IDs notified before thumbnail was in the grid
|
||||
this.activeTab = 'user'; // 'user' or 'system'
|
||||
this._cachedUser = [];
|
||||
this._cachedSystem = [];
|
||||
|
||||
// Generate/retrieve unique tab ID
|
||||
this.tabId = sessionStorage.getItem('f0ck_tab_id');
|
||||
@@ -5086,6 +5196,12 @@ class NotificationSystem {
|
||||
}
|
||||
|
||||
console.log("[NotificationSystem] Tab visible, signaling active...");
|
||||
// If SSE died while hidden, restart it now
|
||||
if (!this.es) {
|
||||
console.log("[NotificationSystem] SSE was dead, restarting on tab visible.");
|
||||
this.retryCount = 0;
|
||||
this.initSSE();
|
||||
}
|
||||
if (this.pollDebounced) this.pollDebounced();
|
||||
if (this.checkForNewItems) this.checkForNewItems();
|
||||
// Catch-up on emojis if they were updated while this tab was pruned/backgrounded
|
||||
@@ -5326,6 +5442,8 @@ class NotificationSystem {
|
||||
document.dispatchEvent(new CustomEvent('f0ck:global_chat_background', { detail: data.data }));
|
||||
} else if (data.type === 'global_chat_topic') {
|
||||
document.dispatchEvent(new CustomEvent('f0ck:global_chat_topic', { detail: data.data }));
|
||||
} else if (data.type === 'global_chat_presence') {
|
||||
document.dispatchEvent(new CustomEvent('f0ck:global_chat_presence', { detail: data.data }));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('SSE data parse error', err);
|
||||
@@ -5385,20 +5503,23 @@ class NotificationSystem {
|
||||
this.es = null;
|
||||
}
|
||||
|
||||
// Stop retrying if tab is inactive/hidden to save resources
|
||||
// If tab is hidden, don't retry now — visibilitychange will restart SSE when visible again
|
||||
if (document.hidden) {
|
||||
console.log("[NotificationSystem] Tab hidden, suspended SSE retries.");
|
||||
console.log("[NotificationSystem] Tab hidden, deferring SSE retry until visible.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Exponential backoff for reconnection
|
||||
// Exponential backoff, capped at 30s
|
||||
const delay = Math.min(Math.pow(2, this.retryCount) * 1000, 30000);
|
||||
if (this.retryCount < this.maxRetries) {
|
||||
const delay = Math.pow(2, this.retryCount) * 1000;
|
||||
console.log(`[NotificationSystem] Retrying SSE connection in ${delay}ms... (Attempt ${this.retryCount + 1}/${this.maxRetries})`);
|
||||
console.log(`[NotificationSystem] Retrying SSE in ${delay}ms... (attempt ${this.retryCount + 1}/${this.maxRetries})`);
|
||||
setTimeout(() => this.initSSE(), delay);
|
||||
this.retryCount++;
|
||||
} else {
|
||||
console.error("[NotificationSystem] Max SSE retries reached. Realtime updates disabled.");
|
||||
// Past max retries — keep trying every 30s indefinitely, reset counter so backoff starts fresh
|
||||
console.warn("[NotificationSystem] Max SSE retries reached, falling back to 30s polling.");
|
||||
this.retryCount = 0;
|
||||
setTimeout(() => this.initSSE(), 30000);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -5470,6 +5591,25 @@ class NotificationSystem {
|
||||
this.markAllBtn.addEventListener('click', () => this.markAllRead());
|
||||
}
|
||||
|
||||
// Tab switching in dropdown
|
||||
if (this.dropdown) {
|
||||
this.dropdown.querySelectorAll('.notif-tab').forEach(tab => {
|
||||
tab.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const tabName = tab.dataset.tab;
|
||||
if (tabName === this.activeTab) return;
|
||||
this.activeTab = tabName;
|
||||
// Update active class
|
||||
this.dropdown.querySelectorAll('.notif-tab').forEach(t => t.classList.remove('active'));
|
||||
tab.classList.add('active');
|
||||
if (this.list) this.list.dataset.activeTab = tabName;
|
||||
// Re-render with cached data
|
||||
this._renderActiveTab();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Single Notification Click Handler (Delegated)
|
||||
// Handles both Dropdown and History Page
|
||||
const handleNotificationClick = (e) => {
|
||||
@@ -5706,30 +5846,45 @@ class NotificationSystem {
|
||||
updateUI(notifications) {
|
||||
if (!this.countBadge || !this.list) return;
|
||||
|
||||
const unreadCount = notifications.filter(n => !n.is_read).length;
|
||||
// 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));
|
||||
|
||||
if (unreadCount > 0) {
|
||||
this.countBadge.textContent = unreadCount;
|
||||
const userUnread = this._cachedUser.filter(n => !n.is_read).length;
|
||||
const systemUnread = this._cachedSystem.filter(n => !n.is_read).length;
|
||||
const totalUnread = userUnread + systemUnread;
|
||||
|
||||
// Update main bell badge (total unread)
|
||||
if (totalUnread > 0) {
|
||||
this.countBadge.textContent = totalUnread;
|
||||
this.countBadge.style.display = 'block';
|
||||
} else {
|
||||
this.countBadge.style.display = 'none';
|
||||
}
|
||||
|
||||
// Update per-tab badges
|
||||
const userBadge = document.getElementById('notif-tab-badge-user');
|
||||
const systemBadge = document.getElementById('notif-tab-badge-system');
|
||||
if (userBadge) {
|
||||
userBadge.textContent = userUnread;
|
||||
userBadge.style.display = userUnread > 0 ? '' : 'none';
|
||||
}
|
||||
if (systemBadge) {
|
||||
systemBadge.textContent = systemUnread;
|
||||
systemBadge.style.display = systemUnread > 0 ? '' : 'none';
|
||||
}
|
||||
|
||||
// Forward count to Abyss scroller notification badge if active
|
||||
if (typeof window._scrollerNotifHook === 'function') {
|
||||
window._scrollerNotifHook(unreadCount);
|
||||
window._scrollerNotifHook(totalUnread);
|
||||
}
|
||||
|
||||
// Sync .has-notif highlights on main grid thumbnails for all unread notifications.
|
||||
// This catches the case where the SSE event was missed (tab was backgrounded).
|
||||
// IMPORTANT: this sweep must run BEFORE the early-return for empty notifications,
|
||||
// because when all are read in another tab the server returns [] and we must still
|
||||
// clear any stale .has-notif highlights that are already in the grid.
|
||||
const currentPath = window.location.pathname;
|
||||
const unreadItemIds = new Set();
|
||||
notifications.forEach(n => {
|
||||
if (!n.is_read && n.item_id) {
|
||||
unreadItemIds.add(String(n.item_id));
|
||||
// Skip if the user is currently viewing this item (no need to highlight)
|
||||
if (currentPath === `/${n.item_id}` || currentPath === `/${n.item_id}/`) return;
|
||||
document.querySelectorAll(`a.thumb[href$="/${n.item_id}"], a.lazy-thumb[href$="/${n.item_id}"]`).forEach(el => {
|
||||
el.classList.add('has-notif');
|
||||
@@ -5738,8 +5893,6 @@ class NotificationSystem {
|
||||
});
|
||||
|
||||
// Remove .has-notif from any thumb whose item is no longer in the unread set.
|
||||
// /api/notifications only returns unread items, so we can't rely on n.is_read === true
|
||||
// being present — instead we sweep all highlighted thumbs in the grid.
|
||||
document.querySelectorAll('a.thumb.has-notif, a.lazy-thumb.has-notif').forEach(el => {
|
||||
const match = el.getAttribute('href')?.match(/\/(\d+)$/);
|
||||
if (match && !unreadItemIds.has(match[1])) {
|
||||
@@ -5747,26 +5900,23 @@ class NotificationSystem {
|
||||
}
|
||||
});
|
||||
|
||||
if (notifications.length === 0) {
|
||||
this.list.innerHTML = `<div class="notif-empty">${window.f0ckI18n?.no_notifications || 'No new notifications'}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
this.list.innerHTML = Sanitizer.clean(notifications.map(n => this.renderItem(n)).join(''));
|
||||
// Render the active tab
|
||||
this._renderActiveTab();
|
||||
|
||||
// Live update for History Page
|
||||
const historyContainer = document.querySelector('.notifications-list-full');
|
||||
if (historyContainer) {
|
||||
notifications.forEach(n => {
|
||||
const historyTab = historyContainer.dataset.tab || 'user';
|
||||
const tabNotifs = historyTab === 'system' ? this._cachedSystem : this._cachedUser;
|
||||
tabNotifs.forEach(n => {
|
||||
const existing = historyContainer.querySelector(`.notif-item[data-id="${n.id}"]`);
|
||||
if (!existing) {
|
||||
console.log("[NotificationSystem] Adding new item to history:", n.id);
|
||||
const html = this.renderHistoryItem(n);
|
||||
// Create temp container to turn string into node
|
||||
const temp = document.createElement('div');
|
||||
temp.innerHTML = Sanitizer.clean(html);
|
||||
const node = temp.firstElementChild;
|
||||
node.classList.add('new-item-fade'); // We can add CSS for this later if desired
|
||||
node.classList.add('new-item-fade');
|
||||
historyContainer.prepend(node);
|
||||
} else {
|
||||
console.log("[NotificationSystem] Item already exists:", n.id);
|
||||
@@ -5775,6 +5925,16 @@ class NotificationSystem {
|
||||
}
|
||||
}
|
||||
|
||||
_renderActiveTab() {
|
||||
if (!this.list) return;
|
||||
const items = 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;
|
||||
}
|
||||
this.list.innerHTML = Sanitizer.clean(items.map(n => this.renderItem(n)).join(''));
|
||||
}
|
||||
|
||||
renderHistoryItem(n) {
|
||||
let link = `/${n.item_id}`;
|
||||
let msg = '';
|
||||
@@ -5992,6 +6152,14 @@ class NotificationSystem {
|
||||
|
||||
markAllReadUI() {
|
||||
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) {
|
||||
this.list.innerHTML = `<div class="notif-empty">${window.f0ckI18n?.no_notifications || 'No new notifications'}</div>`;
|
||||
}
|
||||
|
||||
@@ -24,13 +24,26 @@
|
||||
const ytOembedCache = new Map(); // videoId → {title, author_name}
|
||||
|
||||
function updateBadge() {
|
||||
const badge = document.getElementById('gchat-badge');
|
||||
if (!badge) return;
|
||||
const badge = document.getElementById('gchat-badge');
|
||||
const bubble = document.getElementById('gchat-reopen-bubble');
|
||||
if (unreadCount > 0) {
|
||||
badge.textContent = unreadCount > 99 ? '99+' : unreadCount;
|
||||
badge.style.display = 'inline-flex';
|
||||
const label = unreadCount > 99 ? '99+' : String(unreadCount);
|
||||
if (badge) { badge.textContent = label; badge.style.display = 'inline-flex'; }
|
||||
// Bubble badge — create it lazily if it doesn't exist yet
|
||||
if (bubble) {
|
||||
let bb = bubble.querySelector('.gchat-bubble-badge');
|
||||
if (!bb) {
|
||||
bb = document.createElement('span');
|
||||
bb.className = 'gchat-bubble-badge';
|
||||
bubble.appendChild(bb);
|
||||
}
|
||||
bb.textContent = label;
|
||||
bb.style.display = '';
|
||||
}
|
||||
} else {
|
||||
badge.style.display = 'none';
|
||||
if (badge) badge.style.display = 'none';
|
||||
const bb = document.getElementById('gchat-reopen-bubble')?.querySelector('.gchat-bubble-badge');
|
||||
if (bb) bb.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,6 +83,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div id="gchat-topic" style="display:none"></div>
|
||||
<div id="gchat-online"></div>
|
||||
<div id="gchat-messages"></div>
|
||||
<div id="gchat-input-area">
|
||||
<div id="gchat-toolbar">
|
||||
@@ -211,10 +225,50 @@
|
||||
`</a>`;
|
||||
});
|
||||
|
||||
// 6e. Remaining plain https URLs (not already wrapped in a tag) → clickable link
|
||||
// 6d.5 Same-site item page links → post preview card (resolved async)
|
||||
// Only catches /digits paths — direct media file URLs are handled by 6a-6c & 6e.
|
||||
const siteHostEsc = window.location.host.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const siteItemRx = new RegExp(
|
||||
`https?:\/\/${siteHostEsc}\/(\\d+)(?=[\\s<"']|$)`,
|
||||
'gi'
|
||||
);
|
||||
html = html.replace(siteItemRx, (match, itemId) =>
|
||||
`<span class="gchat-item-embed gchat-post-card gchat-post-card--loading" data-item-id="${itemId}">` +
|
||||
`<span class="gchat-post-card__thumb-wrap"><span class="gchat-post-card__thumb-placeholder"><i class="fa-solid fa-spinner fa-spin"></i></span></span>` +
|
||||
`<span class="gchat-post-card__info"><span class="gchat-post-card__id">#${itemId}</span></span>` +
|
||||
`</span>`
|
||||
);
|
||||
|
||||
// 6e. Remaining https URLs → embed media if from allowed host, else plain link
|
||||
html = html.replace(/(^|[\s>])(https?:\/\/[^\s<"]+)/g, (match, pre, url) => {
|
||||
// Don't double-wrap already-embedded URLs
|
||||
if (match.includes('<img') || match.includes('<video') || match.includes('<audio') || match.includes('<iframe') || match.includes('gchat-yt-card')) return match;
|
||||
// Skip URLs already embedded by earlier steps
|
||||
if (match.includes('<img') || match.includes('<video') || match.includes('<audio') ||
|
||||
match.includes('<iframe') || match.includes('gchat-yt-card') || match.includes('gchat-item-embed'))
|
||||
return match;
|
||||
|
||||
// Use URL API for reliable host + extension detection
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
const host = urlObj.host;
|
||||
const path = urlObj.pathname;
|
||||
// Derive CDN host from window.f0ckMediaBase (may be on a different subdomain in prod)
|
||||
let mediaHost = '';
|
||||
try { mediaHost = new URL(window.f0ckMediaBase || '').host; } catch (_) {}
|
||||
const isSameSite = host === window.location.host;
|
||||
const isMediaHost = !!mediaHost && host === mediaHost;
|
||||
const isAllowedHoster = !isSameSite && !isMediaHost && (window.f0ckAllowedImages || []).some(h =>
|
||||
host === h || host.endsWith('.' + h)
|
||||
);
|
||||
if (isSameSite || isMediaHost || isAllowedHoster) {
|
||||
if (/\.(mp4|webm|ogv|mov)(\?.*)?$/i.test(path))
|
||||
return `${pre}<span class="gchat-embed-video"><video src="${url}" controls loop muted playsinline preload="metadata"></video></span>`;
|
||||
if (/\.(jpg|jpeg|png|gif|webp)(\?.*)?$/i.test(path))
|
||||
return `${pre}<span class="gchat-embed-img"><img src="${url}" loading="lazy" alt="" onerror="this.parentNode.style.display='none'"></span>`;
|
||||
if (/\.(mp3|ogg|wav|flac|aac|opus|m4a)(\?.*)?$/i.test(path))
|
||||
return `${pre}<span class="gchat-embed-audio"><audio src="${url}" controls preload="metadata"></audio></span>`;
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
return `${pre}<a href="${url}" target="_blank" rel="noopener noreferrer">${url}<i class="fa-solid fa-arrow-up-right-from-square" style="font-size:0.7em;margin-left:3px;opacity:0.6"></i></a>`;
|
||||
});
|
||||
|
||||
@@ -277,6 +331,70 @@
|
||||
if (authorEl) authorEl.textContent = meta.author_name || '';
|
||||
}
|
||||
|
||||
// Resolve same-site item links → post preview card
|
||||
const itemPreviewCache = new Map(); // id → { item, meta } | null
|
||||
async function fetchItemPreview(wrapEl) {
|
||||
const id = wrapEl.dataset.itemId;
|
||||
if (!id) return;
|
||||
|
||||
let cached = itemPreviewCache.get(id);
|
||||
if (cached === undefined) {
|
||||
try {
|
||||
const [itemRes, metaRes] = await Promise.all([
|
||||
fetch(`/api/v2/item/${id}`),
|
||||
fetch(`/api/v2/scroller/meta?ids=${id}`)
|
||||
]);
|
||||
const itemData = await itemRes.json();
|
||||
const metaData = await metaRes.json();
|
||||
const item = (itemData.success && itemData.rows) ? itemData.rows : null;
|
||||
const meta = metaData[id] || null;
|
||||
cached = item ? { item, meta } : null;
|
||||
} catch (_) {
|
||||
cached = null;
|
||||
}
|
||||
itemPreviewCache.set(id, cached);
|
||||
}
|
||||
|
||||
if (!cached) {
|
||||
// Fallback: plain link
|
||||
const link = document.createElement('a');
|
||||
link.href = `/${id}`;
|
||||
link.target = '_blank';
|
||||
link.rel = 'noopener';
|
||||
link.textContent = `#${id}`;
|
||||
wrapEl.replaceWith(link);
|
||||
return;
|
||||
}
|
||||
|
||||
const { item, meta } = cached;
|
||||
const commentCount = meta ? (meta.comment_count || 0) : 0;
|
||||
const uploader = esc(item.username || 'unknown');
|
||||
const mime = item.mime || '';
|
||||
const thumbSrc = `/t/${id}.webp`;
|
||||
|
||||
// Media type badge
|
||||
let typeBadge = '';
|
||||
if (mime.startsWith('video/')) typeBadge = '<i class="fa-solid fa-film"></i>';
|
||||
else if (mime.startsWith('audio/')) typeBadge = '<i class="fa-solid fa-music"></i>';
|
||||
else if (mime.startsWith('image/')) typeBadge = '<i class="fa-solid fa-image"></i>';
|
||||
|
||||
const card = document.createElement('a');
|
||||
card.className = 'gchat-post-card';
|
||||
card.href = `/${id}`;
|
||||
card.innerHTML =
|
||||
`<span class="gchat-post-card__thumb-wrap">` +
|
||||
`<img class="gchat-post-card__thumb" src="${esc(thumbSrc)}" alt="#${id}" loading="lazy" onerror="this.style.display='none'">` +
|
||||
(typeBadge ? `<span class="gchat-post-card__type-badge">${typeBadge}</span>` : '') +
|
||||
`</span>` +
|
||||
`<span class="gchat-post-card__info">` +
|
||||
`<span class="gchat-post-card__id">#${id}</span>` +
|
||||
`<span class="gchat-post-card__uploader"><i class="fa-solid fa-user"></i> ${uploader}</span>` +
|
||||
`<span class="gchat-post-card__comments"><i class="fa-solid fa-comment"></i> ${commentCount}</span>` +
|
||||
`</span>`;
|
||||
|
||||
wrapEl.replaceWith(card);
|
||||
}
|
||||
|
||||
function appendMsg(msg, scrollForce = false) {
|
||||
const container = document.getElementById('gchat-messages');
|
||||
if (!container) return;
|
||||
@@ -301,6 +419,8 @@
|
||||
|
||||
// Wire up YouTube oEmbed cards
|
||||
node.querySelectorAll('.gchat-yt-card[data-yt-id]').forEach(fetchYtOembed);
|
||||
// Wire up same-site item embeds
|
||||
node.querySelectorAll('.gchat-item-embed[data-item-id]').forEach(fetchItemPreview);
|
||||
|
||||
scrollToBottom(scrollForce);
|
||||
|
||||
@@ -772,9 +892,9 @@
|
||||
let _sseReady = false;
|
||||
|
||||
function setConnecting(connecting) {
|
||||
if (_inputArea) _inputArea.style.opacity = connecting ? '0.35' : '';
|
||||
if (_inputArea) _inputArea.style.pointerEvents = connecting ? 'none' : '';
|
||||
if (_messages) _messages.style.opacity = connecting ? '0.35' : '';
|
||||
if (_inputArea) _inputArea.style.opacity = connecting ? '0.35' : '1';
|
||||
if (_inputArea) _inputArea.style.pointerEvents = connecting ? 'none' : '';
|
||||
if (_messages) _messages.style.opacity = connecting ? '0.35' : '1';
|
||||
const sendBtn = document.getElementById('gchat-send-btn');
|
||||
if (sendBtn) sendBtn.disabled = connecting;
|
||||
}
|
||||
@@ -783,12 +903,39 @@
|
||||
const onSseReady = () => {
|
||||
if (_sseReady) return;
|
||||
_sseReady = true;
|
||||
clearTimeout(_sseUnlockTimer);
|
||||
clearInterval(_pollFallback);
|
||||
setConnecting(false);
|
||||
};
|
||||
document.addEventListener('f0ck:sse_ready', onSseReady);
|
||||
// Failsafe: if SSE already fired before this ran (e.g. fast reconnect), check flag
|
||||
if (document.documentElement.dataset.sseReady === '1') onSseReady();
|
||||
|
||||
// Absolute fallback: if SSE hasn't connected within 10s, unlock anyway and poll
|
||||
let _pollFallback = null;
|
||||
const _sseUnlockTimer = setTimeout(() => {
|
||||
if (_sseReady) return;
|
||||
console.warn('[Chat] SSE not ready after 10s — unlocking in polling mode');
|
||||
setConnecting(false);
|
||||
// Poll /api/chat every 15s as fallback so messages still appear
|
||||
_pollFallback = setInterval(async () => {
|
||||
if (_sseReady) { clearInterval(_pollFallback); return; }
|
||||
try {
|
||||
const res = await fetch('/api/chat', { headers: { 'X-Requested-With': 'XMLHttpRequest' } });
|
||||
const data = await res.json();
|
||||
if (!data.success) return;
|
||||
const container = document.getElementById('gchat-messages');
|
||||
if (!container) return;
|
||||
const lastId = parseInt(container.dataset.lastPollId || '0', 10);
|
||||
const newMsgs = (data.messages || []).filter(m => m.id > lastId);
|
||||
if (newMsgs.length) {
|
||||
newMsgs.forEach(m => appendMsg(m, false));
|
||||
container.dataset.lastPollId = String(Math.max(...newMsgs.map(m => m.id)));
|
||||
}
|
||||
} catch (_) {}
|
||||
}, 15000);
|
||||
}, 10000);
|
||||
|
||||
const textarea = document.getElementById('gchat-input');
|
||||
|
||||
|
||||
@@ -1193,6 +1340,34 @@
|
||||
applyTopic(e.detail?.topic || null);
|
||||
});
|
||||
|
||||
// ── Online users bar ─────────────────────────────────────────────────
|
||||
function renderOnline(users) {
|
||||
const el = document.getElementById('gchat-online');
|
||||
if (!el) return;
|
||||
if (!users || users.length === 0) {
|
||||
el.innerHTML = '';
|
||||
el.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
el.style.display = 'block';
|
||||
// Show up to 8 avatars, then "+N more" pill
|
||||
const MAX_SHOWN = 8;
|
||||
const shown = users.slice(0, MAX_SHOWN);
|
||||
const extra = users.length - shown.length;
|
||||
const avatarHTML = shown.map(u => {
|
||||
const name = esc(u.display_name || u.username || '?');
|
||||
const src = avatarSrc(u);
|
||||
const colorStyle = u.username_color ? `style="border-color:${esc(u.username_color)}"` : '';
|
||||
return `<img src="${src}" class="gchat-online-avatar" title="${name}" alt="${name}" loading="lazy" ${colorStyle}>`;
|
||||
}).join('');
|
||||
const extraPill = extra > 0 ? `<span class="gchat-online-extra">+${extra}</span>` : '';
|
||||
const countLabel = `<span class="gchat-online-count">${users.length} online</span>`;
|
||||
el.innerHTML = `<div class="gchat-online-inner">${countLabel}<div class="gchat-online-avatars">${avatarHTML}${extraPill}</div></div>`;
|
||||
}
|
||||
document.addEventListener('f0ck:global_chat_presence', (e) => {
|
||||
renderOnline(e.detail?.users || []);
|
||||
});
|
||||
|
||||
// Event delegation: reply + admin delete buttons inside #gchat-messages
|
||||
const msgArea = document.getElementById('gchat-messages');
|
||||
const csrf = () => window.f0ckSession?.csrf_token;
|
||||
|
||||
@@ -304,6 +304,7 @@ if (window.__dmLoaded) {
|
||||
let threadHasMore = false;
|
||||
const renderedIds = new Set();
|
||||
let threadMessages = []; // Cache for re-rendering (e.g. emojis)
|
||||
const dmPostPreviewCache = new Map(); // itemId → { item, meta } | null
|
||||
|
||||
// Title management — global across all pages
|
||||
let _dmTitleCount = 0;
|
||||
@@ -693,9 +694,99 @@ if (window.__dmLoaded) {
|
||||
|
||||
const time = timeAgo(m.created_at);
|
||||
div.innerHTML = `<div class="dm-bubble comment-content">${content}</div><span class="dm-msg-time" data-ts="${escHtml(m.created_at)}">${escHtml(time)}</span>`;
|
||||
|
||||
// Async: extract post IDs from raw plaintext and inject preview cards into the bubble
|
||||
if (m.plaintext) resolvePostPreviews(div, m.plaintext);
|
||||
|
||||
return div;
|
||||
}
|
||||
|
||||
// ── Post link preview cards ───────────────────────────────────────────────
|
||||
// Extracts item IDs from the raw plaintext (immune to rendering pipeline
|
||||
// variations: marked / commentSystem / plain-text fallback), then appends
|
||||
// a preview card below the bubble content for each unique ID found.
|
||||
async function resolvePostPreviews(msgDiv, plaintext) {
|
||||
const bubble = msgDiv.querySelector('.dm-bubble');
|
||||
if (!bubble) return;
|
||||
|
||||
// Match bare /12345 and full same-site URLs like https://site.com/12345
|
||||
const siteOriginEsc = window.location.origin.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const itemRx = new RegExp(
|
||||
`(?:${siteOriginEsc})?\\/(\\d+)(?=[\\s,!?\"'\\)\\]<]|$)`,
|
||||
'g'
|
||||
);
|
||||
|
||||
const seen = new Set();
|
||||
let match;
|
||||
while ((match = itemRx.exec(plaintext)) !== null) {
|
||||
const id = match[1];
|
||||
if (!seen.has(id)) seen.add(id);
|
||||
}
|
||||
if (!seen.size) return;
|
||||
|
||||
for (const id of seen) {
|
||||
// Insert loading placeholder card below the bubble text
|
||||
const placeholder = document.createElement('span');
|
||||
placeholder.className = 'dm-post-card dm-post-card--loading';
|
||||
placeholder.innerHTML =
|
||||
`<span class="dm-post-card__thumb-wrap"><span class="dm-post-card__thumb-placeholder"><i class="fa-solid fa-spinner fa-spin"></i></span></span>`+
|
||||
`<span class="dm-post-card__info"><span class="dm-post-card__id">#${id}</span></span>`;
|
||||
bubble.appendChild(placeholder);
|
||||
|
||||
// Fetch item info and meta (with cache)
|
||||
let cached = dmPostPreviewCache.get(id);
|
||||
if (cached === undefined) {
|
||||
try {
|
||||
const [itemRes, metaRes] = await Promise.all([
|
||||
fetch(`/api/v2/item/${id}`),
|
||||
fetch(`/api/v2/scroller/meta?ids=${id}`)
|
||||
]);
|
||||
const itemData = await itemRes.json();
|
||||
const metaData = await metaRes.json();
|
||||
const item = (itemData.success && itemData.rows) ? itemData.rows : null;
|
||||
const meta = metaData[id] || null;
|
||||
cached = item ? { item, meta } : null;
|
||||
} catch (_) {
|
||||
cached = null;
|
||||
}
|
||||
dmPostPreviewCache.set(id, cached);
|
||||
}
|
||||
|
||||
if (!cached) {
|
||||
placeholder.remove(); // no item found — silently drop
|
||||
continue;
|
||||
}
|
||||
|
||||
const { item, meta } = cached;
|
||||
const commentCount = meta ? (meta.comment_count || 0) : 0;
|
||||
const uploader = escHtml(item.username || 'unknown');
|
||||
const mime = item.mime || '';
|
||||
const thumbSrc = `/t/${id}.webp`;
|
||||
|
||||
// Media type badge
|
||||
let typeBadge = '';
|
||||
if (mime.startsWith('video/')) typeBadge = '<i class="fa-solid fa-film"></i>';
|
||||
else if (mime.startsWith('audio/')) typeBadge = '<i class="fa-solid fa-music"></i>';
|
||||
else if (mime.startsWith('image/')) typeBadge = '<i class="fa-solid fa-image"></i>';
|
||||
|
||||
const card = document.createElement('a');
|
||||
card.className = 'dm-post-card';
|
||||
card.href = `/${id}`;
|
||||
card.innerHTML =
|
||||
`<span class="dm-post-card__thumb-wrap">`+
|
||||
`<img class="dm-post-card__thumb" src="${escHtml(thumbSrc)}" alt="#${id}" loading="lazy" onerror="this.style.display='none'">`+
|
||||
(typeBadge ? `<span class="dm-post-card__type-badge">${typeBadge}</span>` : '') +
|
||||
`</span>`+
|
||||
`<span class="dm-post-card__info">`+
|
||||
`<span class="dm-post-card__id">#${id}</span>`+
|
||||
`<span class="dm-post-card__uploader"><i class="fa-solid fa-user"></i> ${uploader}</span>`+
|
||||
`<span class="dm-post-card__comments"><i class="fa-solid fa-comment"></i> ${commentCount}</span>`+
|
||||
`</span>`;
|
||||
|
||||
placeholder.replaceWith(card);
|
||||
}
|
||||
}
|
||||
|
||||
let sendInFlight = false; // debounce guard against double-submit
|
||||
|
||||
function setupDmEmojiPicker() {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,20 +1,20 @@
|
||||
const Cookie = {
|
||||
get: name => {
|
||||
const c = document.cookie.match(`(?:(?:^|.*; *)${name} *= *([^;]*).*$)|^.*$`)[1];
|
||||
if (c) return decodeURIComponent(c);
|
||||
},
|
||||
set: (name, value, opts = {}) => {
|
||||
if (opts.days) {
|
||||
opts['max-age'] = opts.days * 60 * 60 * 24;
|
||||
delete opts.days;
|
||||
}
|
||||
opts.SameSite = 'Strict';
|
||||
opts = Object.entries(opts).reduce((accumulatedStr, [k, v]) => `${accumulatedStr}; ${k}=${v}`, '');
|
||||
document.cookie = name + '=' + encodeURIComponent(value) + opts;
|
||||
}
|
||||
};
|
||||
|
||||
(() => {
|
||||
const Cookie = {
|
||||
get: name => {
|
||||
const c = document.cookie.match(`(?:(?:^|.*; *)${name} *= *([^;]*).*$)|^.*$`)[1];
|
||||
if (c) return decodeURIComponent(c);
|
||||
},
|
||||
set: (name, value, opts = {}) => {
|
||||
if (opts.days) {
|
||||
opts['max-age'] = opts.days * 60 * 60 * 24;
|
||||
delete opts.days;
|
||||
}
|
||||
opts.SameSite = 'Strict';
|
||||
opts = Object.entries(opts).reduce((accumulatedStr, [k, v]) => `${accumulatedStr}; ${k}=${v}`, '');
|
||||
document.cookie = name + '=' + encodeURIComponent(value) + opts;
|
||||
}
|
||||
};
|
||||
|
||||
const themes = window.f0ckThemes || ['amoled', 'atmos', 'f0ck', 'f0ck95d', 'iced', 'orange', 'p1nk', '4d'];
|
||||
const defaultTheme = window.f0ckDefaultTheme || (window.f0ckSession && window.f0ckSession.default_theme) || themes[0] || 'amoled';
|
||||
|
||||
@@ -43,8 +43,8 @@ const Cookie = {
|
||||
e.preventDefault();
|
||||
cycleTheme();
|
||||
const newTheme = document.documentElement.getAttribute('theme') || defaultTheme;
|
||||
// Use scroller toast if available, otherwise site-wide flashMessage
|
||||
if (typeof window._scrollerThemeToast === 'function') {
|
||||
// Use scroller toast only when scroller is actually active
|
||||
if (document.body.classList.contains('scroller-active') && typeof window._scrollerThemeToast === 'function') {
|
||||
window._scrollerThemeToast(newTheme);
|
||||
} else if (typeof window.flashMessage === 'function') {
|
||||
window.flashMessage(`Theme: ${newTheme}`, 2000);
|
||||
|
||||
Reference in New Issue
Block a user