343 lines
13 KiB
TypeScript
343 lines
13 KiB
TypeScript
"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>
|
||
);
|
||
}
|