rust gateway for cms and comments filter
This commit is contained in:
@@ -54,9 +54,9 @@ export default async function RootLayout({
|
|||||||
>
|
>
|
||||||
<Header dict={dict.nav} lang={lang} />
|
<Header dict={dict.nav} lang={lang} />
|
||||||
|
|
||||||
<main className="flex-1 mt-32">{children}</main>
|
{/* УБРАЛИ mt-32 ТУТ */}
|
||||||
|
<main className="flex-1">{children}</main>
|
||||||
|
|
||||||
{/* Добавляем футер и передаем нужную часть словаря */}
|
|
||||||
<Footer dict={dict.footer} lang={lang} />
|
<Footer dict={dict.footer} lang={lang} />
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -177,3 +177,28 @@
|
|||||||
.animate-scan {
|
.animate-scan {
|
||||||
animation: scan 3s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
animation: scan 3s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@layer utilities {
|
||||||
|
.custom-scrollbar-retro::-webkit-scrollbar {
|
||||||
|
width: 16px;
|
||||||
|
}
|
||||||
|
.custom-scrollbar-retro::-webkit-scrollbar-track {
|
||||||
|
background: #dfdfdf;
|
||||||
|
box-shadow:
|
||||||
|
inset 1px 1px #fff,
|
||||||
|
inset -1px -1px #808080;
|
||||||
|
}
|
||||||
|
.custom-scrollbar-retro::-webkit-scrollbar-thumb {
|
||||||
|
background: #c0c0c0;
|
||||||
|
border: 2px solid #dfdfdf;
|
||||||
|
box-shadow:
|
||||||
|
1px 1px #808080,
|
||||||
|
-1px -1px #fff;
|
||||||
|
}
|
||||||
|
.dark .custom-scrollbar-retro::-webkit-scrollbar-track {
|
||||||
|
background: #1a1a1a;
|
||||||
|
}
|
||||||
|
.dark .custom-scrollbar-retro::-webkit-scrollbar-thumb {
|
||||||
|
background: #333;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+178
-72
@@ -1,10 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { LanguageSwitcher } from "@/components/sections/lang/language-switcher";
|
import { LanguageSwitcher } from "@/components/sections/lang/language-switcher";
|
||||||
import { ThemeToggle } from "@/components/sections/theme/theme-toggle";
|
import { ThemeToggle } from "@/components/sections/theme/theme-toggle";
|
||||||
import { Dictionary } from "@/lib/IDict";
|
import { Dictionary } from "@/lib/IDict";
|
||||||
import { CyberButton } from "../ui/cyber-button";
|
import { CyberButton } from "../ui/cyber-button";
|
||||||
|
import { MenuSquare, X, Terminal, FolderOpen, ArrowRight } from "lucide-react";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export function Header({
|
export function Header({
|
||||||
@@ -14,83 +17,186 @@ export function Header({
|
|||||||
dict: Dictionary["nav"];
|
dict: Dictionary["nav"];
|
||||||
lang: string;
|
lang: string;
|
||||||
}) {
|
}) {
|
||||||
|
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||||
|
|
||||||
|
// Ссылки для мобильного меню (расширенный список, как в футере)
|
||||||
|
const mobileLinks = [
|
||||||
|
{ name: dict.features, href: `/${lang}#features`, icon: Terminal },
|
||||||
|
{ name: dict.pricing, href: `/${lang}#pricing`, icon: FolderOpen },
|
||||||
|
{ name: dict.faq, href: `/${lang}#faq`, icon: FolderOpen },
|
||||||
|
{ name: dict.blog, href: `/${lang}/blog`, icon: FolderOpen },
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="fixed top-4 md:top-6 left-1/2 -translate-x-1/2 w-[95%] max-w-5xl z-50 bg-card/60 dark:bg-card/40 backdrop-blur-2xl border border-border/50 rounded-full shadow-lg transition-all duration-500 hover:bg-card/80 dark:hover:bg-card/60">
|
<>
|
||||||
<div className="px-4 md:px-8 h-14 md:h-16 flex items-center justify-between">
|
<header className="fixed top-4 md:top-6 left-1/2 -translate-x-1/2 w-[95%] max-w-5xl z-50 bg-card/30 dark:bg-card/20 backdrop-blur-xl border border-border/50 rounded-full shadow-lg transition-all duration-500 hover:bg-card/50 dark:hover:bg-card/40">
|
||||||
{/* ЛЕВЫЙ БЛОК: Адаптивный Логотип */}
|
<div className="px-4 md:px-8 h-14 md:h-16 flex items-center justify-between">
|
||||||
<Link
|
{/* ЛЕВЫЙ БЛОК: Адаптивный Логотип */}
|
||||||
href={`/${lang}`}
|
<Link
|
||||||
className="flex items-center gap-2 md:gap-3 group hover:scale-105 transition-transform duration-300 w-fit"
|
href={`/${lang}`}
|
||||||
>
|
className="flex items-center gap-2 md:gap-3 group hover:scale-105 transition-transform duration-300 w-fit relative z-50"
|
||||||
<div className="text-lg sm:text-2xl md:text-3xl tracking-wide md:tracking-widest flex items-center select-none font-bold uppercase">
|
onClick={() => setIsMenuOpen(false)}
|
||||||
<span
|
>
|
||||||
className="text-transparent animate-neon-flicker transition-all duration-300"
|
<div className="text-lg sm:text-2xl md:text-3xl tracking-wide md:tracking-widest flex items-center select-none font-bold uppercase">
|
||||||
style={{
|
<span
|
||||||
WebkitTextStroke: "1px #00E5F2",
|
className="text-transparent animate-neon-flicker transition-all duration-300"
|
||||||
filter: "drop-shadow(0 0 5px rgba(0,229,242,0.6))",
|
style={{
|
||||||
}}
|
WebkitTextStroke: "1px #00E5F2",
|
||||||
|
filter: "drop-shadow(0 0 5px rgba(0,229,242,0.6))",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Net
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="text-transparent transition-all duration-300"
|
||||||
|
style={{
|
||||||
|
WebkitTextStroke: "1px #00E5F2",
|
||||||
|
filter: "drop-shadow(0 0 5px rgba(0,229,242,0.6))",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
runner
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="ml-1.5 md:ml-2 text-transparent opacity-90 group-hover:opacity-100 transition-all duration-300"
|
||||||
|
style={{
|
||||||
|
WebkitTextStroke: "1px #8B3DFF",
|
||||||
|
filter: "drop-shadow(0 0 5px rgba(139,61,255,0.6))",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
VPN
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* ЦЕНТРАЛЬНЫЙ БЛОК: Навигация (скрыта на мобилках) */}
|
||||||
|
<nav className="hidden md:flex items-center gap-8 text-sm font-bold text-muted-foreground">
|
||||||
|
<Link
|
||||||
|
href={`/${lang}#features`}
|
||||||
|
className="hover:text-primary transition-colors duration-200"
|
||||||
>
|
>
|
||||||
Net
|
{dict.features}
|
||||||
</span>
|
</Link>
|
||||||
<span
|
<Link
|
||||||
className="text-transparent transition-all duration-300"
|
href={`/${lang}#pricing`}
|
||||||
style={{
|
className="hover:text-primary transition-colors duration-200"
|
||||||
WebkitTextStroke: "1px #00E5F2",
|
|
||||||
filter: "drop-shadow(0 0 5px rgba(0,229,242,0.6))",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
runner
|
{dict.pricing}
|
||||||
</span>
|
</Link>
|
||||||
<span
|
<Link
|
||||||
className="ml-1.5 md:ml-2 text-transparent opacity-90 group-hover:opacity-100 transition-all duration-300"
|
href={`/${lang}#faq`}
|
||||||
style={{
|
className="hover:text-primary transition-colors duration-200"
|
||||||
WebkitTextStroke: "1px #8B3DFF",
|
|
||||||
filter: "drop-shadow(0 0 5px rgba(139,61,255,0.6))",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
VPN
|
{dict.faq}
|
||||||
</span>
|
</Link>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* ПРАВЫЙ БЛОК: Контролы */}
|
||||||
|
<div className="flex items-center gap-1.5 md:gap-4 relative z-50">
|
||||||
|
<div className="flex items-center gap-1 md:gap-2">
|
||||||
|
<LanguageSwitcher />
|
||||||
|
<ThemeToggle />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CyberButton
|
||||||
|
href={`/${lang}/download`}
|
||||||
|
variant="primary"
|
||||||
|
className="hidden sm:block scale-75 md:scale-90 origin-right"
|
||||||
|
>
|
||||||
|
{dict.cta}
|
||||||
|
</CyberButton>
|
||||||
|
|
||||||
|
{/* КНОПКА МОБИЛЬНОГО МЕНЮ */}
|
||||||
|
<button
|
||||||
|
onClick={() => setIsMenuOpen(!isMenuOpen)}
|
||||||
|
className="sm:hidden p-2 rounded-xl bg-background/50 border border-border text-foreground hover:bg-muted transition-colors"
|
||||||
|
aria-label="Toggle System Menu"
|
||||||
|
>
|
||||||
|
{isMenuOpen ? (
|
||||||
|
<X className="size-5" />
|
||||||
|
) : (
|
||||||
|
<MenuSquare className="size-5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
|
||||||
|
|
||||||
{/* ЦЕНТРАЛЬНЫЙ БЛОК: Навигация (скрыта на мобилках) */}
|
|
||||||
<nav className="hidden md:flex items-center gap-8 text-sm font-bold text-muted-foreground">
|
|
||||||
<Link
|
|
||||||
href={`/${lang}#features`}
|
|
||||||
className="hover:text-primary transition-colors duration-200"
|
|
||||||
>
|
|
||||||
{dict.features}
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href={`/${lang}#pricing`}
|
|
||||||
className="hover:text-primary transition-colors duration-200"
|
|
||||||
>
|
|
||||||
{dict.pricing}
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href={`/${lang}#faq`}
|
|
||||||
className="hover:text-primary transition-colors duration-200"
|
|
||||||
>
|
|
||||||
{dict.faq}
|
|
||||||
</Link>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
{/* ПРАВЫЙ БЛОК: Контролы */}
|
|
||||||
<div className="flex items-center gap-1.5 md:gap-4">
|
|
||||||
<div className="flex items-center gap-1 md:gap-2">
|
|
||||||
<LanguageSwitcher />
|
|
||||||
<ThemeToggle />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<CyberButton
|
|
||||||
href={`/${lang}/download`}
|
|
||||||
variant="primary"
|
|
||||||
className="hidden sm:block scale-90 md:scale-100 origin-right"
|
|
||||||
>
|
|
||||||
{dict.cta}
|
|
||||||
</CyberButton>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</header>
|
||||||
</header>
|
|
||||||
|
{/* ВСПЛЫВАЮЩЕЕ ОКНО (Мобильное меню в стиле ретро-ОС) */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{isMenuOpen && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: -20, scale: 0.95 }}
|
||||||
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, y: -20, scale: 0.95 }}
|
||||||
|
transition={{ type: "spring", stiffness: 300, damping: 25 }}
|
||||||
|
className="fixed top-24 left-4 right-4 z-40 sm:hidden origin-top"
|
||||||
|
>
|
||||||
|
{/* Окно-папка */}
|
||||||
|
<div className="bg-[#E5E5E5] dark:bg-[#1E1E1E] border-2 border-t-white border-l-white border-b-gray-500 border-r-gray-500 dark:border-t-gray-600 dark:border-l-gray-600 dark:border-b-black dark:border-r-black p-1 shadow-[4px_4px_0_0_rgba(0,0,0,0.5)]">
|
||||||
|
{/* Заголовок окна (Title bar) */}
|
||||||
|
<div className="bg-blue-800 text-white px-2 py-1 flex items-center justify-between mb-2 font-mono text-xs font-bold tracking-wider">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<Terminal className="size-3" /> C:\NETRUNNER\MENU.EXE
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Содержимое папки (Ссылки) */}
|
||||||
|
<div className="bg-white dark:bg-black border-2 border-inset border-t-gray-500 border-l-gray-500 border-b-white border-r-white dark:border-t-black dark:border-l-black dark:border-b-gray-700 dark:border-r-gray-700 p-2 font-mono flex flex-col gap-1 max-h-[70vh] overflow-y-auto custom-scrollbar-retro">
|
||||||
|
{mobileLinks.map((link) => (
|
||||||
|
<Link
|
||||||
|
key={link.name}
|
||||||
|
href={link.href}
|
||||||
|
onClick={() => setIsMenuOpen(false)}
|
||||||
|
className="flex items-center gap-3 p-3 hover:bg-blue-800 hover:text-white dark:hover:bg-primary/20 dark:hover:text-primary transition-colors group cursor-pointer text-sm text-black dark:text-white shrink-0"
|
||||||
|
>
|
||||||
|
<link.icon className="size-5 text-gray-500 group-hover:text-white dark:group-hover:text-primary transition-colors fill-yellow-200 dark:fill-transparent" />
|
||||||
|
<span className="flex-1">{link.name}</span>
|
||||||
|
<span className="opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
_
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Разделитель */}
|
||||||
|
<div className="my-2 border-t border-dashed border-gray-400 dark:border-gray-700 shrink-0" />
|
||||||
|
|
||||||
|
{/* Доп ссылки из футера */}
|
||||||
|
<Link
|
||||||
|
href={`/${lang}/docs`}
|
||||||
|
onClick={() => setIsMenuOpen(false)}
|
||||||
|
className="flex items-center gap-3 p-3 hover:bg-blue-800 hover:text-white dark:hover:bg-primary/20 dark:hover:text-primary transition-colors group cursor-pointer text-xs text-gray-600 dark:text-gray-400 shrink-0"
|
||||||
|
>
|
||||||
|
<Terminal className="size-4" />
|
||||||
|
<span className="flex-1 uppercase tracking-widest">
|
||||||
|
Documentation
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Кнопка теперь идет СРАЗУ за списком, а не прибита к низу h-[60vh] */}
|
||||||
|
<div className="pt-4 pb-2 px-2 shrink-0">
|
||||||
|
<CyberButton
|
||||||
|
href={`/${lang}/download`}
|
||||||
|
variant="primary"
|
||||||
|
className="w-full text-xs"
|
||||||
|
>
|
||||||
|
<span className="flex items-center justify-center gap-2">
|
||||||
|
{dict.cta} <ArrowRight className="size-3" />
|
||||||
|
</span>
|
||||||
|
</CyberButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* Оверлей для закрытия меню по клику вне его */}
|
||||||
|
{isMenuOpen && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-30 sm:hidden bg-black/20 backdrop-blur-sm"
|
||||||
|
onClick={() => setIsMenuOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -178,15 +178,21 @@ export function Hero({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="relative pt-32 pb-20 md:pt-48 md:pb-32 flex flex-col items-center text-center px-4 min-h-[95vh] justify-center overflow-hidden">
|
<section className="relative pt-32 pb-20 md:pt-48 md:pb-32 flex flex-col items-center text-center px-4 min-h-[95vh] justify-center overflow-hidden">
|
||||||
|
{/* КОНТЕЙНЕР ФОНА: Теперь он inset-0, без верхних границ */}
|
||||||
<div className="absolute inset-0 -z-10 bg-background pointer-events-none">
|
<div className="absolute inset-0 -z-10 bg-background pointer-events-none">
|
||||||
<MatrixBackground isSecure={isVpnOn} theme={resolvedTheme} />
|
<MatrixBackground isSecure={isVpnOn} theme={resolvedTheme} />
|
||||||
|
|
||||||
|
{/* ВИНЬЕТКА: Сделаем ее погуще сверху, чтобы хедер читался идеально */}
|
||||||
|
<div className="absolute inset-0 bg-[radial-gradient(circle_at_center,_transparent_10%,_var(--background)_110%)] opacity-80" />
|
||||||
|
|
||||||
|
{/* Дополнительный мягкий градиент сверху для "воздуха" */}
|
||||||
|
<div className="absolute inset-x-0 top-0 h-40 bg-gradient-to-b from-background via-transparent to-transparent opacity-90" />
|
||||||
</div>
|
</div>
|
||||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_center,_transparent_20%,_var(--background)_100%)] -z-10 opacity-70" />
|
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.5 }}
|
transition={{ duration: 0.5 }}
|
||||||
className="relative z-10 w-full max-w-4xl mx-auto"
|
className="relative z-10 w-full max-w-4xl mx-auto pt-40 md:pt-48 pb-20"
|
||||||
>
|
>
|
||||||
{/* ИНВЕРСИЯ БЕЙДЖА */}
|
{/* ИНВЕРСИЯ БЕЙДЖА */}
|
||||||
<motion.div
|
<motion.div
|
||||||
|
|||||||
@@ -10,13 +10,12 @@ export default function MatrixBackground({
|
|||||||
theme?: string;
|
theme?: string;
|
||||||
}) {
|
}) {
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
// Используем Ref вместо State для позиции мыши, чтобы не перерисовывать компонент
|
|
||||||
const mousePos = useRef({ x: -1000, y: -1000 });
|
const mousePos = useRef({ x: -1000, y: -1000 });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
if (!canvas) return;
|
if (!canvas) return;
|
||||||
const ctx = canvas.getContext("2d", { alpha: false }); // Оптимизация производительности
|
const ctx = canvas.getContext("2d", { alpha: false });
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
|
|
||||||
let width = (canvas.width = window.innerWidth);
|
let width = (canvas.width = window.innerWidth);
|
||||||
@@ -26,6 +25,7 @@ export default function MatrixBackground({
|
|||||||
const columns = Math.floor(width / fontSize);
|
const columns = Math.floor(width / fontSize);
|
||||||
const drops = Array(columns).fill(1);
|
const drops = Array(columns).fill(1);
|
||||||
|
|
||||||
|
// Хакерский набор символов
|
||||||
const chars = "01ヲアウエオカキケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワン".split("");
|
const chars = "01ヲアウエオカキケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワン".split("");
|
||||||
|
|
||||||
const isDark = theme === "dark";
|
const isDark = theme === "dark";
|
||||||
@@ -41,11 +41,6 @@ export default function MatrixBackground({
|
|||||||
const handleResize = () => {
|
const handleResize = () => {
|
||||||
width = canvas.width = window.innerWidth;
|
width = canvas.width = window.innerWidth;
|
||||||
height = canvas.height = window.innerHeight;
|
height = canvas.height = window.innerHeight;
|
||||||
// При ресайзе пересчитываем колонки без сброса анимации
|
|
||||||
const newCols = Math.floor(width / fontSize);
|
|
||||||
if (newCols > drops.length) {
|
|
||||||
for (let i = drops.length; i < newCols; i++) drops[i] = 1;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener("mousemove", handleMouseMove);
|
window.addEventListener("mousemove", handleMouseMove);
|
||||||
@@ -58,33 +53,30 @@ export default function MatrixBackground({
|
|||||||
if (deltaTime < interval) return;
|
if (deltaTime < interval) return;
|
||||||
lastTime = currentTime - (deltaTime % interval);
|
lastTime = currentTime - (deltaTime % interval);
|
||||||
|
|
||||||
// Заливка фона (создает шлейф)
|
// Рисуем шлейф: чем меньше alpha, тем длиннее "хвосты" у цифр
|
||||||
// В светлой теме прозрачность шлейфа должна быть выше, чтобы буквы не исчезали мгновенно
|
|
||||||
ctx.fillStyle = isDark
|
ctx.fillStyle = isDark
|
||||||
? "rgba(11, 13, 23, 0.15)"
|
? "rgba(11, 13, 23, 0.25)"
|
||||||
: "rgba(248, 250, 252, 0.25)";
|
: "rgba(248, 250, 252, 0.30)";
|
||||||
ctx.fillRect(0, 0, width, height);
|
ctx.fillRect(0, 0, width, height);
|
||||||
|
|
||||||
ctx.font = `bold ${fontSize}px monospace`;
|
ctx.font = `${fontSize}px monospace`;
|
||||||
ctx.shadowBlur = 0; // Сбрасываем тени для основных букв (оптимизация)
|
|
||||||
|
|
||||||
for (let i = 0; i < drops.length; i++) {
|
for (let i = 0; i < drops.length; i++) {
|
||||||
const x = i * fontSize;
|
const x = i * fontSize;
|
||||||
const y = drops[i] * fontSize;
|
const y = drops[i] * fontSize;
|
||||||
|
|
||||||
// Расчет яркости в зависимости от курсора
|
|
||||||
const dx = x - mousePos.current.x;
|
const dx = x - mousePos.current.x;
|
||||||
const dy = y - mousePos.current.y;
|
const dy = y - mousePos.current.y;
|
||||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||||
const radius = 300; // Радиус "взгляда"
|
const radius = 350; // Радиус подсветки за курсором
|
||||||
|
|
||||||
// Плавное затухание: у курсора ярко, вдали - почти не видно
|
|
||||||
const highlight = Math.max(0, 1 - distance / radius);
|
const highlight = Math.max(0, 1 - distance / radius);
|
||||||
|
|
||||||
// Подбираем альфу так, чтобы буквы было видно даже без мыши, но очень тускло
|
// Базовая яркость очень низкая (0.05), чтобы фон не бил в глаза
|
||||||
const baseOpacity = isDark ? 0.08 : 0.15;
|
const baseOpacity = isDark ? 0.15 : 0.2;
|
||||||
const finalOpacity = baseOpacity + highlight * 0.5;
|
const finalOpacity = baseOpacity + highlight * 0.6;
|
||||||
|
|
||||||
|
// Основной цвет
|
||||||
if (isSecure) {
|
if (isSecure) {
|
||||||
ctx.fillStyle = isDark
|
ctx.fillStyle = isDark
|
||||||
? `rgba(0, 240, 255, ${finalOpacity})`
|
? `rgba(0, 240, 255, ${finalOpacity})`
|
||||||
@@ -95,8 +87,8 @@ export default function MatrixBackground({
|
|||||||
: `rgba(109, 40, 217, ${finalOpacity})`;
|
: `rgba(109, 40, 217, ${finalOpacity})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Добавляем небольшой Bloom эффект только самым ярким символам у курсора
|
// Мягкое свечение только там, где "взгляд" (highlight)
|
||||||
if (isDark && highlight > 0.7) {
|
if (isDark && highlight > 0.8) {
|
||||||
ctx.shadowBlur = 10;
|
ctx.shadowBlur = 10;
|
||||||
ctx.shadowColor = isSecure ? "#00F0FF" : "#8B3DFF";
|
ctx.shadowColor = isSecure ? "#00F0FF" : "#8B3DFF";
|
||||||
} else {
|
} else {
|
||||||
@@ -106,8 +98,7 @@ export default function MatrixBackground({
|
|||||||
const char = chars[Math.floor(Math.random() * chars.length)];
|
const char = chars[Math.floor(Math.random() * chars.length)];
|
||||||
ctx.fillText(char, x, y);
|
ctx.fillText(char, x, y);
|
||||||
|
|
||||||
// Скорость падения и рандомный сброс
|
if (y > height && Math.random() > 0.985) {
|
||||||
if (y > height && Math.random() > 0.98) {
|
|
||||||
drops[i] = 0;
|
drops[i] = 0;
|
||||||
}
|
}
|
||||||
drops[i]++;
|
drops[i]++;
|
||||||
@@ -121,7 +112,7 @@ export default function MatrixBackground({
|
|||||||
window.removeEventListener("mousemove", handleMouseMove);
|
window.removeEventListener("mousemove", handleMouseMove);
|
||||||
window.removeEventListener("resize", handleResize);
|
window.removeEventListener("resize", handleResize);
|
||||||
};
|
};
|
||||||
}, [isSecure, theme]); // mousePos удален из зависимостей
|
}, [isSecure, theme]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<canvas
|
<canvas
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export function TerminalGuestbook({ dict }: { dict: Dictionary["guestbook"] }) {
|
|||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
pb.autoCancellation(false);
|
||||||
// Начальная загрузка
|
// Начальная загрузка
|
||||||
const fetchComments = async () => {
|
const fetchComments = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -86,9 +87,27 @@ export function TerminalGuestbook({ dict }: { dict: Dictionary["guestbook"] }) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
await pb
|
|
||||||
.collection("comments")
|
const proxyUrl =
|
||||||
.create({ nickname: nickname.trim(), message: message.trim() });
|
process.env.NEXT_PUBLIC_PROXY_URL || "http://localhost:3001";
|
||||||
|
|
||||||
|
const res = await fetch(`${proxyUrl}/api/comments`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
nickname: nickname.trim(),
|
||||||
|
message: message.trim(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
// Если Rust вернул 406 Not Acceptable (грязь в тексте)
|
||||||
|
if (res.status === 406) {
|
||||||
|
throw new Error(dict.statusErrorBreach); // или кастомная ошибка "Мат запрещен"
|
||||||
|
}
|
||||||
|
throw new Error("Server error");
|
||||||
|
}
|
||||||
|
|
||||||
setNickname("");
|
setNickname("");
|
||||||
setMessage("");
|
setMessage("");
|
||||||
setStatus({ type: "success", text: dict.statusSuccess });
|
setStatus({ type: "success", text: dict.statusSuccess });
|
||||||
|
|||||||
+14
-1
@@ -8,12 +8,13 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- NEXT_PUBLIC_PB_URL=https://netrunner-vpn.com/cms-api
|
- NEXT_PUBLIC_PB_URL=https://netrunner-vpn.com/cms-api
|
||||||
|
# URL прокси для продакшена (если фронт стучится напрямую, или через nginx)
|
||||||
|
- NEXT_PUBLIC_PROXY_URL=https://netrunner-vpn.com/api/proxy
|
||||||
|
|
||||||
netrunner-cms:
|
netrunner-cms:
|
||||||
image: ghcr.io/muchobien/pocketbase:latest
|
image: ghcr.io/muchobien/pocketbase:latest
|
||||||
container_name: netrunner-cms
|
container_name: netrunner-cms
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
# Указываем только аргументы, так как путь к бинарнику уже в ENTRYPOINT
|
|
||||||
command:
|
command:
|
||||||
- serve
|
- serve
|
||||||
- --http=0.0.0.0:8090
|
- --http=0.0.0.0:8090
|
||||||
@@ -22,3 +23,15 @@ services:
|
|||||||
- "127.0.0.1:8090:8090"
|
- "127.0.0.1:8090:8090"
|
||||||
volumes:
|
volumes:
|
||||||
- ./pb_data:/pb_data
|
- ./pb_data:/pb_data
|
||||||
|
|
||||||
|
ws-proxy:
|
||||||
|
build: ./proxy-ws
|
||||||
|
container_name: netrunner-ws-proxy
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:3001:3001"
|
||||||
|
environment:
|
||||||
|
# Важно: прокси стучится в CMS по ВНУТРЕННЕМУ имени докера
|
||||||
|
- PB_INTERNAL_URL=http://netrunner-cms:8090
|
||||||
|
depends_on:
|
||||||
|
- netrunner-cms
|
||||||
|
|||||||
@@ -15,6 +15,15 @@ if (isServer) {
|
|||||||
pb.autoCancellation(false);
|
pb.autoCancellation(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
// Фильтруем ошибки абортов (CANCELLED), которые генерит PocketBase
|
||||||
|
const originalError = console.error;
|
||||||
|
console.error = (...args) => {
|
||||||
|
if (args[0]?.message?.includes("The request was autocancelled")) return;
|
||||||
|
originalError.apply(console, args);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Базовый интерфейс для записей PocketBase.
|
* Базовый интерфейс для записей PocketBase.
|
||||||
* Наследуемся от RecordModel, чтобы иметь системные поля (id, created, updated, и т.д.)
|
* Наследуемся от RecordModel, чтобы иметь системные поля (id, created, updated, и т.д.)
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
/target
|
||||||
Generated
+1927
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,14 @@
|
|||||||
|
[package]
|
||||||
|
name = "proxy-ws"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
axum = "0.7"
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
reqwest = { version = "0.11", features = ["json"] }
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
serde_json = "1.0"
|
||||||
|
rustrict = "0.7"
|
||||||
|
regex = "1.10"
|
||||||
|
unicode-segmentation = "1.10" # Для корректной работы с юникодом (эмодзи и т.д.)
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# proxy-ws/Dockerfile
|
||||||
|
|
||||||
|
# Stage 1: Build
|
||||||
|
FROM rust:1.80-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Установка зависимостей для сборки под Alpine (musl)
|
||||||
|
RUN apk add --no-cache musl-dev
|
||||||
|
|
||||||
|
# Кэшируем зависимости (создаем пустой проект)
|
||||||
|
COPY Cargo.toml ./
|
||||||
|
RUN mkdir src && echo "fn main() {}" > src/main.rs
|
||||||
|
RUN cargo build --release
|
||||||
|
RUN rm -rf src
|
||||||
|
|
||||||
|
# Копируем реальный код и собираем
|
||||||
|
COPY src ./src
|
||||||
|
# Обновляем время файла, чтобы cargo точно пересобрал
|
||||||
|
RUN touch src/main.rs
|
||||||
|
RUN cargo build --release
|
||||||
|
|
||||||
|
# Stage 2: Runtime
|
||||||
|
FROM alpine:latest
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Копируем бинарник из первой стадии
|
||||||
|
COPY --from=builder /app/target/release/proxy-ws ./proxy-ws
|
||||||
|
|
||||||
|
# Прокси будет слушать порт 3001
|
||||||
|
EXPOSE 3001
|
||||||
|
|
||||||
|
CMD ["./proxy-ws"]
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
use axum::{Json, Router, http::StatusCode, response::IntoResponse, routing::post};
|
||||||
|
use regex::Regex;
|
||||||
|
use reqwest::Client;
|
||||||
|
use rustrict::CensorStr;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::sync::LazyLock;
|
||||||
|
use unicode_segmentation::UnicodeSegmentation;
|
||||||
|
|
||||||
|
#[derive(Deserialize, Serialize, Debug)]
|
||||||
|
struct CommentPayload {
|
||||||
|
nickname: String,
|
||||||
|
message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Расширенный список КОРНЕЙ. Регулярка будет искать эти корни внутри слов.
|
||||||
|
// Используем \p{L}* (любые буквы) вокруг корня, чтобы ловить склонения.
|
||||||
|
static BAD_WORDS_REGEX: LazyLock<Regex> = LazyLock::new(|| {
|
||||||
|
Regex::new(r"(?i)(хуй|хуя|пизд|ебат|ебан|бляд|блят|шлюх|сука|сучк|порно|наркот|убить|насилие|heroin|meth|murder|gore|соли|закладк|мефедрон|гашиш)").unwrap()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Функция жесткой нормализации текста перед проверкой
|
||||||
|
fn normalize_for_filter(input: &str) -> String {
|
||||||
|
let mut normalized = String::new();
|
||||||
|
let mut prev_char: Option<char> = None;
|
||||||
|
|
||||||
|
// 1. Приводим к нижнему регистру
|
||||||
|
let lower_input = input.to_lowercase();
|
||||||
|
|
||||||
|
// 2. Идем по символам (с учетом юникода)
|
||||||
|
for g in lower_input.graphemes(true) {
|
||||||
|
let mut ch = g.chars().next().unwrap_or(' ');
|
||||||
|
|
||||||
|
// Пропускаем символы, которые не являются буквами или цифрами (запятые, точки, пробелы)
|
||||||
|
if !ch.is_alphanumeric() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Замена "leetspeak" и похожих английских букв на русские (базовая защита)
|
||||||
|
ch = match ch {
|
||||||
|
'a' | '@' => 'а',
|
||||||
|
'b' => 'в',
|
||||||
|
'c' => 'с',
|
||||||
|
'e' | '3' => 'е',
|
||||||
|
'o' | '0' => 'о',
|
||||||
|
'p' => 'р',
|
||||||
|
'x' => 'х',
|
||||||
|
'y' => 'у',
|
||||||
|
'm' => 'м',
|
||||||
|
't' => 'т',
|
||||||
|
'k' => 'к',
|
||||||
|
_ => ch,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 4. Сжатие повторяющихся символов (хххуууууууй -> хуй)
|
||||||
|
if let Some(p) = prev_char {
|
||||||
|
if p == ch {
|
||||||
|
continue; // Пропускаем дубликат
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
normalized.push(ch);
|
||||||
|
prev_char = Some(ch);
|
||||||
|
}
|
||||||
|
|
||||||
|
normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
// Функция проверки на спам (КАПС или слишком длинные слова)
|
||||||
|
fn is_spam(input: &str) -> bool {
|
||||||
|
let alpha_chars: String = input.chars().filter(|c| c.is_alphabetic()).collect();
|
||||||
|
if alpha_chars.is_empty() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Считаем процент заглавных букв
|
||||||
|
let uppercase_count = alpha_chars.chars().filter(|c| c.is_uppercase()).count();
|
||||||
|
let uppercase_ratio = uppercase_count as f64 / alpha_chars.len() as f64;
|
||||||
|
|
||||||
|
// Если больше 70% текста - КАПС (при длине текста больше 10 символов)
|
||||||
|
if input.len() > 10 && uppercase_ratio > 0.7 {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверка на слишком длинные "слова" без пробелов (защита от aaaaaaaaaaaaaa)
|
||||||
|
for word in input.split_whitespace() {
|
||||||
|
if word.len() > 35 {
|
||||||
|
// Нормальных слов длиннее 35 символов почти нет
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
let app = Router::new().route("/api/comments", post(create_comment));
|
||||||
|
|
||||||
|
println!("🚀 Netrunner Gateway started on 0.0.0.0:3001");
|
||||||
|
let listener = tokio::net::TcpListener::bind("0.0.0.0:3001").await.unwrap();
|
||||||
|
axum::serve(listener, app).await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_comment(Json(payload): Json<CommentPayload>) -> impl IntoResponse {
|
||||||
|
// --- 1. Проверка на спам (КАПС, длинные слова) ---
|
||||||
|
if is_spam(&payload.message) || is_spam(&payload.nickname) {
|
||||||
|
println!("🗑️ Blocked by SPAM filter: {}", payload.nickname);
|
||||||
|
return (
|
||||||
|
StatusCode::NOT_ACCEPTABLE,
|
||||||
|
"Transmission rejected: Spam pattern detected.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 2. Английский фильтр (Crate rustrict) ---
|
||||||
|
if payload.message.is_inappropriate() || payload.nickname.is_inappropriate() {
|
||||||
|
println!("🛡️ Blocked by rustrict: {}", payload.nickname);
|
||||||
|
return (
|
||||||
|
StatusCode::NOT_ACCEPTABLE,
|
||||||
|
"Transmission rejected: Policy violation.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 3. Русский хардкорный фильтр ---
|
||||||
|
let normalized_msg = normalize_for_filter(&payload.message);
|
||||||
|
let normalized_nick = normalize_for_filter(&payload.nickname);
|
||||||
|
|
||||||
|
if BAD_WORDS_REGEX.is_match(&normalized_msg) || BAD_WORDS_REGEX.is_match(&normalized_nick) {
|
||||||
|
println!(
|
||||||
|
"🛡️ Blocked by Deep Regex: [Original: {}] -> [Normalized: {}]",
|
||||||
|
payload.message, normalized_msg
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
StatusCode::NOT_ACCEPTABLE,
|
||||||
|
"Transmission rejected: Policy violation.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 4. ОТПРАВКА В POCKETBASE ---
|
||||||
|
let pb_base_url =
|
||||||
|
std::env::var("PB_INTERNAL_URL").unwrap_or_else(|_| "http://localhost:8090".to_string());
|
||||||
|
let pb_url = format!("{}/api/collections/comments/records", pb_base_url);
|
||||||
|
|
||||||
|
let client = Client::new();
|
||||||
|
let pb_response = client.post(pb_url).json(&payload).send().await;
|
||||||
|
|
||||||
|
match pb_response {
|
||||||
|
Ok(res) if res.status().is_success() => {
|
||||||
|
(StatusCode::OK, "Transmission secured and delivered.")
|
||||||
|
}
|
||||||
|
Ok(res) => {
|
||||||
|
println!("⚠️ PocketBase rejected: {:?}", res.status());
|
||||||
|
(StatusCode::BAD_REQUEST, "Database rejected transmission.")
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
println!("💥 PB Connection error: {}", e);
|
||||||
|
(StatusCode::INTERNAL_SERVER_ERROR, "Uplink offline.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+5920
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user