899c54e1fc
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>
244 lines
9.6 KiB
TypeScript
244 lines
9.6 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useEffect } from "react";
|
||
import { motion, Variants } from "framer-motion";
|
||
import { Button } from "@/components/ui/button";
|
||
import {
|
||
Monitor,
|
||
Smartphone,
|
||
Terminal,
|
||
Apple,
|
||
Download,
|
||
ExternalLink,
|
||
Bell,
|
||
Loader2,
|
||
Clock,
|
||
} from "lucide-react";
|
||
import { Dictionary } from "@/lib/IDict";
|
||
import { pb, type PBDownload, getPbFileUrl } from "@/lib/pb";
|
||
|
||
interface DownloadClientProps {
|
||
dict: Dictionary["download"];
|
||
lang: string;
|
||
/**
|
||
* Адрес PocketBase — приходит пропсом из серверного page.tsx, а не читается
|
||
* напрямую из process.env.NEXT_PUBLIC_* здесь (см. комментарий в
|
||
* app/[lang]/auth/auth-client.tsx для полного объяснения).
|
||
*/
|
||
pbUrl: string;
|
||
}
|
||
|
||
/**
|
||
* Страница загрузки клиентов VPN по платформам (Windows/Linux/Android/macOS/iOS).
|
||
* Список платформ и ссылки на бинарники/магазины подтягиваются из PocketBase-коллекции
|
||
* `downloads_{lang}` (см. lib/pb.ts тип PBDownload) — т.е. версии, статус "active/dev"
|
||
* и ссылки можно менять в CMS без деплоя фронта.
|
||
*
|
||
* Для каждой платформы независимо решаются два вопроса:
|
||
* - есть ли прямой файл для скачивания (platform.file) — иначе кнопка "Build Pending";
|
||
* - есть ли ссылка на магазин/репозиторий (platform.storeLink) — иначе "Coming soon".
|
||
* Платформы со статусом "dev" рендерятся приглушённо (grayscale) с единственной
|
||
* кнопкой "уведомить о релизе".
|
||
*/
|
||
export function DownloadClient({ dict, lang, pbUrl }: DownloadClientProps) {
|
||
const [platforms, setPlatforms] = useState<PBDownload[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
|
||
useEffect(() => {
|
||
async function fetchDownloads() {
|
||
try {
|
||
setLoading(true);
|
||
pb.baseURL = pbUrl;
|
||
const records = await pb
|
||
.collection(`downloads_${lang}`)
|
||
.getFullList<PBDownload>({
|
||
sort: "order",
|
||
requestKey: null,
|
||
});
|
||
setPlatforms(records);
|
||
} catch (error) {
|
||
console.error("Ошибка загрузки данных платформы:", error);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}
|
||
fetchDownloads();
|
||
}, [lang, pbUrl]);
|
||
|
||
// Единый конфиг стилей: Primary (Фиолетовый) для базы, Secondary (Циан) для акцентов
|
||
const getPlatformIcon = (platformId: string) => {
|
||
const props = {
|
||
className:
|
||
"size-8 text-secondary drop-shadow-[0_0_10px_rgba(0,229,242,0.8)]",
|
||
};
|
||
switch (platformId) {
|
||
case "windows":
|
||
return <Monitor {...props} />;
|
||
case "linux":
|
||
return <Terminal {...props} />;
|
||
case "android":
|
||
return <Smartphone {...props} />;
|
||
case "macos":
|
||
case "ios":
|
||
return <Apple {...props} />;
|
||
default:
|
||
return <Download {...props} />;
|
||
}
|
||
};
|
||
|
||
const containerVariants: Variants = {
|
||
hidden: { opacity: 0 },
|
||
show: {
|
||
opacity: 1,
|
||
transition: { staggerChildren: 0.1 },
|
||
},
|
||
};
|
||
|
||
const itemVariants: Variants = {
|
||
hidden: { opacity: 0, y: 40 },
|
||
show: {
|
||
opacity: 1,
|
||
y: 0,
|
||
transition: { type: "spring", stiffness: 300, damping: 24 },
|
||
},
|
||
};
|
||
|
||
return (
|
||
<div className="min-h-screen pt-32 pb-24 px-4 container mx-auto max-w-5xl relative">
|
||
{/* Мягкое фоновое свечение */}
|
||
<div className="absolute top-1/4 left-1/2 -translate-x-1/2 w-full max-w-[800px] h-[400px] bg-primary/5 blur-[120px] rounded-full pointer-events-none -z-10" />
|
||
|
||
<div className="text-center mb-20">
|
||
<h1 className="text-4xl md:text-6xl font-black tracking-tighter mb-6 text-foreground">
|
||
{dict.title}
|
||
</h1>
|
||
<p className="text-muted-foreground text-lg max-w-2xl mx-auto font-medium">
|
||
{dict.subtitle}
|
||
</p>
|
||
</div>
|
||
|
||
{loading ? (
|
||
<div className="flex flex-col items-center justify-center py-20 text-primary opacity-50">
|
||
<Loader2 className="w-10 h-10 animate-spin mb-4" />
|
||
<span className="font-mono text-xs uppercase tracking-[0.3em]">
|
||
Accessing_Build_Server...
|
||
</span>
|
||
</div>
|
||
) : (
|
||
<motion.div
|
||
variants={containerVariants}
|
||
initial="hidden"
|
||
animate="show"
|
||
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 md:gap-8"
|
||
>
|
||
{platforms.map((platform) => (
|
||
<motion.div
|
||
key={platform.id}
|
||
variants={itemVariants}
|
||
className={`bg-card/40 backdrop-blur-xl border border-border/50 rounded-[2.5rem] p-8 md:p-10 flex flex-col transition-all duration-500 relative overflow-hidden group hover:border-primary/40 hover:shadow-[0_0_40px_rgba(139,61,255,0.15)] ${
|
||
platform.status === "dev" ? "opacity-60 grayscale-[50%]" : ""
|
||
}`}
|
||
>
|
||
{/* Статус бейдж */}
|
||
<div className="absolute top-8 right-8">
|
||
{platform.status === "active" ? (
|
||
<div className="px-3 py-1 rounded-full bg-primary/10 border border-primary/20 text-primary text-[10px] font-bold uppercase tracking-widest shadow-sm">
|
||
{dict.badges.active}
|
||
</div>
|
||
) : (
|
||
<div className="px-3 py-1 rounded-full bg-muted border border-border text-muted-foreground text-[10px] font-bold uppercase tracking-widest">
|
||
{dict.badges.dev}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Иконка в Циановом (Secondary) стиле */}
|
||
<div className="mb-10 p-5 rounded-[1.5rem] bg-secondary/10 inline-flex border border-secondary/20 shadow-[0_0_20px_rgba(0,229,242,0.15)] w-fit transition-transform duration-500 group-hover:scale-110">
|
||
{getPlatformIcon(platform.platformId)}
|
||
</div>
|
||
|
||
<div className="mb-10">
|
||
<h3 className="text-2xl font-bold text-foreground mb-2 tracking-tight">
|
||
{platform.name}
|
||
</h3>
|
||
<p className="text-sm text-muted-foreground font-mono">
|
||
{platform.version}
|
||
</p>
|
||
</div>
|
||
|
||
{/* Кнопки действий: Основная всегда Фиолетовая (Primary) */}
|
||
<div className="mt-auto flex flex-col gap-3">
|
||
{platform.status === "active" ? (
|
||
<>
|
||
{/* Прямое скачивание */}
|
||
{platform.file ? (
|
||
<Button
|
||
asChild
|
||
className="w-full h-14 rounded-2xl font-bold gap-3 bg-primary text-primary-foreground hover:bg-primary/90 shadow-[0_0_20px_rgba(139,61,255,0.3)] transition-all active:scale-[0.98]"
|
||
>
|
||
<a
|
||
href={getPbFileUrl(platform, platform.file, pbUrl)}
|
||
download
|
||
>
|
||
<Download className="size-5" />
|
||
{dict.buttons.direct}
|
||
</a>
|
||
</Button>
|
||
) : (
|
||
<Button
|
||
disabled
|
||
variant="outline"
|
||
className="w-full h-14 rounded-2xl font-bold gap-3 opacity-50 bg-background/50 border-dashed"
|
||
>
|
||
<Download className="size-5" />
|
||
Build Pending
|
||
</Button>
|
||
)}
|
||
|
||
{/* Ссылка на магазин (Вместо GitHub) */}
|
||
{platform.storeLink ? (
|
||
<Button
|
||
asChild
|
||
variant="outline"
|
||
className="w-full h-14 rounded-2xl gap-3 border-border/50 bg-background/30 text-muted-foreground hover:text-foreground hover:border-primary/30 transition-all"
|
||
>
|
||
<a
|
||
href={platform.storeLink}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
>
|
||
<ExternalLink className="size-4" />
|
||
{dict.buttons.store}
|
||
</a>
|
||
</Button>
|
||
) : (
|
||
<Button
|
||
variant="outline"
|
||
disabled
|
||
className="w-full h-14 rounded-2xl gap-3 border-border/20 bg-background/20 text-muted-foreground/40 cursor-not-allowed"
|
||
>
|
||
<Clock className="size-4" />
|
||
{dict.buttons.soon}
|
||
</Button>
|
||
)}
|
||
</>
|
||
) : (
|
||
// В разработке
|
||
<Button
|
||
variant="secondary"
|
||
disabled
|
||
className="w-full h-14 rounded-2xl gap-3 opacity-40 cursor-not-allowed bg-muted"
|
||
>
|
||
<Bell className="size-4" />
|
||
{dict.buttons.notify}
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</motion.div>
|
||
))}
|
||
</motion.div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|