docs
This commit is contained in:
@@ -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" &&
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -31,6 +31,10 @@ export const metadata: Metadata = {
|
||||
},
|
||||
};
|
||||
|
||||
// Корневой layout для всех локализованных страниц (сегмент [lang]). Здесь, а не в
|
||||
// каждой отдельной странице, грузится словарь для Header/Footer и подключается тема,
|
||||
// т.к. эти элементы общие для всего сайта. params.lang приходит из динамического
|
||||
// сегмента маршрута и совпадает с тем, что определил proxy.ts при редиректе.
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
params,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 /> },
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user