Files
netrunner-landing/app/[lang]/docs/docs-client.tsx
T
2026-07-02 23:13:24 +07:00

266 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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";
import { Dictionary } from "@/lib/IDict";
interface PBDocument {
id: string;
title: string;
category: "Core" | "Security" | "Network" | "Legal";
status: "Encrypted" | "Public" | "Internal" | "Dynamic";
content: string;
created: string;
}
const CATEGORIES = ["All", "Core", "Security", "Network", "Legal"];
/**
* "Хранилище" документов раздела /docs — стилизовано под хакерский терминал.
* Загружает полный список документов из PocketBase-коллекции `documents_{lang}`
* один раз на клиенте (без пагинации — предполагается, что документов немного),
* дальше поиск и фильтрация по категории применяются локально без новых запросов.
*
* Два режима отображения одного компонента:
* - сетка карточек (grid) — когда `selectedDoc` не выбран;
* - "ридер" одного документа на весь экран — когда пользователь кликнул карточку.
* Переключение между ними анимируется через AnimatePresence с mode="wait".
*/
export function DocsClient({
dict,
lang,
}: {
dict: Dictionary["docs"];
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 records = await pb
.collection(`documents_${lang}`)
.getFullList<PBDocument>({
sort: "-created",
});
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 (
<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">
<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">
{dict.establishing}
</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 }}
>
<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)]" />
{dict.vaultTitle}
</h1>
<p className="text-muted-foreground mt-2 text-xs md:text-sm uppercase tracking-widest">
{dict.vaultSubtitle}
</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={dict.searchPlaceholder}
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>
<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>{dict.empty}</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" />
{dict.closeSession}
</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>
<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 || dict.noContent}
</div>
<div className="mt-8 flex items-center gap-2 text-primary text-xs md:text-sm">
<span>{dict.eof}</span>
<div className="w-2 h-4 bg-primary animate-pulse" />
</div>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}