Files
netrunner-landing/app/[lang]/docs/docs-client.tsx
T
nineap 899c54e1fc Unify auth with backend cookie sessions; add dev-deploy pipeline
Auth: drop localStorage session token entirely, talk to netrunner-backend
via credentials:"include" HttpOnly cookies shared over the common parent
domain, and redirect to the account subdomain (ЛК) after login instead of
a dead local /profile route.

Config: NEXT_PUBLIC_* values used by client components (API_URL,
ACCOUNT_ORIGIN, BOT_USERNAME, PB_URL) get inlined into the JS bundle at
image build time and can't be overridden by docker-compose environment at
container start. Moved these reads into server components (page.tsx) and
thread them down as props, so one built image can genuinely serve both
prod and dev by config alone.

CI/CD: split the old single auto-deploy-to-prod workflow into build.yml
(build+push on every push to main) and deploy.yml with deploy-dev (auto,
same VPS as netrunner-backend's dev environment) and deploy-prod (manual
dispatch only) — mirrors netrunner-backend's pipeline shape. Added
docker-compose.dev-remote.yml pointing at the dev backend.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 15:26:28 +07:00

274 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,
pbUrl,
}: {
dict: Dictionary["docs"];
lang: string;
/**
* Адрес PocketBase — приходит пропсом из серверного page.tsx, а не читается
* напрямую из process.env.NEXT_PUBLIC_* здесь (см. комментарий в
* app/[lang]/auth/auth-client.tsx для полного объяснения).
*/
pbUrl: 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);
pb.baseURL = pbUrl;
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, pbUrl]);
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>
);
}