Unify auth with backend cookie sessions; add dev-deploy pipeline
Auth: drop localStorage session token entirely, talk to netrunner-backend via credentials:"include" HttpOnly cookies shared over the common parent domain, and redirect to the account subdomain (ЛК) after login instead of a dead local /profile route. Config: NEXT_PUBLIC_* values used by client components (API_URL, ACCOUNT_ORIGIN, BOT_USERNAME, PB_URL) get inlined into the JS bundle at image build time and can't be overridden by docker-compose environment at container start. Moved these reads into server components (page.tsx) and thread them down as props, so one built image can genuinely serve both prod and dev by config alone. CI/CD: split the old single auto-deploy-to-prod workflow into build.yml (build+push on every push to main) and deploy.yml with deploy-dev (auto, same VPS as netrunner-backend's dev environment) and deploy-prod (manual dispatch only) — mirrors netrunner-backend's pipeline shape. Added docker-compose.dev-remote.yml pointing at the dev backend. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import {
|
||||
Shield,
|
||||
@@ -25,27 +24,45 @@ declare global {
|
||||
interface AuthClientProps {
|
||||
dict: Dictionary["auth"];
|
||||
lang: string;
|
||||
/**
|
||||
* Origin ЛК (netrunner-backend) и база API — приходят как props из
|
||||
* серверного `page.tsx`, а не читаются напрямую из
|
||||
* `process.env.NEXT_PUBLIC_*` здесь: этот файл — `"use client"`, и
|
||||
* Next.js инлайнит `NEXT_PUBLIC_*` в клиентский бандл на этапе `next
|
||||
* build`, а не читает их заново при старте контейнера. Один и тот же
|
||||
* собранный образ используется и для прода, и для дев-деплоя — если бы
|
||||
* значение было зашито в бандл на сборке, dev-окружение стучалось бы в
|
||||
* прод-бэкенд независимо от `environment:` в docker-compose. Читая их в
|
||||
* серверном компоненте (`page.tsx`, исполняется заново на каждый запрос)
|
||||
* и прокидывая пропсами, получаем настоящую runtime-конфигурируемость
|
||||
* из одного и того же образа.
|
||||
*/
|
||||
accountOrigin: string;
|
||||
apiBase: string;
|
||||
botUsername: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Экран авторизации через Telegram Login Widget.
|
||||
*
|
||||
* Логика в двух независимых ветках:
|
||||
* 1. Если в URL уже есть токен (боту редиректит юзера обратно с `?token=` или `#token=`,
|
||||
* либо токен уже лежит в localStorage) — сразу проверяем его на `/api/me`.
|
||||
* 2. Если токена нет — показываем статус "awaiting" и инжектим Telegram Widget script;
|
||||
* после успешного логина в Telegram виджет дергает `window.onTelegramAuth`, мы шлём
|
||||
* данные пользователя на `/api/auth/telegram`, получаем токен и сохраняем его.
|
||||
* Сессия — исключительно HttpOnly cookie, которую ставит netrunner-backend
|
||||
* (сабдомен account.netrunner-vpn.com, общий родительский домен
|
||||
* `.netrunner-vpn.com` — cookie видна и лендингу, и ЛК). Здесь ничего не
|
||||
* хранится и не читается на стороне JS: ни localStorage, ни query/hash с
|
||||
* токеном — раньше лендинг таскал токен в `localStorage["nrxp_token"]` и в
|
||||
* URL, это ровно то, чего требовалось избежать.
|
||||
*
|
||||
* Токен читается и из query, и из hash, потому что Telegram Login Widget в разных
|
||||
* сценариях редиректа (виджет на этой же странице vs. редирект из бота) кладёт токен
|
||||
* то в query-параметр, то в хэш — приходится поддерживать оба варианта.
|
||||
*
|
||||
* Статусы: processing (идёт проверка) / granted (успех, редирект в профиль) /
|
||||
* denied (сеть недоступна или подпись невалидна) / awaiting (ждём логина в Telegram).
|
||||
* Раз ЛК физически находится на другом сабдомене, после успешного логина —
|
||||
* полный переход браузера на `${accountOrigin}/profile`, а не
|
||||
* клиентский роутинг (страницы `/profile` в самом лендинге нет и не будет).
|
||||
*/
|
||||
export function AuthClient({ dict, lang }: AuthClientProps) {
|
||||
const router = useRouter();
|
||||
export function AuthClient({
|
||||
dict,
|
||||
lang,
|
||||
accountOrigin,
|
||||
apiBase,
|
||||
botUsername,
|
||||
}: AuthClientProps) {
|
||||
const [status, setStatus] = useState<
|
||||
"processing" | "granted" | "denied" | "awaiting"
|
||||
>("processing");
|
||||
@@ -56,80 +73,38 @@ export function AuthClient({ dict, lang }: AuthClientProps) {
|
||||
|
||||
useEffect(() => {
|
||||
// Защита от двойного запуска в React 18 Strict Mode (эффект монтируется дважды
|
||||
// в dev-режиме) — без этого флага мы бы дважды дернули /api/me и задвоили логи.
|
||||
// в dev-режиме) — без этого флага мы бы дважды дернули /users/me и задвоили логи.
|
||||
if (initialized.current) return;
|
||||
initialized.current = true;
|
||||
|
||||
const authenticate = async () => {
|
||||
setLogs((prev) => [...prev, `> ${dict.processing}`]);
|
||||
await new Promise((resolve) => setTimeout(resolve, 800));
|
||||
|
||||
const hash = window.location.hash;
|
||||
let token = localStorage.getItem("nrxp_token");
|
||||
|
||||
// Токен может прийти двумя путями: как query-параметр (?token=...) или
|
||||
// в hash-фрагменте (#token=...) — зависит от того, как именно бот/виджет
|
||||
// сформировал редирект. Проверяем оба варианта и в любом случае вычищаем
|
||||
// токен из URL через replaceState, чтобы он не остался в истории браузера.
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
const queryToken = searchParams.get("token");
|
||||
|
||||
if (queryToken) {
|
||||
token = queryToken;
|
||||
searchParams.delete("token");
|
||||
const newSearch = searchParams.toString();
|
||||
const newUrl =
|
||||
window.location.pathname +
|
||||
(newSearch ? `?${newSearch}` : "") +
|
||||
window.location.hash;
|
||||
window.history.replaceState(null, "", newUrl);
|
||||
} else if (hash.includes("token=")) {
|
||||
const params = new URLSearchParams(hash.replace("#", "?"));
|
||||
const urlToken = params.get("token");
|
||||
if (urlToken) {
|
||||
token = urlToken;
|
||||
window.history.replaceState(
|
||||
null,
|
||||
"",
|
||||
window.location.pathname + window.location.search,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
setLogs((prev) => [...prev, dict.logs.waitInit]);
|
||||
setStatus("awaiting");
|
||||
return;
|
||||
}
|
||||
|
||||
if (status !== "processing") setStatus("processing");
|
||||
setLogs((prev) => [...prev, `> ${dict.verifying}`]);
|
||||
|
||||
try {
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "/api";
|
||||
const res = await fetch(`${API_BASE}/me`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
const res = await fetch(`${apiBase}/users/me`, {
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
localStorage.setItem("nrxp_token", token);
|
||||
setLogs((prev) => [...prev, dict.logs.sigValid]);
|
||||
setStatus("granted");
|
||||
setLogs((prev) => [...prev, `> ${dict.redirecting}`]);
|
||||
setTimeout(() => router.push(`/${lang}/profile`), 1500);
|
||||
setTimeout(() => {
|
||||
window.location.href = `${accountOrigin}/profile`;
|
||||
}, 1500);
|
||||
} else {
|
||||
localStorage.removeItem("nrxp_token");
|
||||
setLogs((prev) => [...prev, dict.logs.apiRejected]);
|
||||
setLogs((prev) => [...prev, dict.logs.waitInit]);
|
||||
setStatus("awaiting");
|
||||
}
|
||||
} catch (err) {
|
||||
} catch {
|
||||
setLogs((prev) => [...prev, dict.logs.coreOffline]);
|
||||
setStatus("denied");
|
||||
}
|
||||
};
|
||||
|
||||
authenticate();
|
||||
}, [dict, lang, router]);
|
||||
}, [dict, apiBase, accountOrigin]);
|
||||
|
||||
// Регистрируем глобальный колбэк для Telegram Login Widget: сам виджет
|
||||
// подключается сторонним script-тегом ниже (см. следующий useEffect) и после
|
||||
@@ -139,23 +114,23 @@ export function AuthClient({ dict, lang }: AuthClientProps) {
|
||||
setStatus("processing");
|
||||
setLogs((prev) => [...prev, dict.logs.authPayload, dict.logs.verifyHmac]);
|
||||
try {
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "/api";
|
||||
const res = await fetch(`${API_BASE}/auth/telegram`, {
|
||||
const res = await fetch(`${apiBase}/auth/telegram`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(user),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
localStorage.setItem("nrxp_token", data.token);
|
||||
setLogs((prev) => [
|
||||
...prev,
|
||||
dict.logs.sigValid,
|
||||
`> ${dict.redirecting}`,
|
||||
]);
|
||||
setStatus("granted");
|
||||
setTimeout(() => router.push(`/${lang}/profile`), 1500);
|
||||
setTimeout(() => {
|
||||
window.location.href = `${accountOrigin}/profile`;
|
||||
}, 1500);
|
||||
} else {
|
||||
setLogs((prev) => [...prev, dict.logs.invalidSig]);
|
||||
setStatus("denied");
|
||||
@@ -165,7 +140,7 @@ export function AuthClient({ dict, lang }: AuthClientProps) {
|
||||
setStatus("denied");
|
||||
}
|
||||
};
|
||||
}, [dict, lang, router]);
|
||||
}, [dict, lang, apiBase, accountOrigin]);
|
||||
|
||||
// Инжектим официальный Telegram Widget script только когда реально дошли до
|
||||
// экрана "awaiting" (нет токена) — виджет сам рисует кнопку логина в контейнере
|
||||
@@ -181,16 +156,13 @@ export function AuthClient({ dict, lang }: AuthClientProps) {
|
||||
const script = document.createElement("script");
|
||||
script.src = "https://telegram.org/js/telegram-widget.js?22";
|
||||
script.async = true;
|
||||
script.setAttribute(
|
||||
"data-telegram-login",
|
||||
process.env.NEXT_PUBLIC_BOT_USERNAME || "ntrnr_vpn_bot",
|
||||
);
|
||||
script.setAttribute("data-telegram-login", botUsername);
|
||||
script.setAttribute("data-size", "large");
|
||||
script.setAttribute("data-onauth", "onTelegramAuth(user)");
|
||||
script.setAttribute("data-request-access", "write");
|
||||
tgContainerRef.current.appendChild(script);
|
||||
}
|
||||
}, [status]);
|
||||
}, [status, botUsername]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen pt-32 pb-24 px-4 container mx-auto max-w-2xl relative flex flex-col items-center justify-center font-mono">
|
||||
@@ -278,7 +250,7 @@ export function AuthClient({ dict, lang }: AuthClientProps) {
|
||||
{dict.noToken}
|
||||
</p>
|
||||
<CyberButton
|
||||
href={`https://t.me/${process.env.NEXT_PUBLIC_BOT_USERNAME || "ntrnr_vpn_bot"}`}
|
||||
href={`https://t.me/${botUsername}`}
|
||||
variant="primary"
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { getDictionary } from "@/lib/dictionaries";
|
||||
import { AuthClient } from "./auth-client";
|
||||
|
||||
// Читаются здесь, а не в auth-client.tsx: этот файл — серверный компонент,
|
||||
// исполняется заново на каждый запрос, поэтому значения из `environment:`
|
||||
// docker-compose реально доходят до рантайма (в отличие от `NEXT_PUBLIC_*`
|
||||
// внутри "use client"-файла, которые Next.js зашивает в бандл при сборке
|
||||
// образа — один и тот же образ иначе не смог бы обслуживать и прод, и dev).
|
||||
// Намеренно НЕ NEXT_PUBLIC_* — этим переменным незачем попадать в клиентский
|
||||
// бандл вообще, раз их и так читает только сервер.
|
||||
export default async function AuthPage({
|
||||
params,
|
||||
}: {
|
||||
@@ -9,5 +16,18 @@ export default async function AuthPage({
|
||||
const { lang } = await params;
|
||||
const dict = await getDictionary(lang);
|
||||
|
||||
return <AuthClient dict={dict.auth} lang={lang} />;
|
||||
const accountOrigin =
|
||||
process.env.ACCOUNT_ORIGIN || "https://account.netrunner-vpn.com";
|
||||
const apiBase = process.env.API_URL || `${accountOrigin}/api/v1`;
|
||||
const botUsername = process.env.BOT_USERNAME || "ntrnr_vpn_bot";
|
||||
|
||||
return (
|
||||
<AuthClient
|
||||
dict={dict.auth}
|
||||
lang={lang}
|
||||
accountOrigin={accountOrigin}
|
||||
apiBase={apiBase}
|
||||
botUsername={botUsername}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -40,9 +40,16 @@ const CATEGORIES = ["All", "Core", "Security", "Network", "Legal"];
|
||||
export function DocsClient({
|
||||
dict,
|
||||
lang,
|
||||
pbUrl,
|
||||
}: {
|
||||
dict: Dictionary["docs"];
|
||||
lang: string;
|
||||
/**
|
||||
* Адрес PocketBase — приходит пропсом из серверного page.tsx, а не читается
|
||||
* напрямую из process.env.NEXT_PUBLIC_* здесь (см. комментарий в
|
||||
* app/[lang]/auth/auth-client.tsx для полного объяснения).
|
||||
*/
|
||||
pbUrl: string;
|
||||
}) {
|
||||
const [docs, setDocs] = useState<PBDocument[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -54,6 +61,7 @@ export function DocsClient({
|
||||
async function fetchDocs() {
|
||||
try {
|
||||
setLoading(true);
|
||||
pb.baseURL = pbUrl;
|
||||
const records = await pb
|
||||
.collection(`documents_${lang}`)
|
||||
.getFullList<PBDocument>({
|
||||
@@ -67,7 +75,7 @@ export function DocsClient({
|
||||
}
|
||||
}
|
||||
fetchDocs();
|
||||
}, [lang]);
|
||||
}, [lang, pbUrl]);
|
||||
|
||||
const filteredDocs = docs.filter((doc) => {
|
||||
const matchesSearch =
|
||||
|
||||
@@ -9,5 +9,14 @@ export default async function DocsPage({
|
||||
const { lang } = await params;
|
||||
const dict = await getDictionary(lang);
|
||||
|
||||
return <DocsClient dict={dict.docs} lang={lang} />;
|
||||
// Читаем PB_PUBLIC_URL, а не NEXT_PUBLIC_PB_URL: Next.js статически подставляет
|
||||
// NEXT_PUBLIC_*-переменные везде, где они встречаются в коде (в т.ч. внутри серверных
|
||||
// компонентов), на этапе `next build` — так что чтение NEXT_PUBLIC_PB_URL здесь давало бы
|
||||
// то же самое собранное-в-образ значение, что и в клиентском бандле. PB_PUBLIC_URL —
|
||||
// обычная (не NEXT_PUBLIC_) переменная, поэтому читается по-настоящему заново при каждом
|
||||
// запросе (см. подробный комментарий в app/[lang]/auth/auth-client.tsx про ACCOUNT_ORIGIN).
|
||||
const pbUrl =
|
||||
process.env.PB_PUBLIC_URL || "https://netrunner-vpn.com/cms-api";
|
||||
|
||||
return <DocsClient dict={dict.docs} lang={lang} pbUrl={pbUrl} />;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,12 @@ import { pb, type PBDownload, getPbFileUrl } from "@/lib/pb";
|
||||
interface DownloadClientProps {
|
||||
dict: Dictionary["download"];
|
||||
lang: string;
|
||||
/**
|
||||
* Адрес PocketBase — приходит пропсом из серверного page.tsx, а не читается
|
||||
* напрямую из process.env.NEXT_PUBLIC_* здесь (см. комментарий в
|
||||
* app/[lang]/auth/auth-client.tsx для полного объяснения).
|
||||
*/
|
||||
pbUrl: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -34,7 +40,7 @@ interface DownloadClientProps {
|
||||
* Платформы со статусом "dev" рендерятся приглушённо (grayscale) с единственной
|
||||
* кнопкой "уведомить о релизе".
|
||||
*/
|
||||
export function DownloadClient({ dict, lang }: DownloadClientProps) {
|
||||
export function DownloadClient({ dict, lang, pbUrl }: DownloadClientProps) {
|
||||
const [platforms, setPlatforms] = useState<PBDownload[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
@@ -42,6 +48,7 @@ export function DownloadClient({ dict, lang }: DownloadClientProps) {
|
||||
async function fetchDownloads() {
|
||||
try {
|
||||
setLoading(true);
|
||||
pb.baseURL = pbUrl;
|
||||
const records = await pb
|
||||
.collection(`downloads_${lang}`)
|
||||
.getFullList<PBDownload>({
|
||||
@@ -56,7 +63,7 @@ export function DownloadClient({ dict, lang }: DownloadClientProps) {
|
||||
}
|
||||
}
|
||||
fetchDownloads();
|
||||
}, [lang]);
|
||||
}, [lang, pbUrl]);
|
||||
|
||||
// Единый конфиг стилей: Primary (Фиолетовый) для базы, Secondary (Циан) для акцентов
|
||||
const getPlatformIcon = (platformId: string) => {
|
||||
@@ -170,7 +177,7 @@ export function DownloadClient({ dict, lang }: DownloadClientProps) {
|
||||
className="w-full h-14 rounded-2xl font-bold gap-3 bg-primary text-primary-foreground hover:bg-primary/90 shadow-[0_0_20px_rgba(139,61,255,0.3)] transition-all active:scale-[0.98]"
|
||||
>
|
||||
<a
|
||||
href={getPbFileUrl(platform, platform.file)}
|
||||
href={getPbFileUrl(platform, platform.file, pbUrl)}
|
||||
download
|
||||
>
|
||||
<Download className="size-5" />
|
||||
|
||||
@@ -9,6 +9,14 @@ export default async function DownloadPage({
|
||||
const { lang } = await params;
|
||||
const dict = await getDictionary(lang);
|
||||
|
||||
// Добавили передачу lang
|
||||
return <DownloadClient dict={dict.download} lang={lang} />;
|
||||
// Читаем PB_PUBLIC_URL, а не NEXT_PUBLIC_PB_URL: Next.js статически подставляет
|
||||
// NEXT_PUBLIC_*-переменные везде, где они встречаются в коде (в т.ч. внутри серверных
|
||||
// компонентов), на этапе `next build` — так что чтение NEXT_PUBLIC_PB_URL здесь давало бы
|
||||
// то же самое собранное-в-образ значение, что и в клиентском бандле. PB_PUBLIC_URL —
|
||||
// обычная (не NEXT_PUBLIC_) переменная, поэтому читается по-настоящему заново при каждом
|
||||
// запросе (см. подробный комментарий в app/[lang]/auth/auth-client.tsx про ACCOUNT_ORIGIN).
|
||||
const pbUrl =
|
||||
process.env.PB_PUBLIC_URL || "https://netrunner-vpn.com/cms-api";
|
||||
|
||||
return <DownloadClient dict={dict.download} lang={lang} pbUrl={pbUrl} />;
|
||||
}
|
||||
|
||||
+11
-2
@@ -40,13 +40,22 @@ export default async function Home({
|
||||
const { lang } = await params;
|
||||
const dict = await getDictionary(lang);
|
||||
|
||||
// Читаем PB_PUBLIC_URL, а не NEXT_PUBLIC_PB_URL: Next.js статически подставляет
|
||||
// NEXT_PUBLIC_*-переменные везде, где они встречаются в коде (в т.ч. внутри серверных
|
||||
// компонентов), на этапе `next build` — так что чтение NEXT_PUBLIC_PB_URL здесь давало бы
|
||||
// то же самое собранное-в-образ значение, что и в клиентском бандле. PB_PUBLIC_URL —
|
||||
// обычная (не NEXT_PUBLIC_) переменная, поэтому читается по-настоящему заново при каждом
|
||||
// запросе (см. подробный комментарий в app/[lang]/auth/auth-client.tsx про ACCOUNT_ORIGIN).
|
||||
const pbUrl =
|
||||
process.env.PB_PUBLIC_URL || "https://netrunner-vpn.com/cms-api";
|
||||
|
||||
return (
|
||||
<>
|
||||
<Hero dict={dict.hero} lang={lang} />
|
||||
<Features dict={dict.features} />
|
||||
<Pricing dict={dict.pricing} lang={lang} />
|
||||
<Pricing dict={dict.pricing} lang={lang} pbUrl={pbUrl} />
|
||||
<FAQ dict={dict.faq} lang={lang} />
|
||||
<TerminalGuestbook dict={dict.guestbook} />
|
||||
<TerminalGuestbook dict={dict.guestbook} pbUrl={pbUrl} />
|
||||
|
||||
<Suspense fallback={<SectionSkeleton />}>
|
||||
<BlogPreview lang={lang} dict={dict.blog} />
|
||||
|
||||
@@ -9,5 +9,14 @@ export default async function TerminalPage({
|
||||
const { lang } = await params;
|
||||
const dict = await getDictionary(lang);
|
||||
|
||||
return <TerminalClient dict={dict.terminal} />;
|
||||
// Читаем PB_PUBLIC_URL, а не NEXT_PUBLIC_PB_URL: Next.js статически подставляет
|
||||
// NEXT_PUBLIC_*-переменные везде, где они встречаются в коде (в т.ч. внутри серверных
|
||||
// компонентов), на этапе `next build` — так что чтение NEXT_PUBLIC_PB_URL здесь давало бы
|
||||
// то же самое собранное-в-образ значение, что и в клиентском бандле. PB_PUBLIC_URL —
|
||||
// обычная (не NEXT_PUBLIC_) переменная, поэтому читается по-настоящему заново при каждом
|
||||
// запросе (см. подробный комментарий в app/[lang]/auth/auth-client.tsx про ACCOUNT_ORIGIN).
|
||||
const pbUrl =
|
||||
process.env.PB_PUBLIC_URL || "https://netrunner-vpn.com/cms-api";
|
||||
|
||||
return <TerminalClient dict={dict.terminal} pbUrl={pbUrl} />;
|
||||
}
|
||||
|
||||
@@ -24,7 +24,18 @@ import { Dictionary } from "@/lib/IDict";
|
||||
* - клиентский кулдаун 60 секунд между сообщениями через localStorage, синхронизирован
|
||||
* с сервером тем же ключом last_comment_time, что и в TerminalGuestbook.
|
||||
*/
|
||||
export function TerminalClient({ dict }: { dict: Dictionary["terminal"] }) {
|
||||
export function TerminalClient({
|
||||
dict,
|
||||
pbUrl,
|
||||
}: {
|
||||
dict: Dictionary["terminal"];
|
||||
/**
|
||||
* Адрес PocketBase — приходит пропсом из серверного page.tsx, а не читается
|
||||
* напрямую из process.env.NEXT_PUBLIC_* здесь (см. комментарий в
|
||||
* app/[lang]/auth/auth-client.tsx для полного объяснения).
|
||||
*/
|
||||
pbUrl: string;
|
||||
}) {
|
||||
const [messages, setMessages] = useState<any[]>([]);
|
||||
const [nickname, setNickname] = useState("");
|
||||
const [inputMsg, setInputMsg] = useState("");
|
||||
@@ -41,6 +52,7 @@ export function TerminalClient({ dict }: { dict: Dictionary["terminal"] }) {
|
||||
const prevScrollHeight = useRef<number>(0);
|
||||
|
||||
useEffect(() => {
|
||||
pb.baseURL = pbUrl;
|
||||
fetchHistory(1, true);
|
||||
|
||||
pb.collection("comments").subscribe("*", (e) => {
|
||||
@@ -59,7 +71,7 @@ export function TerminalClient({ dict }: { dict: Dictionary["terminal"] }) {
|
||||
return () => {
|
||||
pb.collection("comments").unsubscribe("*");
|
||||
};
|
||||
}, []);
|
||||
}, [pbUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (cooldown > 0) {
|
||||
|
||||
Reference in New Issue
Block a user