899c54e1fc
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>
274 lines
12 KiB
TypeScript
274 lines
12 KiB
TypeScript
"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>
|
||
);
|
||
}
|