Files
netrunner-landing/components/sections/TerminalGuestbook.tsx
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

373 lines
16 KiB
TypeScript
Raw Permalink 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, useRef } from "react";
import { motion, AnimatePresence } from "framer-motion";
import {
Terminal,
Send,
ShieldAlert,
CheckCircle2,
User,
MessageSquare,
Zap,
} from "lucide-react";
import { pb } from "@/lib/pb";
import { Dictionary } from "@/lib/IDict";
import { CyberButton } from "../ui/cyber-button";
import { cn } from "@/lib/utils";
/**
* Встроенная в главную страницу гостевая книга ("Terminal Guestbook").
* Данные читаются напрямую из PocketBase (коллекция `comments`) с realtime-подпиской,
* а отправка идёт не напрямую в PocketBase, а через Rust-шлюз `/api/proxy/api/comments`
* (см. next.config.ts rewrites) — там сообщение проходит модерацию (в т.ч. ИИ-фильтр
* на мат/спам/токсичность) прежде чем попасть в базу.
*
* Защита от спама:
* - honeypot-поле "website" — невидимое обычным пользователям поле; если оно заполнено,
* значит форму заполнил бот, и мы тихо отклоняем отправку;
* - клиентский троттлинг через localStorage (last_comment_time) — блокирует повторную
* отправку в течение 60 секунд ещё до запроса на сервер;
* - сервер (Rust) отдельно тоже может отклонить сообщение через 406 Not Acceptable —
* это отдельный кейс от обычных сетевых ошибок, обрабатываем его конкретным текстом
* про нарушение политики, а не общим "сервер недоступен".
*/
export function TerminalGuestbook({
dict,
pbUrl,
}: {
dict: Dictionary["guestbook"];
/**
* Адрес PocketBase — приходит пропсом из серверного page.tsx, а не читается
* напрямую из process.env.NEXT_PUBLIC_* здесь (см. комментарий в
* app/[lang]/auth/auth-client.tsx для полного объяснения).
*/
pbUrl: string;
}) {
const [comments, setComments] = useState<any[]>([]);
const [nickname, setNickname] = useState("");
const [message, setMessage] = useState("");
const [honeypot, setHoneypot] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const [cooldown, setCooldown] = useState(0);
const [status, setStatus] = useState<{
type: "success" | "error" | null;
text: string;
}>({ type: null, text: "" });
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
// Отключаем автоотмену и в клиентском экземпляре: без этого PocketBase SDK
// отменяет параллельные одинаковые запросы (getList вызывается и здесь, и в
// терминальной странице /terminal), что приводит к ложным AbortError.
pb.autoCancellation(false);
pb.baseURL = pbUrl;
// Начальная загрузка
const fetchComments = async () => {
try {
const records = await pb
.collection("comments")
.getList(1, 50, { sort: "-created", requestKey: null });
setComments(records.items);
} catch (e) {
console.error(e);
}
};
fetchComments();
// Кулдаун
const lastPost = localStorage.getItem("last_comment_time");
if (lastPost) {
const timePassed = Math.floor((Date.now() - parseInt(lastPost)) / 1000);
if (timePassed < 60) setCooldown(60 - timePassed);
}
// Реалтайм
pb.collection("comments").subscribe("*", (e) => {
if (e.action === "create") {
setComments((prev) => [e.record, ...prev].slice(0, 50));
}
});
return () => {
pb.collection("comments").unsubscribe("*");
};
}, [pbUrl]);
useEffect(() => {
if (cooldown > 0) {
const timer = setTimeout(() => setCooldown((c) => c - 1), 1000);
return () => clearTimeout(timer);
}
}, [cooldown]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// Honeypot: обычный пользователь это поле не видит и не заполняет, боты — заполняют.
if (honeypot)
return setStatus({ type: "error", text: dict.statusErrorBreach });
if (!nickname.trim() || !message.trim())
return setStatus({ type: "error", text: dict.statusErrorEmpty });
if (nickname.length > 20 || message.length > 150)
return setStatus({ type: "error", text: dict.statusErrorSize });
if (cooldown > 0)
return setStatus({
type: "error",
text: dict.statusErrorFirewall.replace("{{time}}", cooldown.toString()),
});
try {
setIsSubmitting(true);
const proxyUrl = "/api/proxy";
const res = await fetch(`${proxyUrl}/api/comments`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
nickname: nickname.trim(),
message: message.trim(),
}),
});
if (!res.ok) {
// Если Rust вернул 406 Not Acceptable (грязь в тексте)
if (res.status === 406) {
throw new Error("FILTER_VIOLATION");
}
throw new Error("SERVER_ERROR");
}
setNickname("");
setMessage("");
setStatus({ type: "success", text: dict.statusSuccess });
setCooldown(60);
localStorage.setItem("last_comment_time", Date.now().toString());
} catch (err: any) {
// 🔥 Обработка ошибки фильтра нейронки
if (err.message === "FILTER_VIOLATION") {
setStatus({
type: "error",
// Если добавишь statusErrorFilter в словари — будет тянуть оттуда, иначе фоллбек
text:
(dict as any).statusErrorFilter ||
"⛔ Отклонено: Ваше сообщение нарушает политику безопасности (мат, спам или токсичность). Пожалуйста, измените формулировку для прохождения фильтра.",
});
} else {
setStatus({ type: "error", text: dict.statusErrorOffline });
}
} finally {
setIsSubmitting(false);
// Увеличил таймаут до 8 секунд, чтобы юзер успел прочитать длинный текст ошибки
setTimeout(() => setStatus({ type: null, text: "" }), 8000);
}
};
return (
<section
id="guestbook"
className="py-32 px-4 container mx-auto max-w-6xl relative overflow-hidden"
>
{/* Мягкие свечения */}
<div className="absolute -top-20 -right-20 w-[800px] h-[800px] bg-primary/5 dark:bg-primary/10 blur-[150px] rounded-full -z-10 opacity-30 dark:opacity-60" />
<div className="absolute -bottom-20 -left-20 w-[800px] h-[800px] bg-secondary/5 dark:bg-secondary/10 blur-[150px] rounded-full -z-10 opacity-30 dark:opacity-60" />
{/* Заголовок секции */}
<div className="text-center mb-16">
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full border border-secondary/30 bg-secondary/10 text-secondary text-xs font-bold tracking-[0.2em] mb-4 uppercase">
<Zap className="size-3 fill-current" /> {dict.subtitle}
</div>
<h2 className="text-4xl md:text-6xl font-black tracking-tighter text-foreground font-mono">
{dict.title}
</h2>
</div>
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 items-start">
{/* ЛЕВАЯ КОЛОНКА (4/12): Философия и Форма */}
<div className="lg:col-span-5 space-y-8">
{/* Блок философии */}
<div className="bg-primary/5 border-l-4 border-primary p-6 rounded-r-2xl backdrop-blur-sm">
<h3 className="text-xl font-bold text-primary mb-3 flex items-center gap-2 font-mono uppercase tracking-tight">
<ShieldAlert className="size-5" /> {dict.philosophyTitle}
</h3>
<p className="text-muted-foreground leading-relaxed text-sm antialiased italic">
"{dict.philosophyText}"
</p>
</div>
{/* Форма отправки */}
<div className="bg-card/40 backdrop-blur-xl border border-border/50 rounded-[2.5rem] p-8 shadow-xl">
<form onSubmit={handleSubmit} className="space-y-5">
<div className="space-y-2">
<label
className={cn(
"text-[10px] font-mono uppercase tracking-[0.2em] flex items-center gap-2 ml-1 mb-2",
"text-primary/80 dark:text-primary font-bold",
)}
>
<User className="size-3" /> {dict.labelAlias}
</label>
<input
type="text"
maxLength={20}
value={nickname}
onChange={(e) => setNickname(e.target.value)}
placeholder={dict.placeholderAlias}
disabled={isSubmitting || cooldown > 0}
className={cn(
"w-full rounded-2xl px-5 py-4 text-sm outline-none transition-all font-mono",
"disabled:opacity-50 text-foreground",
"bg-background/50 border border-border/80 placeholder:text-muted-foreground/60 focus:border-primary focus:bg-background shadow-sm",
"dark:bg-background/50 dark:border-border/50 dark:placeholder:text-muted-foreground/30 dark:focus:border-primary/50",
)}
/>
</div>
<div className="space-y-2">
<label className="text-[10px] font-mono text-primary uppercase tracking-[0.2em] flex items-center gap-2 ml-1">
<MessageSquare className="size-3" /> {dict.labelPayload}
</label>
<textarea
maxLength={150}
rows={4}
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder={dict.placeholderPayload}
disabled={isSubmitting || cooldown > 0}
className={cn(
"w-full rounded-2xl px-5 py-4 text-sm outline-none transition-all font-mono",
"disabled:opacity-50 text-foreground",
"bg-background/50 border border-border/80 placeholder:text-muted-foreground/60 focus:border-primary focus:bg-background shadow-sm",
"dark:bg-background/50 dark:border-border/50 dark:placeholder:text-muted-foreground/30 dark:focus:border-primary/50",
)}
/>
<div className="text-right text-[10px] text-muted-foreground/40 font-mono pr-2">
{message.length}/150
</div>
</div>
<input
type="text"
name="website"
tabIndex={-1}
autoComplete="off"
value={honeypot}
onChange={(e) => setHoneypot(e.target.value)}
style={{ position: "absolute", left: "-9999px" }}
/>
<AnimatePresence mode="wait">
{status.text && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
className={`p-4 rounded-2xl text-xs font-mono flex items-start gap-3 border ${
status.type === "error"
? "bg-destructive/10 border-destructive/20 text-destructive"
: "bg-secondary/10 border-secondary/20 text-secondary"
}`}
>
{status.type === "error" ? (
<ShieldAlert className="size-4 shrink-0 mt-0.5" />
) : (
<CheckCircle2 className="size-4 shrink-0 mt-0.5" />
)}
<span className="leading-relaxed">{status.text}</span>
</motion.div>
)}
</AnimatePresence>
<CyberButton
type="submit"
variant="primary"
disabled={isSubmitting || cooldown > 0}
// Добавляем cursor-net-wait при загрузке
className={cn("w-full", isSubmitting && "cursor-net-wait")}
>
{isSubmitting ? (
dict.btnEncrypting
) : cooldown > 0 ? (
dict.cooldown.replace("{{time}}", cooldown.toString())
) : (
<span className="flex items-center gap-2">
<Send className="size-4" /> {dict.btnBroadcast}
</span>
)}
</CyberButton>
</form>
</div>
</div>
{/* ПРАВАЯ КОЛОНКА (7/12): Фид сообщений */}
<div
className={cn(
"lg:col-span-7 h-[700px] flex flex-col p-8 rounded-[3rem] backdrop-blur-md transition-all",
"bg-white/90 border border-border/80 shadow-xl",
"dark:bg-black/20 dark:border-primary/20 dark:shadow-2xl",
)}
>
<div className="flex justify-between items-center mb-8 px-4">
<div className="flex items-center gap-3">
<div className="size-2 rounded-full bg-secondary animate-pulse shadow-[0_0_10px_rgba(0,229,242,0.8)]" />
<span className="font-mono text-xs text-secondary uppercase tracking-[0.3em] font-bold">
{dict.liveFeed}
</span>
</div>
<span className="text-[10px] font-mono text-muted-foreground/40 uppercase">
Encrypted_Uplink_v2.4
</span>
</div>
<div
ref={scrollRef}
className="flex-1 overflow-y-auto pr-4 space-y-6 custom-scrollbar-cyber cursor-net-crosshair"
>
<AnimatePresence initial={false}>
{comments.map((comment) => (
<motion.div
key={comment.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="relative group"
>
<div className="absolute -left-2 top-0 bottom-0 w-0.5 bg-gradient-to-b from-secondary/0 via-secondary/20 to-secondary/0 group-hover:via-secondary/50 transition-all" />
{/* Сообщение */}
<div
className={cn(
"p-5 rounded-[1.5rem] transition-all group-hover:translate-x-1",
"bg-muted/30 border border-border/50 group-hover:bg-muted/60",
"dark:bg-white/5 dark:border-white/5 dark:group-hover:bg-white/[0.08] dark:group-hover:border-white/10",
)}
>
<div className="flex justify-between items-center mb-3">
<span className="font-black text-secondary font-mono text-sm tracking-tight">
@{comment.nickname}
</span>
<span className="text-[10px] text-muted-foreground/40 font-mono">
{new Date(comment.created).toLocaleTimeString()}
</span>
</div>
<p className="text-muted-foreground text-[15px] leading-relaxed break-words font-medium">
{comment.message}
</p>
</div>
</motion.div>
))}
</AnimatePresence>
{comments.length === 0 && (
<div className="h-full flex flex-col items-center justify-center text-muted-foreground/20 font-mono space-y-4">
<Terminal className="size-12 opacity-10" />
<span className="text-xs uppercase tracking-[0.2em]">
{dict.noTransmissions}
</span>
</div>
)}
</div>
</div>
</div>
</section>
);
}