base with cms integrated
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
import { pb, getPbImage } from "@/lib/pb";
|
||||
import { notFound } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { ArrowLeft, Calendar, Clock, ShieldCheck } from "lucide-react";
|
||||
|
||||
interface PBPost {
|
||||
id: string;
|
||||
collectionId: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
content: string;
|
||||
image: string;
|
||||
lang: string;
|
||||
created: string;
|
||||
published: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ lang: string; slug: string }>;
|
||||
}
|
||||
|
||||
export default async function BlogPostPage({ params }: Props) {
|
||||
const { lang, slug } = await params;
|
||||
const isRu = lang === "ru";
|
||||
|
||||
let post: PBPost;
|
||||
|
||||
try {
|
||||
post = await pb
|
||||
.collection("posts")
|
||||
.getFirstListItem<PBPost>(
|
||||
`slug = "${slug}" && lang = "${lang}" && published = true`,
|
||||
{ cache: "no-store" },
|
||||
);
|
||||
} catch (error) {
|
||||
// В продакшене лучше не логировать каждый 404, чтобы не забивать консоль
|
||||
notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-screen pt-32 pb-20 bg-background">
|
||||
<div className="container mx-auto max-w-3xl px-4">
|
||||
{/* Хлебные крошки / Назад */}
|
||||
<Link
|
||||
href={`/${lang}/blog`}
|
||||
className="inline-flex items-center gap-2 text-sm font-mono text-muted-foreground hover:text-primary mb-12 transition-colors group"
|
||||
>
|
||||
<ArrowLeft
|
||||
size={16}
|
||||
className="group-hover:-translate-x-1 transition-transform"
|
||||
/>
|
||||
<span className="opacity-50">root@netrunner:~#</span>
|
||||
<span>cd ../logs</span>
|
||||
</Link>
|
||||
|
||||
<article className="relative">
|
||||
{/* Обложка с неоновым свечением */}
|
||||
{post.image && (
|
||||
<div className="w-full h-[300px] md:h-[450px] rounded-3xl overflow-hidden mb-12 border border-border/50 shadow-2xl shadow-primary/10 relative">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={getPbImage(post, post.image)}
|
||||
alt={post.title}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-background via-transparent to-transparent opacity-60" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Шапка статьи */}
|
||||
<div className="flex flex-wrap items-center gap-6 text-[10px] font-mono text-primary mb-8 uppercase tracking-[0.2em]">
|
||||
<span className="flex items-center gap-2 px-3 py-1 bg-primary/10 rounded-full border border-primary/20">
|
||||
<Calendar size={12} />
|
||||
{new Date(post.created).toLocaleDateString(
|
||||
isRu ? "ru-RU" : "en-US",
|
||||
)}
|
||||
</span>
|
||||
<span className="flex items-center gap-2 text-muted-foreground">
|
||||
<Clock size={12} />
|
||||
{isRu ? "Чтение: 4 мин" : "Read: 4 min"}
|
||||
</span>
|
||||
<span className="flex items-center gap-2 text-green-500/70">
|
||||
<ShieldCheck size={12} />
|
||||
{isRu ? "Проверено: Netrunner" : "Verified: Netrunner"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h1 className="text-4xl md:text-6xl font-extrabold tracking-tighter mb-10 leading-[1.1] text-foreground">
|
||||
{post.title}
|
||||
</h1>
|
||||
|
||||
{/* Тело статьи с Tailwind Typography (prose) */}
|
||||
<div
|
||||
className="prose prose-invert prose-p:text-muted-foreground/90 prose-p:leading-relaxed prose-headings:text-foreground prose-headings:tracking-tighter prose-a:text-primary prose-strong:text-foreground prose-code:text-primary max-w-none text-lg selection:bg-primary/30"
|
||||
dangerouslySetInnerHTML={{ __html: post.content }}
|
||||
/>
|
||||
|
||||
{/* Футер статьи */}
|
||||
<div className="mt-20 pt-10 border-t border-border/30 flex justify-between items-center">
|
||||
<div className="text-[10px] font-mono text-muted-foreground/40 uppercase tracking-widest">
|
||||
End of transmission // node_th_01
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<div className="w-2 h-2 bg-primary rounded-full animate-ping" />
|
||||
<div className="w-2 h-2 bg-primary rounded-full opacity-50" />
|
||||
<div className="w-2 h-2 bg-primary rounded-full opacity-20" />
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { pb } from "@/lib/pb";
|
||||
import Link from "next/link";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
// Типизация записи из PocketBase
|
||||
interface PBPost {
|
||||
id: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
excerpt: string;
|
||||
lang: string;
|
||||
created: string;
|
||||
published: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ lang: string }>;
|
||||
}
|
||||
|
||||
export default async function BlogIndexPage({ params }: Props) {
|
||||
const { lang } = await params;
|
||||
const isRu = lang === "ru";
|
||||
|
||||
let records: PBPost[] = [];
|
||||
|
||||
try {
|
||||
// Возвращаем фильтрацию: только нужный язык и только опубликованные
|
||||
records = await pb.collection("posts").getFullList<PBPost>({
|
||||
filter: `lang = "${lang}" && published = true`,
|
||||
sort: "-created",
|
||||
cache: "no-store", // Чтобы новые посты из админки появлялись сразу
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch blog posts:", err);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-screen pt-32 pb-20 container mx-auto max-w-5xl px-4">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="mb-6 border-primary/30 text-primary uppercase tracking-widest"
|
||||
>
|
||||
{isRu ? "Архив терминала" : "Terminal Archive"}
|
||||
</Badge>
|
||||
|
||||
<h1 className="text-5xl md:text-6xl font-extrabold tracking-tighter mb-12">
|
||||
{isRu ? "Data Logs" : "System Logs"}
|
||||
</h1>
|
||||
|
||||
{records.length === 0 ? (
|
||||
<div className="p-20 border border-dashed border-border/50 rounded-3xl text-center bg-card/10">
|
||||
<p className="text-muted-foreground font-mono italic">
|
||||
{isRu
|
||||
? "[Записи не найдены в этом секторе]"
|
||||
: "[No records found in this sector]"}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
{records.map((post) => (
|
||||
<Link
|
||||
href={`/${lang}/blog/${post.slug}`}
|
||||
key={post.id}
|
||||
className="group block"
|
||||
>
|
||||
<article className="p-8 border border-border/50 rounded-2xl bg-card/20 hover:bg-card/40 hover:border-primary/50 transition-all duration-300 h-full flex flex-col">
|
||||
<div className="text-xs text-primary font-mono mb-4 flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 bg-primary rounded-full animate-pulse" />
|
||||
{new Date(post.created).toLocaleDateString(
|
||||
isRu ? "ru-RU" : "en-US",
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h2 className="text-2xl font-bold mb-3 group-hover:text-primary transition-colors duration-300">
|
||||
{post.title}
|
||||
</h2>
|
||||
|
||||
<p className="text-muted-foreground text-sm leading-relaxed line-clamp-3">
|
||||
{post.excerpt}
|
||||
</p>
|
||||
|
||||
<div className="mt-8 pt-4 border-t border-border/30 text-xs font-bold uppercase tracking-widest text-primary/50 group-hover:text-primary transition-colors">
|
||||
{isRu ? "Дешифровать →" : "Decrypt entry →"}
|
||||
</div>
|
||||
</article>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import {
|
||||
Search,
|
||||
FileText,
|
||||
Shield,
|
||||
Network,
|
||||
Scale,
|
||||
ChevronLeft,
|
||||
TerminalSquare,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { pb } from "@/lib/pb";
|
||||
|
||||
// --- МОДЕЛЬ ДАННЫХ POCKETBASE ---
|
||||
interface PBDocument {
|
||||
id: string;
|
||||
title: string;
|
||||
category: "Core" | "Security" | "Network" | "Legal";
|
||||
status: "Encrypted" | "Public" | "Internal" | "Dynamic";
|
||||
content: string;
|
||||
created: string; // Системное поле PocketBase
|
||||
}
|
||||
|
||||
const CATEGORIES = ["All", "Core", "Security", "Network", "Legal"];
|
||||
|
||||
export function DocsClient({ lang }: { lang: string }) {
|
||||
const [docs, setDocs] = useState<PBDocument[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState("");
|
||||
const [activeCategory, setActiveCategory] = useState("All");
|
||||
const [selectedDoc, setSelectedDoc] = useState<PBDocument | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchDocs() {
|
||||
try {
|
||||
setLoading(true);
|
||||
const filterString = `lang = "${lang}"`;
|
||||
const records = await pb
|
||||
.collection("documents")
|
||||
.getFullList<PBDocument>({
|
||||
sort: "-created",
|
||||
filter: filterString,
|
||||
});
|
||||
setDocs(records);
|
||||
} catch (error) {
|
||||
console.error("Vault access error:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
fetchDocs();
|
||||
}, [lang]);
|
||||
|
||||
const filteredDocs = docs.filter((doc) => {
|
||||
const matchesSearch =
|
||||
doc.title.toLowerCase().includes(search.toLowerCase()) ||
|
||||
doc.content.toLowerCase().includes(search.toLowerCase());
|
||||
const matchesCategory =
|
||||
activeCategory === "All" || doc.category === activeCategory;
|
||||
return matchesSearch && matchesCategory;
|
||||
});
|
||||
|
||||
const getCategoryIcon = (cat: string) => {
|
||||
switch (cat) {
|
||||
case "Core":
|
||||
return <TerminalSquare className="w-4 h-4 text-primary" />;
|
||||
case "Security":
|
||||
return <Shield className="w-4 h-4 text-secondary" />;
|
||||
case "Network":
|
||||
return <Network className="w-4 h-4 text-primary" />;
|
||||
case "Legal":
|
||||
return <Scale className="w-4 h-4 text-muted-foreground" />;
|
||||
default:
|
||||
return <FileText className="w-4 h-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string) => dateStr.split(" ")[0];
|
||||
|
||||
return (
|
||||
// Добавлен overflow-x-hidden для защиты от горизонтального скролла на мобилках
|
||||
<div className="min-h-screen pt-24 md:pt-32 pb-24 px-4 sm:px-6 container mx-auto max-w-6xl relative font-mono overflow-x-hidden md:overflow-x-visible">
|
||||
{/* Исправлено свечение: теперь max-w-full, чтобы не распирать экран */}
|
||||
<div className="absolute top-1/4 left-1/2 -translate-x-1/2 w-full max-w-[800px] h-[300px] md:h-[500px] bg-primary/10 md:bg-primary/5 blur-[100px] md:blur-[150px] rounded-full pointer-events-none -z-10" />
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
{loading ? (
|
||||
<motion.div
|
||||
key="loader"
|
||||
className="flex flex-col items-center justify-center py-40 text-primary"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
>
|
||||
<Loader2 className="w-8 h-8 animate-spin mb-4" />
|
||||
<p className="text-xs uppercase tracking-[0.3em] animate-pulse text-center">
|
||||
Establishing_secure_link...
|
||||
</p>
|
||||
</motion.div>
|
||||
) : !selectedDoc ? (
|
||||
<motion.div
|
||||
key="grid"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20, filter: "blur(10px)" }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
{/* Заголовок и поиск: центрирование на мобильных (items-center text-center) */}
|
||||
<div className="mb-10 md:mb-12 flex flex-col md:flex-row gap-6 items-center md:items-end justify-between text-center md:text-left">
|
||||
<div>
|
||||
<h1 className="text-3xl md:text-4xl font-bold tracking-tight text-foreground flex items-center justify-center md:justify-start gap-3">
|
||||
<div className="w-3 h-3 bg-primary animate-pulse shadow-[0_0_10px_rgba(0,240,255,0.8)]" />
|
||||
DATA_VAULT
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-2 text-xs md:text-sm uppercase tracking-widest">
|
||||
System logs & documentation // V4.2
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="relative w-full max-w-md md:max-w-none md:w-96 group mx-auto md:mx-0">
|
||||
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-muted-foreground group-focus-within:text-primary transition-colors" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="root@nrxp:~# grep -i ..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full bg-card/50 backdrop-blur-md border border-border/50 rounded-xl py-3 pl-12 pr-4 text-sm text-foreground focus:outline-none focus:border-primary/50 focus:shadow-[0_0_15px_rgba(0,240,255,0.15)] transition-all placeholder:text-muted-foreground/50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Фильтры: центрирование на мобильных (justify-center) */}
|
||||
<div className="flex flex-wrap justify-center md:justify-start gap-2 mb-8">
|
||||
{CATEGORIES.map((cat) => (
|
||||
<button
|
||||
key={cat}
|
||||
onClick={() => setActiveCategory(cat)}
|
||||
className={`px-3 md:px-4 py-2 rounded-lg text-xs md:text-sm font-bold transition-all duration-300 border ${
|
||||
activeCategory === cat
|
||||
? "bg-primary/10 border-primary/50 text-primary shadow-[0_0_10px_rgba(0,240,255,0.2)]"
|
||||
: "bg-card/30 border-border/50 text-muted-foreground hover:bg-card/60 hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{cat}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Сетка документов */}
|
||||
<motion.div
|
||||
layout
|
||||
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4"
|
||||
>
|
||||
<AnimatePresence>
|
||||
{filteredDocs.map((doc) => (
|
||||
<motion.div
|
||||
layout
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.9 }}
|
||||
key={doc.id}
|
||||
onClick={() => setSelectedDoc(doc)}
|
||||
className="group bg-card/30 backdrop-blur-sm border border-border/50 hover:border-primary/40 rounded-2xl p-5 md:p-6 cursor-pointer transition-all duration-300 hover:shadow-[0_0_30px_rgba(0,240,255,0.1)] hover:-translate-y-1 flex flex-col h-48"
|
||||
>
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div className="p-2 rounded-lg bg-background/50 border border-border/50 group-hover:bg-primary/10 group-hover:border-primary/30 transition-colors">
|
||||
{getCategoryIcon(doc.category)}
|
||||
</div>
|
||||
<span
|
||||
className={`text-[9px] md:text-[10px] uppercase font-bold tracking-wider px-2 py-1 rounded border ${
|
||||
doc.status === "Encrypted"
|
||||
? "text-primary border-primary/30 bg-primary/5"
|
||||
: doc.status === "Public"
|
||||
? "text-secondary border-secondary/30 bg-secondary/5"
|
||||
: "text-muted-foreground border-border bg-muted/20"
|
||||
}`}
|
||||
>
|
||||
{doc.status}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-base md:text-lg font-bold text-foreground mb-1 line-clamp-2">
|
||||
{doc.title}
|
||||
</h3>
|
||||
<div className="mt-auto flex items-center justify-between text-[10px] md:text-xs text-muted-foreground">
|
||||
<span>{doc.category}</span>
|
||||
<span>{formatDate(doc.created)}</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
|
||||
{filteredDocs.length === 0 && (
|
||||
<div className="text-center py-20 text-muted-foreground">
|
||||
<TerminalSquare className="w-12 h-12 mx-auto mb-4 opacity-20" />
|
||||
<p>0 records found for query {`"${search}"`}</p>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
) : (
|
||||
// --- РЕЖИМ ЧТЕНИЯ ---
|
||||
<motion.div
|
||||
key="reader"
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: -20 }}
|
||||
className="w-full max-w-4xl mx-auto"
|
||||
>
|
||||
{/* Кнопка "Назад": центрируем на мобилках */}
|
||||
<button
|
||||
onClick={() => setSelectedDoc(null)}
|
||||
className="mb-6 md:mb-8 flex items-center justify-center md:justify-start gap-2 w-full md:w-auto text-muted-foreground hover:text-primary transition-colors text-sm font-bold uppercase tracking-wider group"
|
||||
>
|
||||
<ChevronLeft className="w-5 h-5 group-hover:-translate-x-1 transition-transform" />
|
||||
Close Session
|
||||
</button>
|
||||
|
||||
<div className="bg-card/40 backdrop-blur-2xl border border-border/50 rounded-2xl md:rounded-3xl overflow-hidden shadow-2xl">
|
||||
{/* Шапка терминала: перенос на новую строку на мобильных */}
|
||||
<div className="bg-background/80 border-b border-border/50 px-4 md:px-6 py-3 md:py-4 flex flex-col sm:flex-row items-center gap-3 sm:justify-between text-center sm:text-left">
|
||||
<div className="flex items-center gap-3 md:gap-4">
|
||||
<div className="hidden sm:flex gap-2">
|
||||
<div className="w-3 h-3 rounded-full bg-red-500/50" />
|
||||
<div className="w-3 h-3 rounded-full bg-yellow-500/50" />
|
||||
<div className="w-3 h-3 rounded-full bg-green-500/50" />
|
||||
</div>
|
||||
<span className="text-muted-foreground text-[10px] md:text-xs uppercase tracking-widest sm:border-l border-border/50 sm:pl-4">
|
||||
{selectedDoc.id}.txt
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-[10px] md:text-xs font-bold text-primary">
|
||||
{getCategoryIcon(selectedDoc.category)}
|
||||
{selectedDoc.category}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Тело документа: уменьшены отступы для телефона (p-5 вместо p-8) */}
|
||||
<div className="p-5 sm:p-8 md:p-12 relative">
|
||||
<h2 className="text-xl md:text-3xl font-bold text-foreground mb-6 md:mb-8 pb-4 md:pb-6 border-b border-border/30">
|
||||
{selectedDoc.title}
|
||||
</h2>
|
||||
<div className="text-muted-foreground leading-relaxed whitespace-pre-wrap text-sm md:text-base">
|
||||
{selectedDoc.content || "NO_CONTENT_FOUND"}
|
||||
</div>
|
||||
<div className="mt-8 flex items-center gap-2 text-primary text-xs md:text-sm">
|
||||
<span>EOF</span>
|
||||
<div className="w-2 h-4 bg-primary animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { getDictionary } from "@/lib/dictionaries";
|
||||
import { DocsClient } from "./docs-client";
|
||||
|
||||
export default async function DocsPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ lang: string }>;
|
||||
}) {
|
||||
const { lang } = await params;
|
||||
const dict = await getDictionary(lang);
|
||||
|
||||
// Передаем весь словарь или нужную часть, плюс текущий язык
|
||||
return <DocsClient lang={lang} />;
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
"use client";
|
||||
|
||||
// Добавили импорт Variants
|
||||
import { motion, Variants } from "framer-motion";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Monitor,
|
||||
Smartphone,
|
||||
Terminal,
|
||||
Apple,
|
||||
Download,
|
||||
ExternalLink,
|
||||
Bell,
|
||||
} from "lucide-react";
|
||||
import { Dictionary } from "@/lib/IDict";
|
||||
|
||||
export function DownloadClient({ dict }: { dict: Dictionary["download"] }) {
|
||||
const platforms = [
|
||||
{
|
||||
id: "windows",
|
||||
name: "Windows",
|
||||
version: dict.platforms.windows,
|
||||
icon: <Monitor className="w-8 h-8" />,
|
||||
status: "active",
|
||||
color: "text-primary",
|
||||
shadow: "shadow-[0_0_20px_rgba(0,240,255,0.2)]",
|
||||
borderHover: "hover:border-primary/50",
|
||||
},
|
||||
{
|
||||
id: "android",
|
||||
name: "Android",
|
||||
version: dict.platforms.android,
|
||||
icon: <Smartphone className="w-8 h-8" />,
|
||||
status: "active",
|
||||
color: "text-secondary",
|
||||
shadow: "shadow-[0_0_20px_rgba(112,0,255,0.2)]",
|
||||
borderHover: "hover:border-secondary/50",
|
||||
},
|
||||
{
|
||||
id: "linux",
|
||||
name: "Linux",
|
||||
version: dict.platforms.linux,
|
||||
icon: <Terminal className="w-8 h-8" />,
|
||||
status: "active",
|
||||
color: "text-primary",
|
||||
shadow: "shadow-[0_0_20px_rgba(0,240,255,0.2)]",
|
||||
borderHover: "hover:border-primary/50",
|
||||
},
|
||||
{
|
||||
id: "macos",
|
||||
name: "macOS",
|
||||
version: dict.platforms.macos,
|
||||
icon: <Apple className="w-8 h-8" />,
|
||||
status: "dev",
|
||||
color: "text-muted-foreground",
|
||||
shadow: "shadow-none",
|
||||
borderHover: "hover:border-border",
|
||||
},
|
||||
{
|
||||
id: "ios",
|
||||
name: "iOS",
|
||||
version: dict.platforms.ios,
|
||||
icon: <Apple className="w-8 h-8" />,
|
||||
status: "dev",
|
||||
color: "text-muted-foreground",
|
||||
shadow: "shadow-none",
|
||||
borderHover: "hover:border-border",
|
||||
},
|
||||
];
|
||||
|
||||
// Явно указываем тип Variants
|
||||
const containerVariants: Variants = {
|
||||
hidden: { opacity: 0 },
|
||||
show: {
|
||||
opacity: 1,
|
||||
transition: { staggerChildren: 0.1 },
|
||||
},
|
||||
};
|
||||
|
||||
// Явно указываем тип Variants
|
||||
const itemVariants: Variants = {
|
||||
hidden: { opacity: 0, y: 40 },
|
||||
show: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: { type: "spring", stiffness: 300, damping: 24 },
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen pt-32 pb-24 px-4 container mx-auto max-w-5xl relative">
|
||||
{/* Декоративное свечение */}
|
||||
<div className="absolute top-1/4 left-1/2 -translate-x-1/2 w-[100vw] md:w-[600px] h-[400px] bg-primary/5 blur-[120px] rounded-full pointer-events-none -z-10" />
|
||||
|
||||
<div className="text-center mb-16">
|
||||
<h1 className="text-4xl md:text-5xl font-extrabold tracking-tight mb-4 text-foreground">
|
||||
{dict.title}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-lg max-w-2xl mx-auto">
|
||||
{dict.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate="show"
|
||||
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"
|
||||
>
|
||||
{platforms.map((platform) => (
|
||||
<motion.div
|
||||
key={platform.id}
|
||||
variants={itemVariants}
|
||||
className={`bg-card/40 backdrop-blur-xl border border-border/50 rounded-[2rem] p-8 flex flex-col transition-all duration-500 relative overflow-hidden group ${platform.borderHover}`}
|
||||
>
|
||||
{/* Статус бейдж */}
|
||||
<div className="absolute top-6 right-6">
|
||||
{platform.status === "active" ? (
|
||||
<div className="px-3 py-1 rounded-full bg-primary/10 border border-primary/20 text-primary text-xs font-bold uppercase tracking-wider">
|
||||
{dict.badges.active}
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-3 py-1 rounded-full bg-muted border border-border text-muted-foreground text-xs font-bold uppercase tracking-wider">
|
||||
{dict.badges.dev}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Иконка */}
|
||||
<div
|
||||
className={`mb-6 p-4 rounded-2xl bg-background/50 inline-flex border border-border/50 ${platform.color} ${platform.shadow} transition-all duration-300`}
|
||||
>
|
||||
{platform.icon}
|
||||
</div>
|
||||
|
||||
<h3 className="text-2xl font-bold text-foreground mb-1">
|
||||
{platform.name}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mb-8">
|
||||
{platform.version}
|
||||
</p>
|
||||
|
||||
{/* Кнопки действий */}
|
||||
<div className="mt-auto flex flex-col gap-3">
|
||||
{platform.status === "active" ? (
|
||||
<>
|
||||
<Button className="w-full rounded-xl font-bold gap-2">
|
||||
<Download className="w-4 h-4" />
|
||||
{dict.buttons.direct}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full rounded-xl gap-2 border-border/50 bg-background/50 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
{platform.id === "android"
|
||||
? dict.buttons.store
|
||||
: dict.buttons.github}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled
|
||||
className="w-full rounded-xl gap-2 opacity-50 cursor-not-allowed"
|
||||
>
|
||||
<Bell className="w-4 h-4" />
|
||||
{dict.buttons.notify}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { getDictionary } from "@/lib/dictionaries";
|
||||
import { DownloadClient } from "./download-client";
|
||||
|
||||
export default async function DownloadPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ lang: string }>;
|
||||
}) {
|
||||
const { lang } = await params;
|
||||
const dict = await getDictionary(lang);
|
||||
|
||||
return <DownloadClient dict={dict.download} />;
|
||||
}
|
||||
+26
-3
@@ -1,15 +1,34 @@
|
||||
import { Metadata } from "next"; // Импортируем тип Metadata
|
||||
import { ThemeProvider } from "@/components/sections/theme/theme-provider";
|
||||
import "../globals.css";
|
||||
import { getDictionary } from "@/lib/dictionaries";
|
||||
import { Header } from "@/components/sections/Header";
|
||||
import { JetBrains_Mono } from "next/font/google"; // Импортируем шрифт
|
||||
import { JetBrains_Mono } from "next/font/google";
|
||||
import { Footer } from "@/components/sections/Footer";
|
||||
|
||||
// Настраиваем JetBrains Mono
|
||||
const jetbrainsMono = JetBrains_Mono({
|
||||
subsets: ["latin", "cyrillic"], // Важно добавить cyrillic для русского языка
|
||||
subsets: ["latin", "cyrillic"],
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
// Настройка метаданных
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
template: "%s | Netrunner VPN",
|
||||
default: "Netrunner VPN — Система следит за всеми, кроме тебя.",
|
||||
},
|
||||
description:
|
||||
"Цифровой след скрыт. Логи уничтожены. Скорость не ограничена. Ваш персональный защищенный туннель Netrunner.",
|
||||
icons: {
|
||||
icon: [
|
||||
{
|
||||
url: "/logo.svg",
|
||||
href: "/logo.svg",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
params,
|
||||
@@ -32,7 +51,11 @@ export default async function RootLayout({
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<Header dict={dict.nav} lang={lang} />
|
||||
|
||||
<main className="flex-1 mt-32">{children}</main>
|
||||
|
||||
{/* Добавляем футер и передаем нужную часть словаря */}
|
||||
<Footer dict={dict.footer} lang={lang} />
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+9
-7
@@ -21,14 +21,16 @@ export default async function Home({
|
||||
<>
|
||||
<Hero dict={dict.hero} />
|
||||
<Features dict={dict.features} />
|
||||
<Pricing dict={dict.pricing} />
|
||||
<FAQ dict={dict.faq} />
|
||||
<Pricing dict={dict.pricing} lang={lang} />
|
||||
<FAQ dict={dict.faq} lang={lang} />
|
||||
|
||||
{/*
|
||||
<Suspense fallback={<div className="py-20 text-center">Loading...</div>}>
|
||||
<BlogPreview dict={dict.blog} lang={lang} />
|
||||
</Suspense>
|
||||
*/}
|
||||
{
|
||||
<Suspense
|
||||
fallback={<div className="py-20 text-center">Loading...</div>}
|
||||
>
|
||||
<BlogPreview lang={lang} />
|
||||
</Suspense>
|
||||
}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user