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

343 lines
13 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,
Loader2,
ShieldCheck,
ShieldAlert,
} from "lucide-react";
import { pb } from "@/lib/pb";
import { Dictionary } from "@/lib/IDict";
/**
* Полноэкранная версия гостевой книги (аналог TerminalGuestbook на главной, но
* без ограничения в 50 последних сообщений — здесь есть постраничная подгрузка
* истории вверх при скролле). Источники и защита от спама те же:
* - realtime через `pb.collection("comments").subscribe("*", ...)` для новых сообщений;
* - отправка идёт через Rust-шлюз `/api/proxy/api/comments`, а не напрямую в PocketBase,
* т.к. там сообщения проходят модерацию (ИИ-фильтр на мат/спам/токсичность);
* - 406 от прокси — отдельный кейс ("отклонено фильтром"), отличный от обычной
* сетевой ошибки ("сервер недоступен");
* - клиентский кулдаун 60 секунд между сообщениями через localStorage, синхронизирован
* с сервером тем же ключом last_comment_time, что и в TerminalGuestbook.
*/
export function TerminalClient({ dict }: { dict: Dictionary["terminal"] }) {
const [messages, setMessages] = useState<any[]>([]);
const [nickname, setNickname] = useState("");
const [inputMsg, setInputMsg] = useState("");
const [errorText, setErrorText] = useState<string | null>(null);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const [isLoading, setIsLoading] = useState(true);
const [isFetchingMore, setIsFetchingMore] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [cooldown, setCooldown] = useState(0);
const scrollRef = useRef<HTMLDivElement>(null);
const prevScrollHeight = useRef<number>(0);
useEffect(() => {
fetchHistory(1, true);
pb.collection("comments").subscribe("*", (e) => {
if (e.action === "create") {
setMessages((prev) => [...prev, e.record]);
setTimeout(scrollToBottom, 50);
}
});
const lastPost = localStorage.getItem("last_comment_time");
if (lastPost) {
const timePassed = Math.floor((Date.now() - parseInt(lastPost)) / 1000);
if (timePassed < 60) setCooldown(60 - timePassed);
}
return () => {
pb.collection("comments").unsubscribe("*");
};
}, []);
useEffect(() => {
if (cooldown > 0) {
const timer = setTimeout(() => setCooldown((c) => c - 1), 1000);
return () => clearTimeout(timer);
}
}, [cooldown]);
useEffect(() => {
if (prevScrollHeight.current > 0 && scrollRef.current) {
const newHeight = scrollRef.current.scrollHeight;
scrollRef.current.scrollTop += newHeight - prevScrollHeight.current;
prevScrollHeight.current = 0;
}
}, [messages]);
// Подгрузка истории постранично (30 сообщений за раз). При скролле наверх (см.
// handleScroll) запрашивается следующая страница, и новые сообщения добавляются
// в начало массива с сохранением позиции скролла (см. эффект с prevScrollHeight).
const fetchHistory = async (pageToLoad: number, isInitial = false) => {
try {
if (isInitial) setIsLoading(true);
else setIsFetchingMore(true);
const res = await pb
.collection("comments")
.getList(pageToLoad, 30, { sort: "-created", requestKey: null });
const fetchedItems = res.items.reverse();
if (isInitial) {
setMessages(fetchedItems);
setTimeout(scrollToBottom, 100);
} else {
if (scrollRef.current) {
prevScrollHeight.current = scrollRef.current.scrollHeight;
}
setMessages((prev) => [...fetchedItems, ...prev]);
}
setHasMore(res.page < res.totalPages);
setPage(pageToLoad);
} catch (e) {
console.error(e);
} finally {
setIsLoading(false);
setIsFetchingMore(false);
}
};
const handleScroll = () => {
if (!scrollRef.current || isFetchingMore || !hasMore) return;
if (scrollRef.current.scrollTop === 0) {
fetchHistory(page + 1);
}
};
const scrollToBottom = () => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setErrorText(null);
if (!nickname.trim() || !inputMsg.trim() || cooldown > 0 || isSubmitting)
return;
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: inputMsg.trim(),
}),
});
if (!res.ok) {
if (res.status === 406) {
throw new Error("FILTER_VIOLATION");
}
throw new Error("SERVER_ERROR");
}
// Успешная отправка
setInputMsg("");
setCooldown(60);
localStorage.setItem("last_comment_time", Date.now().toString());
setTimeout(scrollToBottom, 50);
} catch (err: any) {
console.error(err);
// Выдаем разные ошибки в зависимости от ответа сервера
if (err.message === "FILTER_VIOLATION") {
setErrorText(
(dict as any).statusErrorFilter ||
"⛔ Сообщение отклонено ИИ-фильтром (нарушение политики: мат, спам или токсичность).",
);
} else {
setErrorText(
(dict as any).statusErrorOffline ||
"⚠️ Ошибка связи с сервером. Попробуйте повторить запрос позже.",
);
}
// Убираем ошибку через 8 секунд
setTimeout(() => setErrorText(null), 8000);
} finally {
setIsSubmitting(false);
}
};
return (
<div className="fixed inset-0 pt-20 pb-4 md:pt-24 z-30 bg-background flex justify-center px-2 sm:px-4">
<div className="w-full max-w-5xl flex flex-col bg-card/10 backdrop-blur-3xl border border-border/50 rounded-2xl md:rounded-[2rem] overflow-hidden shadow-2xl relative">
{/* Header */}
<div className="h-12 bg-muted/50 border-b border-border/50 flex items-center px-4 justify-between select-none">
<div className="flex items-center gap-3">
<Terminal className="size-4 text-primary" />
<span className="font-mono text-xs font-bold text-foreground/80 tracking-widest">
{dict.title}
</span>
</div>
<div className="flex items-center gap-2">
<div className="size-2 rounded-full bg-primary/50 animate-pulse" />
<span className="font-mono text-[10px] text-muted-foreground uppercase hidden sm:block">
{dict.systemReady}
</span>
</div>
</div>
{/* Message Area */}
<div
ref={scrollRef}
onScroll={handleScroll}
className="flex-1 overflow-y-auto p-4 md:p-8 font-mono text-sm space-y-4 custom-scrollbar-cyber"
>
{isFetchingMore && (
<div className="text-center text-primary/50 text-xs uppercase animate-pulse my-4">
<Loader2 className="size-4 animate-spin inline mr-2" />{" "}
{dict.loadingHistory}
</div>
)}
{!hasMore && !isLoading && (
<div className="text-center text-muted-foreground/30 text-[10px] uppercase my-8">
--- {dict.noMoreLogs} ---
</div>
)}
{/* MANIFESTO */}
{!isFetchingMore && !hasMore && !isLoading && (
<div className="mb-12 p-6 border border-primary/20 bg-primary/5 rounded-2xl font-mono text-sm relative overflow-hidden group">
<div className="absolute inset-0 bg-gradient-to-b from-transparent via-primary/5 to-transparent -translate-y-full group-hover:animate-scan pointer-events-none" />
<div className="flex items-center gap-3 text-primary mb-4">
<ShieldCheck className="size-5" />
<span className="font-black uppercase tracking-widest">
{dict.manifestoTitle}
</span>
</div>
<p className="text-foreground/80 mb-6 leading-relaxed">
{dict.manifestoText}
</p>
<div className="space-y-2 border-t border-primary/10 pt-4">
<div className="text-[10px] text-primary/50 uppercase tracking-widest mb-2">
{dict.rulesTitle}
</div>
<p className="text-xs text-muted-foreground">{dict.rule1}</p>
<p className="text-xs text-muted-foreground">{dict.rule2}</p>
<p className="text-xs text-secondary/80 font-bold">
{dict.rule3}
</p>
</div>
<div className="mt-6 flex items-center gap-2 text-[10px] text-green-500/60">
<div className="size-1.5 rounded-full bg-green-500 animate-pulse" />
{dict.statusReady}
</div>
</div>
)}
{messages.map((msg, idx) => (
<motion.div
key={msg.id || idx}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
className="flex flex-col mb-4"
>
<div className="text-xs text-muted-foreground/50 mb-1 flex justify-between">
<span>
<span className="text-primary/70">guest@{msg.nickname}</span>
:~#
</span>
<span>{new Date(msg.created).toLocaleTimeString()}</span>
</div>
<div className="text-foreground/90 pl-4 border-l-2 border-primary/30 break-words whitespace-pre-wrap font-medium">
{msg.message}
</div>
</motion.div>
))}
{isLoading && (
<div className="h-full flex items-center justify-center text-primary py-20">
<Loader2 className="size-8 animate-spin" />
</div>
)}
</div>
{/* Input Panel */}
<div className="p-4 bg-muted/20 border-t border-border/50 relative">
{/* Блок вывода ошибок */}
<AnimatePresence>
{errorText && (
<motion.div
initial={{ opacity: 0, y: 10, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 10, scale: 0.95 }}
className="absolute bottom-full left-4 right-4 mb-4 p-3 rounded-xl border bg-destructive/10 border-destructive/20 text-destructive text-xs font-mono flex items-start gap-3 backdrop-blur-md shadow-lg"
>
<ShieldAlert className="size-4 shrink-0 mt-0.5" />
<span className="leading-relaxed">{errorText}</span>
</motion.div>
)}
</AnimatePresence>
<form
onSubmit={handleSubmit}
className="flex flex-col sm:flex-row gap-3"
>
<input
type="text"
maxLength={20}
placeholder={dict.alias}
value={nickname}
onChange={(e) => setNickname(e.target.value)}
className="w-full sm:w-40 bg-background/50 border border-border rounded-xl px-4 py-3 font-mono text-sm focus:border-primary outline-none transition-colors"
disabled={isSubmitting || cooldown > 0}
/>
<div className="flex-1 relative flex items-center">
<input
type="text"
maxLength={150}
placeholder={
cooldown > 0
? dict.cooldown.replace("{{time}}", cooldown.toString())
: dict.message
}
value={inputMsg}
onChange={(e) => setInputMsg(e.target.value)}
className="w-full bg-background/50 border border-border rounded-xl pl-4 pr-12 py-3 font-mono text-sm focus:border-primary outline-none transition-colors"
disabled={isSubmitting || cooldown > 0}
/>
<button
type="submit"
disabled={
isSubmitting ||
cooldown > 0 ||
!nickname.trim() ||
!inputMsg.trim()
}
className="absolute right-2 p-2 bg-primary/20 text-primary hover:bg-primary hover:text-white rounded-lg transition-colors disabled:opacity-30"
>
{isSubmitting ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Send className="size-4" />
)}
</button>
</div>
</form>
</div>
</div>
</div>
);
}