Files
netrunner-landing/components/sections/TerminalGuestbook.tsx
T
2026-07-02 23:13:24 +07:00

361 lines
16 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, 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 }: { dict: Dictionary["guestbook"] }) {
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);
// Начальная загрузка
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("*");
};
}, []);
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>
);
}