Skip to content

Commit 87a82da

Browse files
committed
Add new RxdcodxViewers API endpoints and integrate Credits component
1 parent 12f6ed5 commit 87a82da

File tree

5 files changed

+401
-0
lines changed

5 files changed

+401
-0
lines changed

api/swagger_api.json

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3920,6 +3920,64 @@
39203920
}
39213921
}
39223922
},
3923+
"/api/RxdcodxViewers/refresh-cache": {
3924+
"post": {
3925+
"tags": [
3926+
"RxdcodxViewers"
3927+
],
3928+
"responses": {
3929+
"200": {
3930+
"description": "OK"
3931+
}
3932+
}
3933+
}
3934+
},
3935+
"/api/RxdcodxViewers/followers-info": {
3936+
"get": {
3937+
"tags": [
3938+
"RxdcodxViewers"
3939+
],
3940+
"responses": {
3941+
"200": {
3942+
"description": "OK"
3943+
}
3944+
}
3945+
}
3946+
},
3947+
"/api/RxdcodxViewers/user/{userId}/info": {
3948+
"get": {
3949+
"tags": [
3950+
"RxdcodxViewers"
3951+
],
3952+
"parameters": [
3953+
{
3954+
"name": "userId",
3955+
"in": "path",
3956+
"required": true,
3957+
"schema": {
3958+
"type": "string"
3959+
}
3960+
}
3961+
],
3962+
"responses": {
3963+
"200": {
3964+
"description": "OK"
3965+
}
3966+
}
3967+
}
3968+
},
3969+
"/api/RxdcodxViewers/clear-cache": {
3970+
"post": {
3971+
"tags": [
3972+
"RxdcodxViewers"
3973+
],
3974+
"responses": {
3975+
"200": {
3976+
"description": "OK"
3977+
}
3978+
}
3979+
}
3980+
},
39233981
"/api/ServiceManager/status": {
39243982
"get": {
39253983
"tags": [
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
@use "@/app/global.scss" as *;
2+
3+
.root {
4+
position: relative;
5+
width: 100vw;
6+
height: 100vh;
7+
background: var(--bs-black);
8+
color: var(--bs-white);
9+
overflow: hidden;
10+
display: flex;
11+
align-items: center;
12+
justify-content: center;
13+
}
14+
15+
.maskTop,
16+
.maskBottom {
17+
position: absolute;
18+
left: 0;
19+
width: 100%;
20+
height: 15vh;
21+
pointer-events: none;
22+
z-index: 2;
23+
}
24+
25+
.maskTop {
26+
top: 0;
27+
background: linear-gradient(to bottom,
28+
rgba(0, 0, 0, 1) 0%,
29+
rgba(0, 0, 0, 0) 100%);
30+
}
31+
32+
.maskBottom {
33+
bottom: 0;
34+
background: linear-gradient(to top,
35+
rgba(0, 0, 0, 1) 0%,
36+
rgba(0, 0, 0, 0) 100%);
37+
}
38+
39+
.scroll {
40+
position: absolute;
41+
bottom: -100%;
42+
width: 100%;
43+
will-change: transform;
44+
display: flex;
45+
flex-direction: column;
46+
align-items: center;
47+
gap: 6vh;
48+
}
49+
50+
.play {
51+
animation: moveUp linear;
52+
animation-duration: 45s;
53+
animation-fill-mode: forwards;
54+
}
55+
56+
@keyframes moveUp {
57+
from {
58+
transform: translateY(100%);
59+
}
60+
61+
to {
62+
transform: translateY(-120%);
63+
}
64+
}
65+
66+
.block {
67+
width: 90vw;
68+
max-width: 1600px;
69+
text-align: center;
70+
}
71+
72+
.sectionTitle {
73+
font-size: clamp(48px, 10vw, 140px);
74+
font-weight: 900;
75+
letter-spacing: 0.06em;
76+
text-transform: uppercase;
77+
color: var(--site-text-light);
78+
text-shadow: var(--text-shadow-heavy);
79+
}
80+
81+
.list {
82+
margin-top: 2vh;
83+
display: grid;
84+
grid-template-columns: repeat(3, 1fr);
85+
gap: 1.2vh 4vw;
86+
}
87+
88+
.nameRow {
89+
font-size: clamp(22px, 3.6vw, 56px);
90+
font-weight: 700;
91+
white-space: nowrap;
92+
overflow: hidden;
93+
text-overflow: ellipsis;
94+
}
95+
96+
.empty {
97+
opacity: 0.5;
98+
font-size: clamp(20px, 3vw, 40px);
99+
}
100+
101+
.loading {
102+
position: absolute;
103+
bottom: 2vh;
104+
left: 50%;
105+
transform: translateX(-50%);
106+
color: var(--site-text-light);
107+
font-size: clamp(16px, 2.2vw, 28px);
108+
opacity: 0.8;
109+
}
110+
111+
@media (max-width: 900px) {
112+
.list {
113+
grid-template-columns: repeat(2, 1fr);
114+
}
115+
}
116+
117+
@media (max-width: 600px) {
118+
.list {
119+
grid-template-columns: 1fr;
120+
}
121+
}
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2+
3+
import { RxdcodxViewers, TelegramusHubSignalRContext } from "@/shared/api";
4+
import { useToastModal } from "@/shared/Utils/ToastModal";
5+
6+
import styles from "./Credits.module.scss";
7+
8+
type FollowerInfo = {
9+
userId: string;
10+
userName: string;
11+
userLogin: string;
12+
isModerator: boolean;
13+
isVip: boolean;
14+
followedAt: string | Date;
15+
lastUpdated: string | Date;
16+
};
17+
18+
const SectionTitle: React.FC<{ children: React.ReactNode }> = ({
19+
children,
20+
}) => <div className={styles.sectionTitle}>{children}</div>;
21+
22+
const NameRow: React.FC<{ name: string }> = ({ name }) => (
23+
<div className={styles.nameRow}>{name}</div>
24+
);
25+
26+
const Credits: React.FC = () => {
27+
const api = useMemo(() => new RxdcodxViewers(), []);
28+
const { showToast } = useToastModal();
29+
30+
const [moderators, setModerators] = useState<FollowerInfo[]>([]);
31+
const [vips, setVips] = useState<FollowerInfo[]>([]);
32+
const [followers, setFollowers] = useState<FollowerInfo[]>([]);
33+
const [loading, setLoading] = useState(true);
34+
35+
const containerRef = useRef<HTMLDivElement | null>(null);
36+
37+
// Функция для перезапуска анимации титров с начала
38+
const resetCreditsAnimation = useCallback(() => {
39+
if (!containerRef.current) return;
40+
41+
const el = containerRef.current;
42+
// Убираем класс анимации
43+
el.classList.remove(styles.play);
44+
// Принудительно сбрасываем позицию
45+
el.style.transform = "translateY(100%)";
46+
47+
// В следующем кадре запускаем анимацию заново
48+
requestAnimationFrame(() => {
49+
el.classList.add(styles.play);
50+
});
51+
}, []);
52+
53+
// Обработчик SignalR события CreditsReset
54+
TelegramusHubSignalRContext.useSignalREffect(
55+
"CreditsReset",
56+
() => {
57+
resetCreditsAnimation();
58+
showToast({
59+
type: "info",
60+
title: "Титры сброшены",
61+
message: "Анимация титров перезапущена с начала",
62+
});
63+
},
64+
[resetCreditsAnimation, showToast]
65+
);
66+
67+
useEffect(() => {
68+
const load = async () => {
69+
try {
70+
setLoading(true);
71+
const [modsRes, vipsRes, followersRes] = await Promise.all([
72+
api.rxdcodxViewersModeratorsList(),
73+
api.rxdcodxViewersVipsList(),
74+
api.rxdcodxViewersFollowersList(),
75+
]);
76+
77+
setModerators((modsRes.data as unknown as FollowerInfo[]) ?? []);
78+
setVips((vipsRes.data as unknown as FollowerInfo[]) ?? []);
79+
setFollowers((followersRes.data as unknown as FollowerInfo[]) ?? []);
80+
81+
showToast({
82+
type: "success",
83+
title: "Загружено",
84+
message: "Списки модераторов, VIP и фолловеров обновлены",
85+
data: {
86+
moderators:
87+
(modsRes.data as unknown as FollowerInfo[])?.length ?? 0,
88+
vips: (vipsRes.data as unknown as FollowerInfo[])?.length ?? 0,
89+
followers:
90+
(followersRes.data as unknown as FollowerInfo[])?.length ?? 0,
91+
},
92+
});
93+
} catch (e) {
94+
const msg = e instanceof Error ? e.message : "Неизвестная ошибка";
95+
showToast({ type: "error", title: "Ошибка загрузки", message: msg });
96+
} finally {
97+
setLoading(false);
98+
}
99+
};
100+
101+
load();
102+
}, [api, showToast]);
103+
104+
useEffect(() => {
105+
if (!containerRef.current) return;
106+
// Перезапустить анимацию при обновлении данных
107+
const el = containerRef.current;
108+
// force reflow to restart animation
109+
void el.offsetHeight;
110+
el.classList.remove(styles.play);
111+
// next frame
112+
const id = requestAnimationFrame(() => el.classList.add(styles.play));
113+
return () => cancelAnimationFrame(id);
114+
}, [moderators.length, vips.length, followers.length]);
115+
116+
const renderNames = (list: FollowerInfo[]) => {
117+
if (!list || list.length === 0)
118+
return <div className={styles.empty}></div>;
119+
return list
120+
.slice()
121+
.sort((a, b) => a.userName.localeCompare(b.userName, "ru"))
122+
.map(u => <NameRow key={u.userId} name={u.userName || u.userLogin} />);
123+
};
124+
125+
return (
126+
<div className={styles.root}>
127+
<div className={styles.maskTop} />
128+
<div className={styles.maskBottom} />
129+
<div ref={containerRef} className={`${styles.scroll} ${styles.play}`}>
130+
<div className={styles.block}>
131+
<SectionTitle>TWITCH.TV/RXDCODX</SectionTitle>
132+
</div>
133+
134+
<div className={styles.block}>
135+
<SectionTitle>СПАСИБО МОДЕРАТОРАМ</SectionTitle>
136+
<div className={styles.list}>{renderNames(moderators)}</div>
137+
</div>
138+
139+
<div className={styles.block}>
140+
<SectionTitle>СПАСИБО VIP</SectionTitle>
141+
<div className={styles.list}>{renderNames(vips)}</div>
142+
</div>
143+
144+
<div className={styles.block}>
145+
<SectionTitle>СПАСИБО ФОЛОВЕРАМ</SectionTitle>
146+
<div className={styles.list}>{renderNames(followers)}</div>
147+
</div>
148+
</div>
149+
150+
{loading && <div className={styles.loading}>Загрузка…</div>}
151+
</div>
152+
);
153+
};
154+
155+
export default Credits;

src/routes/config/obsComponentRoutes.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import AutoMessageBillboard from "@/components/OBS_Components/AutoMessageBillboa
55
import AutoMessageBillboardTest from "@/components/OBS_Components/AutoMessageBillboard/AutoMessageBillboardTest";
66
import ChatHorizontal from "@/components/OBS_Components/ChatHorizontal/ChatHorizontal";
77
import ChatVertical from "@/components/OBS_Components/ChatVertical/ChatVertical";
8+
import Credits from "@/components/OBS_Components/Credits/Credits";
89
import { FumoFriday } from "@/components/OBS_Components/FumoFriday";
910
import GaoAlertController from "@/components/OBS_Components/GaoAlert/GaoAlertController";
1011
import HighliteMessage from "@/components/OBS_Components/HighliteMessage/HighliteMessage";
@@ -25,6 +26,16 @@ import { RouteConfig } from "./RouteConfig";
2526

2627
// Массив OBS компонентов (без Layout для интеграции в OBS)
2728
export const obsComponentRoutes: RouteConfig[] = [
29+
{
30+
path: "/credits",
31+
name: "Титры (RXDCODX)",
32+
type: "obs",
33+
element: (
34+
<OBSComponentWrapper>
35+
<Credits />
36+
</OBSComponentWrapper>
37+
),
38+
},
2839
{
2940
path: "/gaoalert",
3041
name: "Гао алертс",

0 commit comments

Comments
 (0)