Files
netrunner-landing/app/[lang]/terminal/terminal-client.tsx
T
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

355 lines
14 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,
pbUrl,
}: {
dict: Dictionary["terminal"];
/**
* Адрес PocketBase — приходит пропсом из серверного page.tsx, а не читается
* напрямую из process.env.NEXT_PUBLIC_* здесь (см. комментарий в
* app/[lang]/auth/auth-client.tsx для полного объяснения).
*/
pbUrl: string;
}) {
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(() => {
pb.baseURL = pbUrl;
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("*");
};
}, [pbUrl]);
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>
);
}