This commit is contained in:
2026-07-02 23:13:24 +07:00
parent 31f354d9ca
commit c04194bb85
36 changed files with 1005 additions and 41 deletions
+33
View File
@@ -13,6 +13,9 @@ import {
import { CyberButton } from "@/components/ui/cyber-button";
import { Dictionary } from "@/lib/IDict";
// Telegram Login Widget вызывает этот колбэк из глобального window после успешной
// авторизации в самом Telegram — вешаем на него типизацию, т.к. виджет грузится
// сторонним <script> и не завязан на React-модель.
declare global {
interface Window {
onTelegramAuth: (user: any) => void;
@@ -24,6 +27,23 @@ interface AuthClientProps {
lang: string;
}
/**
* Экран авторизации через Telegram Login Widget.
*
* Логика в двух независимых ветках:
* 1. Если в URL уже есть токен (боту редиректит юзера обратно с `?token=` или `#token=`,
* либо токен уже лежит в localStorage) — сразу проверяем его на `/api/me`.
* 2. Если токена нет — показываем статус "awaiting" и инжектим Telegram Widget script;
* после успешного логина в Telegram виджет дергает `window.onTelegramAuth`, мы шлём
* данные пользователя на `/api/auth/telegram`, получаем токен и сохраняем его.
*
* Токен читается и из query, и из hash, потому что Telegram Login Widget в разных
* сценариях редиректа (виджет на этой же странице vs. редирект из бота) кладёт токен
* то в query-параметр, то в хэш — приходится поддерживать оба варианта.
*
* Статусы: processing (идёт проверка) / granted (успех, редирект в профиль) /
* denied (сеть недоступна или подпись невалидна) / awaiting (ждём логина в Telegram).
*/
export function AuthClient({ dict, lang }: AuthClientProps) {
const router = useRouter();
const [status, setStatus] = useState<
@@ -35,6 +55,8 @@ export function AuthClient({ dict, lang }: AuthClientProps) {
const widgetInjected = useRef(false);
useEffect(() => {
// Защита от двойного запуска в React 18 Strict Mode (эффект монтируется дважды
// в dev-режиме) — без этого флага мы бы дважды дернули /api/me и задвоили логи.
if (initialized.current) return;
initialized.current = true;
@@ -45,6 +67,10 @@ export function AuthClient({ dict, lang }: AuthClientProps) {
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");
@@ -105,6 +131,9 @@ export function AuthClient({ dict, lang }: AuthClientProps) {
authenticate();
}, [dict, lang, router]);
// Регистрируем глобальный колбэк для Telegram Login Widget: сам виджет
// подключается сторонним script-тегом ниже (см. следующий useEffect) и после
// логина в Telegram вызывает window.onTelegramAuth с данными пользователя.
useEffect(() => {
window.onTelegramAuth = async (user: any) => {
setStatus("processing");
@@ -138,6 +167,10 @@ export function AuthClient({ dict, lang }: AuthClientProps) {
};
}, [dict, lang, router]);
// Инжектим официальный Telegram Widget script только когда реально дошли до
// экрана "awaiting" (нет токена) — виджет сам рисует кнопку логина в контейнере
// и после успеха вызывает window.onTelegramAuth. widgetInjected защищает от
// повторной вставки script-тега при повторных ререндерах.
useEffect(() => {
if (
status === "awaiting" &&
+5
View File
@@ -27,12 +27,17 @@ export default async function BlogPostPage({ params }: Props) {
let post: PBPost;
try {
// cache: "no-store" — в отличие от списка постов (там ISR на час), отдельный
// пост всегда читается свежим: страница не самая горячая по трафику, а свежесть
// контента (например, сразу после публикации) важнее экономии на кэше.
post = await pb
.collection(`posts_${lang}`)
.getFirstListItem<PBPost>(`slug = "${slug}" && published = true`, {
cache: "no-store",
});
} catch (error) {
// getFirstListItem бросает исключение, если запись не найдена (404 от PocketBase)
// или не опубликована — в обоих случаях просто показываем стандартный notFound().
notFound();
}
return (
+3
View File
@@ -24,6 +24,9 @@ export default async function BlogIndexPage({ params }: Props) {
let records: PBPost[] = [];
try {
// Передаём кастомный fetch с next.revalidate, чтобы PocketBase SDK использовал
// нативное ISR-кэширование Next.js (обновление раз в час) вместо запроса на
// каждый рендер — иначе SDK делает обычный fetch без опций кэша Next.js.
records = await pb.collection(`posts_${lang}`).getFullList<PBPost>({
filter: "published = true",
sort: "-created",
+11
View File
@@ -26,6 +26,17 @@ interface PBDocument {
const CATEGORIES = ["All", "Core", "Security", "Network", "Legal"];
/**
* "Хранилище" документов раздела /docs — стилизовано под хакерский терминал.
* Загружает полный список документов из PocketBase-коллекции `documents_{lang}`
* один раз на клиенте (без пагинации — предполагается, что документов немного),
* дальше поиск и фильтрация по категории применяются локально без новых запросов.
*
* Два режима отображения одного компонента:
* - сетка карточек (grid) — когда `selectedDoc` не выбран;
* - "ридер" одного документа на весь экран — когда пользователь кликнул карточку.
* Переключение между ними анимируется через AnimatePresence с mode="wait".
*/
export function DocsClient({
dict,
lang,
+12
View File
@@ -22,6 +22,18 @@ interface DownloadClientProps {
lang: string;
}
/**
* Страница загрузки клиентов VPN по платформам (Windows/Linux/Android/macOS/iOS).
* Список платформ и ссылки на бинарники/магазины подтягиваются из PocketBase-коллекции
* `downloads_{lang}` (см. lib/pb.ts тип PBDownload) — т.е. версии, статус "active/dev"
* и ссылки можно менять в CMS без деплоя фронта.
*
* Для каждой платформы независимо решаются два вопроса:
* - есть ли прямой файл для скачивания (platform.file) — иначе кнопка "Build Pending";
* - есть ли ссылка на магазин/репозиторий (platform.storeLink) — иначе "Coming soon".
* Платформы со статусом "dev" рендерятся приглушённо (grayscale) с единственной
* кнопкой "уведомить о релизе".
*/
export function DownloadClient({ dict, lang }: DownloadClientProps) {
const [platforms, setPlatforms] = useState<PBDownload[]>([]);
const [loading, setLoading] = useState(true);
+4
View File
@@ -31,6 +31,10 @@ export const metadata: Metadata = {
},
};
// Корневой layout для всех локализованных страниц (сегмент [lang]). Здесь, а не в
// каждой отдельной странице, грузится словарь для Header/Footer и подключается тема,
// т.к. эти элементы общие для всего сайта. params.lang приходит из динамического
// сегмента маршрута и совпадает с тем, что определил proxy.ts при редиректе.
export default async function RootLayout({
children,
params,
+8 -1
View File
@@ -4,7 +4,10 @@ import { motion } from "framer-motion";
import { Activity, Globe2, Server } from "lucide-react";
import { Dictionary } from "@/lib/IDict";
// Моковые данные серверов
// Моковые данные серверов. В отличие от остальных разделов (blog/docs/download/pricing/faq),
// список нод пока не подключён к PocketBase или бэкенду — статус/пинг/загрузка захардкожены.
// При появлении реального API нод (вероятно, через proxy-ws) этот массив нужно заменить
// на клиентский fetch по аналогии с download-client.tsx / docs-client.tsx.
const MOCK_NODES = [
{
id: "eu-1",
@@ -78,6 +81,10 @@ const MOCK_NODES = [
},
];
/**
* Список VPN-серверов (нод) с индикацией статуса (online/full/maintenance),
* загрузки и пинга. См. предупреждение выше — данные пока моковые.
*/
export function NodesClient({ dict }: { dict: Dictionary["nodes"] }) {
// Вспомогательная функция для получения статуса
const getStatusText = (status: string) => {
+5
View File
@@ -8,6 +8,11 @@ const SectionSkeleton = () => (
<div className="h-[500px] w-full animate-pulse bg-card/20 rounded-3xl my-10" />
);
// Тяжёлые интерактивные секции (framer-motion анимации, клиентские запросы к PocketBase,
// realtime-подписки) грузятся через next/dynamic, а не обычным import, чтобы не раздувать
// первый JS-бандл главной страницы: Hero и Features рендерятся сразу (статично, легче),
// а Pricing/FAQ/TerminalGuestbook/BlogPreview подгружаются отдельными чанками с плейсхолдером
// SectionSkeleton, пока грузится JS.
const Pricing = dynamic(
() => import("@/components/sections/Pricing").then((mod) => mod.Pricing),
{ loading: () => <SectionSkeleton /> },
+15
View File
@@ -12,6 +12,18 @@ import {
import { pb } from "@/lib/pb";
import { Dictionary } from "@/lib/IDict";
/**
* Полноэкранная версия гостевой книги (аналог TerminalGuestbook на главной, но
* без ограничения в 50 последних сообщений — здесь есть постраничная подгрузка
* истории вверх при скролле). Источники и защита от спама те же:
* - realtime через `pb.collection("comments").subscribe("*", ...)` для новых сообщений;
* - отправка идёт через Rust-шлюз `/api/proxy/api/comments`, а не напрямую в PocketBase,
* т.к. там сообщения проходят модерацию (ИИ-фильтр на мат/спам/токсичность);
* - 406 от прокси — отдельный кейс ("отклонено фильтром"), отличный от обычной
* сетевой ошибки ("сервер недоступен");
* - клиентский кулдаун 60 секунд между сообщениями через localStorage, синхронизирован
* с сервером тем же ключом last_comment_time, что и в TerminalGuestbook.
*/
export function TerminalClient({ dict }: { dict: Dictionary["terminal"] }) {
const [messages, setMessages] = useState<any[]>([]);
const [nickname, setNickname] = useState("");
@@ -64,6 +76,9 @@ export function TerminalClient({ dict }: { dict: Dictionary["terminal"] }) {
}
}, [messages]);
// Подгрузка истории постранично (30 сообщений за раз). При скролле наверх (см.
// handleScroll) запрашивается следующая страница, и новые сообщения добавляются
// в начало массива с сохранением позиции скролла (см. эффект с prevScrollHeight).
const fetchHistory = async (pageToLoad: number, isInitial = false) => {
try {
if (isInitial) setIsLoading(true);