base with cms integrated

This commit is contained in:
Трапезников Кирилл Иванович
2026-04-18 21:16:55 +10:00
parent 6404495696
commit 4b11fec8cc
33 changed files with 2036 additions and 356 deletions
+259
View File
@@ -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>
);
}
+14
View File
@@ -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} />;
}