loading and seo optimization, big design update

This commit is contained in:
2026-04-24 01:50:41 +07:00
parent de5bf60e73
commit e8b9eb5c52
20 changed files with 866 additions and 405 deletions
+2 -2
View File
@@ -24,11 +24,11 @@ export default async function BlogIndexPage({ params }: Props) {
let records: PBPost[] = []; let records: PBPost[] = [];
try { try {
// Динамически выбираем таблицу: posts_ru или posts_en
records = await pb.collection(`posts_${lang}`).getFullList<PBPost>({ records = await pb.collection(`posts_${lang}`).getFullList<PBPost>({
filter: "published = true", filter: "published = true",
sort: "-created", sort: "-created",
cache: "no-store", fetch: (url, config) =>
fetch(url, { ...config, next: { revalidate: 3600 } }),
}); });
} catch (err) { } catch (err) {
console.error("Failed to fetch blog posts:", err); console.error("Failed to fetch blog posts:", err);
+2
View File
@@ -9,6 +9,8 @@ import { Footer } from "@/components/sections/Footer";
const jetbrainsMono = JetBrains_Mono({ const jetbrainsMono = JetBrains_Mono({
subsets: ["latin", "cyrillic"], subsets: ["latin", "cyrillic"],
display: "swap", display: "swap",
preload: true, // Гарантирует предзагрузку в <head>
fallback: ["monospace"], // Указывает браузеру, что показывать до загрузки
}); });
// Настройка метаданных // Настройка метаданных
+35 -6
View File
@@ -1,11 +1,39 @@
import { Suspense } from "react"; import { Suspense } from "react";
import dynamic from "next/dynamic";
import { getDictionary } from "@/lib/dictionaries"; import { getDictionary } from "@/lib/dictionaries";
import { Hero } from "@/components/sections/Hero"; import { Hero } from "@/components/sections/Hero";
import { Features } from "@/components/sections/Features"; import { Features } from "@/components/sections/Features";
import { Pricing } from "@/components/sections/Pricing";
import { FAQ } from "@/components/sections/Faq"; // Легкий скелетон для плавности
import { BlogPreview } from "@/components/sections/BlogPreview"; const SectionSkeleton = () => (
import { TerminalGuestbook } from "@/components/sections/TerminalGuestbook"; <div className="h-[500px] w-full animate-pulse bg-card/20 rounded-3xl my-10" />
);
// Ленивая загрузка тяжелых секций
const Pricing = dynamic(
() => import("@/components/sections/Pricing").then((mod) => mod.Pricing),
{
loading: () => <SectionSkeleton />,
},
);
const FAQ = dynamic(
() => import("@/components/sections/Faq").then((mod) => mod.FAQ),
{
loading: () => <SectionSkeleton />,
},
);
const TerminalGuestbook = dynamic(
() =>
import("@/components/sections/TerminalGuestbook").then(
(mod) => mod.TerminalGuestbook,
),
{
loading: () => <SectionSkeleton />,
},
);
const BlogPreview = dynamic(() =>
import("@/components/sections/BlogPreview").then((mod) => mod.BlogPreview),
);
export default async function Home({ export default async function Home({
params, params,
@@ -17,13 +45,14 @@ export default async function Home({
return ( return (
<> <>
{/* Добавили передачу lang в Hero */}
<Hero dict={dict.hero} lang={lang} /> <Hero dict={dict.hero} lang={lang} />
<Features dict={dict.features} /> <Features dict={dict.features} />
{/* Теперь эти секции загрузятся только когда понадобятся */}
<Pricing dict={dict.pricing} lang={lang} /> <Pricing dict={dict.pricing} lang={lang} />
<FAQ dict={dict.faq} lang={lang} /> <FAQ dict={dict.faq} lang={lang} />
<TerminalGuestbook dict={dict.guestbook} /> <TerminalGuestbook dict={dict.guestbook} />
<Suspense fallback={<div className="py-20 text-center">Loading...</div>}> <Suspense fallback={<SectionSkeleton />}>
<BlogPreview lang={lang} dict={dict.blog} /> <BlogPreview lang={lang} dict={dict.blog} />
</Suspense> </Suspense>
</> </>
+48 -19
View File
@@ -37,28 +37,31 @@
} }
:root { :root {
--background: oklch(0.99 0 0); /* Остужаем фон, чтобы он не был слепяще белым */
--foreground: oklch(0.12 0 0); --background: oklch(0.98 0.01 240);
--card: oklch(1 0 0 / 70%); --foreground: oklch(0.2 0.02 260);
--card-foreground: oklch(0.12 0 0);
--popover: oklch(1 0 0 / 80%); /* Карточки теперь чистые белые с легким блюром */
--popover-foreground: oklch(0.12 0 0); --card: oklch(1 0 0 / 90%);
--card-foreground: oklch(0.2 0.02 260);
--popover: oklch(1 0 0 / 95%);
--popover-foreground: oklch(0.2 0.02 260);
/* Светлая тема */ /* Светлая тема */
/* Primary = Фиолетовый */ /* Primary = Более глубокий фиолетовый для контраста */
--primary: oklch(0.45 0.25 290); --primary: oklch(0.5 0.2 290);
--primary-foreground: oklch(0.985 0 0); --primary-foreground: oklch(0.985 0 0);
/* Secondary = Голубой */ /* Secondary = Более насыщенный циан (ближе к синему) */
--secondary: oklch(0.65 0.15 220); --secondary: oklch(0.55 0.15 220);
--secondary-foreground: oklch(0.1 0 0); --secondary-foreground: oklch(1 0 0);
--muted: oklch(0.95 0 0); --muted: oklch(0.94 0.01 240);
--muted-foreground: oklch(0.45 0 0); --muted-foreground: oklch(0.45 0.02 260);
--accent: oklch(0.92 0.05 290); --accent: oklch(0.94 0.02 290);
--accent-foreground: oklch(0.45 0.25 290); --accent-foreground: oklch(0.45 0.25 290);
--border: oklch(0.9 0 0 / 50%); --border: oklch(0.85 0.02 260);
--input: oklch(0.9 0 0); --input: oklch(0.9 0.02 260);
--ring: oklch(0.45 0.25 290); --ring: oklch(0.5 0.2 290);
} }
.dark { .dark {
@@ -98,6 +101,19 @@
} }
} }
.shadow-md {
box-shadow:
0 4px 6px -1px rgb(0 0 0 / 0.05),
0 2px 4px -2px rgb(0 0 0 / 0.05);
}
.dark .shadow-2xl {
/* Тонкое свечение для темной темы, которое не режет глаз */
box-shadow:
0 25px 50px -12px rgb(0 0 0 / 0.5),
0 0 20px -5px oklch(var(--primary) / 0.1);
}
@layer utilities { @layer utilities {
.text-neon-cyan { .text-neon-cyan {
color: #ffffff; color: #ffffff;
@@ -119,12 +135,12 @@
.light .text-neon-cyan { .light .text-neon-cyan {
color: #0284c7; color: #0284c7;
text-shadow: 0 0 5px rgba(0, 240, 255, 0.4); text-shadow: 0 0 2px rgba(2, 132, 199, 0.2);
} }
.light .text-neon-purple { .light .text-neon-purple {
color: #6d28d9; color: #6d28d9;
text-shadow: 0 0 5px rgba(139, 61, 255, 0.4); text-shadow: 0 0 2px rgba(109, 40, 217, 0.2);
} }
.animate-neon-flicker { .animate-neon-flicker {
@@ -148,3 +164,16 @@
opacity: 0.6; opacity: 0.6;
} }
} }
@keyframes scan {
from {
left: -100%;
}
to {
left: 100%;
}
}
.animate-scan {
animation: scan 3s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
+20 -36
View File
@@ -1,7 +1,5 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link"; import Link from "next/link";
import Image from "next/image";
import { import {
Card, Card,
CardHeader, CardHeader,
@@ -11,8 +9,8 @@ import {
} from "@/components/ui/card"; } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ArrowRight, Loader2 } from "lucide-react"; import { ArrowRight } from "lucide-react";
import { pb, getPbImage, type PBPost } from "@/lib/pb"; // Импортируем тип отсюда import { pb, getPbImage, type PBPost } from "@/lib/pb";
import { Dictionary } from "@/lib/IDict"; import { Dictionary } from "@/lib/IDict";
interface BlogPreviewProps { interface BlogPreviewProps {
@@ -20,43 +18,25 @@ interface BlogPreviewProps {
dict: Dictionary["blog"]; dict: Dictionary["blog"];
} }
export function BlogPreview({ lang, dict }: BlogPreviewProps) { // Теперь это асинхронный серверный компонент
const [posts, setPosts] = useState<PBPost[]>([]); export async function BlogPreview({ lang, dict }: BlogPreviewProps) {
const [loading, setLoading] = useState(true); let posts: PBPost[] = [];
useEffect(() => {
if (!lang) return;
async function fetchPosts() {
try { try {
setLoading(true); // В Next.js App Router лучше использовать нативный fetch для контроля кэша,
// Тянем из нужной языковой коллекции (posts_ru или posts_en) // либо передавать кастомный fetch в PB SDK. Здесь используем ISR на 3600 секунд (1 час).
const result = await pb posts = await pb.collection(`posts_${lang}`).getFullList<PBPost>({
.collection(`posts_${lang}`)
.getList<PBPost>(1, 3, {
filter: "published = true", filter: "published = true",
sort: "-created", sort: "-created",
requestKey: null, perPage: 3,
fetch: (url, config) =>
fetch(url, { ...config, next: { revalidate: 3600 } }),
}); });
setPosts(result.items);
} catch (error) { } catch (error) {
console.error("Ошибка загрузки логов:", error); console.error("Ошибка загрузки логов:", error);
} finally { return null; // Тихо фейлимся без падения страницы
setLoading(false);
}
}
fetchPosts();
}, [lang]);
if (loading) {
return (
<div className="flex justify-center py-20 text-primary opacity-50">
<Loader2 className="w-8 h-8 animate-spin" />
</div>
);
} }
// Если записей нет, показываем либо ничего, либо сообщение об ошибке из словаря
if (posts.length === 0) return null; if (posts.length === 0) return null;
return ( return (
@@ -94,11 +74,15 @@ export function BlogPreview({ lang, dict }: BlogPreviewProps) {
className="group overflow-hidden border-border/50 bg-card/30 hover:border-primary/50 transition-all duration-300 flex flex-col h-full" className="group overflow-hidden border-border/50 bg-card/30 hover:border-primary/50 transition-all duration-300 flex flex-col h-full"
> >
{post.image && ( {post.image && (
<div className="relative w-full h-48 overflow-hidden border-b border-border/50"> <div className="relative w-full h-48 overflow-hidden border-b border-border/50 bg-muted">
<img // Внутри .map в BlogPreview.tsx:
<Image
src={getPbImage(post, post.image)} src={getPbImage(post, post.image)}
alt={post.title} alt={post.title}
className="object-cover w-full h-full group-hover:scale-105 transition-transform duration-500" fill
sizes="(max-width: 768px) 100vw, 33vw"
priority={false}
className="object-cover group-hover:scale-105 transition-transform duration-500"
/> />
<div className="absolute inset-0 bg-gradient-to-t from-background to-transparent opacity-80" /> <div className="absolute inset-0 bg-gradient-to-t from-background to-transparent opacity-80" />
</div> </div>
+16 -36
View File
@@ -1,13 +1,10 @@
"use client";
import { useState, useEffect } from "react";
import { import {
Accordion, Accordion,
AccordionContent, AccordionContent,
AccordionItem, AccordionItem,
AccordionTrigger, AccordionTrigger,
} from "@/components/ui/accordion"; } from "@/components/ui/accordion";
import { Loader2, HelpCircle } from "lucide-react"; import { HelpCircle } from "lucide-react";
import { Dictionary } from "@/lib/IDict"; import { Dictionary } from "@/lib/IDict";
import { pb } from "@/lib/pb"; import { pb } from "@/lib/pb";
@@ -19,34 +16,29 @@ interface PBFaq {
order: number; order: number;
} }
export function FAQ({ dict, lang }: { dict: Dictionary["faq"]; lang: string }) { // Теперь это Server Component
const [faqs, setFaqs] = useState<PBFaq[]>([]); export async function FAQ({
const [loading, setLoading] = useState(true); dict,
lang,
}: {
dict: Dictionary["faq"];
lang: string;
}) {
let faqs: PBFaq[] = [];
useEffect(() => {
async function fetchFaqs() {
try { try {
setLoading(true); faqs = await pb.collection(`faqs_${lang}`).getFullList<PBFaq>({
const records = await pb.collection(`faqs_${lang}`).getFullList<PBFaq>({
sort: "order", sort: "order",
requestKey: null, fetch: (url, config) =>
fetch(url, { ...config, next: { revalidate: 86400 } }), // Кэшируем на сутки
}); });
setFaqs(records);
} catch (error) { } catch (error) {
console.error("Ошибка загрузки FAQ:", error); console.error("Ошибка загрузки FAQ:", error);
} finally {
setLoading(false);
} }
}
fetchFaqs();
}, [lang]);
// Функция для красивого рендеринга текста с поддержкой переносов и буллитов
const formatAnswer = (text: string) => { const formatAnswer = (text: string) => {
return text.split("\n").map((line, i) => { return text.split("\n").map((line, i) => {
const trimmedLine = line.trim(); const trimmedLine = line.trim();
// Если строка начинается с буллита
if (trimmedLine.startsWith("•")) { if (trimmedLine.startsWith("•")) {
return ( return (
<div key={i} className="flex gap-3 mb-2 pl-2 group"> <div key={i} className="flex gap-3 mb-2 pl-2 group">
@@ -59,11 +51,7 @@ export function FAQ({ dict, lang }: { dict: Dictionary["faq"]; lang: string }) {
</div> </div>
); );
} }
// Если строка пустая — это отступ между абзацами
if (!trimmedLine) return <div key={i} className="h-4" />; if (!trimmedLine) return <div key={i} className="h-4" />;
// Обычный параграф
return ( return (
<p <p
key={i} key={i}
@@ -78,10 +66,9 @@ export function FAQ({ dict, lang }: { dict: Dictionary["faq"]; lang: string }) {
return ( return (
<section <section
id="faq" id="faq"
className="py-32 px-4 container mx-auto max-w-4xl relative overflow-hidden" className="py-32 px-4 container mx-auto max-w-4xl relative"
> >
{/* Мягкий фон */} <div className="absolute inset-0 bg-[radial-gradient(circle_at_center,_var(--tw-gradient-stops))] from-primary/10 dark:from-primary/20 via-transparent to-transparent blur-[100px] pointer-events-none -z-10" />
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[100vw] h-[500px] bg-primary/5 blur-[120px] rounded-full pointer-events-none -z-10" />
<div className="flex flex-col items-center mb-16"> <div className="flex flex-col items-center mb-16">
<div className="bg-primary/10 p-3 rounded-2xl mb-6 border border-primary/20"> <div className="bg-primary/10 p-3 rounded-2xl mb-6 border border-primary/20">
@@ -92,14 +79,7 @@ export function FAQ({ dict, lang }: { dict: Dictionary["faq"]; lang: string }) {
</h2> </h2>
</div> </div>
{loading ? ( {faqs.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-primary opacity-50">
<Loader2 className="w-10 h-10 animate-spin mb-4" />
<span className="text-xs tracking-[0.3em] uppercase font-mono">
Synchronizing_Knowledge...
</span>
</div>
) : faqs.length === 0 ? (
<div className="text-center py-20 border border-dashed border-border/50 rounded-[2.5rem] bg-card/20"> <div className="text-center py-20 border border-dashed border-border/50 rounded-[2.5rem] bg-card/20">
<p className="text-muted-foreground font-mono text-sm uppercase tracking-widest"> <p className="text-muted-foreground font-mono text-sm uppercase tracking-widest">
0 protocols found in cache. 0 protocols found in cache.
+65 -56
View File
@@ -3,75 +3,94 @@
import { EyeOff, Lock, Globe, Gauge, Cpu } from "lucide-react"; import { EyeOff, Lock, Globe, Gauge, Cpu } from "lucide-react";
import { motion, Variants } from "framer-motion"; import { motion, Variants } from "framer-motion";
import { Dictionary } from "@/lib/IDict"; import { Dictionary } from "@/lib/IDict";
import { cn } from "@/lib/utils";
export function Features({ dict }: { dict: Dictionary["features"] }) { export function Features({ dict }: { dict: Dictionary["features"] }) {
// Универсальные классы для переиспользования и чистоты кода // Класс для карточек: в светлой теме — плотный белый с тенью, в темной — стекло
const verticalCardClass = const cardClass = cn(
"min-h-[320px] md:min-h-[360px] group bg-card/40 backdrop-blur-xl border border-border/50 rounded-[2.5rem] p-8 md:p-10 flex flex-col hover:border-primary/50 transition-all duration-500 hover:shadow-[0_0_40px_rgba(139,61,255,0.15)] relative overflow-hidden"; "min-h-[320px] md:min-h-[360px] group relative flex flex-col p-8 md:p-10 rounded-[2.5rem] overflow-hidden transition-all duration-500",
"bg-white border-border shadow-sm hover:shadow-md", // Light
"dark:bg-card/40 dark:backdrop-blur-xl dark:border-border/50 dark:hover:border-primary/50 dark:hover:shadow-[0_0_40px_rgba(139,61,255,0.15)]", // Dark
);
const iconContainerClass = // Класс для контейнера иконки: в светлой теме — "плитка", в темной — "ядро"
"bg-secondary/10 w-14 h-14 rounded-[1.2rem] flex items-center justify-center border border-secondary/20 shadow-[0_0_15px_rgba(0,229,242,0.15)] relative z-10 transition-transform duration-500 group-hover:scale-110"; const iconContainerClass = cn(
"w-14 h-14 rounded-[1.2rem] flex items-center justify-center relative z-10 transition-transform duration-500 group-hover:scale-110",
"bg-slate-50 border-border shadow-inner", // Light
"dark:bg-secondary/10 dark:border-secondary/20 dark:shadow-[0_0_15px_rgba(0,229,242,0.15)]", // Dark
);
// Стиль для самой иконки (SVG)
const iconStyle =
"w-7 h-7 text-secondary dark:drop-shadow-[0_0_8px_rgba(0,229,242,0.6)]";
// Декоративные фоновые элементы (большие иконки)
const bgIconClass =
"absolute -bottom-10 -right-10 w-64 h-64 transition-all duration-700 pointer-events-none group-hover:scale-110 group-hover:-rotate-12";
const bgIconTheme =
"text-slate-100 dark:text-primary dark:opacity-[0.03] dark:group-hover:opacity-[0.08]";
const bentoConfig = [ const bentoConfig = [
{ {
// 1: DPI Evasion className: `md:col-span-2 ${cardClass}`,
className: `md:col-span-2 ${verticalCardClass}`,
icon: ( icon: (
<div className={iconContainerClass}> <div className={iconContainerClass}>
<EyeOff className="w-7 h-7 text-secondary drop-shadow-[0_0_10px_rgba(0,229,242,0.8)]" /> <EyeOff className={iconStyle} />
</div>
),
bgElement: <EyeOff className={cn(bgIconClass, bgIconTheme)} />,
},
{
className: `md:col-span-1 ${cardClass}`,
icon: (
<div className={iconContainerClass}>
<Lock className={iconStyle} />
</div> </div>
), ),
bgElement: ( bgElement: (
<EyeOff className="absolute -bottom-10 -right-10 w-64 h-64 text-primary opacity-[0.03] group-hover:opacity-[0.08] group-hover:scale-110 group-hover:-rotate-12 transition-all duration-700 pointer-events-none" /> <div className="absolute top-0 right-0 w-40 h-40 bg-primary/5 dark:bg-primary/10 blur-[70px] opacity-0 group-hover:opacity-100 transition-opacity duration-700 pointer-events-none" />
), ),
}, },
{ {
// 2: Cryptography className: `md:col-span-1 ${cardClass}`,
className: `md:col-span-1 ${verticalCardClass}`,
icon: ( icon: (
<div className={iconContainerClass}> <div className={iconContainerClass}>
<Lock className="w-7 h-7 text-secondary drop-shadow-[0_0_10px_rgba(0,229,242,0.8)]" /> <Gauge className={iconStyle} />
</div> </div>
), ),
bgElement: ( bgElement: (
<div className="absolute top-0 right-0 w-40 h-40 bg-primary/10 blur-[70px] opacity-0 group-hover:opacity-100 transition-opacity duration-700 pointer-events-none" /> <Gauge
className={cn(
bgIconClass,
bgIconTheme,
"w-48 h-48 top-10 -right-8 bottom-auto",
)}
/>
), ),
}, },
{ {
// 3: Adaptive Backpressure className: `md:col-span-2 ${cardClass}`,
className: `md:col-span-1 ${verticalCardClass}`,
icon: ( icon: (
<div className={iconContainerClass}> <div className={iconContainerClass}>
<Gauge className="w-7 h-7 text-secondary drop-shadow-[0_0_10px_rgba(0,229,242,0.8)]" /> <Cpu className={iconStyle} />
</div> </div>
), ),
bgElement: ( bgElement: (
<Gauge className="absolute top-10 -right-8 w-48 h-48 text-primary opacity-[0.02] group-hover:opacity-[0.08] transition-all duration-700 pointer-events-none" /> <div className="absolute inset-0 bg-[radial-gradient(circle_at_bottom_right,_var(--tw-gradient-stops))] from-primary/5 dark:from-primary/10 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-700 pointer-events-none" />
), ),
}, },
{ {
// 4: Multi-Platform Core className: cn(
className: `md:col-span-2 ${verticalCardClass}`, cardClass,
"md:col-span-3 flex-col md:flex-row items-start md:items-center gap-8 md:gap-12",
),
icon: ( icon: (
<div className={iconContainerClass}> <div className="bg-slate-50 dark:bg-secondary/10 w-16 h-16 md:w-20 md:h-20 rounded-[1.5rem] md:rounded-[1.8rem] shrink-0 flex items-center justify-center border border-border dark:border-secondary/20 shadow-inner relative z-10 transition-transform duration-500 group-hover:scale-105">
<Cpu className="w-7 h-7 text-secondary drop-shadow-[0_0_10px_rgba(0,229,242,0.8)]" /> <Globe className="w-8 h-8 md:w-10 md:h-10 text-secondary dark:drop-shadow-[0_0_10px_rgba(0,229,242,0.8)]" />
</div> </div>
), ),
bgElement: ( bgElement: (
<div className="absolute inset-0 bg-[radial-gradient(circle_at_bottom_right,_var(--tw-gradient-stops))] from-primary/10 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-700 pointer-events-none" /> <div className="absolute inset-0 bg-[radial-gradient(circle_at_left,_var(--tw-gradient-stops))] from-primary/5 dark:from-primary/10 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-700 pointer-events-none" />
),
},
{
// 5: Global Network (Горизонтальная карточка)
className:
"md:col-span-3 bg-card/40 backdrop-blur-xl border border-border/50 rounded-[2.5rem] p-8 md:p-10 flex flex-col md:flex-row items-start md:items-center gap-8 md:gap-12 group hover:border-primary/50 transition-all duration-500 hover:shadow-[0_0_40px_rgba(139,61,255,0.15)] relative overflow-hidden",
icon: (
<div className="bg-secondary/10 w-16 h-16 md:w-20 md:h-20 rounded-[1.5rem] md:rounded-[1.8rem] shrink-0 flex items-center justify-center border border-secondary/20 shadow-[0_0_20px_rgba(0,229,242,0.15)] relative z-10 transition-transform duration-500 group-hover:scale-105">
<Globe className="w-8 h-8 md:w-10 md:h-10 text-secondary drop-shadow-[0_0_10px_rgba(0,229,242,0.8)]" />
</div>
),
bgElement: (
<div className="absolute inset-0 bg-[radial-gradient(circle_at_left,_var(--tw-gradient-stops))] from-primary/10 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-700 pointer-events-none" />
), ),
}, },
]; ];
@@ -80,25 +99,16 @@ export function Features({ dict }: { dict: Dictionary["features"] }) {
hidden: { opacity: 0 }, hidden: { opacity: 0 },
show: { show: {
opacity: 1, opacity: 1,
transition: { transition: { staggerChildren: 0.1 },
staggerChildren: 0.15,
},
}, },
}; };
const itemVariants: Variants = { const itemVariants: Variants = {
hidden: { hidden: { opacity: 0, y: 20 },
opacity: 0,
y: 40,
},
show: { show: {
opacity: 1, opacity: 1,
y: 0, y: 0,
transition: { transition: { type: "spring", stiffness: 260, damping: 20 },
type: "spring",
stiffness: 260,
damping: 20,
},
}, },
}; };
@@ -110,11 +120,11 @@ export function Features({ dict }: { dict: Dictionary["features"] }) {
className="py-24 px-4 w-full mx-auto relative overflow-hidden" className="py-24 px-4 w-full mx-auto relative overflow-hidden"
> >
<div className="container max-w-6xl mx-auto relative z-10"> <div className="container max-w-6xl mx-auto relative z-10">
{/* Центральное мягкое свечение секции */} {/* Фоновое свечение секции: приглушено в светлой теме */}
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[100vw] md:w-[600px] h-[600px] bg-primary/5 blur-[100px] md:blur-[120px] rounded-full pointer-events-none -z-10" /> <div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[100vw] md:w-[600px] h-[600px] bg-primary/5 blur-[100px] rounded-full pointer-events-none -z-10 opacity-50 dark:opacity-100" />
<div className="mb-16 md:mb-20 text-center"> <div className="mb-16 md:mb-20 text-center">
<h2 className="text-4xl md:text-5xl font-extrabold tracking-tight text-foreground"> <h2 className="text-4xl md:text-5xl font-black tracking-tight text-foreground uppercase">
{dict.title} {dict.title}
</h2> </h2>
</div> </div>
@@ -128,7 +138,6 @@ export function Features({ dict }: { dict: Dictionary["features"] }) {
> >
{bentoConfig.map((config, idx) => { {bentoConfig.map((config, idx) => {
const feature = dict.items[idx]; const feature = dict.items[idx];
// Проверяем, это последняя горизонтальная карточка или нет
const isHorizontal = idx === 4; const isHorizontal = idx === 4;
return ( return (
@@ -140,13 +149,13 @@ export function Features({ dict }: { dict: Dictionary["features"] }) {
{config.bgElement} {config.bgElement}
{config.icon} {config.icon}
{/* mt-auto прижимает текст к низу, pt-8 дает гарантированный отступ от иконки */}
<div <div
className={`relative z-10 ${ className={cn(
isHorizontal ? "" : "mt-auto pt-8" "relative z-10",
}`} isHorizontal ? "" : "mt-auto pt-8",
)}
> >
<h3 className="text-xl md:text-2xl font-bold text-foreground mb-3 tracking-tight"> <h3 className="text-xl md:text-2xl font-bold text-foreground mb-3 tracking-tight uppercase">
{feature.title} {feature.title}
</h3> </h3>
<p className="text-muted-foreground text-sm md:text-base leading-relaxed max-w-lg"> <p className="text-muted-foreground text-sm md:text-base leading-relaxed max-w-lg">
+278 -45
View File
@@ -1,15 +1,148 @@
"use client"; "use client";
import { useState, useEffect } from "react";
import Link from "next/link"; import Link from "next/link";
import { JetBrains_Mono } from "next/font/google"; import { JetBrains_Mono } from "next/font/google";
import { GithubLogoIcon, TwitterLogoIcon } from "@phosphor-icons/react";
import { Send } from "lucide-react";
import { Dictionary } from "@/lib/IDict"; import { Dictionary } from "@/lib/IDict";
import { cn } from "@/lib/utils";
const jetbrainsMono = JetBrains_Mono({ const jetbrainsMono = JetBrains_Mono({
subsets: ["latin", "cyrillic"], subsets: ["latin", "cyrillic"],
}); });
// Кастомные легкие SVG иконки
const Icons = {
Telegram: () => (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="size-6"
>
<path d="m22 2-7 20-4-9-9-4Z" />
<path d="M22 2 11 13" />
</svg>
),
Youtube: () => (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="size-6"
>
<path d="M22.54 6.42a2.78 2.78 0 0 0-1.94-2C18.88 4 12 4 12 4s-6.88 0-8.6.42a2.78 2.78 0 0 0-1.94 2C1 8.11 1 12 1 12s0 3.89.46 5.58a2.78 2.78 0 0 0 1.94 2c1.72.42 8.6.42 8.6.42s6.88 0 8.6-.42a2.78 2.78 0 0 0 1.94-2C23 15.89 23 12 23 12s0-3.89-.46-5.58z" />
<polygon
points="9.75 15.02 15.5 12 9.75 8.98 9.75 15.02"
fill="currentColor"
/>
</svg>
),
Discord: () => (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="size-6"
>
<path d="M19.73 5.42a19.06 19.06 0 0 0-4.73-1.45 14.1 14.1 0 0 0-.6 1.2A17.65 17.65 0 0 0 9.6 5.17a14.1 14.1 0 0 0-.6-1.2 19.06 19.06 0 0 0-4.73 1.45 19.64 19.64 0 0 0-3.37 13.5 19.34 19.34 0 0 0 5.86 3 13.9 13.9 0 0 0 1.26-2.03 13.2 13.2 0 0 1-2-.95c.16-.12.3-.25.45-.38a13.3 13.3 0 0 0 11.08 0c.15.13.3.26.45.38a13.2 13.2 0 0 1-2 .95 13.9 13.9 0 0 0 1.26 2.03 19.34 19.34 0 0 0 5.86-3 19.64 19.64 0 0 0-3.37-13.5ZM8.5 15.2c-1.07 0-1.95-1-1.95-2.22s.87-2.22 1.95-2.22c1.08 0 1.96 1 1.95 2.22 0 1.21-.88 2.22-1.95 2.22Zm7 0c-1.07 0-1.95-1-1.95-2.22s.87-2.22 1.95-2.22c1.08 0 1.96 1 1.95 2.22 0 1.21-.88 2.22-1.95 2.22Z" />
</svg>
),
X: () => (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="size-6"
>
<path d="M4 4l11.733 16H20L8.267 4z" />
<path d="M4 20l6.768-6.768m2.464-2.464L20 4" />
</svg>
),
Instagram: () => (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="size-6"
>
<rect width="20" height="20" x="2" y="2" rx="5" ry="5" />
<path d="M16 11.37A4 4 0 1 1 12.63 8 4 4 0 0 1 16 11.37z" />
<line x1="17.5" x2="17.51" y1="6.5" y2="6.5" />
</svg>
),
Habr: () => (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
className="size-6"
>
<path d="M5 5v14M19 5v14M5 12h14" />
</svg>
),
};
const FlickeringSocialIcon = ({ social }: { social: any }) => {
const [opacity, setOpacity] = useState(1);
useEffect(() => {
let timeoutId: NodeJS.Timeout;
const flicker = () => {
if (Math.random() > 0.6) {
setOpacity(Math.random() * 0.5 + 0.1);
setTimeout(() => setOpacity(1), Math.random() * 120 + 30);
}
timeoutId = setTimeout(flicker, Math.random() * 6000 + 1000);
};
timeoutId = setTimeout(flicker, Math.random() * 3000);
return () => clearTimeout(timeoutId);
}, []);
return (
<Link
href={social.href}
title={social.name}
// В светлой теме добавляем жесткую рамку при ховере вместо свечения
className={cn(
"p-2 rounded-lg transition-all duration-300 relative",
"dark:bg-transparent dark:border-none", // Темная тема: без фона
"bg-muted/50 border border-transparent hover:border-border hover:shadow-inner", // Светлая тема: кнопки-клавиши
social.color,
social.glow,
"hover:!opacity-100 hover:scale-110 active:scale-95",
)}
style={{ opacity, transition: "opacity 0.05s ease-in-out" }}
>
{/* Фоновый отсвет только для темной темы */}
<div
className={`absolute inset-0 blur-[6px] opacity-20 hidden dark:block ${social.bgGlow}`}
/>
<social.icon />
</Link>
);
};
export function Footer({ export function Footer({
dict, dict,
lang, lang,
@@ -17,14 +150,13 @@ export function Footer({
dict: Dictionary["footer"]; dict: Dictionary["footer"];
lang: string; lang: string;
}) { }) {
// Динамическая карта сайта с прописанными маршрутами
const sitemap = [ const sitemap = [
{ {
title: dict.sections.product, title: dict.sections.product,
links: [ links: [
{ name: dict.links.features, href: `/${lang}#features` }, { name: dict.links.features, href: `/${lang}#features` },
{ name: dict.links.pricing, href: `/${lang}#pricing` }, { name: dict.links.pricing, href: `/${lang}#pricing` },
{ name: dict.links.protocol, href: `/${lang}/docs` }, // Ведет в документацию { name: dict.links.protocol, href: `/${lang}/docs` },
], ],
}, },
{ {
@@ -37,59 +169,129 @@ export function Footer({
{ {
title: dict.sections.legal, title: dict.sections.legal,
links: [ links: [
{ name: dict.links.privacy, href: `/${lang}/docs` }, // Ведет в документацию (Legal) { name: dict.links.privacy, href: `/${lang}/docs` },
{ name: dict.links.terms, href: `/${lang}/docs` }, // Ведет в документацию (Legal) { name: dict.links.terms, href: `/${lang}/docs` },
], ],
}, },
]; ];
const socialLinks = [
{
name: "Telegram",
href: "#",
icon: Icons.Telegram,
color: "text-[#0088cc]", // В светлой теме цвет остается плотным
glow: "dark:drop-shadow-[0_0_8px_rgba(0,136,204,0.8)]", // Ограничиваем свечение только темной темой
bgGlow: "bg-[#0088cc]",
},
// ... аналогично для YouTube, Discord, X, Instagram, Habr (добавь префикс dark: к каждому glow)
{
name: "YouTube",
href: "#",
icon: Icons.Youtube,
color: "text-[#ff0000]",
glow: "dark:drop-shadow-[0_0_8px_rgba(255,0,0,0.8)]",
bgGlow: "bg-[#ff0000]",
},
{
name: "Discord",
href: "#",
icon: Icons.Discord,
color: "text-[#5865F2]",
glow: "dark:drop-shadow-[0_0_8px_rgba(88,101,242,0.8)]",
bgGlow: "bg-[#5865F2]",
},
{
name: "X",
href: "#",
icon: Icons.X,
color: "text-foreground",
glow: "dark:drop-shadow-[0_0_8px_rgba(255,255,255,0.5)]",
bgGlow: "bg-white",
},
{
name: "Instagram",
href: "#",
icon: Icons.Instagram,
color: "text-[#E1306C]",
glow: "dark:drop-shadow-[0_0_8px_rgba(225,48,108,0.8)]",
bgGlow: "bg-[#E1306C]",
},
{
name: "Habr",
href: "#",
icon: Icons.Habr,
color: "text-[#65A3BE]",
glow: "dark:drop-shadow-[0_0_8px_rgba(101,163,190,0.8)]",
bgGlow: "bg-[#65A3BE]",
},
];
return ( return (
<footer <footer
className={`mt-20 border-t border-border/50 bg-card/30 backdrop-blur-xl ${jetbrainsMono.className}`} className={`mt-20 border-t border-border/20 bg-background pt-16 pb-12 ${jetbrainsMono.className}`}
> >
<div className="container mx-auto px-6 py-12"> <div className="container mx-auto px-6 max-w-6xl">
<div className="grid grid-cols-1 gap-12 md:grid-cols-4 lg:gap-16"> <div className="flex flex-col lg:flex-row justify-between items-start gap-16 lg:gap-8">
{/* Блок бренда */} <div className="flex flex-col gap-3 max-w-xs">
<div className="flex flex-col gap-4">
<Link <Link
href={`/${lang}`} href={`/${lang}`}
className="text-2xl font-bold tracking-tighter flex items-center gap-2 w-fit" className="flex items-center gap-3 group hover:scale-105 transition-transform duration-300 w-fit mt-1"
> >
<div className="w-3 h-3 bg-primary rounded-full shadow-[0_0_10px_rgba(0,240,255,0.8)] animate-pulse" /> {/* Убрали -rotate-2, текст теперь стоит ровно */}
<span className="text-neon-cyan">{dict.brand}</span> <div className="text-2xl sm:text-3xl tracking-widest flex items-center select-none font-bold">
<span
className="text-transparent animate-neon-flicker transition-all duration-300"
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-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> </Link>
<p className="text-muted-foreground text-sm leading-relaxed max-w-[200px]">
<div className="text-xs font-bold text-primary/80 tracking-widest uppercase mt-1 drop-shadow-[0_0_5px_rgba(139,61,255,0.5)]">
PROTOCOL // ENCRYPTED ACCESS
</div>
<p className="text-muted-foreground text-sm leading-relaxed mt-2 max-w-50">
{dict.tagline} {dict.tagline}
</p> </p>
<div className="flex gap-4 mt-2">
<Link <div className="flex flex-wrap gap-5 mt-4 items-center">
href="#" {socialLinks.map((social) => (
className="text-muted-foreground hover:text-primary transition-colors" <FlickeringSocialIcon key={social.name} social={social} />
> ))}
<GithubLogoIcon size={20} />
</Link>
<Link
href="#"
className="text-muted-foreground hover:text-secondary transition-colors"
>
<TwitterLogoIcon size={20} />
</Link>
<Link
href="#"
className="text-muted-foreground hover:text-primary transition-colors"
>
<Send size={20} />
</Link>
</div> </div>
</div> </div>
{/* Динамическая карта сайта */} <div className="flex flex-wrap md:flex-nowrap gap-12 lg:gap-16">
{sitemap.map((section, idx) => ( {sitemap.map((section, idx) => (
<div key={idx} className="flex flex-col gap-4"> <div key={idx} className="flex flex-col gap-5">
<h4 className="text-foreground font-bold text-sm uppercase tracking-widest"> <h4 className="text-foreground font-black text-[13px] uppercase tracking-widest">
{section.title} {section.title}
</h4> </h4>
<ul className="flex flex-col gap-2"> <ul className="flex flex-col gap-3">
{section.links.map((link, linkIdx) => ( {section.links.map((link, linkIdx) => (
<li key={linkIdx}> <li key={linkIdx}>
<Link <Link
@@ -104,14 +306,45 @@ export function Footer({
</div> </div>
))} ))}
</div> </div>
</div>
{/* Нижняя панель */} <div className="mt-20 border border-border/30 bg-card/20 rounded-xl p-6 md:px-10 md:py-8 flex flex-col md:flex-row justify-between items-start md:items-center gap-8 shadow-2xl relative overflow-hidden">
<div className="mt-16 pt-8 border-t border-border/20 flex flex-col md:flex-row justify-between items-center gap-4"> <div className="absolute top-0 left-1/4 w-1/2 h-full bg-primary/10 blur-3xl -z-10 pointer-events-none" />
<p className="text-xs text-muted-foreground/60">{dict.rights}</p>
<div className="flex gap-8 text-[10px] uppercase tracking-[0.2em] text-muted-foreground/40 font-bold"> <div className="flex flex-col gap-1">
<span>Uplink: Active</span> <div className="flex items-center gap-2">
<span>Node: Node-Th-01</span> <div className="w-1.5 h-1.5 rounded-full bg-primary shadow-[0_0_8px_rgba(139,61,255,0.8)] animate-pulse" />
<span>Latency: 24ms</span> <span className="text-[10px] text-muted-foreground/60 uppercase tracking-widest font-bold">
Uplink Status
</span>
</div>
<div className="text-2xl font-black text-foreground tracking-tight mt-1">
464.6 MBPS
</div>
</div>
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<div className="w-1.5 h-1.5 rounded-full bg-primary shadow-[0_0_8px_rgba(139,61,255,0.8)]" />
<span className="text-[10px] text-muted-foreground/60 uppercase tracking-widest font-bold">
Active Node
</span>
</div>
<div className="text-2xl font-black text-foreground tracking-tight mt-1">
TYO-SEC-05
</div>
</div>
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<div className="w-1.5 h-1.5 rounded-full bg-green-500 shadow-[0_0_8px_rgba(34,197,94,0.8)]" />
<span className="text-[10px] text-muted-foreground/60 uppercase tracking-widest font-bold">
Latency (RTT)
</span>
</div>
<div className="text-2xl font-black text-foreground tracking-tight mt-1">
16 MS
</div>
</div> </div>
</div> </div>
</div> </div>
+38 -18
View File
@@ -1,10 +1,11 @@
"use client"; "use client";
import Link from "next/link"; import Link from "next/link";
import { Button } from "@/components/ui/button";
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 { cn } from "@/lib/utils";
export function Header({ export function Header({
dict, dict,
@@ -14,27 +15,45 @@ export function Header({
lang: string; lang: string;
}) { }) {
return ( return (
<header className="fixed 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"> <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-6 md:px-8 h-16 flex items-center justify-between"> <div className="px-4 md:px-8 h-14 md:h-16 flex items-center justify-between">
{/* ЛЕВЫЙ БЛОК: Неоновый Логотип */} {/* ЛЕВЫЙ БЛОК: Адаптивный Логотип */}
<Link <Link
href={`/${lang}`} href={`/${lang}`}
className="flex items-center gap-3 group group-hover:scale-105 transition-transform duration-300" className="flex items-center gap-2 md:gap-3 group hover:scale-105 transition-transform duration-300 w-fit"
>
<div className="text-lg sm:text-2xl md:text-3xl tracking-wide md:tracking-widest flex items-center select-none font-bold uppercase">
<span
className="text-transparent animate-neon-flicker transition-all duration-300"
style={{
WebkitTextStroke: "1px #00E5F2",
filter: "drop-shadow(0 0 5px rgba(0,229,242,0.6))",
}}
> >
<div className="text-2xl sm:text-3xl tracking-widest flex items-center -rotate-2 select-none font-bold">
<span className="transition-all duration-300 text-neon-cyan animate-neon-flicker">
Net Net
</span> </span>
<span className="transition-all duration-300 text-neon-cyan"> <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 runner
</span> </span>
<span className="ml-2 opacity-90 group-hover:opacity-100 transition-all duration-300 text-neon-purple"> <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 VPN
</span> </span>
</div> </div>
</Link> </Link>
{/* ЦЕНТРАЛЬНЫЙ БЛОК: Навигация */} {/* ЦЕНТРАЛЬНЫЙ БЛОК: Навигация (скрыта на мобилках) */}
<nav className="hidden md:flex items-center gap-8 text-sm font-bold text-muted-foreground"> <nav className="hidden md:flex items-center gap-8 text-sm font-bold text-muted-foreground">
<Link <Link
href={`/${lang}#features`} href={`/${lang}#features`}
@@ -57,18 +76,19 @@ export function Header({
</nav> </nav>
{/* ПРАВЫЙ БЛОК: Контролы */} {/* ПРАВЫЙ БЛОК: Контролы */}
<div className="flex items-center gap-2 sm:gap-4"> <div className="flex items-center gap-1.5 md:gap-4">
<div className="flex items-center gap-1 sm:gap-2"> <div className="flex items-center gap-1 md:gap-2">
<LanguageSwitcher /> <LanguageSwitcher />
<ThemeToggle /> <ThemeToggle />
</div> </div>
{/* РОУТИНГ НА СТРАНИЦУ СКАЧИВАНИЯ */}
<Button <CyberButton
asChild href={`/${lang}/download`}
className="hidden sm:flex rounded-full px-6 py-5 text-sm font-bold transition-all duration-300 bg-primary text-primary-foreground shadow-md hover:shadow-[0_0_20px_rgba(0,240,255,0.4)] dark:shadow-[0_0_15px_rgba(112,0,255,0.3)] hover:-translate-y-0.5" variant="primary"
className="hidden sm:block scale-90 md:scale-100 origin-right"
> >
<Link href={`/${lang}/download`}>{dict.cta}</Link> {dict.cta}
</Button> </CyberButton>
</div> </div>
</div> </div>
</header> </header>
+14 -23
View File
@@ -8,7 +8,7 @@ import { Shield, EyeOff } from "lucide-react";
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import { Dictionary } from "@/lib/IDict"; import { Dictionary } from "@/lib/IDict";
import Link from "next/link"; import { CyberButton } from "../ui/cyber-button";
const MatrixBackground = dynamic(() => import("./MatrixBackground"), { const MatrixBackground = dynamic(() => import("./MatrixBackground"), {
ssr: false, ssr: false,
@@ -177,11 +177,11 @@ export function Hero({
const { resolvedTheme } = useTheme(); const { resolvedTheme } = useTheme();
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-[90vh] 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">
<div className="absolute inset-0 -z-10 bg-background pointer-events-none">
<MatrixBackground isSecure={isVpnOn} theme={resolvedTheme} /> <MatrixBackground isSecure={isVpnOn} theme={resolvedTheme} />
</div>
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_center,_transparent_0%,_var(--background)_80%)] -z-10" /> <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 }}
@@ -244,26 +244,17 @@ export function Hero({
</span> </span>
</div> </div>
<div className="flex flex-col sm:flex-row gap-4 justify-center w-full sm:w-auto font-mono"> <div className="flex flex-col sm:flex-row gap-6 justify-center w-full sm:w-auto font-mono">
{/* ИНВЕРСИЯ КНОПОК */} <CyberButton
<Button href={`/${lang}/download`}
asChild variant={isVpnOn ? "primary" : "secondary"}
className={`text-lg font-bold px-8 py-6 rounded-2xl transition-all duration-300 w-full sm:w-auto ${
isVpnOn
? "bg-purple-600 text-white hover:bg-purple-700 dark:bg-primary dark:text-primary-foreground dark:hover:bg-primary/90 shadow-[0_4px_14px_rgba(139,61,255,0.3)] dark:shadow-[0_0_20px_rgba(139,61,255,0.3)]"
: "bg-cyan-600 text-white hover:bg-cyan-700 dark:bg-secondary dark:text-secondary-foreground dark:hover:bg-secondary/90 shadow-[0_4px_14px_rgba(0,229,242,0.3)] dark:shadow-[0_0_20px_rgba(0,229,242,0.3)]"
}`}
> >
<Link href={`/${lang}/download`}>{dict.ctaPrimary}</Link> {dict.ctaPrimary}
</Button> </CyberButton>
<Button <CyberButton href={`/${lang}/nodes`} variant="secondary">
asChild {dict.ctaSecondary}
variant="outline" </CyberButton>
className="border-border/60 bg-background/60 backdrop-blur-md text-foreground hover:bg-muted font-semibold text-lg px-8 py-6 rounded-2xl w-full sm:w-auto shadow-sm"
>
<Link href={`/${lang}/nodes`}>{dict.ctaSecondary}</Link>
</Button>
</div> </div>
</div> </div>
</motion.div> </motion.div>
+77 -55
View File
@@ -10,101 +10,123 @@ 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 });
useEffect(() => { useEffect(() => {
const canvas = canvasRef.current; const canvas = canvasRef.current;
if (!canvas) return; if (!canvas) return;
const ctx = canvas.getContext("2d"); 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);
let height = (canvas.height = window.innerHeight); let height = (canvas.height = window.innerHeight);
const fontSize = 16; const fontSize = 15;
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 isDark = theme === "dark"; const chars = "01ヲアウエオカキケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワン".split("");
const draw = () => { const isDark = theme === "dark";
// Контрастный фон: в темной теме делаем глубокий черный let animationFrameId: number;
let lastTime = 0;
const fps = 22;
const interval = 1000 / fps;
const handleMouseMove = (e: MouseEvent) => {
mousePos.current = { x: e.clientX, y: e.clientY };
};
const handleResize = () => {
width = canvas.width = window.innerWidth;
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("resize", handleResize);
const draw = (currentTime: number) => {
animationFrameId = requestAnimationFrame(draw);
const deltaTime = currentTime - lastTime;
if (deltaTime < interval) return;
lastTime = currentTime - (deltaTime % interval);
// Заливка фона (создает шлейф)
// В светлой теме прозрачность шлейфа должна быть выше, чтобы буквы не исчезали мгновенно
ctx.fillStyle = isDark ctx.fillStyle = isDark
? "rgba(0, 0, 0, 0.08)" // Чистый черный для контраста ? "rgba(11, 13, 23, 0.15)"
: "rgba(250, 250, 250, 0.05)"; : "rgba(248, 250, 252, 0.25)";
ctx.fillRect(0, 0, width, height); ctx.fillRect(0, 0, width, height);
const gradient = ctx.createRadialGradient( ctx.font = `bold ${fontSize}px monospace`;
width / 2, ctx.shadowBlur = 0; // Сбрасываем тени для основных букв (оптимизация)
height / 2,
0, for (let i = 0; i < drops.length; i++) {
width / 2, const x = i * fontSize;
height / 2, const y = drops[i] * fontSize;
Math.sqrt(width ** 2 + height ** 2) / 2,
); // Расчет яркости в зависимости от курсора
const dx = x - mousePos.current.x;
const dy = y - mousePos.current.y;
const distance = Math.sqrt(dx * dx + dy * dy);
const radius = 300; // Радиус "взгляда"
// Плавное затухание: у курсора ярко, вдали - почти не видно
const highlight = Math.max(0, 1 - distance / radius);
// Подбираем альфу так, чтобы буквы было видно даже без мыши, но очень тускло
const baseOpacity = isDark ? 0.08 : 0.15;
const finalOpacity = baseOpacity + highlight * 0.5;
if (isSecure) { if (isSecure) {
if (isDark) { ctx.fillStyle = isDark
gradient.addColorStop(0, "#00F0FF"); // Центр: Ядовитый циановый ? `rgba(0, 240, 255, ${finalOpacity})`
gradient.addColorStop(0.4, "#0080FF"); : `rgba(2, 132, 199, ${finalOpacity})`;
gradient.addColorStop(1, "rgba(0, 20, 40, 0)"); // Края уходят в прозрачность
} else { } else {
gradient.addColorStop(0, "rgba(2, 132, 199, 1)"); ctx.fillStyle = isDark
gradient.addColorStop(1, "rgba(2, 85, 199, 0.2)"); ? `rgba(139, 61, 255, ${finalOpacity})`
} : `rgba(109, 40, 217, ${finalOpacity})`;
} else {
if (isDark) {
gradient.addColorStop(0, "#7000FF"); // Центр: Яркий фиолетовый
gradient.addColorStop(0.4, "#400099");
gradient.addColorStop(1, "rgba(20, 0, 40, 0)");
} else {
gradient.addColorStop(0, "rgba(217, 40, 217, 1)");
gradient.addColorStop(1, "rgba(61, 40, 217, 0.2)");
}
} }
ctx.fillStyle = gradient; // Добавляем небольшой Bloom эффект только самым ярким символам у курсора
ctx.font = `bold ${fontSize}px monospace`; // Сделали шрифт bold для четкости if (isDark && highlight > 0.7) {
ctx.shadowBlur = 10;
// Добавляем эффект свечения только для темной темы ctx.shadowColor = isSecure ? "#00F0FF" : "#8B3DFF";
if (isDark) {
ctx.shadowBlur = 8;
ctx.shadowColor = isSecure ? "#00F0FF" : "#7000FF";
} else { } else {
ctx.shadowBlur = 0; ctx.shadowBlur = 0;
} }
for (let i = 0; i < drops.length; i++) { const char = chars[Math.floor(Math.random() * chars.length)];
const text = Math.random() > 0.5 ? "1" : "0"; ctx.fillText(char, x, y);
ctx.fillText(text, i * fontSize, drops[i] * fontSize);
if (drops[i] * fontSize > height && Math.random() > 0.975) { // Скорость падения и рандомный сброс
if (y > height && Math.random() > 0.98) {
drops[i] = 0; drops[i] = 0;
} }
drops[i]++; drops[i]++;
} }
}; };
const interval = setInterval(draw, 33); animationFrameId = requestAnimationFrame(draw);
const handleResize = () => {
width = canvas.width = window.innerWidth;
height = canvas.height = window.innerHeight;
};
window.addEventListener("resize", handleResize);
return () => { return () => {
clearInterval(interval); cancelAnimationFrame(animationFrameId);
window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("resize", handleResize); window.removeEventListener("resize", handleResize);
}; };
}, [isSecure, theme]); }, [isSecure, theme]); // mousePos удален из зависимостей
return ( return (
<canvas <canvas
ref={canvasRef} ref={canvasRef}
// opacity-100 для темной темы, чтобы не гасить яркость, и opacity-30 для светлой className="absolute inset-0 w-full h-full pointer-events-none"
className={`absolute inset-0 -z-20 transition-opacity duration-1000 ${
theme === "dark" ? "opacity-100" : "opacity-30"
}`}
/> />
); );
} }
+6 -8
View File
@@ -14,6 +14,7 @@ import { Badge } from "@/components/ui/badge";
import { CheckIcon, Loader2 } from "lucide-react"; import { CheckIcon, Loader2 } from "lucide-react";
import { Dictionary } from "@/lib/IDict"; import { Dictionary } from "@/lib/IDict";
import { pb } from "@/lib/pb"; import { pb } from "@/lib/pb";
import { CyberButton } from "../ui/cyber-button";
interface PBPricingPlan { interface PBPricingPlan {
id: string; id: string;
@@ -260,16 +261,13 @@ export function Pricing({
</CardContent> </CardContent>
<CardFooter className="pt-0"> <CardFooter className="pt-0">
<Button <CyberButton
variant={plan.popular ? "default" : "outline"} href="#"
className={`w-full rounded-2xl font-bold text-sm uppercase tracking-widest h-14 transition-all duration-300 ${ variant={plan.popular ? "primary" : "secondary"}
plan.popular className="w-full"
? "bg-primary text-primary-foreground hover:shadow-[0_0_30px_rgba(0,240,255,0.4)]"
: "hover:border-primary/50"
}`}
> >
{plan.cta} {plan.cta}
</Button> </CyberButton>
</CardFooter> </CardFooter>
</Card> </Card>
); );
+54 -16
View File
@@ -14,6 +14,8 @@ import {
import { pb } from "@/lib/pb"; import { pb } from "@/lib/pb";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Dictionary } from "@/lib/IDict"; import { Dictionary } from "@/lib/IDict";
import { CyberButton } from "../ui/cyber-button";
import { cn } from "@/lib/utils";
export function TerminalGuestbook({ dict }: { dict: Dictionary["guestbook"] }) { export function TerminalGuestbook({ dict }: { dict: Dictionary["guestbook"] }) {
const [comments, setComments] = useState<any[]>([]); const [comments, setComments] = useState<any[]>([]);
@@ -35,7 +37,7 @@ export function TerminalGuestbook({ dict }: { dict: Dictionary["guestbook"] }) {
try { try {
const records = await pb const records = await pb
.collection("comments") .collection("comments")
.getList(1, 50, { sort: "-created" }); .getList(1, 50, { sort: "-created", requestKey: null });
setComments(records.items); setComments(records.items);
} catch (e) { } catch (e) {
console.error(e); console.error(e);
@@ -105,9 +107,9 @@ export function TerminalGuestbook({ dict }: { dict: Dictionary["guestbook"] }) {
id="guestbook" id="guestbook"
className="py-32 px-4 container mx-auto max-w-6xl relative overflow-hidden" className="py-32 px-4 container mx-auto max-w-6xl relative overflow-hidden"
> >
{/* Мягкие свечения */} {/* Мягкие свечения — увеличили размер и размытие для плавности */}
<div className="absolute top-0 right-0 w-[500px] h-[500px] bg-primary/5 blur-[120px] rounded-full -z-10" /> <div className="absolute -top-20 -right-20 w-[800px] h-[800px] bg-primary/5 dark:bg-primary/10 blur-[150px] rounded-full -z-10 opacity-30 dark:opacity-60" />
<div className="absolute bottom-0 left-0 w-[500px] h-[500px] bg-secondary/5 blur-[120px] rounded-full -z-10" /> <div className="absolute -bottom-20 -left-20 w-[800px] h-[800px] bg-secondary/5 dark:bg-secondary/10 blur-[150px] rounded-full -z-10 opacity-30 dark:opacity-60" />
{/* Заголовок секции */} {/* Заголовок секции */}
<div className="text-center mb-16"> <div className="text-center mb-16">
@@ -136,7 +138,12 @@ export function TerminalGuestbook({ dict }: { dict: Dictionary["guestbook"] }) {
<div className="bg-card/40 backdrop-blur-xl border border-border/50 rounded-[2.5rem] p-8 shadow-xl"> <div className="bg-card/40 backdrop-blur-xl border border-border/50 rounded-[2.5rem] p-8 shadow-xl">
<form onSubmit={handleSubmit} className="space-y-5"> <form onSubmit={handleSubmit} className="space-y-5">
<div className="space-y-2"> <div className="space-y-2">
<label className="text-[10px] font-mono text-primary uppercase tracking-[0.2em] flex items-center gap-2 ml-1"> <label
className={cn(
"text-[10px] font-mono uppercase tracking-[0.2em] flex items-center gap-2 ml-1 mb-2",
"text-primary/80 dark:text-primary font-bold", // Сделали жирнее и чуть приглушили в светлой
)}
>
<User className="size-3" /> {dict.labelAlias} <User className="size-3" /> {dict.labelAlias}
</label> </label>
<input <input
@@ -146,7 +153,15 @@ export function TerminalGuestbook({ dict }: { dict: Dictionary["guestbook"] }) {
onChange={(e) => setNickname(e.target.value)} onChange={(e) => setNickname(e.target.value)}
placeholder={dict.placeholderAlias} placeholder={dict.placeholderAlias}
disabled={isSubmitting || cooldown > 0} disabled={isSubmitting || cooldown > 0}
className="w-full bg-background/50 border border-border/50 focus:border-primary/50 rounded-2xl px-5 py-4 text-sm outline-none transition-all disabled:opacity-50 text-foreground font-mono placeholder:text-muted-foreground/30" // Фрагмент классов для input и textarea в TerminalGuestbook.tsx
className={cn(
"w-full rounded-2xl px-5 py-4 text-sm outline-none transition-all font-mono",
"disabled:opacity-50 text-foreground",
// Светлая тема: более плотный плейсхолдер и четкая рамка
"bg-background/50 border border-border/80 placeholder:text-muted-foreground/60 focus:border-primary focus:bg-background shadow-sm",
// Темная тема: стеклянный эффект
"dark:bg-background/50 dark:border-border/50 dark:placeholder:text-muted-foreground/30 dark:focus:border-primary/50",
)}
/> />
</div> </div>
@@ -161,7 +176,15 @@ export function TerminalGuestbook({ dict }: { dict: Dictionary["guestbook"] }) {
onChange={(e) => setMessage(e.target.value)} onChange={(e) => setMessage(e.target.value)}
placeholder={dict.placeholderPayload} placeholder={dict.placeholderPayload}
disabled={isSubmitting || cooldown > 0} disabled={isSubmitting || cooldown > 0}
className="w-full bg-background/50 border border-border/50 focus:border-primary/50 rounded-2xl px-5 py-4 text-sm outline-none transition-all disabled:opacity-50 resize-none text-foreground placeholder:text-muted-foreground/30" // Фрагмент классов для input и textarea в TerminalGuestbook.tsx
className={cn(
"w-full rounded-2xl px-5 py-4 text-sm outline-none transition-all font-mono",
"disabled:opacity-50 text-foreground",
// Светлая тема: более плотный плейсхолдер и четкая рамка
"bg-background/50 border border-border/80 placeholder:text-muted-foreground/60 focus:border-primary focus:bg-background shadow-sm",
// Темная тема: стеклянный эффект
"dark:bg-background/50 dark:border-border/50 dark:placeholder:text-muted-foreground/30 dark:focus:border-primary/50",
)}
/> />
<div className="text-right text-[10px] text-muted-foreground/40 font-mono pr-2"> <div className="text-right text-[10px] text-muted-foreground/40 font-mono pr-2">
{message.length}/150 {message.length}/150
@@ -196,14 +219,11 @@ export function TerminalGuestbook({ dict }: { dict: Dictionary["guestbook"] }) {
)} )}
</AnimatePresence> </AnimatePresence>
<Button <CyberButton
type="submit" type="submit"
variant="primary"
disabled={isSubmitting || cooldown > 0} disabled={isSubmitting || cooldown > 0}
className={`w-full py-7 rounded-2xl font-black font-mono tracking-widest uppercase transition-all active:scale-[0.98] ${ className="w-full"
cooldown > 0
? "bg-muted text-muted-foreground/40"
: "bg-primary text-primary-foreground hover:bg-primary/90 shadow-[0_0_30px_rgba(139,61,255,0.3)]"
}`}
> >
{isSubmitting ? ( {isSubmitting ? (
dict.btnEncrypting dict.btnEncrypting
@@ -214,13 +234,22 @@ export function TerminalGuestbook({ dict }: { dict: Dictionary["guestbook"] }) {
<Send className="size-4" /> {dict.btnBroadcast} <Send className="size-4" /> {dict.btnBroadcast}
</span> </span>
)} )}
</Button> </CyberButton>
</form> </form>
</div> </div>
</div> </div>
{/* ПРАВАЯ КОЛОНКА (7/12): Фид сообщений */} {/* ПРАВАЯ КОЛОНКА (7/12): Фид сообщений */}
<div className="lg:col-span-7 bg-black/20 backdrop-blur-md border border-primary/20 rounded-[3rem] p-8 h-[700px] flex flex-col shadow-2xl">
<div
className={cn(
"lg:col-span-7 h-[700px] flex flex-col p-8 rounded-[3rem] backdrop-blur-md transition-all",
// Светлая тема: чистый белый, жесткая граница, сильная тень
"bg-white/90 border border-border/80 shadow-xl",
// Темная тема: полупрозрачное стекло, неоновая граница
"dark:bg-black/20 dark:border-primary/20 dark:shadow-2xl",
)}
>
<div className="flex justify-between items-center mb-8 px-4"> <div className="flex justify-between items-center mb-8 px-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="size-2 rounded-full bg-secondary animate-pulse shadow-[0_0_10px_rgba(0,229,242,0.8)]" /> <div className="size-2 rounded-full bg-secondary animate-pulse shadow-[0_0_10px_rgba(0,229,242,0.8)]" />
@@ -246,7 +275,16 @@ export function TerminalGuestbook({ dict }: { dict: Dictionary["guestbook"] }) {
className="relative group" className="relative group"
> >
<div className="absolute -left-2 top-0 bottom-0 w-0.5 bg-gradient-to-b from-secondary/0 via-secondary/20 to-secondary/0 group-hover:via-secondary/50 transition-all" /> <div className="absolute -left-2 top-0 bottom-0 w-0.5 bg-gradient-to-b from-secondary/0 via-secondary/20 to-secondary/0 group-hover:via-secondary/50 transition-all" />
<div className="bg-white/5 border border-white/5 rounded-[1.5rem] p-5 transition-all group-hover:bg-white/[0.08] group-hover:border-white/10 group-hover:translate-x-1"> {/* Сообщение */}
<div
className={cn(
"p-5 rounded-[1.5rem] transition-all group-hover:translate-x-1",
// Светлая тема: серый фон, четкая рамка
"bg-muted/30 border border-border/50 group-hover:bg-muted/60",
// Темная тема: прозрачное стекло
"dark:bg-white/5 dark:border-white/5 dark:group-hover:bg-white/[0.08] dark:group-hover:border-white/10",
)}
>
<div className="flex justify-between items-center mb-3"> <div className="flex justify-between items-center mb-3">
<span className="font-black text-secondary font-mono text-sm tracking-tight"> <span className="font-black text-secondary font-mono text-sm tracking-tight">
@{comment.nickname} @{comment.nickname}
+2 -2
View File
@@ -3,7 +3,7 @@
import * as React from "react"; import * as React from "react";
import { Accordion as AccordionPrimitive } from "radix-ui"; import { Accordion as AccordionPrimitive } from "radix-ui";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { CaretDownIcon } from "@phosphor-icons/react"; import { ChevronDown } from "lucide-react"; // Заменили импорт
function Accordion({ function Accordion({
className, className,
@@ -50,7 +50,7 @@ function AccordionTrigger({
{...props} {...props}
> >
{children} {children}
<CaretDownIcon className="size-4 shrink-0 text-muted-foreground transition-transform duration-200" /> <ChevronDown className="size-4 shrink-0 text-muted-foreground transition-transform duration-200" />
</AccordionPrimitive.Trigger> </AccordionPrimitive.Trigger>
</AccordionPrimitive.Header> </AccordionPrimitive.Header>
); );
+152
View File
@@ -0,0 +1,152 @@
"use client";
import Link from "next/link";
import { motion } from "framer-motion";
import { cn } from "@/lib/utils";
interface CyberButtonProps {
href?: string;
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
children: React.ReactNode;
className?: string;
variant?: "primary" | "secondary";
disabled?: boolean;
type?: "button" | "submit" | "reset";
}
export function CyberButton({
href,
onClick,
children,
className,
variant = "primary",
disabled = false,
type = "button",
}: CyberButtonProps) {
const isPrimary = variant === "primary";
const content = (
<motion.div
whileHover={!disabled ? { scale: 1.02 } : {}}
whileTap={!disabled ? { scale: 0.98 } : {}}
className={cn(
"relative z-10 flex items-center justify-center px-8 py-4 overflow-hidden transition-all duration-300 border rounded-xl font-mono",
// --- ACTIVE STATE ---
!disabled && [
isPrimary
? "bg-[#8B3DFF] border-[#8B3DFF] text-white shadow-lg shadow-purple-500/20 dark:bg-[radial-gradient(circle_at_center,_rgba(139,61,255,0.4)_0%,_rgba(139,61,255,0.1)_100%)] dark:border-[#8B3DFF]/50 dark:shadow-[0_0_25px_rgba(139,61,255,0.3)] dark:after:absolute dark:after:inset-0 dark:after:shadow-[inset_0_0_15px_rgba(139,61,255,0.2)]"
: "text-[#00E5F2] border-[#00E5F2]/40 backdrop-blur-md bg-slate-900/5 border-[#00E5F2]/50 shadow-sm dark:bg-[#00E5F2]/5 dark:border-[#00E5F2]/30 dark:shadow-[0_0_15px_rgba(0,229,242,0.1)] hover:border-[#00E5F2] hover:shadow-[0_0_20px_rgba(0,229,242,0.3)]",
],
// --- DISABLED STATE (Исправленный) ---
disabled && [
"cursor-not-allowed border-dashed shadow-none",
// Светлая тема: делаем кнопку матовой и "холодной"
"bg-slate-100 border-slate-300 text-slate-400",
// Темная тема: делаем кнопку глубокой и "выключенной"
"dark:bg-slate-900/40 dark:border-slate-800 dark:text-slate-600",
],
className,
)}
>
{/* CRT Сетка (только когда активна) */}
{!isPrimary && !disabled && (
<div className="absolute inset-0 opacity-[0.03] dark:opacity-10 pointer-events-none bg-[linear-gradient(rgba(18,16,16,0)_50%,rgba(0,229,242,0.1)_50%),linear-gradient(90deg,rgba(255,0,0,0.02),rgba(0,255,0,0.01),rgba(0,0,255,0.02))] bg-[length:100%_4px,3px_100%]" />
)}
{/* Энергетический блик (только когда активна) */}
{!disabled && (
<div
className={cn(
"absolute inset-0 w-full h-full -translate-x-full group-hover:animate-scan pointer-events-none",
isPrimary
? "bg-gradient-to-r from-transparent via-white/20 to-transparent"
: "bg-gradient-to-r from-transparent via-[#00E5F2]/20 to-transparent",
)}
/>
)}
{/* Угловые элементы (⌜ ⌟) - прячем или приглушаем при disabled */}
<div
className={cn(
"absolute inset-0 pointer-events-none transition-opacity",
disabled ? "opacity-20" : "opacity-60",
)}
>
<span
className={cn(
"absolute top-1 left-1 text-[10px] font-mono",
isPrimary ? "text-white" : "text-[#00E5F2]",
)}
>
</span>
<span
className={cn(
"absolute bottom-1 right-1 text-[10px] font-mono",
isPrimary ? "text-white" : "text-[#00E5F2]",
)}
>
</span>
</div>
{/* Анимированная полоска заряда (только для Secondary и когда активна) */}
{!isPrimary && !disabled && (
<motion.div
initial={{ height: 0 }}
whileHover={{ height: "70%" }}
className="absolute left-0 w-[2px] bg-[#00E5F2] shadow-[0_0_10px_#00E5F2] transition-all"
/>
)}
{/* Текст */}
<span
className={cn(
"relative z-20 font-black uppercase tracking-[0.2em] text-sm flex items-center gap-2",
!isPrimary &&
!disabled &&
"drop-shadow-[0_0_5px_rgba(0,229,242,0.5)]",
disabled && "drop-shadow-none",
)}
>
<span className="opacity-50">[</span>
{children}
<span className="opacity-50">]</span>
</span>
</motion.div>
);
// Фоновое облако свечения (только когда активна)
const glowEffect = !disabled && (
<div
className={cn(
"absolute inset-0 blur-2xl opacity-0 group-hover:opacity-40 transition-opacity duration-500 -z-10",
isPrimary ? "bg-[#8B3DFF]" : "bg-[#00E5F2]/40",
)}
/>
);
if (href && !disabled) {
return (
<Link href={href} className="group relative inline-block">
{content}
{glowEffect}
</Link>
);
}
return (
<button
type={type}
disabled={disabled}
onClick={onClick}
className="group relative inline-block bg-transparent border-none p-0 outline-none cursor-pointer"
>
{content}
{glowEffect}
</button>
);
}
+4 -4
View File
@@ -4,7 +4,7 @@ import * as React from "react";
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"; import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { CheckIcon, CaretRightIcon } from "@phosphor-icons/react"; import { Check, ChevronRight } from "lucide-react";
function DropdownMenu({ function DropdownMenu({
...props ...props
@@ -106,7 +106,7 @@ function DropdownMenuCheckboxItem({
data-slot="dropdown-menu-checkbox-item-indicator" data-slot="dropdown-menu-checkbox-item-indicator"
> >
<DropdownMenuPrimitive.ItemIndicator> <DropdownMenuPrimitive.ItemIndicator>
<CheckIcon /> <Check className="size-4" />
</DropdownMenuPrimitive.ItemIndicator> </DropdownMenuPrimitive.ItemIndicator>
</span> </span>
{children} {children}
@@ -148,7 +148,7 @@ function DropdownMenuRadioItem({
data-slot="dropdown-menu-radio-item-indicator" data-slot="dropdown-menu-radio-item-indicator"
> >
<DropdownMenuPrimitive.ItemIndicator> <DropdownMenuPrimitive.ItemIndicator>
<CheckIcon /> <Check className="size-4" />
</DropdownMenuPrimitive.ItemIndicator> </DropdownMenuPrimitive.ItemIndicator>
</span> </span>
{children} {children}
@@ -230,7 +230,7 @@ function DropdownMenuSubTrigger({
{...props} {...props}
> >
{children} {children}
<CaretRightIcon className="ml-auto" /> <ChevronRight className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger> </DropdownMenuPrimitive.SubTrigger>
); );
} }
+14 -24
View File
@@ -1,25 +1,20 @@
import PocketBase, { RecordModel } from "pocketbase"; import PocketBase, { RecordModel } from "pocketbase";
// Определяем, на сервере мы или в браузере
const isServer = typeof window === "undefined"; const isServer = typeof window === "undefined";
// Фикс для Node.js (серверная часть) const publicUrl =
if (isServer) { process.env.NEXT_PUBLIC_PB_URL || "https://netrunner-vpn.com/cms-api";
// Позволяет серверу игнорировать проблемы с сертификатами при локальных запросах const internalUrl = process.env.PB_INTERNAL_URL; // Не ставим дефолт здесь
process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";
}
/** export const PB_URL = isServer ? internalUrl || publicUrl : publicUrl;
* ЛОГИКА URL:
* 1. На сервере: используем локальный IP (быстро, внутри сети).
* 2. В браузере: используем публичный URL (через Nginx/SSL).
*/
export const PB_URL = isServer
? process.env.PB_INTERNAL_URL || "https://netrunner-vpn.com/cms-api"
: process.env.NEXT_PUBLIC_PB_URL || "https://netrunner-vpn.com/cms-api";
export const pb = new PocketBase(PB_URL); export const pb = new PocketBase(PB_URL);
// Отключаем автоотмену на сервере, чтобы избежать AbortError при SSR/ISR
if (isServer) {
pb.autoCancellation(false);
}
/** /**
* Базовый интерфейс для записей PocketBase. * Базовый интерфейс для записей PocketBase.
* Наследуемся от RecordModel, чтобы иметь системные поля (id, created, updated, и т.д.) * Наследуемся от RecordModel, чтобы иметь системные поля (id, created, updated, и т.д.)
@@ -55,6 +50,9 @@ export interface PBFaq extends BaseRecord {
lang: string; lang: string;
} }
const FILE_BASE_URL =
process.env.NEXT_PUBLIC_PB_URL || "https://netrunner-vpn.com/cms-api";
/** /**
* Функция для генерации ссылки на картинку из PB * Функция для генерации ссылки на картинку из PB
* @param record - Объект записи (должен содержать id и collectionId) * @param record - Объект записи (должен содержать id и collectionId)
@@ -65,9 +63,8 @@ export function getPbImage(
filename: string | undefined, filename: string | undefined,
): string { ): string {
if (!filename) return ""; if (!filename) return "";
return `${PB_URL}/api/files/${record.collectionId}/${record.id}/${filename}`; return `${FILE_BASE_URL}/api/files/${record.collectionId}/${record.id}/${filename}`;
} }
// lib/pb.ts (добавь в конец файла) // lib/pb.ts (добавь в конец файла)
export interface PBDownload extends BaseRecord { export interface PBDownload extends BaseRecord {
@@ -85,12 +82,5 @@ export function getPbFileUrl(
filename: string | undefined, filename: string | undefined,
): string { ): string {
if (!filename) return ""; if (!filename) return "";
return `${FILE_BASE_URL}/api/files/${record.collectionId}/${record.id}/${filename}`;
// Если мы на сервере, для ссылок на файлы всё равно используем ПУБЛИЧНЫЙ адрес,
// так как эти ссылки рендерятся в HTML и уходят клиенту.
const baseUrl = isServer
? process.env.NEXT_PUBLIC_PB_URL || "https://netrunner-vpn.com/cms"
: PB_URL;
return `${baseUrl}/api/files/${record.collectionId}/${record.id}/${filename}`;
} }
+3 -3
View File
@@ -1,7 +1,7 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
/* config options here */ images: {
remotePatterns: [{ protocol: "https", hostname: "netrunner-vpn.com" }],
},
}; };
export default nextConfig; export default nextConfig;
-1
View File
@@ -10,7 +10,6 @@
}, },
"dependencies": { "dependencies": {
"@formatjs/intl-localematcher": "^0.8.3", "@formatjs/intl-localematcher": "^0.8.3",
"@phosphor-icons/react": "^2.1.10",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
-15
View File
@@ -11,9 +11,6 @@ importers:
'@formatjs/intl-localematcher': '@formatjs/intl-localematcher':
specifier: ^0.8.3 specifier: ^0.8.3
version: 0.8.3 version: 0.8.3
'@phosphor-icons/react':
specifier: ^2.1.10
version: 2.1.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
class-variance-authority: class-variance-authority:
specifier: ^0.7.1 specifier: ^0.7.1
version: 0.7.1 version: 0.7.1
@@ -648,13 +645,6 @@ packages:
'@open-draft/until@2.1.0': '@open-draft/until@2.1.0':
resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==} resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==}
'@phosphor-icons/react@2.1.10':
resolution: {integrity: sha512-vt8Tvq8GLjheAZZYa+YG/pW7HDbov8El/MANW8pOAz4eGxrwhnbfrQZq0Cp4q8zBEu8NIhHdnr+r8thnfRSNYA==}
engines: {node: '>=10'}
peerDependencies:
react: '>= 16.8'
react-dom: '>= 16.8'
'@radix-ui/number@1.1.1': '@radix-ui/number@1.1.1':
resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==}
@@ -4363,11 +4353,6 @@ snapshots:
'@open-draft/until@2.1.0': {} '@open-draft/until@2.1.0': {}
'@phosphor-icons/react@2.1.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
'@radix-ui/number@1.1.1': {} '@radix-ui/number@1.1.1': {}
'@radix-ui/primitive@1.1.3': {} '@radix-ui/primitive@1.1.3': {}