Update base
This commit is contained in:
0
deleted/.gitkeep
Normal file
0
deleted/.gitkeep
Normal file
0
pending/.gitkeep
Normal file
0
pending/.gitkeep
Normal file
0
pending/ca/.gitkeep
Normal file
0
pending/ca/.gitkeep
Normal file
0
pending/t/.gitkeep
Normal file
0
pending/t/.gitkeep
Normal file
BIN
public/memes/orakel_mystic.png
Normal file
BIN
public/memes/orakel_mystic.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 749 KiB |
BIN
public/memes/user_orakel.png
Normal file
BIN
public/memes/user_orakel.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 814 KiB |
BIN
public/memes/von10_orakel.png
Normal file
BIN
public/memes/von10_orakel.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 916 KiB |
@@ -966,23 +966,114 @@ html[theme="4d"] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.notif-header {
|
.notif-header {
|
||||||
padding: 10px;
|
padding: 8px 10px 6px;
|
||||||
border-bottom: 1px solid var(--nav-border-color);
|
border-bottom: 1px solid var(--nav-border-color);
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
color: var(--white);
|
color: var(--white);
|
||||||
background: var(--nav-bg);
|
background: var(--nav-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.notif-header button {
|
.notif-header button#mark-all-read {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.8rem;
|
font-size: 0.7rem;
|
||||||
padding: 0;
|
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 {
|
.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 {
|
.embed-responsive-image {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
@@ -10110,6 +10264,223 @@ body.layout-modern .tag-controls {
|
|||||||
max-width: 200px;
|
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 */
|
/* Köpfe — random corner image */
|
||||||
#koepfe-img {
|
#koepfe-img {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@@ -10909,7 +11280,7 @@ textarea#profile_description {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.meta-suggestion:hover {
|
.meta-suggestion:hover {
|
||||||
background: var(--accent);
|
background: var(--bg);
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
color: #000 !important;
|
color: #000 !important;
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
@@ -11101,6 +11472,23 @@ body.scroller-active #gchat-widget {
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: scale(1.08);
|
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 {
|
body.sidebar-right-hidden #gchat-reopen-bubble {
|
||||||
right: 18px;
|
right: 18px;
|
||||||
}
|
}
|
||||||
@@ -11226,6 +11614,68 @@ body.scroller-active #gchat-reopen-bubble {
|
|||||||
|
|
||||||
/* Messages area */
|
/* Messages area */
|
||||||
/* Pinned topic bar */
|
/* 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 {
|
#gchat-topic {
|
||||||
padding: 5px 10px;
|
padding: 5px 10px;
|
||||||
font-size: 0.78em;
|
font-size: 0.78em;
|
||||||
@@ -11691,7 +12141,7 @@ body.scroller-active #gchat-reopen-bubble {
|
|||||||
|
|
||||||
.gchat-embed-video video {
|
.gchat-embed-video video {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
max-height: 180px;
|
max-height: 350px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
@@ -11831,3 +12281,245 @@ body.scroller-active #gchat-reopen-bubble {
|
|||||||
cursor: default;
|
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) ---
|
// --- Gesture Support (Mobile & Desktop) ---
|
||||||
// Inject HUD and Overlay
|
// Inject HUD, Overlay, and Danmaku toggle
|
||||||
const existingHUD = container.querySelector('.v0ck_hud');
|
const existingHUD = container.querySelector('.v0ck_hud');
|
||||||
if (!existingHUD) {
|
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', `
|
container.insertAdjacentHTML('beforeend', `
|
||||||
<div class="v0ck_hud v0ck_hidden" style="z-index: 10000;">
|
<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>
|
<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>
|
</div>
|
||||||
<div class="ruffle-gesture-overlay"></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');
|
const hud = container.querySelector('.v0ck_hud');
|
||||||
@@ -1379,7 +1429,7 @@ window.cancelAnimFrame = (function () {
|
|||||||
// that were applied so the main-site layout is fully restored.
|
// that were applied so the main-site layout is fully restored.
|
||||||
if (document.body.classList.contains('scroller-active')) {
|
if (document.body.classList.contains('scroller-active')) {
|
||||||
document.head.querySelectorAll('[data-pjax-abyss]').forEach(el => el.remove());
|
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
|
// Restore body
|
||||||
['overflow', 'background', 'height'].forEach(p => document.body.style.removeProperty(p));
|
['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)
|
// Restore #main (element persists across PJAX, its inline styles must be cleared)
|
||||||
const _m = document.getElementById('main');
|
const _m = document.getElementById('main');
|
||||||
if (_m) ['height', 'overflow'].forEach(p => _m.style.removeProperty(p));
|
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
|
// Immediately close image modal on any navigation
|
||||||
@@ -2224,7 +2278,7 @@ window.cancelAnimFrame = (function () {
|
|||||||
if (visualizerRafId) window.cancelAnimFrame(visualizerRafId);
|
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 => {
|
media.forEach(m => {
|
||||||
try {
|
try {
|
||||||
@@ -2301,6 +2355,26 @@ window.cancelAnimFrame = (function () {
|
|||||||
if (isNavigating) return;
|
if (isNavigating) return;
|
||||||
isNavigating = true;
|
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
|
// Dispatch pjax:start so navigation-aware listeners (e.g. metadata modal, image modal) can react
|
||||||
if (!options.keepMedia) {
|
if (!options.keepMedia) {
|
||||||
window.dispatchEvent(new Event('pjax:start'));
|
window.dispatchEvent(new Event('pjax:start'));
|
||||||
@@ -3059,6 +3133,25 @@ window.cancelAnimFrame = (function () {
|
|||||||
const url = window.location.href;
|
const url = window.location.href;
|
||||||
const p = window.location.pathname;
|
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
|
// Item detection logic MUST match loadPageAjax/loadItemAjax analysis
|
||||||
// Priorities: Item first, then Special/Grid
|
// Priorities: Item first, then Special/Grid
|
||||||
const parts = p.split('/').filter(Boolean);
|
const parts = p.split('/').filter(Boolean);
|
||||||
@@ -3458,6 +3551,11 @@ window.cancelAnimFrame = (function () {
|
|||||||
params.append('strict', '1');
|
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()}` :
|
const fetchUrl = ctx.notif ? `/ajax/notifications?${params.toString()}` :
|
||||||
ctx.tags ? `/api/tags?ajax=true&${params.toString()}` :
|
ctx.tags ? `/api/tags?ajax=true&${params.toString()}` :
|
||||||
ctx.subs ? `/ajax/subscriptions?${params.toString()}` :
|
ctx.subs ? `/ajax/subscriptions?${params.toString()}` :
|
||||||
@@ -3551,6 +3649,11 @@ window.cancelAnimFrame = (function () {
|
|||||||
params.append('strict', '1');
|
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()}` :
|
const fetchUrl = ctx.notif ? `/ajax/notifications?${params.toString()}` :
|
||||||
ctx.tags ? `/api/tags?ajax=true&${params.toString()}` :
|
ctx.tags ? `/api/tags?ajax=true&${params.toString()}` :
|
||||||
ctx.subs ? `/ajax/subscriptions?${params.toString()}` :
|
ctx.subs ? `/ajax/subscriptions?${params.toString()}` :
|
||||||
@@ -4971,6 +5074,10 @@ if (sbtForm) {
|
|||||||
|
|
||||||
// Notification System
|
// Notification System
|
||||||
class NotificationSystem {
|
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() {
|
constructor() {
|
||||||
this.bell = document.getElementById('nav-notif-btn');
|
this.bell = document.getElementById('nav-notif-btn');
|
||||||
this.dropdown = document.getElementById('notif-dropdown');
|
this.dropdown = document.getElementById('notif-dropdown');
|
||||||
@@ -4981,6 +5088,9 @@ class NotificationSystem {
|
|||||||
this.retryCount = 0;
|
this.retryCount = 0;
|
||||||
this.maxRetries = 20; // Increased retries
|
this.maxRetries = 20; // Increased retries
|
||||||
this.pendingNotifIds = new Set(); // item IDs notified before thumbnail was in the grid
|
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
|
// Generate/retrieve unique tab ID
|
||||||
this.tabId = sessionStorage.getItem('f0ck_tab_id');
|
this.tabId = sessionStorage.getItem('f0ck_tab_id');
|
||||||
@@ -5086,6 +5196,12 @@ class NotificationSystem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.log("[NotificationSystem] Tab visible, signaling active...");
|
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.pollDebounced) this.pollDebounced();
|
||||||
if (this.checkForNewItems) this.checkForNewItems();
|
if (this.checkForNewItems) this.checkForNewItems();
|
||||||
// Catch-up on emojis if they were updated while this tab was pruned/backgrounded
|
// 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 }));
|
document.dispatchEvent(new CustomEvent('f0ck:global_chat_background', { detail: data.data }));
|
||||||
} else if (data.type === 'global_chat_topic') {
|
} else if (data.type === 'global_chat_topic') {
|
||||||
document.dispatchEvent(new CustomEvent('f0ck:global_chat_topic', { detail: data.data }));
|
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) {
|
} catch (err) {
|
||||||
console.error('SSE data parse error', err);
|
console.error('SSE data parse error', err);
|
||||||
@@ -5385,20 +5503,23 @@ class NotificationSystem {
|
|||||||
this.es = null;
|
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) {
|
if (document.hidden) {
|
||||||
console.log("[NotificationSystem] Tab hidden, suspended SSE retries.");
|
console.log("[NotificationSystem] Tab hidden, deferring SSE retry until visible.");
|
||||||
return;
|
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) {
|
if (this.retryCount < this.maxRetries) {
|
||||||
const delay = Math.pow(2, this.retryCount) * 1000;
|
console.log(`[NotificationSystem] Retrying SSE in ${delay}ms... (attempt ${this.retryCount + 1}/${this.maxRetries})`);
|
||||||
console.log(`[NotificationSystem] Retrying SSE connection in ${delay}ms... (Attempt ${this.retryCount + 1}/${this.maxRetries})`);
|
|
||||||
setTimeout(() => this.initSSE(), delay);
|
setTimeout(() => this.initSSE(), delay);
|
||||||
this.retryCount++;
|
this.retryCount++;
|
||||||
} else {
|
} 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());
|
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)
|
// Single Notification Click Handler (Delegated)
|
||||||
// Handles both Dropdown and History Page
|
// Handles both Dropdown and History Page
|
||||||
const handleNotificationClick = (e) => {
|
const handleNotificationClick = (e) => {
|
||||||
@@ -5706,30 +5846,45 @@ class NotificationSystem {
|
|||||||
updateUI(notifications) {
|
updateUI(notifications) {
|
||||||
if (!this.countBadge || !this.list) return;
|
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) {
|
const userUnread = this._cachedUser.filter(n => !n.is_read).length;
|
||||||
this.countBadge.textContent = unreadCount;
|
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';
|
this.countBadge.style.display = 'block';
|
||||||
} else {
|
} else {
|
||||||
this.countBadge.style.display = 'none';
|
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
|
// Forward count to Abyss scroller notification badge if active
|
||||||
if (typeof window._scrollerNotifHook === 'function') {
|
if (typeof window._scrollerNotifHook === 'function') {
|
||||||
window._scrollerNotifHook(unreadCount);
|
window._scrollerNotifHook(totalUnread);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sync .has-notif highlights on main grid thumbnails for all unread notifications.
|
// 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 currentPath = window.location.pathname;
|
||||||
const unreadItemIds = new Set();
|
const unreadItemIds = new Set();
|
||||||
notifications.forEach(n => {
|
notifications.forEach(n => {
|
||||||
if (!n.is_read && n.item_id) {
|
if (!n.is_read && n.item_id) {
|
||||||
unreadItemIds.add(String(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;
|
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 => {
|
document.querySelectorAll(`a.thumb[href$="/${n.item_id}"], a.lazy-thumb[href$="/${n.item_id}"]`).forEach(el => {
|
||||||
el.classList.add('has-notif');
|
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.
|
// 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 => {
|
document.querySelectorAll('a.thumb.has-notif, a.lazy-thumb.has-notif').forEach(el => {
|
||||||
const match = el.getAttribute('href')?.match(/\/(\d+)$/);
|
const match = el.getAttribute('href')?.match(/\/(\d+)$/);
|
||||||
if (match && !unreadItemIds.has(match[1])) {
|
if (match && !unreadItemIds.has(match[1])) {
|
||||||
@@ -5747,26 +5900,23 @@ class NotificationSystem {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (notifications.length === 0) {
|
// Render the active tab
|
||||||
this.list.innerHTML = `<div class="notif-empty">${window.f0ckI18n?.no_notifications || 'No new notifications'}</div>`;
|
this._renderActiveTab();
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.list.innerHTML = Sanitizer.clean(notifications.map(n => this.renderItem(n)).join(''));
|
|
||||||
|
|
||||||
// Live update for History Page
|
// Live update for History Page
|
||||||
const historyContainer = document.querySelector('.notifications-list-full');
|
const historyContainer = document.querySelector('.notifications-list-full');
|
||||||
if (historyContainer) {
|
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}"]`);
|
const existing = historyContainer.querySelector(`.notif-item[data-id="${n.id}"]`);
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
console.log("[NotificationSystem] Adding new item to history:", n.id);
|
console.log("[NotificationSystem] Adding new item to history:", n.id);
|
||||||
const html = this.renderHistoryItem(n);
|
const html = this.renderHistoryItem(n);
|
||||||
// Create temp container to turn string into node
|
|
||||||
const temp = document.createElement('div');
|
const temp = document.createElement('div');
|
||||||
temp.innerHTML = Sanitizer.clean(html);
|
temp.innerHTML = Sanitizer.clean(html);
|
||||||
const node = temp.firstElementChild;
|
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);
|
historyContainer.prepend(node);
|
||||||
} else {
|
} else {
|
||||||
console.log("[NotificationSystem] Item already exists:", n.id);
|
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) {
|
renderHistoryItem(n) {
|
||||||
let link = `/${n.item_id}`;
|
let link = `/${n.item_id}`;
|
||||||
let msg = '';
|
let msg = '';
|
||||||
@@ -5992,6 +6152,14 @@ class NotificationSystem {
|
|||||||
|
|
||||||
markAllReadUI() {
|
markAllReadUI() {
|
||||||
this.countBadge.style.display = 'none';
|
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) {
|
if (this.list) {
|
||||||
this.list.innerHTML = `<div class="notif-empty">${window.f0ckI18n?.no_notifications || 'No new notifications'}</div>`;
|
this.list.innerHTML = `<div class="notif-empty">${window.f0ckI18n?.no_notifications || 'No new notifications'}</div>`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,12 +25,25 @@
|
|||||||
|
|
||||||
function updateBadge() {
|
function updateBadge() {
|
||||||
const badge = document.getElementById('gchat-badge');
|
const badge = document.getElementById('gchat-badge');
|
||||||
if (!badge) return;
|
const bubble = document.getElementById('gchat-reopen-bubble');
|
||||||
if (unreadCount > 0) {
|
if (unreadCount > 0) {
|
||||||
badge.textContent = unreadCount > 99 ? '99+' : unreadCount;
|
const label = unreadCount > 99 ? '99+' : String(unreadCount);
|
||||||
badge.style.display = 'inline-flex';
|
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 {
|
} 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>
|
</div>
|
||||||
<div id="gchat-topic" style="display:none"></div>
|
<div id="gchat-topic" style="display:none"></div>
|
||||||
|
<div id="gchat-online"></div>
|
||||||
<div id="gchat-messages"></div>
|
<div id="gchat-messages"></div>
|
||||||
<div id="gchat-input-area">
|
<div id="gchat-input-area">
|
||||||
<div id="gchat-toolbar">
|
<div id="gchat-toolbar">
|
||||||
@@ -211,10 +225,50 @@
|
|||||||
`</a>`;
|
`</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) => {
|
html = html.replace(/(^|[\s>])(https?:\/\/[^\s<"]+)/g, (match, pre, url) => {
|
||||||
// Don't double-wrap already-embedded URLs
|
// 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')) return match;
|
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>`;
|
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 || '';
|
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) {
|
function appendMsg(msg, scrollForce = false) {
|
||||||
const container = document.getElementById('gchat-messages');
|
const container = document.getElementById('gchat-messages');
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
@@ -301,6 +419,8 @@
|
|||||||
|
|
||||||
// Wire up YouTube oEmbed cards
|
// Wire up YouTube oEmbed cards
|
||||||
node.querySelectorAll('.gchat-yt-card[data-yt-id]').forEach(fetchYtOembed);
|
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);
|
scrollToBottom(scrollForce);
|
||||||
|
|
||||||
@@ -772,9 +892,9 @@
|
|||||||
let _sseReady = false;
|
let _sseReady = false;
|
||||||
|
|
||||||
function setConnecting(connecting) {
|
function setConnecting(connecting) {
|
||||||
if (_inputArea) _inputArea.style.opacity = connecting ? '0.35' : '';
|
if (_inputArea) _inputArea.style.opacity = connecting ? '0.35' : '1';
|
||||||
if (_inputArea) _inputArea.style.pointerEvents = connecting ? 'none' : '';
|
if (_inputArea) _inputArea.style.pointerEvents = connecting ? 'none' : '';
|
||||||
if (_messages) _messages.style.opacity = connecting ? '0.35' : '';
|
if (_messages) _messages.style.opacity = connecting ? '0.35' : '1';
|
||||||
const sendBtn = document.getElementById('gchat-send-btn');
|
const sendBtn = document.getElementById('gchat-send-btn');
|
||||||
if (sendBtn) sendBtn.disabled = connecting;
|
if (sendBtn) sendBtn.disabled = connecting;
|
||||||
}
|
}
|
||||||
@@ -783,12 +903,39 @@
|
|||||||
const onSseReady = () => {
|
const onSseReady = () => {
|
||||||
if (_sseReady) return;
|
if (_sseReady) return;
|
||||||
_sseReady = true;
|
_sseReady = true;
|
||||||
|
clearTimeout(_sseUnlockTimer);
|
||||||
|
clearInterval(_pollFallback);
|
||||||
setConnecting(false);
|
setConnecting(false);
|
||||||
};
|
};
|
||||||
document.addEventListener('f0ck:sse_ready', onSseReady);
|
document.addEventListener('f0ck:sse_ready', onSseReady);
|
||||||
// Failsafe: if SSE already fired before this ran (e.g. fast reconnect), check flag
|
// Failsafe: if SSE already fired before this ran (e.g. fast reconnect), check flag
|
||||||
if (document.documentElement.dataset.sseReady === '1') onSseReady();
|
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');
|
const textarea = document.getElementById('gchat-input');
|
||||||
|
|
||||||
|
|
||||||
@@ -1193,6 +1340,34 @@
|
|||||||
applyTopic(e.detail?.topic || null);
|
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
|
// Event delegation: reply + admin delete buttons inside #gchat-messages
|
||||||
const msgArea = document.getElementById('gchat-messages');
|
const msgArea = document.getElementById('gchat-messages');
|
||||||
const csrf = () => window.f0ckSession?.csrf_token;
|
const csrf = () => window.f0ckSession?.csrf_token;
|
||||||
|
|||||||
@@ -304,6 +304,7 @@ if (window.__dmLoaded) {
|
|||||||
let threadHasMore = false;
|
let threadHasMore = false;
|
||||||
const renderedIds = new Set();
|
const renderedIds = new Set();
|
||||||
let threadMessages = []; // Cache for re-rendering (e.g. emojis)
|
let threadMessages = []; // Cache for re-rendering (e.g. emojis)
|
||||||
|
const dmPostPreviewCache = new Map(); // itemId → { item, meta } | null
|
||||||
|
|
||||||
// Title management — global across all pages
|
// Title management — global across all pages
|
||||||
let _dmTitleCount = 0;
|
let _dmTitleCount = 0;
|
||||||
@@ -693,9 +694,99 @@ if (window.__dmLoaded) {
|
|||||||
|
|
||||||
const time = timeAgo(m.created_at);
|
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>`;
|
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;
|
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
|
let sendInFlight = false; // debounce guard against double-submit
|
||||||
|
|
||||||
function setupDmEmojiPicker() {
|
function setupDmEmojiPicker() {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,4 @@
|
|||||||
|
(() => {
|
||||||
const Cookie = {
|
const Cookie = {
|
||||||
get: name => {
|
get: name => {
|
||||||
const c = document.cookie.match(`(?:(?:^|.*; *)${name} *= *([^;]*).*$)|^.*$`)[1];
|
const c = document.cookie.match(`(?:(?:^|.*; *)${name} *= *([^;]*).*$)|^.*$`)[1];
|
||||||
@@ -14,7 +15,6 @@ const Cookie = {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
(() => {
|
|
||||||
const themes = window.f0ckThemes || ['amoled', 'atmos', 'f0ck', 'f0ck95d', 'iced', 'orange', 'p1nk', '4d'];
|
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';
|
const defaultTheme = window.f0ckDefaultTheme || (window.f0ckSession && window.f0ckSession.default_theme) || themes[0] || 'amoled';
|
||||||
|
|
||||||
@@ -43,8 +43,8 @@ const Cookie = {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
cycleTheme();
|
cycleTheme();
|
||||||
const newTheme = document.documentElement.getAttribute('theme') || defaultTheme;
|
const newTheme = document.documentElement.getAttribute('theme') || defaultTheme;
|
||||||
// Use scroller toast if available, otherwise site-wide flashMessage
|
// Use scroller toast only when scroller is actually active
|
||||||
if (typeof window._scrollerThemeToast === 'function') {
|
if (document.body.classList.contains('scroller-active') && typeof window._scrollerThemeToast === 'function') {
|
||||||
window._scrollerThemeToast(newTheme);
|
window._scrollerThemeToast(newTheme);
|
||||||
} else if (typeof window.flashMessage === 'function') {
|
} else if (typeof window.flashMessage === 'function') {
|
||||||
window.flashMessage(`Theme: ${newTheme}`, 2000);
|
window.flashMessage(`Theme: ${newTheme}`, 2000);
|
||||||
|
|||||||
@@ -13,10 +13,12 @@
|
|||||||
"mod": "mod",
|
"mod": "mod",
|
||||||
"settings": "Einstellungen",
|
"settings": "Einstellungen",
|
||||||
"logout": "Abmelden",
|
"logout": "Abmelden",
|
||||||
"notifications": "Nuttis",
|
"notifications": "Benachrichtigungen",
|
||||||
"mark_all_read": "Alle als gelesen markieren",
|
"mark_all_read": "Alle als gelesen markieren",
|
||||||
"no_notifications": "Keine neuen Nuttis",
|
"no_notifications": "Keine neuen Benachrichtigungen",
|
||||||
"view_all_notifications": "Alle Nuttis anzeigen",
|
"view_all_notifications": "Alle anzeigen",
|
||||||
|
"notif_tab_user": "Benutzer",
|
||||||
|
"notif_tab_system": "System",
|
||||||
"manage_subscriptions": "Abonnements verwalten",
|
"manage_subscriptions": "Abonnements verwalten",
|
||||||
"favorites": "Favoriten",
|
"favorites": "Favoriten",
|
||||||
"direct_messages": "Direktnachrichten",
|
"direct_messages": "Direktnachrichten",
|
||||||
@@ -265,7 +267,7 @@
|
|||||||
},
|
},
|
||||||
"comments": {
|
"comments": {
|
||||||
"write_comment": "Kommentar schreiben...",
|
"write_comment": "Kommentar schreiben...",
|
||||||
"post": "Abschnalzen",
|
"post": "Senden",
|
||||||
"cancel": "Abbrechen"
|
"cancel": "Abbrechen"
|
||||||
},
|
},
|
||||||
"upload_btn": {
|
"upload_btn": {
|
||||||
@@ -414,12 +416,13 @@
|
|||||||
"loading": "Gespräche werden geladen…",
|
"loading": "Gespräche werden geladen…",
|
||||||
"decrypting": "Nachrichten werden entschlüsselt…",
|
"decrypting": "Nachrichten werden entschlüsselt…",
|
||||||
"input_placeholder": "Nachricht schreiben…",
|
"input_placeholder": "Nachricht schreiben…",
|
||||||
"send": "Abschnalzen"
|
"send": "Senden"
|
||||||
},
|
},
|
||||||
"profile": {
|
"profile": {
|
||||||
"message_btn": "Nachricht",
|
"message_btn": "Nachricht",
|
||||||
"legacy_record": "Legacy-Eintrag – Erster Upload:",
|
"legacy_record": "Legacy-Eintrag – Erster Upload:",
|
||||||
"joined": "Beigetreten:",
|
"joined": "Beigetreten:",
|
||||||
|
"age_days": "{n} Tage",
|
||||||
"stat_comments": "Kommentare:",
|
"stat_comments": "Kommentare:",
|
||||||
"stat_tags": "Tags:",
|
"stat_tags": "Tags:",
|
||||||
"stat_halls": "Hallen:",
|
"stat_halls": "Hallen:",
|
||||||
@@ -486,6 +489,8 @@
|
|||||||
"months": "{n} Monaten",
|
"months": "{n} Monaten",
|
||||||
"day": "{n} Tag",
|
"day": "{n} Tag",
|
||||||
"days": "{n} Tagen",
|
"days": "{n} Tagen",
|
||||||
|
"week": "{n} Woche",
|
||||||
|
"weeks": "{n} Wochen",
|
||||||
"hour": "{n} Stunde",
|
"hour": "{n} Stunde",
|
||||||
"hours": "{n} Stunden",
|
"hours": "{n} Stunden",
|
||||||
"minute": "{n} Minute",
|
"minute": "{n} Minute",
|
||||||
@@ -552,5 +557,92 @@
|
|||||||
"slow_down": "Langsamer!",
|
"slow_down": "Langsamer!",
|
||||||
"error_send": "Fehler beim Senden",
|
"error_send": "Fehler beim Senden",
|
||||||
"network_error": "Netzwerkfehler"
|
"network_error": "Netzwerkfehler"
|
||||||
|
},
|
||||||
|
"scroller": {
|
||||||
|
"just_now": "gerade eben",
|
||||||
|
"add": "Hinzufügen",
|
||||||
|
"update_preset": "Voreinstellung aktualisieren",
|
||||||
|
"update_preset_sub": "Änderungen speichern und Feed neu laden",
|
||||||
|
"no_presets": "Noch keine Voreinstellungen gespeichert.",
|
||||||
|
"copy_clipboard": "In Zwischenablage kopieren",
|
||||||
|
"copied": "Kopiert ✓",
|
||||||
|
"recent": "Kürzlich",
|
||||||
|
"nothing_found": "Nichts gefunden mit aktuellen Filtern",
|
||||||
|
"adjust_filters": "Filter anpassen",
|
||||||
|
"failed_load_comments": "Laden fehlgeschlagen",
|
||||||
|
"no_custom_emojis": "Keine eigenen Emojis",
|
||||||
|
"login_required": "Du musst eingeloggt sein, um Inhalte hinzuzufügen.",
|
||||||
|
"rehost_failed": "Rehost fehlgeschlagen: {msg}",
|
||||||
|
"chan_load_failed": "4chan-Thread konnte nicht geladen werden. Er ist möglicherweise archiviert oder enthält keine kompatiblen Medien.",
|
||||||
|
"fetch_failed": "Abruf fehlgeschlagen: {msg}",
|
||||||
|
"invalid_chan_url": "Bitte gib eine gültige 4chan-Thread-URL ein",
|
||||||
|
"chan_catalog_failed": "Katalog konnte nicht geladen werden",
|
||||||
|
"anonymous": "Anonym",
|
||||||
|
"back": "Zurück",
|
||||||
|
"settings": "Einstellungen",
|
||||||
|
"filters": "Filter",
|
||||||
|
"volume": "Lautstärke",
|
||||||
|
"reset_all": "Alles zurücksetzen",
|
||||||
|
"rating": "Bewertung",
|
||||||
|
"all": "Alle",
|
||||||
|
"untagged": "Ohne Tags",
|
||||||
|
"media_type": "Medientyp",
|
||||||
|
"video": "Video",
|
||||||
|
"image": "Bild",
|
||||||
|
"audio": "Audio",
|
||||||
|
"order": "Reihenfolge",
|
||||||
|
"random": "Zufall",
|
||||||
|
"newest": "Neueste",
|
||||||
|
"oldest": "Älteste",
|
||||||
|
"tags": "Tags",
|
||||||
|
"search_tags": "Tags suchen…",
|
||||||
|
"saved_presets": "Gespeicherte Voreinstellungen",
|
||||||
|
"save_preset": "Aktuelle Filter als Voreinstellung speichern",
|
||||||
|
"apply_reload": "Anwenden & Neu laden",
|
||||||
|
"chan_threads": "4chan-Fäden",
|
||||||
|
"thread_gallery": "Faden-Galerie",
|
||||||
|
"load_by_url": "Per URL laden",
|
||||||
|
"load": "Laden",
|
||||||
|
"browse_boards": "Bretter durchsuchen",
|
||||||
|
"go": "Los",
|
||||||
|
"search_threads": "Fäden suchen…",
|
||||||
|
"loading_catalog": "Katalog wird geladen…",
|
||||||
|
"appearance": "Darstellung",
|
||||||
|
"hide_ui": "UI verbergen",
|
||||||
|
"hide_ui_desc": "Blendet die Leiste und Aktionsknöpfe für volle Immersion aus",
|
||||||
|
"start_sound": "Mit Ton starten",
|
||||||
|
"start_sound_desc": "Automatisch Stummschaltung aufheben beim Öffnen",
|
||||||
|
"animated_bg": "Animierter Hintergrund",
|
||||||
|
"animated_bg_desc": "Live-Videoframes hinter dem Player; deaktivieren für statisches Vorschaubild",
|
||||||
|
"playback": "Wiedergabe",
|
||||||
|
"auto_next": "Auto-weiter",
|
||||||
|
"auto_next_desc": "Automatisch zum nächsten Inhalt wechseln, wenn das Medium endet",
|
||||||
|
"loops_before_next": "Schleifen vor Weiter",
|
||||||
|
"loops_before_next_desc": "Wie oft abspielen, bevor weitergeschaltet wird (Videos & Audio)",
|
||||||
|
"comments": "Kommentare",
|
||||||
|
"open": "Öffnen",
|
||||||
|
"loading": "Laden…",
|
||||||
|
"no_comments": "Noch keine Kommentare",
|
||||||
|
"write_comment": "Kommentar schreiben...",
|
||||||
|
"add_tag_placeholder": "Tag zu diesem Inhalt hinzufügen…",
|
||||||
|
"add_tag": "Tag hinzufügen",
|
||||||
|
"close": "Schließen",
|
||||||
|
"share": "Teilen",
|
||||||
|
"copy_link": "Link kopieren",
|
||||||
|
"send_dm": "Per DM senden",
|
||||||
|
"share_inbox": "An den Posteingang eines Benutzers teilen",
|
||||||
|
"search_user": "Benutzer suchen…",
|
||||||
|
"login_to_comment": "Einloggen zum Kommentieren",
|
||||||
|
"doomscroll": "Dunkelscrollen",
|
||||||
|
"favourite": "Favorit",
|
||||||
|
"view": "Ansehen",
|
||||||
|
"open_post": "Beitrag öffnen",
|
||||||
|
"already_added": "Bereits hinzugefügt",
|
||||||
|
"add_to_site": "Zur Seite hinzufügen",
|
||||||
|
"add_to_site_first": "Erst zur Seite hinzufügen, um Tags zu vergeben",
|
||||||
|
"left_hand": "Linkshändermodus",
|
||||||
|
"left_hand_desc": "Du weißt bescheid.",
|
||||||
|
"replying_to": "Antwort an {user}",
|
||||||
|
"reply": "Antworten"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,8 @@
|
|||||||
"mark_all_read": "Mark all read",
|
"mark_all_read": "Mark all read",
|
||||||
"no_notifications": "No new notifications",
|
"no_notifications": "No new notifications",
|
||||||
"view_all_notifications": "View all notifications",
|
"view_all_notifications": "View all notifications",
|
||||||
|
"notif_tab_user": "User",
|
||||||
|
"notif_tab_system": "System",
|
||||||
"manage_subscriptions": "manage subscriptions",
|
"manage_subscriptions": "manage subscriptions",
|
||||||
"favorites": "Favorites",
|
"favorites": "Favorites",
|
||||||
"direct_messages": "Direct Messages",
|
"direct_messages": "Direct Messages",
|
||||||
@@ -424,6 +426,7 @@
|
|||||||
"message_btn": "✉ Message",
|
"message_btn": "✉ Message",
|
||||||
"legacy_record": "Legacy Record – First Upload:",
|
"legacy_record": "Legacy Record – First Upload:",
|
||||||
"joined": "Joined:",
|
"joined": "Joined:",
|
||||||
|
"age_days": "{n} days",
|
||||||
"stat_comments": "Comments:",
|
"stat_comments": "Comments:",
|
||||||
"stat_tags": "Tags:",
|
"stat_tags": "Tags:",
|
||||||
"stat_halls": "Halls:",
|
"stat_halls": "Halls:",
|
||||||
@@ -490,6 +493,8 @@
|
|||||||
"months": "{n} months",
|
"months": "{n} months",
|
||||||
"day": "{n} day",
|
"day": "{n} day",
|
||||||
"days": "{n} days",
|
"days": "{n} days",
|
||||||
|
"week": "{n} week",
|
||||||
|
"weeks": "{n} weeks",
|
||||||
"hour": "{n} hour",
|
"hour": "{n} hour",
|
||||||
"hours": "{n} hours",
|
"hours": "{n} hours",
|
||||||
"minute": "{n} minute",
|
"minute": "{n} minute",
|
||||||
@@ -554,5 +559,92 @@
|
|||||||
"slow_down": "Slow down!",
|
"slow_down": "Slow down!",
|
||||||
"error_send": "Error sending",
|
"error_send": "Error sending",
|
||||||
"network_error": "Network error"
|
"network_error": "Network error"
|
||||||
|
},
|
||||||
|
"scroller": {
|
||||||
|
"just_now": "just now",
|
||||||
|
"add": "Add",
|
||||||
|
"update_preset": "Update & apply preset",
|
||||||
|
"update_preset_sub": "Save changes and reload feed now",
|
||||||
|
"no_presets": "No saved presets yet.",
|
||||||
|
"copy_clipboard": "Copy to clipboard",
|
||||||
|
"copied": "Copied ✓",
|
||||||
|
"recent": "Recent",
|
||||||
|
"nothing_found": "Nothing found with current filters",
|
||||||
|
"adjust_filters": "Adjust filters",
|
||||||
|
"failed_load_comments": "Failed to load",
|
||||||
|
"no_custom_emojis": "No custom emojis",
|
||||||
|
"login_required": "You must be logged in to add items.",
|
||||||
|
"rehost_failed": "Rehost failed: {msg}",
|
||||||
|
"chan_load_failed": "Could not load 4chan thread. It might be archived or have no compatible media.",
|
||||||
|
"fetch_failed": "Fetch failed: {msg}",
|
||||||
|
"invalid_chan_url": "Please enter a valid 4chan thread URL",
|
||||||
|
"chan_catalog_failed": "Failed to load catalog",
|
||||||
|
"anonymous": "Anonymous",
|
||||||
|
"back": "Back",
|
||||||
|
"settings": "Settings",
|
||||||
|
"filters": "Filters",
|
||||||
|
"volume": "Volume",
|
||||||
|
"reset_all": "Reset all",
|
||||||
|
"rating": "Rating",
|
||||||
|
"all": "All",
|
||||||
|
"untagged": "Untagged",
|
||||||
|
"media_type": "Media type",
|
||||||
|
"video": "Video",
|
||||||
|
"image": "Image",
|
||||||
|
"audio": "Audio",
|
||||||
|
"order": "Order",
|
||||||
|
"random": "Random",
|
||||||
|
"newest": "Newest",
|
||||||
|
"oldest": "Oldest",
|
||||||
|
"tags": "Tags",
|
||||||
|
"search_tags": "Search tags…",
|
||||||
|
"saved_presets": "Saved presets",
|
||||||
|
"save_preset": "Save current filters as preset",
|
||||||
|
"apply_reload": "Apply & Reload",
|
||||||
|
"chan_threads": "4chan Threads",
|
||||||
|
"thread_gallery": "Thread Gallery",
|
||||||
|
"load_by_url": "Load by URL",
|
||||||
|
"load": "Load",
|
||||||
|
"browse_boards": "Browse Boards",
|
||||||
|
"go": "Go",
|
||||||
|
"search_threads": "Search threads…",
|
||||||
|
"loading_catalog": "Loading catalog…",
|
||||||
|
"appearance": "Appearance",
|
||||||
|
"hide_ui": "Hide UI",
|
||||||
|
"hide_ui_desc": "Hides the top bar and action buttons for full immersion",
|
||||||
|
"start_sound": "Start with sound",
|
||||||
|
"start_sound_desc": "Automatically unmute when you open the scroller",
|
||||||
|
"animated_bg": "Animated background",
|
||||||
|
"animated_bg_desc": "Live video frames behind the player; disable for static thumbnail",
|
||||||
|
"playback": "Playback",
|
||||||
|
"auto_next": "Auto-next",
|
||||||
|
"auto_next_desc": "Automatically advance to the next item when media ends",
|
||||||
|
"loops_before_next": "Loops before next",
|
||||||
|
"loops_before_next_desc": "How many times to play before advancing (videos & audio)",
|
||||||
|
"comments": "Comments",
|
||||||
|
"open": "Open",
|
||||||
|
"loading": "Loading…",
|
||||||
|
"no_comments": "No comments yet",
|
||||||
|
"write_comment": "Write a comment...",
|
||||||
|
"add_tag_placeholder": "Add a tag to this item…",
|
||||||
|
"add_tag": "Add tag",
|
||||||
|
"close": "Close",
|
||||||
|
"share": "Share",
|
||||||
|
"copy_link": "Copy link",
|
||||||
|
"send_dm": "Send via DM",
|
||||||
|
"share_inbox": "Share to a user's inbox",
|
||||||
|
"search_user": "Search for a user…",
|
||||||
|
"login_to_comment": "Log in to comment",
|
||||||
|
"doomscroll": "doomscroll",
|
||||||
|
"favourite": "Favourite",
|
||||||
|
"view": "View",
|
||||||
|
"open_post": "Open post",
|
||||||
|
"already_added": "Already added",
|
||||||
|
"add_to_site": "Add to site",
|
||||||
|
"add_to_site_first": "Add to site first to tag",
|
||||||
|
"left_hand": "Left hand mode",
|
||||||
|
"left_hand_desc": "You know why.",
|
||||||
|
"replying_to": "Replying to {user}",
|
||||||
|
"reply": "Reply"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,8 @@
|
|||||||
"mark_all_read": "Alles als gelezen markeren",
|
"mark_all_read": "Alles als gelezen markeren",
|
||||||
"no_notifications": "Geen nieuwe meldingen",
|
"no_notifications": "Geen nieuwe meldingen",
|
||||||
"view_all_notifications": "Alle meldingen bekijken",
|
"view_all_notifications": "Alle meldingen bekijken",
|
||||||
|
"notif_tab_user": "Gebruiker",
|
||||||
|
"notif_tab_system": "Systeem",
|
||||||
"manage_subscriptions": "abonnementen beheren",
|
"manage_subscriptions": "abonnementen beheren",
|
||||||
"favorites": "Favorieten",
|
"favorites": "Favorieten",
|
||||||
"direct_messages": "Directe Berichten",
|
"direct_messages": "Directe Berichten",
|
||||||
@@ -420,6 +422,7 @@
|
|||||||
"message_btn": "✉ bericht",
|
"message_btn": "✉ bericht",
|
||||||
"legacy_record": "Legacy Record – Eerste Upload:",
|
"legacy_record": "Legacy Record – Eerste Upload:",
|
||||||
"joined": "Lid geworden:",
|
"joined": "Lid geworden:",
|
||||||
|
"age_days": "{n} dagen",
|
||||||
"stat_comments": "Opmerkingen:",
|
"stat_comments": "Opmerkingen:",
|
||||||
"stat_tags": "Tags:",
|
"stat_tags": "Tags:",
|
||||||
"stat_halls": "Hallen:",
|
"stat_halls": "Hallen:",
|
||||||
@@ -486,6 +489,8 @@
|
|||||||
"months": "{n} maanden",
|
"months": "{n} maanden",
|
||||||
"day": "{n} dag",
|
"day": "{n} dag",
|
||||||
"days": "{n} dagen",
|
"days": "{n} dagen",
|
||||||
|
"week": "{n} week",
|
||||||
|
"weeks": "{n} weken",
|
||||||
"hour": "{n} uur",
|
"hour": "{n} uur",
|
||||||
"hours": "{n} uur",
|
"hours": "{n} uur",
|
||||||
"minute": "{n} minuut",
|
"minute": "{n} minuut",
|
||||||
@@ -550,5 +555,92 @@
|
|||||||
"slow_down": "Langzamer!",
|
"slow_down": "Langzamer!",
|
||||||
"error_send": "Versturen mislukt",
|
"error_send": "Versturen mislukt",
|
||||||
"network_error": "Netwerkfout"
|
"network_error": "Netwerkfout"
|
||||||
|
},
|
||||||
|
"scroller": {
|
||||||
|
"just_now": "zojuist",
|
||||||
|
"add": "Toevoegen",
|
||||||
|
"update_preset": "Voorinstelling bijwerken",
|
||||||
|
"update_preset_sub": "Wijzigingen opslaan en feed herladen",
|
||||||
|
"no_presets": "Nog geen opgeslagen voorinstellingen.",
|
||||||
|
"copy_clipboard": "Kopiëren naar klembord",
|
||||||
|
"copied": "Gekopieerd ✓",
|
||||||
|
"recent": "Recent",
|
||||||
|
"nothing_found": "Niets gevonden met huidige filters",
|
||||||
|
"adjust_filters": "Filters aanpassen",
|
||||||
|
"failed_load_comments": "Laden mislukt",
|
||||||
|
"no_custom_emojis": "Geen aangepaste emoji's",
|
||||||
|
"login_required": "Je moet ingelogd zijn om items toe te voegen.",
|
||||||
|
"rehost_failed": "Rehost mislukt: {msg}",
|
||||||
|
"chan_load_failed": "4chan-thread kon niet worden geladen. Het is mogelijk gearchiveerd of bevat geen compatibele media.",
|
||||||
|
"fetch_failed": "Ophalen mislukt: {msg}",
|
||||||
|
"invalid_chan_url": "Voer een geldige 4chan-thread-URL in",
|
||||||
|
"chan_catalog_failed": "Catalogus kon niet worden geladen",
|
||||||
|
"anonymous": "Anoniem",
|
||||||
|
"back": "Terug",
|
||||||
|
"settings": "Instellingen",
|
||||||
|
"filters": "Filters",
|
||||||
|
"volume": "Volume",
|
||||||
|
"reset_all": "Alles resetten",
|
||||||
|
"rating": "Beoordeling",
|
||||||
|
"all": "Alles",
|
||||||
|
"untagged": "Zonder tags",
|
||||||
|
"media_type": "Mediatype",
|
||||||
|
"video": "Video",
|
||||||
|
"image": "Afbeelding",
|
||||||
|
"audio": "Audio",
|
||||||
|
"order": "Volgorde",
|
||||||
|
"random": "Willekeurig",
|
||||||
|
"newest": "Nieuwste",
|
||||||
|
"oldest": "Oudste",
|
||||||
|
"tags": "Tags",
|
||||||
|
"search_tags": "Tags zoeken…",
|
||||||
|
"saved_presets": "Opgeslagen voorinstellingen",
|
||||||
|
"save_preset": "Huidige filters opslaan als voorinstelling",
|
||||||
|
"apply_reload": "Toepassen & Herladen",
|
||||||
|
"chan_threads": "4chan-threads",
|
||||||
|
"thread_gallery": "Thread-galerij",
|
||||||
|
"load_by_url": "Laden via URL",
|
||||||
|
"load": "Laden",
|
||||||
|
"browse_boards": "Borden doorbladeren",
|
||||||
|
"go": "Ga",
|
||||||
|
"search_threads": "Threads zoeken…",
|
||||||
|
"loading_catalog": "Catalogus laden…",
|
||||||
|
"appearance": "Uiterlijk",
|
||||||
|
"hide_ui": "UI verbergen",
|
||||||
|
"hide_ui_desc": "Verbergt de bovenste balk en actieknoppen voor volledige onderdompeling",
|
||||||
|
"start_sound": "Met geluid starten",
|
||||||
|
"start_sound_desc": "Automatisch demping opheffen bij openen",
|
||||||
|
"animated_bg": "Geanimeerde achtergrond",
|
||||||
|
"animated_bg_desc": "Live videoframes achter de speler; uitschakelen voor statische thumbnail",
|
||||||
|
"playback": "Afspelen",
|
||||||
|
"auto_next": "Auto-volgende",
|
||||||
|
"auto_next_desc": "Automatisch naar het volgende item gaan wanneer media eindigt",
|
||||||
|
"loops_before_next": "Lussen voor volgende",
|
||||||
|
"loops_before_next_desc": "Hoe vaak afspelen voordat wordt doorgegaan (video's & audio)",
|
||||||
|
"comments": "Opmerkingen",
|
||||||
|
"open": "Openen",
|
||||||
|
"loading": "Laden…",
|
||||||
|
"no_comments": "Nog geen opmerkingen",
|
||||||
|
"write_comment": "Schrijf een opmerking...",
|
||||||
|
"add_tag_placeholder": "Tag toevoegen aan dit item…",
|
||||||
|
"add_tag": "Tag toevoegen",
|
||||||
|
"close": "Sluiten",
|
||||||
|
"share": "Delen",
|
||||||
|
"copy_link": "Link kopiëren",
|
||||||
|
"send_dm": "Via DM versturen",
|
||||||
|
"share_inbox": "Delen naar de inbox van een gebruiker",
|
||||||
|
"search_user": "Zoek een gebruiker…",
|
||||||
|
"login_to_comment": "Inloggen om te reageren",
|
||||||
|
"doomscroll": "doomscroll",
|
||||||
|
"favourite": "Favoriet",
|
||||||
|
"view": "Bekijken",
|
||||||
|
"open_post": "Bericht openen",
|
||||||
|
"already_added": "Al toegevoegd",
|
||||||
|
"add_to_site": "Toevoegen aan site",
|
||||||
|
"add_to_site_first": "Eerst toevoegen aan site om tags te plaatsen",
|
||||||
|
"left_hand": "Linkshandige modus",
|
||||||
|
"left_hand_desc": "Je weet wel waarom.",
|
||||||
|
"replying_to": "Antwoord aan {user}",
|
||||||
|
"reply": "Antwoorden"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,8 @@
|
|||||||
"mark_all_read": "Alle als gelesen markieren",
|
"mark_all_read": "Alle als gelesen markieren",
|
||||||
"no_notifications": "Keine neuen Hinweise",
|
"no_notifications": "Keine neuen Hinweise",
|
||||||
"view_all_notifications": "Alle Hinweise betrachten",
|
"view_all_notifications": "Alle Hinweise betrachten",
|
||||||
|
"notif_tab_user": "Benutzer",
|
||||||
|
"notif_tab_system": "System",
|
||||||
"manage_subscriptions": "Abonnements verwalten",
|
"manage_subscriptions": "Abonnements verwalten",
|
||||||
"favorites": "Favoriten",
|
"favorites": "Favoriten",
|
||||||
"direct_messages": "Direktnachrichten",
|
"direct_messages": "Direktnachrichten",
|
||||||
@@ -387,7 +389,7 @@
|
|||||||
},
|
},
|
||||||
"ranking": {
|
"ranking": {
|
||||||
"title": "Rangliste",
|
"title": "Rangliste",
|
||||||
"top_contributors": "Top-Beitragende",
|
"top_contributors": "Top Etikettierer",
|
||||||
"col_rank": "Rang",
|
"col_rank": "Rang",
|
||||||
"col_avatar": "Profilbild",
|
"col_avatar": "Profilbild",
|
||||||
"col_username": "Benutzername",
|
"col_username": "Benutzername",
|
||||||
@@ -424,6 +426,7 @@
|
|||||||
"message_btn": "Nachricht",
|
"message_btn": "Nachricht",
|
||||||
"legacy_record": "Veralteter Datensatz – Erste Aufladierung:",
|
"legacy_record": "Veralteter Datensatz – Erste Aufladierung:",
|
||||||
"joined": "Beigetreten:",
|
"joined": "Beigetreten:",
|
||||||
|
"age_days": "{n} Tage",
|
||||||
"stat_comments": "Kommentare:",
|
"stat_comments": "Kommentare:",
|
||||||
"stat_tags": "Etiketten:",
|
"stat_tags": "Etiketten:",
|
||||||
"stat_halls": "Hallen:",
|
"stat_halls": "Hallen:",
|
||||||
@@ -485,11 +488,13 @@
|
|||||||
"timeago": {
|
"timeago": {
|
||||||
"just_now": "gerade eben",
|
"just_now": "gerade eben",
|
||||||
"year": "{n} Jahr",
|
"year": "{n} Jahr",
|
||||||
"years": "{n} Jahre",
|
"years": "{n} Jahren",
|
||||||
"month": "{n} Monat",
|
"month": "{n} Monat",
|
||||||
"months": "{n} Monate",
|
"months": "{n} Monaten",
|
||||||
"day": "{n} Tag",
|
"day": "{n} Tag",
|
||||||
"days": "{n} Tage",
|
"days": "{n} Tagen",
|
||||||
|
"week": "{n} Woche",
|
||||||
|
"weeks": "{n} Wochen",
|
||||||
"hour": "{n} Stunde",
|
"hour": "{n} Stunde",
|
||||||
"hours": "{n} Stunden",
|
"hours": "{n} Stunden",
|
||||||
"minute": "{n} Minute",
|
"minute": "{n} Minute",
|
||||||
@@ -556,5 +561,92 @@
|
|||||||
"slow_down": "Gemach, gemach!",
|
"slow_down": "Gemach, gemach!",
|
||||||
"error_send": "Sendung fehlgeschlagen",
|
"error_send": "Sendung fehlgeschlagen",
|
||||||
"network_error": "Netzwerkfehler"
|
"network_error": "Netzwerkfehler"
|
||||||
|
},
|
||||||
|
"scroller": {
|
||||||
|
"just_now": "gerade eben",
|
||||||
|
"add": "Hinzufügen",
|
||||||
|
"update_preset": "Voreinstellung aktualisieren",
|
||||||
|
"update_preset_sub": "Änderungen speichern und Futterstrom neu laden",
|
||||||
|
"no_presets": "Noch keine Voreinstellungen gespeichert.",
|
||||||
|
"copy_clipboard": "In die Zwischenablage kopieren",
|
||||||
|
"copied": "Kopiert ✓",
|
||||||
|
"recent": "Kürzlich",
|
||||||
|
"nothing_found": "Nichts gefunden mit aktuellen Filtern",
|
||||||
|
"adjust_filters": "Filter anpassen",
|
||||||
|
"failed_load_comments": "Ladung fehlgeschlagen",
|
||||||
|
"no_custom_emojis": "Keine benutzerdefinierten Emojis",
|
||||||
|
"login_required": "Sie müssen angemeldet sein, um Elemente hinzuzufügen.",
|
||||||
|
"rehost_failed": "Umladierung fehlgeschlagen: {msg}",
|
||||||
|
"chan_load_failed": "Der Vierkanal-Faden konnte nicht geladen werden. Er ist möglicherweise archiviert oder enthält keine kompatiblen Medien.",
|
||||||
|
"fetch_failed": "Abruf fehlgeschlagen: {msg}",
|
||||||
|
"invalid_chan_url": "Bitte geben Sie eine gültige Vierkanal-Faden-Elfe ein",
|
||||||
|
"chan_catalog_failed": "Katalog konnte nicht geladen werden",
|
||||||
|
"anonymous": "Anonym",
|
||||||
|
"back": "Zurück",
|
||||||
|
"settings": "Einstellungen",
|
||||||
|
"filters": "Filter",
|
||||||
|
"volume": "Lautstärke",
|
||||||
|
"reset_all": "Alles zurücksetzen",
|
||||||
|
"rating": "Bewertung",
|
||||||
|
"all": "Alle",
|
||||||
|
"untagged": "Ohne Etiketten",
|
||||||
|
"media_type": "Medientyp",
|
||||||
|
"video": "Video",
|
||||||
|
"image": "Lichtbild",
|
||||||
|
"audio": "Tondatei",
|
||||||
|
"order": "Reihenfolge",
|
||||||
|
"random": "Zufall",
|
||||||
|
"newest": "Neueste",
|
||||||
|
"oldest": "Älteste",
|
||||||
|
"tags": "Etiketten",
|
||||||
|
"search_tags": "Etiketten suchen…",
|
||||||
|
"saved_presets": "Gespeicherte Voreinstellungen",
|
||||||
|
"save_preset": "Aktuelle Filter als Voreinstellung speichern",
|
||||||
|
"apply_reload": "Anwenden & Neu laden",
|
||||||
|
"chan_threads": "Vierkanal-Fäden",
|
||||||
|
"thread_gallery": "Faden-Galerie",
|
||||||
|
"load_by_url": "Per Elfe laden",
|
||||||
|
"load": "Laden",
|
||||||
|
"browse_boards": "Bretter durchstöbern",
|
||||||
|
"go": "Los",
|
||||||
|
"search_threads": "Fäden durchsuchen…",
|
||||||
|
"loading_catalog": "Katalog wird geladen…",
|
||||||
|
"appearance": "Darstellung",
|
||||||
|
"hide_ui": "Oberfläche verbergen",
|
||||||
|
"hide_ui_desc": "Verbirgt die Leiste und Aktionsknöpfe für volle Versenkung",
|
||||||
|
"start_sound": "Mit Ton beginnen",
|
||||||
|
"start_sound_desc": "Automatisch Stummschaltung aufheben beim Öffnen des Scrollers",
|
||||||
|
"animated_bg": "Belebter Hintergrund",
|
||||||
|
"animated_bg_desc": "Lebendige Videoframes hinter dem Abspieler; deaktivieren für ruhendes Vorschaubild",
|
||||||
|
"playback": "Wiedergabe",
|
||||||
|
"auto_next": "Selbsttätiges Weiter",
|
||||||
|
"auto_next_desc": "Selbsttätig zum nächsten Inhalt wechseln, wenn das Medium endet",
|
||||||
|
"loops_before_next": "Schleifen vor Weiter",
|
||||||
|
"loops_before_next_desc": "Wie oft abspielen, bevor weitergeschaltet wird (Videos & Tondateien)",
|
||||||
|
"comments": "Kommentare",
|
||||||
|
"open": "Öffnen",
|
||||||
|
"loading": "Ladung wird aufbereitet…",
|
||||||
|
"no_comments": "Noch keine Kommentare vorhanden",
|
||||||
|
"write_comment": "Schreiben Sie doch einen Kommentar...",
|
||||||
|
"add_tag_placeholder": "Etikett zu diesem Inhalt hinzufügen…",
|
||||||
|
"add_tag": "Etikett hinzufügen",
|
||||||
|
"close": "Schließen",
|
||||||
|
"share": "Teilen",
|
||||||
|
"copy_link": "Verknüpfung kopieren",
|
||||||
|
"send_dm": "Per Direktnachricht senden",
|
||||||
|
"share_inbox": "An den Posteingang eines Benutzers teilen",
|
||||||
|
"search_user": "Benutzer suchen…",
|
||||||
|
"login_to_comment": "Anmelden zum Kommentieren",
|
||||||
|
"doomscroll": "Verderbensrolle",
|
||||||
|
"favourite": "Favorit",
|
||||||
|
"view": "Ansehen",
|
||||||
|
"open_post": "Pfosten öffnen",
|
||||||
|
"already_added": "Bereits hinzugefügt",
|
||||||
|
"add_to_site": "Zur Weltnetzpräsenz hinzufügen",
|
||||||
|
"add_to_site_first": "Erst zur Weltnetzpräsenz hinzufügen, um Etiketten zu vergeben",
|
||||||
|
"left_hand": "Linkshändermodus",
|
||||||
|
"left_hand_desc": "Sie wissen schon wieso.",
|
||||||
|
"replying_to": "Antwort an {user}",
|
||||||
|
"reply": "Antworten"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -857,6 +857,60 @@ const page = Math.max(1, parseInt(req.url.qs?.page) || 1);
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.post(/^\/api\/v2\/admin\/users\/reassign-uploads\/?$/, lib.auth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { source_user_id, source_username, target_username } = req.post;
|
||||||
|
if (!source_user_id && !source_username) throw new Error('Missing source_user_id or source_username');
|
||||||
|
if (!target_username || !target_username.trim()) throw new Error('Missing target_username');
|
||||||
|
|
||||||
|
// Resolve source user (registered or ghost)
|
||||||
|
let sourceLogin, sourceUser;
|
||||||
|
if (source_user_id) {
|
||||||
|
const source = await db`SELECT id, login, "user" FROM "user" WHERE id = ${+source_user_id} LIMIT 1`;
|
||||||
|
if (!source.length) throw new Error('Source user not found');
|
||||||
|
if (source[0].login === 'deleted_user') throw new Error('Cannot reassign uploads from the protected deleted_user account.');
|
||||||
|
sourceLogin = source[0].login;
|
||||||
|
sourceUser = source[0].user;
|
||||||
|
} else {
|
||||||
|
// Ghost/legacy user — just use the username directly
|
||||||
|
sourceLogin = source_username.trim();
|
||||||
|
sourceUser = source_username.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve target user
|
||||||
|
const target = await db`SELECT id, login, "user" FROM "user" WHERE login ILIKE ${target_username.trim()} LIMIT 1`;
|
||||||
|
if (!target.length) throw new Error('Target user "' + target_username.trim() + '" not found');
|
||||||
|
|
||||||
|
const targetLogin = target[0].login;
|
||||||
|
const targetId = target[0].id;
|
||||||
|
|
||||||
|
if (source_user_id && +source_user_id === targetId) throw new Error('Source and target user are the same.');
|
||||||
|
|
||||||
|
// Reassign all items
|
||||||
|
const result = await db`
|
||||||
|
UPDATE items
|
||||||
|
SET username = ${targetLogin}
|
||||||
|
WHERE username ILIKE ${sourceLogin} OR username ILIKE ${sourceUser}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Log in audit
|
||||||
|
await audit.log(req.session.id, 'admin_reassign_uploads', 'user', source_user_id ? +source_user_id : null, {
|
||||||
|
source_login: sourceLogin,
|
||||||
|
target_login: targetLogin,
|
||||||
|
target_id: targetId,
|
||||||
|
count: result.count
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
count: result.count,
|
||||||
|
msg: `Successfully reassigned ${result.count} uploads from ${sourceLogin} to ${targetLogin}.`
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: err.message }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
router.post(/^\/api\/v2\/admin\/users\/bulk-delete-items\/?$/, lib.auth, async (req, res) => {
|
router.post(/^\/api\/v2\/admin\/users\/bulk-delete-items\/?$/, lib.auth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { user_id, username } = req.post;
|
const { user_id, username } = req.post;
|
||||||
|
|||||||
@@ -294,6 +294,43 @@ export default router => {
|
|||||||
|
|
||||||
// Background processing block
|
// Background processing block
|
||||||
(async () => {
|
(async () => {
|
||||||
|
const sanitizeError = (err) => {
|
||||||
|
if (!err) return `Failed to process ${url}`;
|
||||||
|
|
||||||
|
// Priority 1: meaningful error from stderr (yt-dlp/curl/etc)
|
||||||
|
if (err.stderr) {
|
||||||
|
const stderr = String(err.stderr).trim();
|
||||||
|
|
||||||
|
// yt-dlp specific patterns
|
||||||
|
const errorMatch = stderr.match(/ERROR:\s*(.+)$/m);
|
||||||
|
if (errorMatch) return errorMatch[1].trim();
|
||||||
|
|
||||||
|
// curl specific patterns
|
||||||
|
if (stderr.startsWith('curl: ')) return stderr;
|
||||||
|
|
||||||
|
// Fallback to last meaningful line of stderr
|
||||||
|
const lines = stderr.split('\n').map(l => l.trim()).filter(l => l && !l.includes('WARNING:'));
|
||||||
|
if (lines.length > 0) return lines[lines.length - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
const msg = String(err.message || '');
|
||||||
|
|
||||||
|
// Priority 2: Extract HTTP codes
|
||||||
|
const httpCode = msg.match(/HTTP Error (\d+)/i)?.[1]
|
||||||
|
|| msg.match(/\b(4\d{2}|5\d{2})\b/)?.[1]
|
||||||
|
|| null;
|
||||||
|
if (httpCode) return `Download/Process failed (HTTP ${httpCode})`;
|
||||||
|
|
||||||
|
// Priority 3: Sanitize raw queue.spawn errors
|
||||||
|
if (msg.startsWith('Command \'')) {
|
||||||
|
const match = msg.match(/failed with code (\d+)/);
|
||||||
|
const code = match ? match[1] : '1';
|
||||||
|
return `Process failed (code ${code})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return msg || `Failed to process ${url}`;
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const proxyArgs = (cfg.main.socks && cfg.main.socks !== 'undefined') ? ['--proxy', cfg.main.socks] : [];
|
const proxyArgs = (cfg.main.socks && cfg.main.socks !== 'undefined') ? ['--proxy', cfg.main.socks] : [];
|
||||||
const ytdlpArgs = ['--js-runtimes', 'node', '--geo-bypass', '--extractor-args', 'youtube:player-client=ios,web'];
|
const ytdlpArgs = ['--js-runtimes', 'node', '--geo-bypass', '--extractor-args', 'youtube:player-client=ios,web'];
|
||||||
@@ -303,16 +340,7 @@ export default router => {
|
|||||||
const uuid = await queue.genuuid();
|
const uuid = await queue.genuuid();
|
||||||
const isInstagram = /instagram\.com/i.test(url);
|
const isInstagram = /instagram\.com/i.test(url);
|
||||||
|
|
||||||
const dlError = (err) => {
|
const dlError = (err) => sanitizeError(err);
|
||||||
if (!err) return `Failed to download from ${url}`;
|
|
||||||
const errStr = String(err.stderr || err.message || '');
|
|
||||||
const httpCode = errStr.match(/HTTP Error (\d+)/i)?.[1]
|
|
||||||
|| errStr.match(/\b(4\d{2}|5\d{2})\b/)?.[1]
|
|
||||||
|| null;
|
|
||||||
if (httpCode) return `Failed to download from ${url} (HTTP ${httpCode})`;
|
|
||||||
if (err.code != null) return `Failed to download from ${url} (code ${err.code})`;
|
|
||||||
return `Failed to download from ${url}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
let source;
|
let source;
|
||||||
console.log(`[UPLOAD-URL-ASYNC] Starting Stage 1 (constrained) download for ${url} (user: ${session.user})`);
|
console.log(`[UPLOAD-URL-ASYNC] Starting Stage 1 (constrained) download for ${url} (user: ${session.user})`);
|
||||||
@@ -330,7 +358,7 @@ export default router => {
|
|||||||
])).stdout.trim();
|
])).stdout.trim();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn(`[UPLOAD-URL-ASYNC] Stage 1 failed: ${err.message}`);
|
console.warn(`[UPLOAD-URL-ASYNC] Stage 1 failed: ${err.message}`);
|
||||||
if (isInstagram) throw err;
|
if (isInstagram) throw new Error(sanitizeError(err));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
source = (await queue.spawn('yt-dlp', [
|
source = (await queue.spawn('yt-dlp', [
|
||||||
@@ -365,7 +393,11 @@ export default router => {
|
|||||||
const proxyHost = cfg.main.socks.includes('://') ? cfg.main.socks.split('://')[1] : cfg.main.socks;
|
const proxyHost = cfg.main.socks.includes('://') ? cfg.main.socks.split('://')[1] : cfg.main.socks;
|
||||||
curlArgs.push('--socks5-hostname', proxyHost);
|
curlArgs.push('--socks5-hostname', proxyHost);
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
await queue.spawn('curl', curlArgs);
|
await queue.spawn('curl', curlArgs);
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error(sanitizeError(err));
|
||||||
|
}
|
||||||
|
|
||||||
const fallbackMime = (await queue.spawn('file', ['--mime-type', '-b', fallbackTmp])).stdout.trim();
|
const fallbackMime = (await queue.spawn('file', ['--mime-type', '-b', fallbackTmp])).stdout.trim();
|
||||||
const extension = cfg.mimes[fallbackMime];
|
const extension = cfg.mimes[fallbackMime];
|
||||||
@@ -549,7 +581,7 @@ export default router => {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[UPLOAD-URL-ASYNC] Final Error:', err);
|
console.error('[UPLOAD-URL-ASYNC] Final Error:', err);
|
||||||
// Error notification
|
// Error notification
|
||||||
await db`INSERT INTO notifications (user_id, type, reference_id, item_id, data) VALUES (${session.id}, 'upload_error', 0, ${null}, ${db.json({ url, msg: err.message })})`;
|
await db`INSERT INTO notifications (user_id, type, reference_id, item_id, data) VALUES (${session.id}, 'upload_error', 0, ${null}, ${db.json({ url, msg: sanitizeError(err) })})`;
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
|
|||||||
467
src/inc/routes/external.mjs
Normal file
467
src/inc/routes/external.mjs
Normal file
@@ -0,0 +1,467 @@
|
|||||||
|
import cfg from "../config.mjs";
|
||||||
|
import db from "../sql.mjs";
|
||||||
|
import lib from "../lib.mjs";
|
||||||
|
import queue from "../queue.mjs";
|
||||||
|
import { promises as fs } from "fs";
|
||||||
|
import path from "path";
|
||||||
|
import { getManualApproval, getBypassDuplicateCheck } from "../settings.mjs";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* external.mjs — External source handlers (4chan threads, etc.)
|
||||||
|
*/
|
||||||
|
export default (router) => {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to fetch data (JSON or Buffer) using curl if a proxy is configured.
|
||||||
|
* This ensures we respect the SOCKS5 proxy for all external 4chan requests.
|
||||||
|
*/
|
||||||
|
async function fetchWithProxy(url, asBuffer = false) {
|
||||||
|
const curlArgs = [
|
||||||
|
'-s', '-f', '-L',
|
||||||
|
'-A', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
|
||||||
|
'--max-time', '30',
|
||||||
|
url
|
||||||
|
];
|
||||||
|
if (cfg.main.socks && cfg.main.socks !== 'undefined' && cfg.main.socks !== '') {
|
||||||
|
const proxyHost = cfg.main.socks.includes('://') ? cfg.main.socks.split('://')[1] : cfg.main.socks;
|
||||||
|
curlArgs.push('--socks5-hostname', proxyHost);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { stdout } = await queue.spawn('curl', curlArgs, { encoding: asBuffer ? 'buffer' : 'utf8' });
|
||||||
|
if (asBuffer) return stdout;
|
||||||
|
const text = typeof stdout === 'string' ? stdout.trim() : stdout.toString().trim();
|
||||||
|
if (!text.startsWith('{') && !text.startsWith('[')) {
|
||||||
|
console.error('[EXTERNAL] Non-JSON response from', url, '— first 200 chars:', text.slice(0, 200));
|
||||||
|
throw new Error('Expected JSON but got non-JSON response');
|
||||||
|
}
|
||||||
|
return JSON.parse(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/v2/scroller/external/4chan/:board/:tid
|
||||||
|
// Proxies 4chan thread JSON
|
||||||
|
router.get(/^\/api\/v2\/scroller\/external\/4chan\/(?<board>[a-z0-9]+)\/(?<tid>\d+)\/?$/, async (req, res) => {
|
||||||
|
const { board, tid } = req.params || {};
|
||||||
|
|
||||||
|
if (!board || !tid) {
|
||||||
|
console.error('[EXTERNAL] Missing board or tid:', req.params);
|
||||||
|
return res.reply({ code: 400, body: JSON.stringify({ success: false, error: 'invalid_parameters' }) });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = `https://a.4cdn.org/${board}/thread/${tid}.json`;
|
||||||
|
console.log(`[EXTERNAL] Fetching 4chan thread: ${url}`);
|
||||||
|
|
||||||
|
const data = await fetchWithProxy(url);
|
||||||
|
const posts = data.posts || [];
|
||||||
|
|
||||||
|
// Check which media URLs are already rehosted on this platform
|
||||||
|
const rehosts = {};
|
||||||
|
const mediaPosts = posts.filter(p => p.tim && p.ext);
|
||||||
|
const cdn4Urls = mediaPosts.map(p => `https://i.4cdn.org/${board}/${p.tim}${p.ext}`);
|
||||||
|
if (cdn4Urls.length > 0) {
|
||||||
|
try {
|
||||||
|
const rows = await db`SELECT id, src FROM items WHERE src IN (${cdn4Urls})`;
|
||||||
|
rows.forEach(r => { rehosts[r.src] = r.id; });
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[EXTERNAL] DB src check error:', e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.reply({
|
||||||
|
headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-cache' },
|
||||||
|
body: JSON.stringify({ success: true, posts, board, tid, rehosts })
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[EXTERNAL] 4chan fetch error:', err.message);
|
||||||
|
return res.reply({
|
||||||
|
code: 500,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ success: false, msg: 'fetch_failed' })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/v2/scroller/external/rehost-meta
|
||||||
|
// Given item IDs, return their metadata (username, avatar, timestamp)
|
||||||
|
router.post(/^\/api\/v2\/scroller\/external\/rehost-meta\/?$/, async (req, res) => {
|
||||||
|
const ids = (req.post?.ids || '').split(',').map(Number).filter(n => n > 0);
|
||||||
|
if (!ids.length) return res.reply({ headers: { 'Content-Type': 'application/json' }, body: '{}' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ratingTagIds = [1, 2, cfg.nsfl_tag_id || 3];
|
||||||
|
const rows = await db`
|
||||||
|
SELECT i.id, i.username, i.stamp,
|
||||||
|
COALESCE(uo.display_name, i.username) as display_name,
|
||||||
|
uo.avatar_file, uo.avatar,
|
||||||
|
(SELECT ta.tag_id FROM tags_assign ta
|
||||||
|
WHERE ta.item_id = i.id AND ta.tag_id = ANY(${ratingTagIds}::int[])
|
||||||
|
ORDER BY ta.tag_id LIMIT 1) AS rating_tag_id
|
||||||
|
FROM items i
|
||||||
|
LEFT JOIN "user" u ON u."user" = i.username
|
||||||
|
LEFT JOIN user_options uo ON uo.user_id = u.id
|
||||||
|
WHERE i.id = ANY(${ids}::int[])`;
|
||||||
|
const meta = {};
|
||||||
|
rows.forEach(r => {
|
||||||
|
let rating_label = '?', rating_class = 'untagged';
|
||||||
|
if (r.rating_tag_id == 1) { rating_label = 'SFW'; rating_class = 'sfw'; }
|
||||||
|
else if (r.rating_tag_id == 2) { rating_label = 'NSFW'; rating_class = 'nsfw'; }
|
||||||
|
else if (r.rating_tag_id == (cfg.nsfl_tag_id || 3)) { rating_label = 'NSFL'; rating_class = 'nsfl'; }
|
||||||
|
meta[r.id] = {
|
||||||
|
username: r.username,
|
||||||
|
display_name: r.display_name,
|
||||||
|
avatar: r.avatar_file ? `/a/${r.avatar_file}` : (r.avatar ? `/t/${r.avatar}.webp` : '/a/default.png'),
|
||||||
|
stamp: r.stamp,
|
||||||
|
rating_class,
|
||||||
|
rating_label
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return res.reply({
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(meta)
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[EXTERNAL] rehost-meta error:', e.message);
|
||||||
|
return res.reply({ code: 500, headers: { 'Content-Type': 'application/json' }, body: '{}' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/v2/scroller/external/4chan/:board/catalog
|
||||||
|
// Proxies 4chan board catalog JSON
|
||||||
|
router.get(/^\/api\/v2\/scroller\/external\/4chan\/(?<board>[a-z0-9]+)\/catalog\/?$/, async (req, res) => {
|
||||||
|
const { board } = req.params || {};
|
||||||
|
if (!board) return res.reply({ code: 400, body: JSON.stringify({ success: false }) });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pages = await fetchWithProxy(`https://a.4cdn.org/${board}/catalog.json`);
|
||||||
|
const threads = [];
|
||||||
|
for (const page of pages) {
|
||||||
|
for (const t of (page.threads || [])) {
|
||||||
|
threads.push({
|
||||||
|
no: t.no,
|
||||||
|
sub: t.sub || '',
|
||||||
|
com: (t.com || '').replace(/<[^>]+>/g, '').slice(0, 120),
|
||||||
|
replies: t.replies || 0,
|
||||||
|
images: t.images || 0,
|
||||||
|
tim: t.tim,
|
||||||
|
ext: t.ext,
|
||||||
|
sticky: t.sticky || 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return res.reply({
|
||||||
|
headers: { 'Content-Type': 'application/json', 'Cache-Control': 'max-age=120' },
|
||||||
|
body: JSON.stringify({ success: true, board, threads })
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[EXTERNAL] Catalog fetch error:', err.message);
|
||||||
|
return res.reply({
|
||||||
|
code: 500,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ success: false, msg: 'catalog_fetch_failed' })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/v2/scroller/external/4chan/:board/find/:postno
|
||||||
|
// Resolves a post number to its parent thread ID
|
||||||
|
router.get(/^\/api\/v2\/scroller\/external\/4chan\/(?<board>[a-z0-9]+)\/find\/(?<postno>\d+)\/?$/, async (req, res) => {
|
||||||
|
const { board, postno } = req.params || {};
|
||||||
|
if (!board || !postno) return res.reply({ code: 400, body: JSON.stringify({ success: false }) });
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1) Try as thread OP — if postno IS the thread, this returns 200
|
||||||
|
try {
|
||||||
|
const thread = await fetchWithProxy(`https://a.4cdn.org/${board}/thread/${postno}.json`);
|
||||||
|
if (thread && thread.posts) {
|
||||||
|
return res.reply({
|
||||||
|
headers: { 'Content-Type': 'application/json', 'Cache-Control': 'max-age=300' },
|
||||||
|
body: JSON.stringify({ success: true, tid: Number(postno), board })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (_) { /* 404 — post is not an OP, continue searching */ }
|
||||||
|
|
||||||
|
// 2) Search catalog's last_replies for the post
|
||||||
|
const pages = await fetchWithProxy(`https://a.4cdn.org/${board}/catalog.json`);
|
||||||
|
for (const page of pages) {
|
||||||
|
for (const t of (page.threads || [])) {
|
||||||
|
// Check OP
|
||||||
|
if (t.no === Number(postno)) {
|
||||||
|
return res.reply({
|
||||||
|
headers: { 'Content-Type': 'application/json', 'Cache-Control': 'max-age=300' },
|
||||||
|
body: JSON.stringify({ success: true, tid: t.no, board })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Check last_replies
|
||||||
|
if (t.last_replies) {
|
||||||
|
for (const r of t.last_replies) {
|
||||||
|
if (r.no === Number(postno)) {
|
||||||
|
return res.reply({
|
||||||
|
headers: { 'Content-Type': 'application/json', 'Cache-Control': 'max-age=300' },
|
||||||
|
body: JSON.stringify({ success: true, tid: t.no, board })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not found
|
||||||
|
return res.reply({
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ success: false, msg: 'post_not_found' })
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[EXTERNAL] Find post error:', err.message);
|
||||||
|
return res.reply({
|
||||||
|
code: 500,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ success: false, msg: 'find_failed' })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/v2/scroller/external/4chan/:board/media/:file
|
||||||
|
// Proxies 4chan media — streams directly to client for fast playback start
|
||||||
|
router.get(/^\/api\/v2\/scroller\/external\/4chan\/(?<board>[a-z0-9]+)\/media\/(?<file>[^/]+)$/, async (req, res) => {
|
||||||
|
const { board, file } = req.params || {};
|
||||||
|
const url = `https://i.4cdn.org/${board}/${file}`;
|
||||||
|
|
||||||
|
const ext = file.split('.').pop();
|
||||||
|
const mimes = {
|
||||||
|
'jpg': 'image/jpeg', 'jpeg': 'image/jpeg', 'png': 'image/png',
|
||||||
|
'gif': 'image/gif', 'webp': 'image/webp',
|
||||||
|
'webm': 'video/webm', 'mp4': 'video/mp4'
|
||||||
|
};
|
||||||
|
const contentType = mimes[ext] || 'application/octet-stream';
|
||||||
|
|
||||||
|
const curlArgs = [
|
||||||
|
'-s', '-f', '-L',
|
||||||
|
'-A', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
|
||||||
|
'--max-time', '60',
|
||||||
|
url
|
||||||
|
];
|
||||||
|
if (cfg.main.socks && cfg.main.socks !== 'undefined' && cfg.main.socks !== '') {
|
||||||
|
const proxyHost = cfg.main.socks.includes('://') ? cfg.main.socks.split('://')[1] : cfg.main.socks;
|
||||||
|
curlArgs.push('--socks5-hostname', proxyHost);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { spawn } = await import('child_process');
|
||||||
|
const curl = spawn('curl', curlArgs);
|
||||||
|
|
||||||
|
res.writeHead(200, {
|
||||||
|
'Content-Type': contentType,
|
||||||
|
'Cache-Control': 'public, max-age=86400',
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Cross-Origin-Resource-Policy': 'cross-origin',
|
||||||
|
'Transfer-Encoding': 'chunked'
|
||||||
|
});
|
||||||
|
|
||||||
|
curl.stdout.pipe(res);
|
||||||
|
|
||||||
|
curl.stderr.on('data', () => {}); // suppress stderr
|
||||||
|
curl.on('error', () => { try { res.end(); } catch(_) {} });
|
||||||
|
curl.on('close', (code) => {
|
||||||
|
if (code !== 0) try { res.end(); } catch(_) {}
|
||||||
|
});
|
||||||
|
|
||||||
|
// If the client disconnects, kill curl
|
||||||
|
req.on('close', () => { try { curl.kill(); } catch(_) {} });
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/v2/scroller/rehost
|
||||||
|
// Downloads an external item and adds it to the platform
|
||||||
|
router.post(/^\/api\/v2\/scroller\/rehost\/?$/, lib.loggedin, async (req, res) => {
|
||||||
|
const { url, rating: initialRating, tags: tagsRaw, comment, is_oc } = req.post || {};
|
||||||
|
|
||||||
|
if (!url) return res.reply({ code: 400, body: JSON.stringify({ success: false, msg: 'URL is required' }) });
|
||||||
|
|
||||||
|
const board = url.match(/boards\.4cdn\.org\/([a-z0-9]+)\//)?.[1]
|
||||||
|
|| url.match(/i\.4cdn\.org\/([a-z0-9]+)\//)?.[1]
|
||||||
|
|| url.match(/\/4chan\/([a-z0-9]+)\/media\//)?.[1]
|
||||||
|
|| null;
|
||||||
|
|
||||||
|
let rating = initialRating;
|
||||||
|
if (board === 'gif') rating = 'nsfw';
|
||||||
|
else if (board === 'wsg') rating = 'sfw';
|
||||||
|
|
||||||
|
if (!rating || !['sfw', 'nsfw', 'nsfl'].includes(rating)) {
|
||||||
|
return res.reply({ code: 400, body: JSON.stringify({ success: false, msg: 'Rating is required' }) });
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = req.session;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const uuid = await queue.genuuid();
|
||||||
|
const tmpPath = path.join(cfg.paths.tmp, `${uuid}.tmp`);
|
||||||
|
|
||||||
|
// Download via curl (lightweight)
|
||||||
|
const curlArgs = [
|
||||||
|
'-s', '-f', '-L', url, '-o', tmpPath,
|
||||||
|
'--max-filesize', `${cfg.main.maxfilesize || 100 * 1024 * 1024}`,
|
||||||
|
'--connect-timeout', '30',
|
||||||
|
'--max-time', '300',
|
||||||
|
'--user-agent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
|
||||||
|
];
|
||||||
|
if (cfg.main.socks && cfg.main.socks !== 'undefined' && cfg.main.socks !== '') {
|
||||||
|
const proxyHost = cfg.main.socks.includes('://') ? cfg.main.socks.split('://')[1] : cfg.main.socks;
|
||||||
|
curlArgs.push('--socks5-hostname', proxyHost);
|
||||||
|
}
|
||||||
|
|
||||||
|
await queue.spawn('curl', curlArgs);
|
||||||
|
|
||||||
|
// Detect MIME
|
||||||
|
const mime = (await queue.spawn('file', ['--mime-type', '-b', tmpPath])).stdout.trim();
|
||||||
|
const ext = cfg.mimes[mime];
|
||||||
|
if (!ext) {
|
||||||
|
throw new Error(`Unsupported file type: ${mime}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalTmp = path.join(cfg.paths.tmp, `${uuid}.${ext}`);
|
||||||
|
await fs.rename(tmpPath, finalTmp);
|
||||||
|
|
||||||
|
const checksum = (await queue.spawn('sha256sum', [finalTmp])).stdout.trim().split(' ')[0];
|
||||||
|
|
||||||
|
// Repost check
|
||||||
|
if (!getBypassDuplicateCheck()) {
|
||||||
|
const repost = await queue.checkrepostsum(checksum);
|
||||||
|
if (repost) {
|
||||||
|
await fs.unlink(finalTmp).catch(() => {});
|
||||||
|
return res.reply({
|
||||||
|
code: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ success: true, repost: true, item_id: repost, msg: 'Already on site' })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const phash = await queue.generatePHash(finalTmp).catch(() => null);
|
||||||
|
|
||||||
|
// PHash duplicate check
|
||||||
|
if (phash && !getBypassDuplicateCheck()) {
|
||||||
|
const phashMatch = await queue.checkrepostphash(phash);
|
||||||
|
if (phashMatch) {
|
||||||
|
await fs.unlink(finalTmp).catch(() => {});
|
||||||
|
return res.reply({
|
||||||
|
code: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ success: true, repost: true, item_id: phashMatch, msg: 'Already on site (visual match)' })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const filename = `${uuid}.${ext}`;
|
||||||
|
const isApprovalRequired = getManualApproval();
|
||||||
|
const destDir = isApprovalRequired ? path.join(cfg.paths.pending, 'b') : cfg.paths.b;
|
||||||
|
|
||||||
|
await fs.copyFile(finalTmp, path.join(destDir, filename));
|
||||||
|
await fs.unlink(finalTmp).catch(() => {});
|
||||||
|
|
||||||
|
const insertChecksum = getBypassDuplicateCheck() ? `${checksum}_bypass_${Date.now()}` : checksum;
|
||||||
|
|
||||||
|
const [{ id: itemid }] = await db`
|
||||||
|
insert into items ${db({
|
||||||
|
src: url,
|
||||||
|
dest: filename,
|
||||||
|
mime: mime,
|
||||||
|
size: (await fs.stat(path.join(destDir, filename))).size,
|
||||||
|
checksum: insertChecksum,
|
||||||
|
phash: phash,
|
||||||
|
username: session.user,
|
||||||
|
userchannel: 'web',
|
||||||
|
usernetwork: 'web',
|
||||||
|
stamp: ~~(Date.now() / 1000),
|
||||||
|
active: !isApprovalRequired,
|
||||||
|
is_oc: !!is_oc
|
||||||
|
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc')}
|
||||||
|
RETURNING id
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Process thumbnail
|
||||||
|
try {
|
||||||
|
await queue.genThumbnail(filename, mime, itemid, url, isApprovalRequired);
|
||||||
|
if (rating === 'nsfw' || rating === 'nsfl') await queue.genBlurredThumbnail(itemid, isApprovalRequired);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[REHOST] Thumbnail error:', err);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tags
|
||||||
|
const ratingTagId = rating === 'sfw' ? 1 : (rating === 'nsfw' ? 2 : (cfg.nsfl_tag_id || 3));
|
||||||
|
await db`insert into tags_assign ${db({ item_id: itemid, tag_id: ratingTagId, user_id: session.id })} on conflict do nothing`;
|
||||||
|
|
||||||
|
const tags = tagsRaw ? tagsRaw.split(',').map(t => t.trim()).filter(Boolean) : [];
|
||||||
|
// Board tag in chan-style format e.g. /gif/, /wsg/
|
||||||
|
if (board) tags.push(`/${board}/`);
|
||||||
|
// Auto-tag rating based on board
|
||||||
|
if (board === 'wsg') tags.push('sfw');
|
||||||
|
else if (board === 'gif') tags.push('nsfw');
|
||||||
|
for (const tagName of tags) {
|
||||||
|
let tagRow = await db`select id from tags where normalized = slugify(${tagName}) limit 1`;
|
||||||
|
if (tagRow.length === 0) {
|
||||||
|
await db`insert into tags ${db({ tag: tagName }, 'tag')} on conflict do nothing`;
|
||||||
|
tagRow = await db`select id from tags where normalized = slugify(${tagName}) limit 1`;
|
||||||
|
}
|
||||||
|
if (tagRow.length) {
|
||||||
|
await db`insert into tags_assign ${db({ item_id: itemid, tag_id: tagRow[0].id, user_id: session.id })} on conflict do nothing`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
await db`INSERT INTO notifications (user_id, type, reference_id, item_id) VALUES (${session.id}, 'upload_success', 0, ${itemid})`;
|
||||||
|
|
||||||
|
// Broadcast new_item event for live grid updates (only if auto-approved)
|
||||||
|
if (!isApprovalRequired) {
|
||||||
|
try {
|
||||||
|
await db`SELECT pg_notify('new_item', ${JSON.stringify({
|
||||||
|
id: itemid,
|
||||||
|
dest: filename,
|
||||||
|
mime: mime,
|
||||||
|
username: session.user,
|
||||||
|
display_name: session.display_name || null,
|
||||||
|
tag_id: rating === 'sfw' ? 1 : (rating === 'nsfw' ? 2 : (cfg.nsfl_tag_id || 3)),
|
||||||
|
is_oc: false
|
||||||
|
})})`;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[REHOST] new_item notify failed:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push to Matrix channel (only if auto-approved)
|
||||||
|
if (!isApprovalRequired) {
|
||||||
|
try {
|
||||||
|
const self = router.self;
|
||||||
|
const matrixCfg = cfg.clients?.find(c => c.type === 'matrix');
|
||||||
|
if (matrixCfg?.notification_channel_id && self?.bot?.clients) {
|
||||||
|
const clients = await Promise.all(self.bot.clients);
|
||||||
|
const matrixWrapper = clients.find(c => c.type === 'matrix');
|
||||||
|
if (matrixWrapper?.client) {
|
||||||
|
const message = `${session.user} uploaded a new item ${cfg.main.url.full}/${itemid}`;
|
||||||
|
await matrixWrapper.client.send(matrixCfg.notification_channel_id, message);
|
||||||
|
console.log(`[REHOST] Matrix notification sent for item ${itemid}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[REHOST] Matrix notification error:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.reply({
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ success: true, item_id: itemid })
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[REHOST] Error:', err);
|
||||||
|
return res.reply({
|
||||||
|
code: 500,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ success: false, msg: err.message })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
};
|
||||||
@@ -139,6 +139,7 @@ export default (router, tpl) => {
|
|||||||
timeago: lib.timeAgo(userData.created_at),
|
timeago: lib.timeAgo(userData.created_at),
|
||||||
timefull: userData.created_at
|
timefull: userData.created_at
|
||||||
};
|
};
|
||||||
|
userData.age_days = Math.floor((Date.now() - new Date(userData.created_at).getTime()) / 86400000);
|
||||||
|
|
||||||
if (userData.banned) {
|
if (userData.banned) {
|
||||||
if (!userData.ban_expires) {
|
if (!userData.ban_expires) {
|
||||||
|
|||||||
@@ -6,6 +6,27 @@ import { setMotd } from "../motd.mjs";
|
|||||||
export const clients = new Set();
|
export const clients = new Set();
|
||||||
const activeTabs = new Map(); // sessionId -> tabId
|
const activeTabs = new Map(); // sessionId -> tabId
|
||||||
|
|
||||||
|
// Broadcast the deduplicated online-user list to all connected clients
|
||||||
|
function broadcastChatPresence() {
|
||||||
|
const seen = new Set();
|
||||||
|
const users = [];
|
||||||
|
for (const client of clients) {
|
||||||
|
if (client.userId && !seen.has(client.userId)) {
|
||||||
|
seen.add(client.userId);
|
||||||
|
users.push({
|
||||||
|
username: client.username,
|
||||||
|
display_name: client.display_name,
|
||||||
|
avatar_file: client.avatar_file,
|
||||||
|
avatar: client.avatar,
|
||||||
|
username_color: client.username_color
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const client of clients) {
|
||||||
|
client.send({ type: 'global_chat_presence', data: { users } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function pruneInactiveClients(sessionId, currentTabId) {
|
function pruneInactiveClients(sessionId, currentTabId) {
|
||||||
for (const client of clients) {
|
for (const client of clients) {
|
||||||
if (client.sessionId === sessionId && client.tabId !== currentTabId) {
|
if (client.sessionId === sessionId && client.tabId !== currentTabId) {
|
||||||
@@ -286,9 +307,33 @@ db.listen('global_chat_topic', (payload) => {
|
|||||||
|
|
||||||
export default (router, tpl) => {
|
export default (router, tpl) => {
|
||||||
|
|
||||||
async function getNotificationHistory(userId, page = 1, limit = 50) {
|
const USER_TYPES = ['comment_reply', 'subscription', 'mention', 'upload_comment'];
|
||||||
|
const SYSTEM_TYPES = ['approve', 'deny', 'item_deleted', 'upload_success', 'upload_error', 'admin_pending', 'report'];
|
||||||
|
|
||||||
|
async function getNotificationHistory(userId, page = 1, limit = 50, tab = null) {
|
||||||
const offset = (page - 1) * limit;
|
const offset = (page - 1) * limit;
|
||||||
const notifications = await db`
|
const typeFilter = tab === 'system' ? SYSTEM_TYPES : (tab === 'user' ? USER_TYPES : null);
|
||||||
|
const notifications = typeFilter
|
||||||
|
? await db`
|
||||||
|
SELECT n.id, n.type, n.item_id, n.reference_id, n.created_at, n.is_read, n.data,
|
||||||
|
COALESCE(u.user, 'System') as from_user,
|
||||||
|
COALESCE(uo.display_name, '') as from_display_name,
|
||||||
|
COALESCE(u.id, 0) as from_user_id,
|
||||||
|
uo.username_color,
|
||||||
|
i.dest, i.mime
|
||||||
|
FROM notifications n
|
||||||
|
LEFT JOIN comments c ON n.reference_id = c.id
|
||||||
|
LEFT JOIN "user" u ON c.user_id = u.id
|
||||||
|
LEFT JOIN user_options uo ON u.id = uo.user_id
|
||||||
|
LEFT JOIN items i ON n.item_id = i.id
|
||||||
|
WHERE n.user_id = ${userId}
|
||||||
|
AND n.type = ANY(${typeFilter})
|
||||||
|
AND (n.type IN ('admin_pending', 'deny', 'item_deleted', 'report') OR i.id IS NULL OR (i.active = true AND i.is_deleted = false))
|
||||||
|
ORDER BY n.created_at DESC
|
||||||
|
LIMIT ${limit + 1}
|
||||||
|
OFFSET ${offset}
|
||||||
|
`
|
||||||
|
: await db`
|
||||||
SELECT n.id, n.type, n.item_id, n.reference_id, n.created_at, n.is_read, n.data,
|
SELECT n.id, n.type, n.item_id, n.reference_id, n.created_at, n.is_read, n.data,
|
||||||
COALESCE(u.user, 'System') as from_user,
|
COALESCE(u.user, 'System') as from_user,
|
||||||
COALESCE(uo.display_name, '') as from_display_name,
|
COALESCE(uo.display_name, '') as from_display_name,
|
||||||
@@ -348,7 +393,7 @@ export default (router, tpl) => {
|
|||||||
WHERE n.user_id = ${req.session.id} AND n.is_read = false
|
WHERE n.user_id = ${req.session.id} AND n.is_read = false
|
||||||
AND (n.type IN ('admin_pending', 'deny', 'item_deleted', 'report') OR i.id IS NULL OR (i.active = true AND i.is_deleted = false))
|
AND (n.type IN ('admin_pending', 'deny', 'item_deleted', 'report') OR i.id IS NULL OR (i.active = true AND i.is_deleted = false))
|
||||||
ORDER BY n.created_at DESC
|
ORDER BY n.created_at DESC
|
||||||
LIMIT 20
|
LIMIT 1000
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const processed = notifications.map(n => {
|
const processed = notifications.map(n => {
|
||||||
@@ -437,17 +482,14 @@ export default (router, tpl) => {
|
|||||||
// For guests, we use tabId to avoid IP-based pruning collisions (CGNAT).
|
// For guests, we use tabId to avoid IP-based pruning collisions (CGNAT).
|
||||||
const sessionId = sessionCookie || `guest-${tabId}`;
|
const sessionId = sessionCookie || `guest-${tabId}`;
|
||||||
|
|
||||||
// Pruning/Active logic only for logged-in users
|
// sessionId used for presence deduplication only — all tabs from same session connect freely
|
||||||
|
// Soft cap: max 10 SSE connections per session (prevents runaway tab abuse)
|
||||||
|
const MAX_TABS_PER_SESSION = 10;
|
||||||
if (!isGuest) {
|
if (!isGuest) {
|
||||||
const currentActive = activeTabs.get(sessionId);
|
const sessionClients = Array.from(clients).filter(c => c.sessionId === sessionId);
|
||||||
if (currentActive && currentActive !== tabId) {
|
if (sessionClients.length >= MAX_TABS_PER_SESSION) {
|
||||||
// Check if the current active tab is actually still connected
|
// Close the oldest connection (FIFO) to free the slot
|
||||||
const activeClient = Array.from(clients).find(c => c.sessionId === sessionId && c.tabId === currentActive);
|
sessionClients[0].close();
|
||||||
if (activeClient) {
|
|
||||||
// console.log(`[SSE] Denying connection for inactive tab ${tabId} (Active: ${currentActive})`);
|
|
||||||
res.writeHead(204); // No Content
|
|
||||||
return res.end();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -463,6 +505,11 @@ export default (router, tpl) => {
|
|||||||
|
|
||||||
const client = {
|
const client = {
|
||||||
userId: (req.session && typeof req.session === 'object') ? req.session.id : null,
|
userId: (req.session && typeof req.session === 'object') ? req.session.id : null,
|
||||||
|
username: req.session?.user || null,
|
||||||
|
display_name: req.session?.display_name || null,
|
||||||
|
avatar_file: req.session?.avatar_file || null,
|
||||||
|
avatar: req.session?.avatar || null,
|
||||||
|
username_color: req.session?.username_color || null,
|
||||||
sessionId,
|
sessionId,
|
||||||
tabId,
|
tabId,
|
||||||
send: (data) => {
|
send: (data) => {
|
||||||
@@ -500,13 +547,11 @@ export default (router, tpl) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Set as active tab and prune others (only for logged-in users)
|
// Track active tab (no pruning — all tabs are allowed to coexist)
|
||||||
if (!isGuest) {
|
if (!isGuest) activeTabs.set(sessionId, tabId);
|
||||||
activeTabs.set(sessionId, tabId);
|
|
||||||
pruneInactiveClients(sessionId, tabId);
|
|
||||||
}
|
|
||||||
|
|
||||||
clients.add(client);
|
clients.add(client);
|
||||||
|
broadcastChatPresence(); // notify everyone of new user
|
||||||
|
|
||||||
// Keep-alive ping
|
// Keep-alive ping
|
||||||
const pingInterval = setInterval(() => {
|
const pingInterval = setInterval(() => {
|
||||||
@@ -520,6 +565,7 @@ export default (router, tpl) => {
|
|||||||
res.on('close', () => {
|
res.on('close', () => {
|
||||||
clearInterval(pingInterval);
|
clearInterval(pingInterval);
|
||||||
clients.delete(client);
|
clients.delete(client);
|
||||||
|
broadcastChatPresence(); // notify everyone user left
|
||||||
if (activeTabs.get(sessionId) === tabId) {
|
if (activeTabs.get(sessionId) === tabId) {
|
||||||
// activeTabs.delete(sessionId); // Keep it set so we know who was last active
|
// activeTabs.delete(sessionId); // Keep it set so we know who was last active
|
||||||
}
|
}
|
||||||
@@ -531,11 +577,9 @@ export default (router, tpl) => {
|
|||||||
const tabId = req.url.qs?.tabId;
|
const tabId = req.url.qs?.tabId;
|
||||||
const sessionId = req.cookies?.session;
|
const sessionId = req.cookies?.session;
|
||||||
|
|
||||||
// Only track active tabs for logged-in users
|
// Track which tab is focused (informational only, no pruning)
|
||||||
if (tabId && sessionId) {
|
if (tabId && sessionId) {
|
||||||
console.log(`[SSE] Tab ${tabId} became active for session ${sessionId}`);
|
|
||||||
activeTabs.set(sessionId, tabId);
|
activeTabs.set(sessionId, tabId);
|
||||||
pruneInactiveClients(sessionId, tabId);
|
|
||||||
return res.reply({ body: JSON.stringify({ success: true }) });
|
return res.reply({ body: JSON.stringify({ success: true }) });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -546,7 +590,8 @@ export default (router, tpl) => {
|
|||||||
// Notification History Page
|
// Notification History Page
|
||||||
router.get('/notifications', async (req, res) => {
|
router.get('/notifications', async (req, res) => {
|
||||||
if (!req.session) return res.redirect('/login');
|
if (!req.session) return res.redirect('/login');
|
||||||
const data = await getNotificationHistory(req.session.id, 1);
|
const tab = req.url.qs?.tab || 'user';
|
||||||
|
const data = await getNotificationHistory(req.session.id, 1, 50, tab);
|
||||||
data.session = req.session;
|
data.session = req.session;
|
||||||
data.hidePagination = true;
|
data.hidePagination = true;
|
||||||
data.pagination = {
|
data.pagination = {
|
||||||
@@ -564,7 +609,8 @@ export default (router, tpl) => {
|
|||||||
success: false
|
success: false
|
||||||
}, 401);
|
}, 401);
|
||||||
const page = parseInt(req.url.qs.page) || 1;
|
const page = parseInt(req.url.qs.page) || 1;
|
||||||
const data = await getNotificationHistory(req.session.id, page);
|
const tab = req.url.qs.tab || null;
|
||||||
|
const data = await getNotificationHistory(req.session.id, page, 50, tab);
|
||||||
|
|
||||||
const html = tpl.render('snippets/notifications-list', data, req);
|
const html = tpl.render('snippets/notifications-list', data, req);
|
||||||
|
|
||||||
|
|||||||
@@ -414,7 +414,7 @@ process.on('uncaughtException', err => {
|
|||||||
// because the session middleware will have completed by the time router callbacks execute.
|
// because the session middleware will have completed by the time router callbacks execute.
|
||||||
app.use(async (req, res) => {
|
app.use(async (req, res) => {
|
||||||
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return;
|
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return;
|
||||||
if (['/login', '/register', '/api/v2/upload', '/api/v2/settings/uploadAvatar', '/api/v2/admin/memes', '/api/v2/admin/emojis', '/api/v2/meta/extract-file', '/api/v2/meta/strip-gps'].includes(req.url.pathname)) return;
|
if (['/login', '/register', '/api/v2/upload', '/api/v2/settings/uploadAvatar', '/api/v2/admin/memes', '/api/v2/admin/emojis', '/api/v2/meta/extract-file', '/api/v2/meta/strip-gps', '/api/v2/scroller/external/rehost-meta'].includes(req.url.pathname)) return;
|
||||||
// Hall manager routes are handled by bypass middleware with their own session auth
|
// Hall manager routes are handled by bypass middleware with their own session auth
|
||||||
if (req.url.pathname.match(/^\/api\/v2\/admin\/halls(\/|$)/)) return;
|
if (req.url.pathname.match(/^\/api\/v2\/admin\/halls(\/|$)/)) return;
|
||||||
// User hall image upload is handled by bypass middleware below
|
// User hall image upload is handled by bypass middleware below
|
||||||
@@ -737,6 +737,7 @@ process.on('uncaughtException', err => {
|
|||||||
custom_brand_images_json: JSON.stringify(cfg.websrv.custom_brand_image || []),
|
custom_brand_images_json: JSON.stringify(cfg.websrv.custom_brand_image || []),
|
||||||
allowed_comment_images: cfg.websrv.allowed_comment_images || [],
|
allowed_comment_images: cfg.websrv.allowed_comment_images || [],
|
||||||
allowed_comment_images_json: JSON.stringify(cfg.websrv.allowed_comment_images || []),
|
allowed_comment_images_json: JSON.stringify(cfg.websrv.allowed_comment_images || []),
|
||||||
|
paths_images: cfg.websrv.paths?.images || '/b',
|
||||||
|
|
||||||
get fonts() {
|
get fonts() {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -71,11 +71,13 @@
|
|||||||
<button data-id="{{ u.id }}" data-name="{!! u.user !!}" data-username="{{ u.login }}" onclick="deleteUploads(this)" class="btn-modern btn-files">Del Files</button>
|
<button data-id="{{ u.id }}" data-name="{!! u.user !!}" data-username="{{ u.login }}" onclick="deleteUploads(this)" class="btn-modern btn-files">Del Files</button>
|
||||||
<button data-id="{{ u.id }}" data-name="{!! u.user !!}" data-username="{{ u.login }}" onclick="deleteComments(this)" class="btn-modern btn-comms">Del Comms</button>
|
<button data-id="{{ u.id }}" data-name="{!! u.user !!}" data-username="{{ u.login }}" onclick="deleteComments(this)" class="btn-modern btn-comms">Del Comms</button>
|
||||||
<button data-id="{{ u.id }}" data-name="{!! u.user !!}" data-username="{{ u.login }}" onclick="adminBulkDeleteHalls(this)" class="btn-modern btn-comms" title="Delete all user halls" style="background: rgba(255, 0, 255, 0.1); color: #ff00ff; border: 1px solid rgba(255, 0, 255, 0.2);"><i class="fa fa-folder-open"></i> Del Halls</button>
|
<button data-id="{{ u.id }}" data-name="{!! u.user !!}" data-username="{{ u.login }}" onclick="adminBulkDeleteHalls(this)" class="btn-modern btn-comms" title="Delete all user halls" style="background: rgba(255, 0, 255, 0.1); color: #ff00ff; border: 1px solid rgba(255, 0, 255, 0.2);"><i class="fa fa-folder-open"></i> Del Halls</button>
|
||||||
|
<button data-id="{{ u.id }}" data-name="{!! u.user !!}" data-username="{{ u.login }}" onclick="adminReassignUploads(this)" class="btn-modern" title="Reassign uploads to another user" style="background: rgba(0, 200, 180, 0.1); color: #00c8b4; border: 1px solid rgba(0, 200, 180, 0.2);"><i class="fa fa-right-left"></i> Reassign</button>
|
||||||
@if(u.failed_attempts > 0)
|
@if(u.failed_attempts > 0)
|
||||||
<button data-username="{{ u.login }}" onclick="adminResetLoginAttempts(this)" class="btn-modern btn-pw" title="Reset Login Attempts" style="background: rgba(255, 204, 0, 0.1); color: #ffcc00; border: 1px solid rgba(255, 204, 0, 0.2);"><i class="fa fa-unlock"></i> Reset IP</button>
|
<button data-username="{{ u.login }}" onclick="adminResetLoginAttempts(this)" class="btn-modern btn-pw" title="Reset Login Attempts" style="background: rgba(255, 204, 0, 0.1); color: #ffcc00; border: 1px solid rgba(255, 204, 0, 0.2);"><i class="fa fa-unlock"></i> Reset IP</button>
|
||||||
@endif
|
@endif
|
||||||
@else
|
@else
|
||||||
<button data-id="" data-name="{{ u.user }}" data-username="{{ u.login }}" onclick="deleteUploads(this)" class="btn-modern btn-files">Del Legacy Files</button>
|
<button data-id="" data-name="{{ u.user }}" data-username="{{ u.login }}" onclick="deleteUploads(this)" class="btn-modern btn-files">Del Legacy Files</button>
|
||||||
|
<button data-id="" data-name="{{ u.user }}" data-username="{{ u.login }}" onclick="adminReassignUploads(this)" class="btn-modern" title="Reassign uploads to another user" style="background: rgba(0, 200, 180, 0.1); color: #00c8b4; border: 1px solid rgba(0, 200, 180, 0.2);"><i class="fa fa-right-left"></i> Reassign</button>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
@if(u.id && u.login !== 'deleted_user')
|
@if(u.id && u.login !== 'deleted_user')
|
||||||
|
|||||||
@@ -6,7 +6,11 @@
|
|||||||
<h2>{{ t('notifications.page_title') }}</h2>
|
<h2>{{ t('notifications.page_title') }}</h2>
|
||||||
<button id="mark-all-read-page" class="btn-small">{{ t('notifications.mark_all_read') }}</button>
|
<button id="mark-all-read-page" class="btn-small">{{ t('notifications.mark_all_read') }}</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="notifications-container" class="posts notifications-list-full" data-page="{{ pagination.page }}">
|
<div class="notif-page-tabs">
|
||||||
|
<button class="notif-page-tab active" data-tab="user">{{ t('nav.notif_tab_user') }}</button>
|
||||||
|
<button class="notif-page-tab" data-tab="system">{{ t('nav.notif_tab_system') }}</button>
|
||||||
|
</div>
|
||||||
|
<div id="notifications-container" class="posts notifications-list-full" data-page="{{ pagination.page }}" data-tab="user">
|
||||||
@include(snippets/notifications-list)
|
@include(snippets/notifications-list)
|
||||||
</div>
|
</div>
|
||||||
@if(pagination.next)
|
@if(pagination.next)
|
||||||
@@ -15,8 +19,38 @@
|
|||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
<script>
|
<script>
|
||||||
// Initialize mark all read for the page
|
// Tab switching for notification history page
|
||||||
(function () {
|
(function () {
|
||||||
|
const USER_TYPES = ['comment_reply', 'subscription', 'mention', 'upload_comment'];
|
||||||
|
const tabs = document.querySelectorAll('.notif-page-tab');
|
||||||
|
const container = document.getElementById('notifications-container');
|
||||||
|
|
||||||
|
tabs.forEach(tab => {
|
||||||
|
tab.addEventListener('click', async () => {
|
||||||
|
tabs.forEach(t => t.classList.remove('active'));
|
||||||
|
tab.classList.add('active');
|
||||||
|
const tabName = tab.dataset.tab;
|
||||||
|
container.dataset.tab = tabName;
|
||||||
|
container.dataset.page = '1';
|
||||||
|
|
||||||
|
// Load first page for this tab
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/ajax/notifications?page=1&tab=${tabName}`);
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success) {
|
||||||
|
container.innerHTML = data.html || `<div class="notif-empty">${window.f0ckI18n?.no_notifications || 'No new notifications'}</div>`;
|
||||||
|
const footbar = document.getElementById('footbar');
|
||||||
|
if (footbar) {
|
||||||
|
footbar.style.display = data.hasMore ? '' : 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load notifications tab', e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mark all read
|
||||||
const btn = document.getElementById('mark-all-read-page');
|
const btn = document.getElementById('mark-all-read-page');
|
||||||
if (btn) {
|
if (btn) {
|
||||||
btn.onclick = async () => {
|
btn.onclick = async () => {
|
||||||
|
|||||||
@@ -70,6 +70,10 @@
|
|||||||
}
|
}
|
||||||
.topbar-icon-btn:hover { background: rgba(255,255,255,.18); transform: scale(1.07); }
|
.topbar-icon-btn:hover { background: rgba(255,255,255,.18); transform: scale(1.07); }
|
||||||
.topbar-icon-btn.has-filter { border-color: var(--accent); color: var(--accent); }
|
.topbar-icon-btn.has-filter { border-color: var(--accent); color: var(--accent); }
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.topbar-left, .topbar-right { gap: 4px; }
|
||||||
|
.topbar-icon-btn { width: 32px; height: 32px; font-size: .78rem; }
|
||||||
|
}
|
||||||
#filter-active-summary {
|
#filter-active-summary {
|
||||||
display: none;
|
display: none;
|
||||||
background: rgba(0,0,0,.6); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
|
background: rgba(0,0,0,.6); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
|
||||||
@@ -236,6 +240,65 @@
|
|||||||
font-size: .6rem; font-weight: 700; line-height: 16px;
|
font-size: .6rem; font-weight: 700; line-height: 16px;
|
||||||
text-align: center; pointer-events: none;
|
text-align: center; pointer-events: none;
|
||||||
}
|
}
|
||||||
|
/* Notification dropdown inside scroller */
|
||||||
|
#scroller-notif-dropdown {
|
||||||
|
width: 340px; max-width: calc(100vw - 16px);
|
||||||
|
background: #1a1a1a; border: 1px solid rgba(255,255,255,.1);
|
||||||
|
box-shadow: 0 8px 32px rgba(0,0,0,.6); border-radius: 8px;
|
||||||
|
overflow: hidden; opacity: 0; transform: translateY(-6px);
|
||||||
|
transition: opacity .2s ease, transform .2s ease;
|
||||||
|
}
|
||||||
|
#scroller-notif-dropdown.visible { opacity: 1; transform: translateY(0); display: block !important; }
|
||||||
|
.notif-header {
|
||||||
|
padding: 8px 10px 6px; border-bottom: 1px solid rgba(255,255,255,.08);
|
||||||
|
display: flex; justify-content: space-between; align-items: center; gap: 8px;
|
||||||
|
font-size: .82rem; font-weight: 700; color: rgba(255,255,255,.75); background: #111;
|
||||||
|
}
|
||||||
|
.notif-header button#scroller-mark-all-read {
|
||||||
|
background: none; border: none; color: var(--accent, #e91e8c); cursor: pointer;
|
||||||
|
font-size: .8rem; padding: 2px 4px; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.notif-header button#scroller-mark-all-read:hover { color: #fff; }
|
||||||
|
.notif-tabs { display: flex; gap: 2px; flex: 1; min-width: 0; }
|
||||||
|
.notif-tab {
|
||||||
|
background: none; border: none; color: #888; font-size: .72rem; font-weight: 600;
|
||||||
|
padding: 4px 10px; cursor: pointer; border-radius: 4px; transition: all .15s ease;
|
||||||
|
text-transform: uppercase; letter-spacing: .5px; white-space: nowrap; position: relative;
|
||||||
|
}
|
||||||
|
.notif-tab:hover { color: #ccc; background: rgba(255,255,255,.05); }
|
||||||
|
.notif-tab.active { color: var(--accent, #e91e8c); background: rgba(233,30,140,.1); }
|
||||||
|
.notif-tab-badge {
|
||||||
|
display: inline-block; background: #e91e8c; 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;
|
||||||
|
}
|
||||||
|
.notif-list { max-height: 360px; overflow-y: auto; }
|
||||||
|
.notif-item {
|
||||||
|
padding: 10px 14px; border-bottom: 1px solid rgba(255,255,255,.05);
|
||||||
|
background: #1a1a1a; cursor: pointer; transition: background .15s;
|
||||||
|
font-size: .82rem; color: #ddd; display: block; text-decoration: none;
|
||||||
|
}
|
||||||
|
.notif-item:hover { background: #262626; color: #fff; text-decoration: none; }
|
||||||
|
.notif-item.unread { border-left: 3px solid var(--accent, #e91e8c); background: rgba(255,255,255,.03); }
|
||||||
|
.notif-item.notif-with-thumb { display: flex; align-items: flex-start; gap: 10px; }
|
||||||
|
.notif-thumb {
|
||||||
|
flex-shrink: 0; width: 56px; height: 56px; border-radius: 4px;
|
||||||
|
overflow: hidden; background: rgba(255,255,255,.05); border: 1px solid rgba(255,255,255,.08);
|
||||||
|
}
|
||||||
|
.notif-thumb img { width: 100%; height: 100%; object-fit: cover; }
|
||||||
|
.notif-content { flex: 1; min-width: 0; }
|
||||||
|
.notif-user { font-size: .8rem; margin-bottom: 2px; }
|
||||||
|
.notif-msg { color: #aaa; font-size: .78rem; line-height: 1.3; }
|
||||||
|
.notif-time { font-size: .7rem; color: #666; margin-top: 3px; display: block; }
|
||||||
|
.notif-empty { text-align: center; padding: 20px; color: #666; font-size: .82rem; }
|
||||||
|
.notif-footer {
|
||||||
|
padding: 8px 14px; border-top: 1px solid rgba(255,255,255,.08);
|
||||||
|
text-align: center; background: #111;
|
||||||
|
}
|
||||||
|
.notif-footer a, .view-all-notifs {
|
||||||
|
color: var(--accent, #e91e8c); font-size: .78rem; text-decoration: none;
|
||||||
|
}
|
||||||
|
.notif-footer a:hover { text-decoration: underline; }
|
||||||
.scroll-id-link {
|
.scroll-id-link {
|
||||||
display: inline-flex; align-items: center; gap: 5px; font-size: .76rem; color: var(--accent); font-weight: 700;
|
display: inline-flex; align-items: center; gap: 5px; font-size: .76rem; color: var(--accent); font-weight: 700;
|
||||||
text-decoration: none; margin-top: 2px; pointer-events: all; position: relative; z-index: 11; width: fit-content;
|
text-decoration: none; margin-top: 2px; pointer-events: all; position: relative; z-index: 11; width: fit-content;
|
||||||
@@ -277,6 +340,36 @@
|
|||||||
.scroll-btn.faved .scroll-btn-icon i { color: #ff4081; }
|
.scroll-btn.faved .scroll-btn-icon i { color: #ff4081; }
|
||||||
.scroll-btn.faved .scroll-btn-count { color: #ff4081; }
|
.scroll-btn.faved .scroll-btn-count { color: #ff4081; }
|
||||||
|
|
||||||
|
/* Add to site button (External items) */
|
||||||
|
.scroll-btn.rehost-btn .scroll-btn-icon {
|
||||||
|
background: rgba(255, 255, 255, .15);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 1.4rem;
|
||||||
|
}
|
||||||
|
.scroll-btn.rehost-btn:hover .scroll-btn-icon {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
.scroll-btn.rehost-btn.loading .scroll-btn-icon i {
|
||||||
|
animation: spin .8s linear infinite;
|
||||||
|
}
|
||||||
|
.scroll-btn.rehost-btn.success .scroll-btn-icon {
|
||||||
|
background: #4caf50;
|
||||||
|
border-color: #4caf50;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Progress indicators for rehosting */
|
||||||
|
.rehost-progress-toast {
|
||||||
|
position: fixed; bottom: 80px; left: 50%; transform: translateX(-50%);
|
||||||
|
background: rgba(0,0,0,0.85); backdrop-filter: blur(10px);
|
||||||
|
padding: 10px 20px; border-radius: 50px; border: 1px solid var(--accent);
|
||||||
|
z-index: 1000; font-size: 0.85rem; font-weight: 700; color: #fff;
|
||||||
|
display: none; align-items: center; gap: 10px;
|
||||||
|
}
|
||||||
|
.rehost-progress-toast.show { display: flex; }
|
||||||
|
|
||||||
/* ── PROGRESS BAR ─────────────────────────────── */
|
/* ── PROGRESS BAR ─────────────────────────────── */
|
||||||
.scroll-progress-bar {
|
.scroll-progress-bar {
|
||||||
position: absolute; bottom: 0; left: 0; right: 0; height: 4px;
|
position: absolute; bottom: 0; left: 0; right: 0; height: 4px;
|
||||||
@@ -498,7 +591,26 @@
|
|||||||
.scroller-spoiler.revealed, .scroller-spoiler:hover { color: inherit; background: rgba(255,255,255,.1); }
|
.scroller-spoiler.revealed, .scroller-spoiler:hover { color: inherit; background: rgba(255,255,255,.1); }
|
||||||
.scroller-blur { filter: blur(5px); cursor: pointer; transition: filter .2s; display: inline-block; }
|
.scroller-blur { filter: blur(5px); cursor: pointer; transition: filter .2s; display: inline-block; }
|
||||||
.scroller-blur.revealed, .scroller-blur:hover { filter: none; }
|
.scroller-blur.revealed, .scroller-blur:hover { filter: none; }
|
||||||
.comment-time { font-size: .67rem; color: rgba(255,255,255,.38); margin-top: 4px; }
|
.comment-meta { display: flex; align-items: center; gap: 10px; margin-top: 4px; }
|
||||||
|
.comment-time { font-size: .67rem; color: rgba(255,255,255,.38); }
|
||||||
|
.comment-reply-btn {
|
||||||
|
background: none; border: none; color: rgba(255,255,255,.45); font-size: .67rem;
|
||||||
|
font-weight: 700; cursor: pointer; padding: 0; text-transform: none;
|
||||||
|
}
|
||||||
|
.comment-reply-btn:hover { color: var(--accent, #fff); }
|
||||||
|
#reply-indicator {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
padding: 6px 12px; background: rgba(255,255,255,.05);
|
||||||
|
border-top: 2px solid var(--accent, #4fc3f7);
|
||||||
|
font-size: .78rem; color: rgba(255,255,255,.65);
|
||||||
|
}
|
||||||
|
.reply-indicator-icon { font-size: .72rem; color: var(--accent, #4fc3f7); }
|
||||||
|
.reply-indicator-text { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
#reply-cancel-btn {
|
||||||
|
background: none; border: none; color: rgba(255,255,255,.4); cursor: pointer;
|
||||||
|
padding: 2px 4px; font-size: .8rem;
|
||||||
|
}
|
||||||
|
#reply-cancel-btn:hover { color: #fff; }
|
||||||
#comments-loading, #comments-empty { text-align: center; padding: 40px 20px; color: rgba(255,255,255,.35); font-size: .85rem; }
|
#comments-loading, #comments-empty { text-align: center; padding: 40px 20px; color: rgba(255,255,255,.35); font-size: .85rem; }
|
||||||
#comments-loading .loader-spinner { margin: 0 auto 10px; }
|
#comments-loading .loader-spinner { margin: 0 auto 10px; }
|
||||||
|
|
||||||
@@ -687,6 +799,101 @@
|
|||||||
}
|
}
|
||||||
.scroll-tag-sugg-item:hover, .scroll-tag-sugg-item.selected { background: rgba(255,255,255,.08); }
|
.scroll-tag-sugg-item:hover, .scroll-tag-sugg-item.selected { background: rgba(255,255,255,.08); }
|
||||||
.scroll-tag-sugg-count { font-size: .72rem; color: rgba(255,255,255,.35); margin-left: auto; }
|
.scroll-tag-sugg-count { font-size: .72rem; color: rgba(255,255,255,.35); margin-left: auto; }
|
||||||
|
|
||||||
|
/* ── 4CHAN CATALOG GRID ──────────────────────── */
|
||||||
|
#chan-panel { z-index: 802; }
|
||||||
|
.chan-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 10px; }
|
||||||
|
.chan-thread-card {
|
||||||
|
background: rgba(255,255,255,.05); border: 1px solid rgba(255,255,255,.08);
|
||||||
|
border-radius: 12px; overflow: hidden; cursor: pointer; transition: background .14s, border-color .14s, transform .12s;
|
||||||
|
}
|
||||||
|
.chan-thread-card:hover { background: rgba(255,255,255,.1); border-color: var(--accent); transform: translateY(-2px); }
|
||||||
|
.chan-thread-card.sticky { border-color: rgba(255,200,0,.3); }
|
||||||
|
.chan-thread-thumb {
|
||||||
|
width: 100%; aspect-ratio: 16/9; object-fit: cover; display: block;
|
||||||
|
background: rgba(255,255,255,.03);
|
||||||
|
}
|
||||||
|
.chan-thread-info { padding: 8px 10px; }
|
||||||
|
.chan-thread-sub {
|
||||||
|
font-size: .76rem; font-weight: 700; color: #fff; line-height: 1.3;
|
||||||
|
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
|
||||||
|
min-height: 1.3em;
|
||||||
|
}
|
||||||
|
.chan-thread-com {
|
||||||
|
font-size: .68rem; color: rgba(255,255,255,.5); margin-top: 3px; line-height: 1.25;
|
||||||
|
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
|
||||||
|
}
|
||||||
|
.chan-thread-stats {
|
||||||
|
display: flex; gap: 10px; margin-top: 6px; font-size: .65rem; color: rgba(255,255,255,.4); font-weight: 600;
|
||||||
|
}
|
||||||
|
.chan-thread-stats i { margin-right: 3px; font-size: .58rem; }
|
||||||
|
#chan-open-btn i { color: #789922; }
|
||||||
|
#chan-open-btn:hover i { color: var(--accent); }
|
||||||
|
#chan-gallery-btn.active i { color: var(--accent); }
|
||||||
|
|
||||||
|
/* ── Thread Gallery Sidebar ──────── */
|
||||||
|
#chan-gallery-sidebar {
|
||||||
|
position: fixed; top: 55px; right: 0; bottom: 5px; width: 180px;
|
||||||
|
background: rgba(10,10,14,.92); backdrop-filter: blur(20px);
|
||||||
|
border-left: 1px solid rgba(255,255,255,.08);
|
||||||
|
overflow-y: auto; overflow-x: hidden;
|
||||||
|
z-index: 90; display: none;
|
||||||
|
scrollbar-width: thin; scrollbar-color: rgba(255,255,255,.15) transparent;
|
||||||
|
}
|
||||||
|
#chan-gallery-sidebar.open { display: block; }
|
||||||
|
#chan-gallery-sidebar::-webkit-scrollbar { width: 4px; }
|
||||||
|
#chan-gallery-sidebar::-webkit-scrollbar-thumb { background: rgba(255,255,255,.15); border-radius: 2px; }
|
||||||
|
.gallery-thumb {
|
||||||
|
width: 100%; aspect-ratio: 1; object-fit: cover; display: block;
|
||||||
|
cursor: pointer; opacity: .6; transition: opacity .15s, outline-color .15s;
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,.06);
|
||||||
|
outline: 2px solid transparent; outline-offset: -2px;
|
||||||
|
}
|
||||||
|
.gallery-thumb:hover { opacity: 1; }
|
||||||
|
.gallery-thumb.active { opacity: 1; outline-color: var(--accent); }
|
||||||
|
.gallery-thumb-wrap {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.gallery-thumb-idx {
|
||||||
|
position: absolute; top: 3px; left: 5px;
|
||||||
|
font-size: .6rem; font-weight: 700; color: rgba(255,255,255,.7);
|
||||||
|
text-shadow: 0 1px 3px rgba(0,0,0,.8);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
/* When gallery is open, narrow the feed */
|
||||||
|
body.gallery-open #scroller-feed { margin-right: 180px; }
|
||||||
|
body.gallery-open .scroll-actions { right: 192px; }
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
#chan-gallery-sidebar { width: 100px; top: 50px; }
|
||||||
|
body.gallery-open #scroller-feed { margin-right: 100px; }
|
||||||
|
body.gallery-open .scroll-actions { right: 112px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── LEFT HAND MODE ──────────────────────── */
|
||||||
|
body.left-hand-mode .scroll-actions { right: auto; left: 10px; }
|
||||||
|
body.left-hand-mode .scroll-meta { right: 0; left: 72px; padding-left: 0; padding-right: 16px; text-align: right; }
|
||||||
|
body.left-hand-mode .scroll-meta-inner { align-items: flex-end; }
|
||||||
|
body.left-hand-mode .scroll-meta-top { flex-direction: row-reverse; }
|
||||||
|
body.left-hand-mode .scroll-tags { justify-content: flex-end; }
|
||||||
|
body.left-hand-mode .scroll-badges { justify-content: flex-end; }
|
||||||
|
body.left-hand-mode.gallery-open .scroll-actions { left: 10px; right: auto; }
|
||||||
|
body.left-hand-mode .topbar-right #chan-open-btn,
|
||||||
|
body.left-hand-mode .topbar-right #chan-gallery-btn { display: none !important; }
|
||||||
|
/* 4chan buttons styled as side-action buttons when inside scroll-actions */
|
||||||
|
.scroll-actions .chan-action-btn {
|
||||||
|
display: flex; flex-direction: column; align-items: center; gap: 3px;
|
||||||
|
cursor: pointer; color: #fff; background: none; border: none; padding: 0;
|
||||||
|
transition: transform .12s;
|
||||||
|
}
|
||||||
|
.scroll-actions .chan-action-btn:hover { transform: scale(1.14); }
|
||||||
|
.scroll-actions .chan-action-btn .scroll-btn-icon {
|
||||||
|
width: 46px; height: 46px; border-radius: 50%;
|
||||||
|
background: rgba(255,255,255,.1); backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
|
||||||
|
border: 1px solid rgba(255,255,255,.14); display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 1.05rem; transition: background .17s;
|
||||||
|
}
|
||||||
|
.scroll-actions .chan-action-btn:hover .scroll-btn-icon { background: rgba(255,255,255,.22); }
|
||||||
|
.scroll-actions .chan-action-btn .scroll-btn-label { font-size: .6rem; font-weight: 600; letter-spacing: .03em; color: rgba(255,255,255,.75); }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body class="scroller-active">
|
<body class="scroller-active">
|
||||||
@@ -703,36 +910,128 @@
|
|||||||
window.scrollerRuffleVolume = @if(typeof session !== 'undefined' && session && session.ruffle_volume !== undefined && session.ruffle_volume !== null){{ session.ruffle_volume }}@else 0.5@endif;
|
window.scrollerRuffleVolume = @if(typeof session !== 'undefined' && session && session.ruffle_volume !== undefined && session.ruffle_volume !== null){{ session.ruffle_volume }}@else 0.5@endif;
|
||||||
window.f0ckAllowedImages = {{ allowed_comment_images_json }};
|
window.f0ckAllowedImages = {{ allowed_comment_images_json }};
|
||||||
window.scrollerPublic = {{ private_society ? 'false' : 'true' }};
|
window.scrollerPublic = {{ private_society ? 'false' : 'true' }};
|
||||||
|
window.scrollerMimeCats = {{ JSON.stringify(scroller_mime_cats) }};
|
||||||
@if(typeof session !== 'undefined' && session)
|
@if(typeof session !== 'undefined' && session)
|
||||||
window.scrollerUsername = "{{ session.user || '' }}";
|
window.scrollerUsername = "{{ session.user || '' }}";
|
||||||
window.scrollerDisplayName = "{{ session.display_name || session.user || '' }}";
|
window.scrollerDisplayName = "{{ session.display_name || session.user || '' }}";
|
||||||
window.scrollerUserAvatar = "{{ session.avatar_file ? '/a/' + session.avatar_file : (session.avatar ? '/t/' + session.avatar + '.webp' : '/a/default.png') }}";
|
window.scrollerUserAvatar = "{{ session.avatar_file ? '/a/' + session.avatar_file : (session.avatar ? '/t/' + session.avatar + '.webp' : '/a/default.png') }}";
|
||||||
|
window.f0ckI18n = {
|
||||||
|
// notifications
|
||||||
|
notif_upload_approved: "{{ t('notifications.upload_approved_short') }}",
|
||||||
|
notif_upload_pending: "{{ t('notifications.upload_pending_short') }}",
|
||||||
|
notif_new_report: "{{ t('notifications.new_report_short') }}",
|
||||||
|
notif_upload_denied: "{{ t('notifications.upload_denied_short') }}",
|
||||||
|
notif_upload_deleted: "{{ t('notifications.upload_deleted_short') }}",
|
||||||
|
notif_upload_success: "{{ t('notifications.upload_success') }}",
|
||||||
|
notif_upload_error: "{{ t('notifications.upload_error') }}",
|
||||||
|
notif_replied: "{{ t('notifications.replied_short') }}",
|
||||||
|
notif_subscribed: "{{ t('notifications.subscribed_short') }}",
|
||||||
|
notif_mentioned: "{{ t('notifications.mentioned_short') }}",
|
||||||
|
notif_commented: "{{ t('notifications.commented') }}",
|
||||||
|
notif_system: "{{ t('notifications.system') }}",
|
||||||
|
notif_admin: "{{ t('notifications.admin') }}",
|
||||||
|
notif_moderation: "{{ t('notifications.moderation') }}",
|
||||||
|
notif_tab_user: "{{ t('nav.notif_tab_user') }}",
|
||||||
|
notif_tab_system: "{{ t('nav.notif_tab_system') }}",
|
||||||
|
no_notifications: "{{ t('nav.no_notifications') }}",
|
||||||
|
// scroller
|
||||||
|
just_now: "{{ t('scroller.just_now') }}",
|
||||||
|
add: "{{ t('scroller.add') }}",
|
||||||
|
update_preset: "{{ t('scroller.update_preset') }}",
|
||||||
|
update_preset_sub: "{{ t('scroller.update_preset_sub') }}",
|
||||||
|
no_presets: "{{ t('scroller.no_presets') }}",
|
||||||
|
copy_clipboard: "{{ t('scroller.copy_clipboard') }}",
|
||||||
|
copied: "{{ t('scroller.copied') }}",
|
||||||
|
recent: "{{ t('scroller.recent') }}",
|
||||||
|
nothing_found: "{{ t('scroller.nothing_found') }}",
|
||||||
|
adjust_filters: "{{ t('scroller.adjust_filters') }}",
|
||||||
|
failed_load_comments: "{{ t('scroller.failed_load_comments') }}",
|
||||||
|
no_custom_emojis: "{{ t('scroller.no_custom_emojis') }}",
|
||||||
|
login_required: "{{ t('scroller.login_required') }}",
|
||||||
|
rehost_failed: "{{ t('scroller.rehost_failed') }}",
|
||||||
|
chan_load_failed: "{{ t('scroller.chan_load_failed') }}",
|
||||||
|
fetch_failed: "{{ t('scroller.fetch_failed') }}",
|
||||||
|
invalid_chan_url: "{{ t('scroller.invalid_chan_url') }}",
|
||||||
|
chan_catalog_failed: "{{ t('scroller.chan_catalog_failed') }}",
|
||||||
|
anonymous: "{{ t('scroller.anonymous') }}",
|
||||||
|
// timeago
|
||||||
|
ta_just_now: "{{ t('timeago.just_now') }}",
|
||||||
|
ta_second: "{{ t('timeago.second') }}",
|
||||||
|
ta_seconds: "{{ t('timeago.seconds') }}",
|
||||||
|
ta_minute: "{{ t('timeago.minute') }}",
|
||||||
|
ta_minutes: "{{ t('timeago.minutes') }}",
|
||||||
|
ta_hour: "{{ t('timeago.hour') }}",
|
||||||
|
ta_hours: "{{ t('timeago.hours') }}",
|
||||||
|
ta_day: "{{ t('timeago.day') }}",
|
||||||
|
ta_days: "{{ t('timeago.days') }}",
|
||||||
|
ta_week: "{{ t('timeago.week') }}",
|
||||||
|
ta_weeks: "{{ t('timeago.weeks') }}",
|
||||||
|
ta_month: "{{ t('timeago.month') }}",
|
||||||
|
ta_months: "{{ t('timeago.months') }}",
|
||||||
|
ta_year: "{{ t('timeago.year') }}",
|
||||||
|
ta_years: "{{ t('timeago.years') }}",
|
||||||
|
ta_ago: "{{ t('timeago.ago') }}",
|
||||||
|
// actions
|
||||||
|
favourite: "{{ t('scroller.favourite') }}",
|
||||||
|
comments_label: "{{ t('scroller.comments') }}",
|
||||||
|
add_tag: "{{ t('scroller.add_tag') }}",
|
||||||
|
share_label: "{{ t('scroller.share') }}",
|
||||||
|
open_label: "{{ t('scroller.open') }}",
|
||||||
|
add_label: "{{ t('scroller.add') }}",
|
||||||
|
view_label: "{{ t('scroller.view') }}",
|
||||||
|
open_post: "{{ t('scroller.open_post') }}",
|
||||||
|
already_added: "{{ t('scroller.already_added') }}",
|
||||||
|
add_to_site: "{{ t('scroller.add_to_site') }}",
|
||||||
|
add_to_site_first: "{{ t('scroller.add_to_site_first') }}",
|
||||||
|
// reply
|
||||||
|
replying_to: "{{ t('scroller.replying_to') }}",
|
||||||
|
reply: "{{ t('scroller.reply') }}"
|
||||||
|
};
|
||||||
@endif
|
@endif
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Loader -->
|
<!-- Loader -->
|
||||||
<div id="scroller-loader">
|
<div id="scroller-loader">
|
||||||
<div class="loader-logo">{{ domain }}</div>
|
<div class="loader-logo">{{ domain }}</div>
|
||||||
<div class="loader-sub">doomscroll</div>
|
<div class="loader-sub">{{ t('scroller.doomscroll') }}</div>
|
||||||
<div class="loader-spinner"></div>
|
<div class="loader-spinner"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Top bar -->
|
<!-- Top bar -->
|
||||||
<div id="scroller-topbar">
|
<div id="scroller-topbar">
|
||||||
<div class="topbar-left">
|
<div class="topbar-left">
|
||||||
<a id="scroller-back" class="topbar-icon-btn" href="/" title="Back"><i class="fa-solid fa-arrow-left"></i></a>
|
<a id="scroller-back" class="topbar-icon-btn" href="/" title="{{ t('scroller.back') }}"><i class="fa-solid fa-arrow-left"></i></a>
|
||||||
<div id="filter-active-summary"></div>
|
<div id="filter-active-summary"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="topbar-right">
|
<div class="topbar-right">
|
||||||
<button id="settings-open-btn" class="topbar-icon-btn" title="Settings"><i class="fa-solid fa-gear"></i></button>
|
<button id="chan-open-btn" class="topbar-icon-btn" title="{{ t('scroller.chan_threads') }}" style="display:none"><i class="fa-solid fa-clover"></i></button>
|
||||||
<button id="filter-open-btn" class="topbar-icon-btn" title="Filters (F)"><i class="fa-solid fa-sliders"></i></button>
|
<button id="chan-gallery-btn" class="topbar-icon-btn" title="{{ t('scroller.thread_gallery') }} (G)" style="display:none"><i class="fa-solid fa-grip"></i></button>
|
||||||
|
<button id="settings-open-btn" class="topbar-icon-btn" title="{{ t('scroller.settings') }}"><i class="fa-solid fa-gear"></i></button>
|
||||||
|
<button id="filter-open-btn" class="topbar-icon-btn" title="{{ t('scroller.filters') }} (F)"><i class="fa-solid fa-sliders"></i></button>
|
||||||
@if(typeof session !== 'undefined' && session)
|
@if(typeof session !== 'undefined' && session)
|
||||||
<a id="scroller-notif-btn" class="topbar-icon-btn" href="/notifications" title="Notifications">
|
<div id="scroller-notif-wrap" style="position:relative;">
|
||||||
|
<button id="scroller-notif-btn" class="topbar-icon-btn" title="Notifications">
|
||||||
<i class="fa-solid fa-bell"></i>
|
<i class="fa-solid fa-bell"></i>
|
||||||
<span id="scroller-notif-badge"></span>
|
<span id="scroller-notif-badge"></span>
|
||||||
</a>
|
</button>
|
||||||
|
<div id="scroller-notif-dropdown" class="notif-dropdown" style="position:fixed; z-index:99999; display:none;">
|
||||||
|
<div class="notif-header">
|
||||||
|
<div class="notif-tabs">
|
||||||
|
<button class="notif-tab active" data-tab="user">{{ t('nav.notif_tab_user') }} <span class="notif-tab-badge" id="scroller-notif-tab-badge-user" style="display:none">0</span></button>
|
||||||
|
<button class="notif-tab" data-tab="system">{{ t('nav.notif_tab_system') }} <span class="notif-tab-badge" id="scroller-notif-tab-badge-system" style="display:none">0</span></button>
|
||||||
|
</div>
|
||||||
|
<button id="scroller-mark-all-read" title="{{ t('nav.mark_all_read') }}"><i class="fa-solid fa-check-double"></i></button>
|
||||||
|
</div>
|
||||||
|
<div class="notif-list" id="scroller-notif-list" data-active-tab="user">
|
||||||
|
<div class="notif-empty">{{ t('nav.no_notifications') }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="notif-footer">
|
||||||
|
<a href="/notifications" target="_blank" class="view-all-notifs">{{ t('nav.view_all_notifications') }}</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@endif
|
@endif
|
||||||
<button id="scroller-mute-btn" class="topbar-icon-btn" title="Volume (M)"><i class="fa-solid fa-volume-xmark"></i></button>
|
<button id="scroller-mute-btn" class="topbar-icon-btn" title="{{ t('scroller.volume') }} (M)"><i class="fa-solid fa-volume-xmark"></i></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -743,13 +1042,17 @@
|
|||||||
<span class="volume-label"><i class="fa-solid fa-volume-low"></i></span>
|
<span class="volume-label"><i class="fa-solid fa-volume-low"></i></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- Thread Gallery Sidebar -->
|
||||||
|
<div id="chan-gallery-sidebar"></div>
|
||||||
|
|
||||||
<!-- Feed -->
|
<!-- Feed -->
|
||||||
<div id="scroller-feed" role="feed" aria-label="Doomscroll feed">
|
<div id="scroller-feed" role="feed" aria-label="Doomscroll feed">
|
||||||
<div id="scroller-sentinel"></div>
|
<div id="scroller-sentinel"></div>
|
||||||
<div id="scroller-empty">
|
<div id="scroller-empty">
|
||||||
<i class="fa-solid fa-binoculars"></i>
|
<i class="fa-solid fa-binoculars"></i>
|
||||||
<p>Nothing found with current filters</p>
|
<p>{{ t('scroller.nothing_found') }}</p>
|
||||||
<button onclick="document.getElementById('filter-open-btn').click()" style="margin-top:8px;padding:8px 20px;border-radius:50px;background:rgba(255,255,255,.08);border:1px solid rgba(255,255,255,.15);color:#fff;cursor:pointer;font-size:.8rem;">Adjust filters</button>
|
<button onclick="document.getElementById('filter-open-btn').click()" style="margin-top:8px;padding:8px 20px;border-radius:50px;background:rgba(255,255,255,.08);border:1px solid rgba(255,255,255,.15);color:#fff;cursor:pointer;font-size:.8rem;">{{ t('scroller.adjust_filters') }}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -758,12 +1061,12 @@
|
|||||||
<div id="filter-panel" class="scroller-panel" role="dialog" aria-label="Feed filters">
|
<div id="filter-panel" class="scroller-panel" role="dialog" aria-label="Feed filters">
|
||||||
<div class="panel-handle"><div class="panel-handle-bar"></div></div>
|
<div class="panel-handle"><div class="panel-handle-bar"></div></div>
|
||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
<span class="panel-title">Filters</span>
|
<span class="panel-title">{{ t('scroller.filters') }}</span>
|
||||||
<button class="filter-reset-btn" id="filter-reset-btn">Reset all</button>
|
<button class="filter-reset-btn" id="filter-reset-btn">{{ t('scroller.reset_all') }}</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="filter-scroll-area">
|
<div class="filter-scroll-area">
|
||||||
<div class="filter-section">
|
<div class="filter-section">
|
||||||
<div class="filter-section-label">Rating</div>
|
<div class="filter-section-label">{{ t('scroller.rating') }}</div>
|
||||||
<div class="pill-group" id="mode-pills">
|
<div class="pill-group" id="mode-pills">
|
||||||
<button class="filter-pill active" data-mode="0"><i class="fa-solid fa-shield-halved"></i>SFW</button>
|
<button class="filter-pill active" data-mode="0"><i class="fa-solid fa-shield-halved"></i>SFW</button>
|
||||||
@if(session)
|
@if(session)
|
||||||
@@ -773,40 +1076,41 @@
|
|||||||
<button class="filter-pill" data-mode="4"><i class="fa-solid fa-skull"></i>NSFL</button>
|
<button class="filter-pill" data-mode="4"><i class="fa-solid fa-skull"></i>NSFL</button>
|
||||||
@endif
|
@endif
|
||||||
@if(session)
|
@if(session)
|
||||||
<button class="filter-pill" data-mode="3">All</button>
|
<button class="filter-pill" data-mode="3">{{ t('scroller.all') }}</button>
|
||||||
@endif
|
@endif
|
||||||
@if(session && (session.admin || session.is_moderator))
|
@if(session && (session.admin || session.is_moderator))
|
||||||
<button class="filter-pill" data-mode="2">Untagged</button>
|
<button class="filter-pill" data-mode="2">{{ t('scroller.untagged') }}</button>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="filter-section">
|
<div class="filter-section">
|
||||||
<div class="filter-section-label">Media type</div>
|
<div class="filter-section-label">{{ t('scroller.media_type') }}</div>
|
||||||
<div class="pill-group" id="mime-pills">
|
<div class="pill-group" id="mime-pills">
|
||||||
<button class="filter-pill active" data-mime=""><i class="fa-solid fa-layer-group"></i>All</button>
|
<button class="filter-pill active" data-mime=""><i class="fa-solid fa-layer-group"></i>{{ t('scroller.all') }}</button>
|
||||||
@if(scroller_mime_cats.includes('video'))
|
@if(scroller_mime_cats.includes('video'))
|
||||||
<button class="filter-pill" data-mime="video"><i class="fa-solid fa-film"></i>Video</button>
|
<button class="filter-pill" data-mime="video"><i class="fa-solid fa-film"></i>{{ t('scroller.video') }}</button>
|
||||||
@endif
|
@endif
|
||||||
@if(scroller_mime_cats.includes('image'))
|
@if(scroller_mime_cats.includes('image'))
|
||||||
<button class="filter-pill" data-mime="image"><i class="fa-solid fa-image"></i>Image</button>
|
<button class="filter-pill" data-mime="image"><i class="fa-solid fa-image"></i>{{ t('scroller.image') }}</button>
|
||||||
@endif
|
@endif
|
||||||
@if(scroller_mime_cats.includes('audio'))
|
@if(scroller_mime_cats.includes('audio'))
|
||||||
<button class="filter-pill" data-mime="audio"><i class="fa-solid fa-music"></i>Audio</button>
|
<button class="filter-pill" data-mime="audio"><i class="fa-solid fa-music"></i>{{ t('scroller.audio') }}</button>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="filter-section">
|
<div class="filter-section">
|
||||||
<div class="filter-section-label">Order</div>
|
<div class="filter-section-label">{{ t('scroller.order') }}</div>
|
||||||
<div class="pill-group" id="order-pills">
|
<div class="pill-group" id="order-pills">
|
||||||
<button class="filter-pill active" data-order="random"><i class="fa-solid fa-shuffle"></i>Random</button>
|
<button class="filter-pill active" data-order="random"><i class="fa-solid fa-shuffle"></i>{{ t('scroller.random') }}</button>
|
||||||
<button class="filter-pill" data-order="newest"><i class="fa-solid fa-clock-rotate-left"></i>Newest</button>
|
<button class="filter-pill" data-order="newest"><i class="fa-solid fa-clock-rotate-left"></i>{{ t('scroller.newest') }}</button>
|
||||||
<button class="filter-pill" data-order="oldest"><i class="fa-solid fa-hourglass-start"></i>Oldest</button>
|
<button class="filter-pill" data-order="oldest"><i class="fa-solid fa-hourglass-start"></i>{{ t('scroller.oldest') }}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="filter-section">
|
<div class="filter-section">
|
||||||
<div class="filter-section-label">Tags</div>
|
<div class="filter-section-label">{{ t('scroller.tags') }}</div>
|
||||||
<div class="tag-search-wrap">
|
<div class="tag-search-wrap">
|
||||||
<input type="text" id="filter-tag-input" placeholder="Search tags…" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" />
|
<input type="text" id="filter-tag-input" placeholder="{{ t('scroller.search_tags') }}" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" />
|
||||||
<span class="tag-search-icon"><i class="fa-solid fa-tag"></i></span>
|
<span class="tag-search-icon"><i class="fa-solid fa-tag"></i></span>
|
||||||
<button id="filter-tag-clear"><i class="fa-solid fa-xmark"></i></button>
|
<button id="filter-tag-clear"><i class="fa-solid fa-xmark"></i></button>
|
||||||
</div>
|
</div>
|
||||||
@@ -814,12 +1118,48 @@
|
|||||||
<div id="filter-active-tags"></div>
|
<div id="filter-active-tags"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="filter-section" style="margin-top:8px">
|
<div class="filter-section" style="margin-top:8px">
|
||||||
<div class="filter-section-label">Saved presets</div>
|
<div class="filter-section-label">{{ t('scroller.saved_presets') }}</div>
|
||||||
<button class="settings-save-preset-btn" id="settings-save-preset-btn"><i class="fa-solid fa-floppy-disk"></i> Save current filters as preset</button>
|
<button class="settings-save-preset-btn" id="settings-save-preset-btn"><i class="fa-solid fa-floppy-disk"></i> {{ t('scroller.save_preset') }}</button>
|
||||||
<div id="settings-presets-list"></div>
|
<div id="settings-presets-list"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="filter-apply-btn" id="filter-apply-btn"><i class="fa-solid fa-check" style="margin-right:8px"></i>Apply & Reload</button>
|
<button class="filter-apply-btn" id="filter-apply-btn"><i class="fa-solid fa-check" style="margin-right:8px"></i>{{ t('scroller.apply_reload') }}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 4CHAN PANEL -->
|
||||||
|
<div id="chan-backdrop" class="scroller-backdrop"></div>
|
||||||
|
<div id="chan-panel" class="scroller-panel" role="dialog" aria-label="4chan threads">
|
||||||
|
<div class="panel-handle"><div class="panel-handle-bar"></div></div>
|
||||||
|
<div class="panel-header">
|
||||||
|
<span class="panel-title"><i class="fa-solid fa-clover" style="margin-right:6px;color:var(--accent)"></i>{{ t('scroller.chan_threads') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="filter-scroll-area">
|
||||||
|
<div class="filter-section">
|
||||||
|
<div class="filter-section-label">{{ t('scroller.load_by_url') }}</div>
|
||||||
|
<div style="display:flex;gap:8px">
|
||||||
|
<input type="text" id="chan-url-input" style="flex:1;padding:10px 14px;background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.12);border-radius:10px;color:#fff;font-size:.86rem;outline:none;" placeholder="https://boards.4chan.org/wsg/thread/..." autocomplete="off">
|
||||||
|
<button id="chan-url-load-btn" style="padding:10px 18px;background:var(--accent);color:#000;border:none;border-radius:10px;font-weight:700;font-size:.84rem;cursor:pointer;white-space:nowrap;">{{ t('scroller.load') }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="filter-section">
|
||||||
|
<div class="filter-section-label">{{ t('scroller.browse_boards') }}</div>
|
||||||
|
<div class="pill-group" id="chan-board-pills">
|
||||||
|
<button class="filter-pill" data-board="gif"><i class="fa-solid fa-fire"></i>/gif/</button>
|
||||||
|
<button class="filter-pill active" data-board="wsg"><i class="fa-solid fa-shield-halved"></i>/wsg/</button>
|
||||||
|
<div style="display:flex;align-items:center;gap:4px;margin-left:4px;">
|
||||||
|
<span style="color:rgba(255,255,255,.4);font-size:.8rem">/</span>
|
||||||
|
<input type="text" id="chan-custom-board" style="width:48px;padding:6px 8px;background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.12);border-radius:8px;color:#fff;font-size:.82rem;outline:none;text-align:center;" placeholder="v" maxlength="6" autocomplete="off">
|
||||||
|
<span style="color:rgba(255,255,255,.4);font-size:.8rem">/</span>
|
||||||
|
<button id="chan-custom-board-btn" class="filter-pill" style="padding:6px 10px;font-size:.78rem;">{{ t('scroller.go') }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="filter-section" style="padding-top:8px">
|
||||||
|
<input type="text" id="chan-catalog-search" style="width:100%;padding:10px 14px;background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.12);border-radius:10px;color:#fff;font-size:.86rem;outline:none;margin-bottom:12px;box-sizing:border-box;" placeholder="{{ t('scroller.search_threads') }}" autocomplete="off">
|
||||||
|
<div id="chan-catalog-grid" class="chan-grid"></div>
|
||||||
|
<div id="chan-catalog-loading" style="text-align:center;padding:30px;color:rgba(255,255,255,.35);display:none"><div class="loader-spinner" style="margin:0 auto 10px"></div>{{ t('scroller.loading_catalog') }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- SETTINGS PANEL -->
|
<!-- SETTINGS PANEL -->
|
||||||
@@ -827,46 +1167,53 @@
|
|||||||
<div id="settings-panel" class="scroller-panel" role="dialog" aria-label="Scroller settings">
|
<div id="settings-panel" class="scroller-panel" role="dialog" aria-label="Scroller settings">
|
||||||
<div class="panel-handle"><div class="panel-handle-bar"></div></div>
|
<div class="panel-handle"><div class="panel-handle-bar"></div></div>
|
||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
<span class="panel-title">Settings</span>
|
<span class="panel-title">{{ t('scroller.settings') }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="filter-scroll-area">
|
<div class="filter-scroll-area">
|
||||||
<div class="filter-section">
|
<div class="filter-section">
|
||||||
<div class="filter-section-label">Appearance</div>
|
<div class="filter-section-label">{{ t('scroller.appearance') }}</div>
|
||||||
<div class="settings-toggle-row">
|
<div class="settings-toggle-row">
|
||||||
<div class="settings-toggle-info">
|
<div class="settings-toggle-info">
|
||||||
<span class="settings-toggle-name">Hide UI</span>
|
<span class="settings-toggle-name">{{ t('scroller.hide_ui') }}</span>
|
||||||
<span class="settings-toggle-desc">Hides the top bar and action buttons for full immersion</span>
|
<span class="settings-toggle-desc">{{ t('scroller.hide_ui_desc') }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-toggle-switch" id="st-hide-ui"><div class="settings-toggle-knob"></div></div>
|
<div class="settings-toggle-switch" id="st-hide-ui"><div class="settings-toggle-knob"></div></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-toggle-row">
|
<div class="settings-toggle-row">
|
||||||
<div class="settings-toggle-info">
|
<div class="settings-toggle-info">
|
||||||
<span class="settings-toggle-name">Start with sound</span>
|
<span class="settings-toggle-name">{{ t('scroller.start_sound') }}</span>
|
||||||
<span class="settings-toggle-desc">Automatically unmute when you open the scroller</span>
|
<span class="settings-toggle-desc">{{ t('scroller.start_sound_desc') }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-toggle-switch" id="st-start-unmuted"><div class="settings-toggle-knob"></div></div>
|
<div class="settings-toggle-switch" id="st-start-unmuted"><div class="settings-toggle-knob"></div></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-toggle-row">
|
<div class="settings-toggle-row">
|
||||||
<div class="settings-toggle-info">
|
<div class="settings-toggle-info">
|
||||||
<span class="settings-toggle-name">Animated background</span>
|
<span class="settings-toggle-name">{{ t('scroller.animated_bg') }}</span>
|
||||||
<span class="settings-toggle-desc">Live video frames behind the player; disable for static thumbnail</span>
|
<span class="settings-toggle-desc">{{ t('scroller.animated_bg_desc') }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-toggle-switch" id="st-canvas-bg"><div class="settings-toggle-knob"></div></div>
|
<div class="settings-toggle-switch" id="st-canvas-bg"><div class="settings-toggle-knob"></div></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="filter-section">
|
|
||||||
<div class="filter-section-label">Playback</div>
|
|
||||||
<div class="settings-toggle-row">
|
<div class="settings-toggle-row">
|
||||||
<div class="settings-toggle-info">
|
<div class="settings-toggle-info">
|
||||||
<span class="settings-toggle-name">Auto-next</span>
|
<span class="settings-toggle-name">{{ t('scroller.left_hand') }}</span>
|
||||||
<span class="settings-toggle-desc">Automatically advance to the next item when media ends</span>
|
<span class="settings-toggle-desc">{{ t('scroller.left_hand_desc') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="settings-toggle-switch" id="st-left-hand"><div class="settings-toggle-knob"></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="filter-section">
|
||||||
|
<div class="filter-section-label">{{ t('scroller.playback') }}</div>
|
||||||
|
<div class="settings-toggle-row">
|
||||||
|
<div class="settings-toggle-info">
|
||||||
|
<span class="settings-toggle-name">{{ t('scroller.auto_next') }}</span>
|
||||||
|
<span class="settings-toggle-desc">{{ t('scroller.auto_next_desc') }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-toggle-switch" id="st-auto-next"><div class="settings-toggle-knob"></div></div>
|
<div class="settings-toggle-switch" id="st-auto-next"><div class="settings-toggle-knob"></div></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-toggle-row" style="margin-top:2px;">
|
<div class="settings-toggle-row" style="margin-top:2px;">
|
||||||
<div class="settings-toggle-info">
|
<div class="settings-toggle-info">
|
||||||
<span class="settings-toggle-name">Loops before next</span>
|
<span class="settings-toggle-name">{{ t('scroller.loops_before_next') }}</span>
|
||||||
<span class="settings-toggle-desc">How many times to play before advancing (videos & audio)</span>
|
<span class="settings-toggle-desc">{{ t('scroller.loops_before_next_desc') }}</span>
|
||||||
</div>
|
</div>
|
||||||
<input type="number" id="st-auto-next-loops" min="0" max="99" value="1"
|
<input type="number" id="st-auto-next-loops" min="0" max="99" value="1"
|
||||||
style="width:56px;padding:5px 8px;background:rgba(255,255,255,.08);border:1px solid rgba(255,255,255,.15);border-radius:8px;color:#fff;font-size:.88rem;text-align:center;outline:none;" />
|
style="width:56px;padding:5px 8px;background:rgba(255,255,255,.08);border:1px solid rgba(255,255,255,.15);border-radius:8px;color:#fff;font-size:.88rem;text-align:center;outline:none;" />
|
||||||
@@ -881,26 +1228,31 @@
|
|||||||
<div id="comments-panel" class="scroller-panel" role="dialog" aria-label="Comments">
|
<div id="comments-panel" class="scroller-panel" role="dialog" aria-label="Comments">
|
||||||
<div class="panel-handle"><div class="panel-handle-bar"></div></div>
|
<div class="panel-handle"><div class="panel-handle-bar"></div></div>
|
||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
<span class="panel-title">Comments <span id="comments-count" style="color:rgba(255,255,255,.45);font-weight:400;font-size:.85rem"></span></span>
|
<span class="panel-title">{{ t('scroller.comments') }} <span id="comments-count" style="color:rgba(255,255,255,.45);font-weight:400;font-size:.85rem"></span></span>
|
||||||
<a id="comments-open-link" href="#" target="_blank" style="color:var(--accent);font-size:.78rem;font-weight:700;text-decoration:none;padding:4px 8px;">
|
<a id="comments-open-link" href="#" target="_blank" style="color:var(--accent);font-size:.78rem;font-weight:700;text-decoration:none;padding:4px 8px;">
|
||||||
<i class="fa-solid fa-up-right-from-square" style="margin-right:4px;font-size:.72rem"></i>Open
|
<i class="fa-solid fa-up-right-from-square" style="margin-right:4px;font-size:.72rem"></i>{{ t('scroller.open') }}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div id="comments-list">
|
<div id="comments-list">
|
||||||
<div id="comments-loading"><div class="loader-spinner"></div>Loading…</div>
|
<div id="comments-loading"><div class="loader-spinner"></div>{{ t('scroller.loading') }}</div>
|
||||||
<div id="comments-empty" style="display:none"><i class="fa-regular fa-comment" style="font-size:2rem;display:block;margin-bottom:10px"></i>No comments yet</div>
|
<div id="comments-empty" style="display:none"><i class="fa-regular fa-comment" style="font-size:2rem;display:block;margin-bottom:10px"></i>{{ t('scroller.no_comments') }}</div>
|
||||||
</div>
|
</div>
|
||||||
@if(typeof session !== 'undefined' && session)
|
@if(typeof session !== 'undefined' && session)
|
||||||
<div id="comments-input-area">
|
<div id="comments-input-area">
|
||||||
<div id="mention-dropdown"></div>
|
<div id="mention-dropdown"></div>
|
||||||
|
<div id="reply-indicator" style="display:none">
|
||||||
|
<i class="fa-solid fa-reply reply-indicator-icon"></i>
|
||||||
|
<span class="reply-indicator-text"></span>
|
||||||
|
<button id="reply-cancel-btn" type="button"><i class="fa-solid fa-xmark"></i></button>
|
||||||
|
</div>
|
||||||
<div class="comment-input-row">
|
<div class="comment-input-row">
|
||||||
<button id="comment-emoji-trigger" class="emoji-trigger" title="Emoji" type="button">☺</button>
|
<button id="comment-emoji-trigger" class="emoji-trigger" title="Emoji" type="button">☺</button>
|
||||||
<textarea id="comment-input" rows="1" placeholder="Write a comment..." maxlength="2000"></textarea>
|
<textarea id="comment-input" rows="1" placeholder="{{ t('scroller.write_comment') }}" maxlength="2000"></textarea>
|
||||||
<button id="comment-send-btn" disabled><i class="fa-solid fa-paper-plane"></i></button>
|
<button id="comment-send-btn" disabled><i class="fa-solid fa-paper-plane"></i></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@else
|
@else
|
||||||
<div class="comments-login-note"><a href="/login">Log in</a> to comment</div>
|
<div class="comments-login-note"><a href="/login">{{ t('scroller.login_to_comment') }}</a></div>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -909,11 +1261,11 @@
|
|||||||
<div id="tag-bar">
|
<div id="tag-bar">
|
||||||
<div id="tag-bar-inner">
|
<div id="tag-bar-inner">
|
||||||
<div style="position:relative;flex:1;min-width:0">
|
<div style="position:relative;flex:1;min-width:0">
|
||||||
<input id="scroll-tag-input" type="text" placeholder="Add a tag to this item…" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" maxlength="70">
|
<input id="scroll-tag-input" type="text" placeholder="{{ t('scroller.add_tag_placeholder') }}" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" maxlength="70">
|
||||||
<div id="scroll-tag-suggestions"></div>
|
<div id="scroll-tag-suggestions"></div>
|
||||||
</div>
|
</div>
|
||||||
<button id="scroll-tag-send-btn" title="Add tag"><i class="fa-solid fa-plus"></i></button>
|
<button id="scroll-tag-send-btn" title="{{ t('scroller.add_tag') }}"><i class="fa-solid fa-plus"></i></button>
|
||||||
<button id="tag-bar-close-btn" title="Close"><i class="fa-solid fa-xmark"></i></button>
|
<button id="tag-bar-close-btn" title="{{ t('scroller.close') }}"><i class="fa-solid fa-xmark"></i></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
@@ -922,24 +1274,24 @@
|
|||||||
<div id="share-backdrop"></div>
|
<div id="share-backdrop"></div>
|
||||||
<div id="share-panel">
|
<div id="share-panel">
|
||||||
<div id="share-panel-handle"></div>
|
<div id="share-panel-handle"></div>
|
||||||
<div id="share-panel-title">Share</div>
|
<div id="share-panel-title">{{ t('scroller.share') }}</div>
|
||||||
<div class="share-row" id="share-copy-row">
|
<div class="share-row" id="share-copy-row">
|
||||||
<div class="share-row-icon copy-icon"><i class="fa-solid fa-link"></i></div>
|
<div class="share-row-icon copy-icon"><i class="fa-solid fa-link"></i></div>
|
||||||
<div class="share-row-text">
|
<div class="share-row-text">
|
||||||
<div class="share-row-title">Copy link</div>
|
<div class="share-row-title">{{ t('scroller.copy_link') }}</div>
|
||||||
<div class="share-row-sub" id="share-copy-sub">Copy to clipboard</div>
|
<div class="share-row-sub" id="share-copy-sub">{{ t('scroller.copy_clipboard') }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@if(typeof session !== 'undefined' && session && private_messages)
|
@if(typeof session !== 'undefined' && session && private_messages)
|
||||||
<div class="share-row" id="share-dm-row">
|
<div class="share-row" id="share-dm-row">
|
||||||
<div class="share-row-icon dm-icon"><i class="fa-solid fa-paper-plane"></i></div>
|
<div class="share-row-icon dm-icon"><i class="fa-solid fa-paper-plane"></i></div>
|
||||||
<div class="share-row-text">
|
<div class="share-row-text">
|
||||||
<div class="share-row-title">Send via DM</div>
|
<div class="share-row-title">{{ t('scroller.send_dm') }}</div>
|
||||||
<div class="share-row-sub">Share to a user's inbox</div>
|
<div class="share-row-sub">{{ t('scroller.share_inbox') }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="share-dm-search">
|
<div id="share-dm-search">
|
||||||
<input id="share-user-input" type="text" placeholder="Search for a user…" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false">
|
<input id="share-user-input" type="text" placeholder="{{ t('scroller.search_user') }}" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false">
|
||||||
<div id="share-user-results"></div>
|
<div id="share-user-results"></div>
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|||||||
@@ -490,6 +490,8 @@
|
|||||||
notif_system: "{{ t('notifications.system') }}",
|
notif_system: "{{ t('notifications.system') }}",
|
||||||
notif_admin: "{{ t('notifications.admin') }}",
|
notif_admin: "{{ t('notifications.admin') }}",
|
||||||
notif_moderation: "{{ t('notifications.moderation') }}",
|
notif_moderation: "{{ t('notifications.moderation') }}",
|
||||||
|
notif_tab_user: "{{ t('nav.notif_tab_user') }}",
|
||||||
|
notif_tab_system: "{{ t('nav.notif_tab_system') }}",
|
||||||
no_notifications: "{{ t('nav.no_notifications') }}",
|
no_notifications: "{{ t('nav.no_notifications') }}",
|
||||||
// meme creator
|
// meme creator
|
||||||
meme: {
|
meme: {
|
||||||
|
|||||||
@@ -46,7 +46,7 @@
|
|||||||
@endif
|
@endif
|
||||||
<link rel="stylesheet" href="/s/css/upload.css?v={{ ts }}">
|
<link rel="stylesheet" href="/s/css/upload.css?v={{ ts }}">
|
||||||
@endif
|
@endif
|
||||||
<script>window.f0ckThemes = {{ themes_json }}; window.f0ckDefaultTheme = "{{ default_theme }}"; window.f0ckDomain = "{{ domain }}"; window.f0ckGitHash = "{{ git_hash }}"; window.f0ckAllowedImages = {{ allowed_comment_images_json }}; window.f0ckEmbedYoutubeInComments = {{ embed_youtube_in_comments ? 'true' : 'false' }}; window.f0ckEnableYoutubeUpload = {{ enable_youtube_upload ? 'true' : 'false' }}; window.f0ckBrandImages = {{ custom_brand_images_json }};</script>
|
<script>window.f0ckThemes = {{ themes_json }}; window.f0ckDefaultTheme = "{{ default_theme }}"; window.f0ckDomain = "{{ domain }}"; window.f0ckGitHash = "{{ git_hash }}"; window.f0ckAllowedImages = {{ allowed_comment_images_json }}; window.f0ckEmbedYoutubeInComments = {{ embed_youtube_in_comments ? 'true' : 'false' }}; window.f0ckEnableYoutubeUpload = {{ enable_youtube_upload ? 'true' : 'false' }}; window.f0ckBrandImages = {{ custom_brand_images_json }}; window.f0ckMediaBase = "{{ paths_images }}";</script>
|
||||||
@if(!private_society || session)
|
@if(!private_society || session)
|
||||||
<script src="/s/js/marked.min.js" defer></script>
|
<script src="/s/js/marked.min.js" defer></script>
|
||||||
<script src="/s/js/comments.js?v={{ ts }}" defer></script>
|
<script src="/s/js/comments.js?v={{ ts }}" defer></script>
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<a href="/tags" title="{{ t('nav.tags') }}"><i class="fa-solid fa-tag"></i></a>
|
<a href="/tags" title="{{ t('nav.tags') }}"><i class="fa-solid fa-tag"></i></a>
|
||||||
<a href="/abyss" title="{{ t('nav.abyss') }}"><i class="fa-solid fa-dungeon"></i></a>
|
<a href="/abyss" title="{{ t('nav.abyss') }}"><i class="fa-solid fa-dice-d6"></i></a>
|
||||||
<a href="#" id="nav-search-btn" title="{{ t('nav.search') }}"><i class="fa-solid fa-magnifying-glass"></i></a>
|
<a href="#" id="nav-search-btn" title="{{ t('nav.search') }}"><i class="fa-solid fa-magnifying-glass"></i></a>
|
||||||
@if(!/^\/\d$/.test(url.pathname))
|
@if(!/^\/\d$/.test(url.pathname))
|
||||||
<a href="/random" id="nav-random" title="{{ t('nav.random') }}"><i class="fa-solid fa-shuffle"></i></a>
|
<a href="/random" id="nav-random" title="{{ t('nav.random') }}"><i class="fa-solid fa-shuffle"></i></a>
|
||||||
@@ -80,10 +80,13 @@
|
|||||||
</a>
|
</a>
|
||||||
<div id="notif-dropdown" class="notif-dropdown">
|
<div id="notif-dropdown" class="notif-dropdown">
|
||||||
<div class="notif-header">
|
<div class="notif-header">
|
||||||
<span>{{ t('nav.notifications') }}</span>
|
<div class="notif-tabs">
|
||||||
<button id="mark-all-read">{{ t('nav.mark_all_read') }}</button>
|
<button class="notif-tab active" data-tab="user">{{ t('nav.notif_tab_user') }} <span class="notif-tab-badge" id="notif-tab-badge-user" style="display:none">0</span></button>
|
||||||
|
<button class="notif-tab" data-tab="system">{{ t('nav.notif_tab_system') }} <span class="notif-tab-badge" id="notif-tab-badge-system" style="display:none">0</span></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="notif-list">
|
<button id="mark-all-read" title="{{ t('nav.mark_all_read') }}"><i class="fa-solid fa-check-double"></i></button>
|
||||||
|
</div>
|
||||||
|
<div class="notif-list" data-active-tab="user">
|
||||||
<div class="notif-empty">{{ t('nav.no_notifications') }}</div>
|
<div class="notif-empty">{{ t('nav.no_notifications') }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="notif-footer">
|
<div class="notif-footer">
|
||||||
@@ -165,7 +168,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<a href="/tags" title="Tags"><i class="fa-solid fa-tag"></i></a>
|
<a href="/tags" title="Tags"><i class="fa-solid fa-tag"></i></a>
|
||||||
<a href="/abyss" title="Abyss"><i class="fa-solid fa-dungeon"></i></a>
|
<a href="/abyss" title="Abyss"><i class="fa-solid fa-dice-d6"></i></a>
|
||||||
@if(!/^\/\d$/.test(url.pathname))
|
@if(!/^\/\d$/.test(url.pathname))
|
||||||
<a href="/random" id="nav-random" title="Random"><i class="fa-solid fa-shuffle"></i></a>
|
<a href="/random" id="nav-random" title="Random"><i class="fa-solid fa-shuffle"></i></a>
|
||||||
@endif
|
@endif
|
||||||
|
|||||||
@@ -22,19 +22,15 @@
|
|||||||
@if(enable_profile_description && user.description)
|
@if(enable_profile_description && user.description)
|
||||||
<div class="profile_description">{!! user.description !!}</div>
|
<div class="profile_description">{!! user.description !!}</div>
|
||||||
@endif
|
@endif
|
||||||
@if(session && session.id !== user.user_id && private_messages)
|
|
||||||
<button id="send-dm-btn" class="btn btn-sm btn-outline-info" data-username="{!! user.user !!}" style="margin-left: 10px; padding: 2px 8px; font-size: 0.8em; border: 1px solid var(--accent); color: var(--accent); background: transparent; cursor: pointer;">{{ t('profile.message_btn') }}</button>
|
|
||||||
@endif
|
|
||||||
@if(session && session.id === user.user_id)
|
|
||||||
<!--<button id="subscribe-all-uploads-btn" class="btn btn-sm btn-outline-info" style="margin-left: 10px; padding: 2px 8px; font-size: 0.8em; border: 1px solid var(--accent); color: var(--accent); background: transparent; cursor: pointer;">Subscribe to all my uploads</button>-->
|
|
||||||
@endif
|
|
||||||
</div>
|
</div>
|
||||||
<div class="profile_head_user_stats">
|
<div class="profile_head_user_stats">
|
||||||
@if(user.is_ghost)
|
@if(user.is_ghost)
|
||||||
<div class="stat-legacy">{{ t('profile.legacy_record') }} <time class="timeago" tooltip="{{ user.timestamp.timefull }}">{{ user.timestamp.timeago }}</time></div>
|
<div class="stat-legacy">{{ t('profile.legacy_record') }} <time class="timeago" tooltip="{{ user.timestamp.timefull }}">{{ user.timestamp.timeago }}</time></div>
|
||||||
@else
|
@else
|
||||||
<div class="stat-id">ID: {{ user.user_id || user.id }}</div>
|
<div class="stat-id">ID: {{ user.user_id || user.id }}</div>
|
||||||
<div class="stat-joined">{{ t('profile.joined') }} <time class="timeago" tooltip="{{ user.timestamp.timefull }}">{{ user.timestamp.timeago }}</time></div>
|
|
||||||
|
<div class="stat-joined" title="{{ user.timestamp.timefull }}">{{ t('profile.age_days', { n: user.age_days }) }}</div>
|
||||||
@if(!user.is_ghost)
|
@if(!user.is_ghost)
|
||||||
<div class="stat-comments">{{ t('profile.stat_comments') }} <a href="/user/{!! user.user !!}/comments">{{ count.comments }}</a></div>
|
<div class="stat-comments">{{ t('profile.stat_comments') }} <a href="/user/{!! user.user !!}/comments">{{ count.comments }}</a></div>
|
||||||
<div class="stat-tags">{{ t('profile.stat_tags') }} {{ count.tags }}</div>
|
<div class="stat-tags">{{ t('profile.stat_tags') }} {{ count.tags }}</div>
|
||||||
@@ -42,25 +38,26 @@
|
|||||||
<div class="stat-halls">{{ t('profile.stat_halls') }} <a href="/user/{!! user.user !!}/halls">{{ count.halls }}</a></div>
|
<div class="stat-halls">{{ t('profile.stat_halls') }} <a href="/user/{!! user.user !!}/halls">{{ count.halls }}</a></div>
|
||||||
@endif
|
@endif
|
||||||
@endif
|
@endif
|
||||||
@if(session)
|
|
||||||
@if(session.id !== user.user_id)
|
@endif
|
||||||
|
</div>
|
||||||
|
@if(session && session.id !== user.user_id)
|
||||||
|
<div class="profile-actions" style="display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px;">
|
||||||
|
@if(private_messages)
|
||||||
|
<button id="send-dm-btn" class="btn btn-sm btn-outline-info" data-username="{!! user.user !!}" style="padding: 2px 8px; font-size: 0.8em; border: 1px solid var(--accent); color: var(--accent); background: transparent; cursor: pointer;">{{ t('profile.message_btn') }}</button>
|
||||||
|
@endif
|
||||||
@if(session.admin || session.is_moderator)
|
@if(session.admin || session.is_moderator)
|
||||||
@if(session.admin || !user.admin)
|
@if(session.admin || !user.admin)
|
||||||
@if(user.banned)
|
@if(user.banned)
|
||||||
<button id="unban-user-btn" class="btn btn-sm btn-outline-success" style="margin-left: 10px; padding: 2px 8px; font-size: 0.8em; border: 1px solid #5cb85c; color: #5cb85c; background: transparent; cursor: pointer;">{{ t('profile.unban_btn') }}</button>
|
<button id="unban-user-btn" class="btn btn-sm btn-outline-success" style="padding: 2px 8px; font-size: 0.8em; border: 1px solid #5cb85c; color: #5cb85c; background: transparent; cursor: pointer;">{{ t('profile.unban_btn') }}</button>
|
||||||
@else
|
@else
|
||||||
<button id="ban-user-btn" class="btn btn-sm btn-outline-danger" style="margin-left: 10px; padding: 2px 8px; font-size: 0.8em; border: 1px solid var(--accent); color: var(--accent); background: transparent; cursor: pointer;">{{ t('profile.ban_btn') }}</button>
|
<button id="ban-user-btn" class="btn btn-sm btn-outline-danger" style="padding: 2px 8px; font-size: 0.8em; border: 1px solid var(--accent); color: var(--accent); background: transparent; cursor: pointer;">{{ t('profile.ban_btn') }}</button>
|
||||||
@endif
|
|
||||||
<button id="warn-user-btn" class="btn btn-sm btn-outline-warning" style="margin-left: 10px; padding: 2px 8px; font-size: 0.8em; border: 1px solid #f0ad4e; color: #f0ad4e; background: transparent; cursor: pointer;">{{ t('profile.warn_btn') }}</button>
|
|
||||||
@endif
|
|
||||||
@if(session.admin)
|
|
||||||
<button id="admin-subscribe-user-btn" class="btn btn-sm btn-outline-warning" style="margin-left: 10px; padding: 2px 8px; font-size: 0.8em; border: 1px solid #f0ad4e; color: #f0ad4e; background: transparent; cursor: pointer;">{{ t('profile.subscribe_uploads_btn') }}</button>
|
|
||||||
@endif
|
|
||||||
@endif
|
|
||||||
@endif
|
@endif
|
||||||
|
<button id="warn-user-btn" class="btn btn-sm btn-outline-warning" style="padding: 2px 8px; font-size: 0.8em; border: 1px solid #f0ad4e; color: #f0ad4e; background: transparent; cursor: pointer;">{{ t('profile.warn_btn') }}</button>
|
||||||
@endif
|
@endif
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="user_content_wrapper">
|
<div class="user_content_wrapper">
|
||||||
@@ -313,39 +310,7 @@
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const subUserBtn = document.getElementById('admin-subscribe-user-btn');
|
|
||||||
if (subUserBtn && userId) {
|
|
||||||
subUserBtn.onclick = async () => {
|
|
||||||
if (!confirm('{{ t('profile.confirm_subscribe_uploads') }}')) return;
|
|
||||||
|
|
||||||
subUserBtn.disabled = true;
|
|
||||||
subUserBtn.innerText = '{{ t('profile.subscribing') }}';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/v2/admin/subscribe-user-to-uploads', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-CSRF-Token': window.f0ckSession?.csrf_token
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ user_id: userId })
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
if (data.success) {
|
|
||||||
alert('{{ t('profile.subscribed') }}');
|
|
||||||
subUserBtn.innerText = '{{ t('profile.subscribed') }}';
|
|
||||||
} else {
|
|
||||||
alert('Error: ' + (data.msg || data.message));
|
|
||||||
subUserBtn.disabled = false;
|
|
||||||
subUserBtn.innerText = '{{ t('profile.subscribe_uploads_btn') }}';
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
alert('Failed to subscribe user: ' + err.message);
|
|
||||||
subUserBtn.disabled = false;
|
|
||||||
subUserBtn.innerText = '{{ t('profile.subscribe_uploads_btn') }}';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
@endif
|
@endif
|
||||||
@@ -353,48 +318,7 @@
|
|||||||
@endif
|
@endif
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
@if(session && session.id === user.user_id)
|
|
||||||
<script>
|
|
||||||
(function () {
|
|
||||||
const subAllBtn = document.getElementById('subscribe-all-uploads-btn');
|
|
||||||
if (subAllBtn) {
|
|
||||||
subAllBtn.onclick = async () => {
|
|
||||||
if (!confirm('{{ t('profile.confirm_subscribe_uploads') }}')) return;
|
|
||||||
|
|
||||||
subAllBtn.disabled = true;
|
|
||||||
subAllBtn.innerText = '{{ t('profile.subscribing') }}';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/v2/user/subscribe-all-uploads', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-CSRF-Token': window.f0ckSession?.csrf_token
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
if (data.success) {
|
|
||||||
alert(data.message);
|
|
||||||
subAllBtn.innerText = '{{ t('profile.subscribed') }}';
|
|
||||||
setTimeout(() => {
|
|
||||||
subAllBtn.innerText = '{{ t('profile.subscribe_to_my_uploads') }}';
|
|
||||||
subAllBtn.disabled = false;
|
|
||||||
}, 3000);
|
|
||||||
} else {
|
|
||||||
alert('Error: ' + data.message);
|
|
||||||
subAllBtn.disabled = false;
|
|
||||||
subAllBtn.innerText = '{{ t('profile.subscribe_to_my_uploads') }}';
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
alert('Failed to subscribe: ' + err.message);
|
|
||||||
subAllBtn.disabled = false;
|
|
||||||
subAllBtn.innerText = '{{ t('profile.subscribe_to_my_uploads') }}';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
<div class="pagination-container-fluid" @if(typeof hidePagination !=='undefined' && hidePagination) style="display: none;" @endif>
|
<div class="pagination-container-fluid" @if(typeof hidePagination !=='undefined' && hidePagination) style="display: none;" @endif>
|
||||||
<div class="pagination-wrapper bottom-pagination fixed-pagination">
|
<div class="pagination-wrapper bottom-pagination fixed-pagination">
|
||||||
|
|||||||
Reference in New Issue
Block a user