mobile app web view

This commit is contained in:
2026-03-10 00:40:39 +07:00
parent 6f4dd88a8e
commit c9dfc681e8
45 changed files with 6539 additions and 135 deletions
+3
View File
@@ -2,3 +2,6 @@
things.txt things.txt
/jniLibs /jniLibs
/app/node_modules /app/node_modules
/app/src-tauri/gen
/app/src-tauri/icons
/app/dist
+3 -1
View File
@@ -1,4 +1,6 @@
{ {
"rust-analyzer.cargo.features": "all", "rust-analyzer.cargo.features": "all",
"rust-analyzer.linkedProjects": ["Cargo.toml"] "rust-analyzer.linkedProjects": ["Cargo.toml"],
"rust-analyzer.cargo.buildScripts.enable": true,
"rust-analyzer.check.command": "check"
} }
Generated
+11 -11
View File
@@ -131,17 +131,6 @@ version = "1.0.101"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea"
[[package]]
name = "app"
version = "0.1.0"
dependencies = [
"netrunner-client",
"netrunner-core",
"serde",
"tauri",
"tauri-build",
]
[[package]] [[package]]
name = "askama" name = "askama"
version = "0.14.0" version = "0.14.0"
@@ -2448,6 +2437,17 @@ dependencies = [
"jni-sys", "jni-sys",
] ]
[[package]]
name = "netrunner-app"
version = "0.1.0"
dependencies = [
"netrunner-client",
"netrunner-core",
"serde",
"tauri",
"tauri-build",
]
[[package]] [[package]]
name = "netrunner-client" name = "netrunner-client"
version = "0.1.0" version = "0.1.0"
+25
View File
@@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "radix-maia",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "style.css",
"baseColor": "mist",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "tabler",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link href="/src/style.css" rel="stylesheet" />
<title>Netrunner VPN</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+29 -3
View File
@@ -10,14 +10,40 @@
"tauri": "tauri" "tauri": "tauri"
}, },
"dependencies": { "dependencies": {
"@fontsource-variable/figtree": "^5.2.10",
"@fontsource-variable/nunito-sans": "^5.2.7",
"@hugeicons/core-free-icons": "^4.0.0",
"@hugeicons/react": "^1.1.5",
"@tabler/icons-react": "^3.40.0",
"@tauri-apps/api": "^2.0.0", "@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-shell": "^2.0.0", "@tauri-apps/plugin-shell": "^2.0.0",
"solid-js": "^1.8.0" "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"framer-motion": "^12.35.2",
"i18next": "^25.8.16",
"i18next-browser-languagedetector": "^8.2.1",
"lucide-react": "^0.577.0",
"radix-ui": "^1.4.3",
"react": "^19.0.0",
"react-country-flag": "^3.1.0",
"react-dom": "^19.0.0",
"react-i18next": "^16.5.6",
"react-router-dom": "^7.1.5",
"shadcn": "^4.0.2",
"tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "^4.2.1",
"@tauri-apps/cli": "^2.0.0", "@tauri-apps/cli": "^2.0.0",
"@types/node": "^25.3.5",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^5.1.4",
"autoprefixer": "^10.4.27",
"postcss": "^8.5.8",
"tailwindcss": "^4.2.1",
"typescript": "^5.0.0", "typescript": "^5.0.0",
"vite": "^7.3.1", "vite": "^7.3.1"
"vite-plugin-solid": "^2.8.0"
} }
} }
+4754 -110
View File
File diff suppressed because it is too large Load Diff
+5 -7
View File
@@ -1,16 +1,14 @@
[package] [package]
name = "app" name = "netrunner-app"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2021"
[[bin]] [lib]
name = "netrunner-app" crate-type = ["cdylib", "staticlib"]
path = "main.rs" name = "netrunner_app"
[build-dependencies] [build-dependencies]
tauri-build = { version = "2.0.0" } tauri-build = { version = "2.0.0", features = [] }
[dependencies] [dependencies]
netrunner-client = { path = "../../client" } netrunner-client = { path = "../../client" }
Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build();
}
+7
View File
@@ -0,0 +1,7 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window",
"windows": ["main"],
"permissions": ["core:default"]
}
-1
View File
@@ -1 +0,0 @@
fn main() {}
+10
View File
@@ -0,0 +1,10 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
// 2. Только после него идут импорты
use tauri;
fn main() {
tauri::Builder::default()
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
+29
View File
@@ -0,0 +1,29 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "netrunner_app",
"version": "0.1.0",
"identifier": "com.netrunner.vpn",
"build": {
"beforeDevCommand": "pnpm dev",
"beforeBuildCommand": "pnpm build",
"devUrl": "http://localhost:5173",
"frontendDist": "../dist"
},
"app": {
"windows": [
{
"title": "Netrunner VPN",
"width": 500,
"height": 800
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": ["icons/icon.png"]
}
}
+33
View File
@@ -0,0 +1,33 @@
import { useState } from "react";
import { Header } from "./features/Header";
import { AnimatedBackground } from "./components/shared/AnimatedBackground";
import { BrowserRouter, Route, Routes } from "react-router-dom";
import { BurgerMenu } from "./features/BurgerMenu";
import { Home } from "./pages/Home";
import { About } from "./pages/About";
import { Settings } from "./pages/Settings";
function App() {
const [isMenuOpen, setIsMenuOpen] = useState(false);
return (
<BrowserRouter>
<div className="min-h-screen w-full bg-background relative overflow-x-hidden">
<AnimatedBackground />
<Header onMenuClick={() => setIsMenuOpen(!isMenuOpen)} />
<BurgerMenu isOpen={isMenuOpen} onClose={() => setIsMenuOpen(false)} />
<main className="flex flex-col items-center justify-center min-h-[calc(100vh-80px)] gap-[10px] p-6">
<Routes>
<Route path="/" element={<Home />} />
<Route path="/settings" element={<Settings />} />
<Route path="/about" element={<About />} />
</Routes>
</main>
</div>
</BrowserRouter>
);
}
export default App;
@@ -0,0 +1,70 @@
import { useSettings } from "@/hooks/SettingsContext";
import { motion } from "framer-motion";
export function AnimatedBackground() {
const { reduceMotion } = useSettings();
if (reduceMotion) {
return <div className="fixed inset-0 w-full h-full bg-background -z-10" />;
}
return (
<div className="fixed w-full h-full">
{/* Каустические пятна (используем контрастные Tailwind цвета) */}
<svg className="absolute w-0 h-0">
<defs>
<filter id="blob-filter">
<feGaussianBlur in="SourceGraphic" stdDeviation="40" />
</filter>
</defs>
</svg>
<div
className="absolute inset-0"
style={{ filter: "url(#blob-filter)", isolation: "isolate" }}
>
<motion.div
className="absolute top-[-10%] left-[-10%] w-[60%] h-[60%] rounded-full bg-chart-1/20"
animate={{
x: [0, 100, 0],
y: [0, 50, 0],
rotate: [0, 90, 0],
}}
style={{ willChange: "transform" }}
transition={{ duration: 20, repeat: Infinity, ease: "easeInOut" }}
/>
<motion.div
className="absolute bottom-[-10%] right-[-10%] w-[50%] h-[50%] rounded-full bg-chart-2/20"
animate={{
x: [0, -50, 0],
y: [0, 100, 0],
rotate: [0, -45, 0],
}}
style={{ willChange: "transform" }}
transition={{ duration: 15, repeat: Infinity, ease: "easeInOut" }}
/>
<motion.div
className="absolute top-[20%] right-[10%] w-[40%] h-[40%] rounded-full bg-chart-3/20"
animate={{
scale: [1, 1.2, 1],
opacity: [0.3, 0.6, 0.3],
}}
style={{ willChange: "transform" }}
transition={{ duration: 10, repeat: Infinity, ease: "easeInOut" }}
/>
<motion.div
className="absolute bottom-[20%] left-[20%] w-[30%] h-[30%] rounded-full bg-chart-3/20 "
animate={{
x: [0, 30, 0],
y: [0, -30, 0],
}}
style={{ willChange: "transform" }}
transition={{ duration: 8, repeat: Infinity, ease: "easeInOut" }}
/>
</div>
</div>
);
}
@@ -0,0 +1,30 @@
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
interface SettingsToggleProps {
label: string;
description: string;
checked: boolean;
onCheckedChange: (checked: boolean) => void;
}
export function SettingsToggle({
label,
description,
checked,
onCheckedChange,
}: SettingsToggleProps) {
return (
<div className="flex items-center justify-between p-4 rounded-xl bg-white/5 border border-white/10 backdrop-blur-sm transition-all hover:bg-white/10">
<div className="space-y-0.5">
<Label className="text-sm font-medium text-foreground">{label}</Label>
<p className="text-[11px] text-muted-foreground">{description}</p>
</div>
<Switch
checked={checked}
onCheckedChange={onCheckedChange}
className="data-[state=checked]:bg-primary"
/>
</div>
);
}
+49
View File
@@ -0,0 +1,49 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary:
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive:
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
outline:
"border-border bg-input/30 text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost:
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span"
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }
+65
View File
@@ -0,0 +1,65 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-4xl border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/80",
outline:
"border-border bg-input/30 hover:bg-input/50 hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-9 gap-1.5 px-3 has-data-[icon=inline-end]:pr-2.5 has-data-[icon=inline-start]:pl-2.5",
xs: "h-6 gap-1 px-2.5 text-xs has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 gap-1 px-3 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
lg: "h-10 gap-1.5 px-4 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
icon: "size-9",
"icon-xs": "size-6 [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
+100
View File
@@ -0,0 +1,100 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({
className,
size = "default",
...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
return (
<div
data-slot="card"
data-size={size}
className={cn(
"group/card flex flex-col gap-6 overflow-hidden rounded-2xl bg-card py-6 text-sm text-card-foreground ring-1 ring-foreground/10 has-[>img:first-child]:pt-0 data-[size=sm]:gap-4 data-[size=sm]:py-4 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"group/card-header @container/card-header grid auto-rows-min items-start gap-2 rounded-t-xl px-6 group-data-[size=sm]/card:px-4 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-6 group-data-[size=sm]/card:[.border-b]:pb-4",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("text-base font-medium", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6 group-data-[size=sm]/card:px-4", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn(
"flex items-center rounded-b-xl px-6 group-data-[size=sm]/card:px-4 [.border-t]:pt-6 group-data-[size=sm]/card:[.border-t]:pt-4",
className
)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
+163
View File
@@ -0,0 +1,163 @@
import * as React from "react"
import { Dialog as DialogPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { IconX } from "@tabler/icons-react"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/80 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-6 rounded-4xl bg-background p-6 text-sm ring-1 ring-foreground/5 duration-100 outline-none sm:max-w-md data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close data-slot="dialog-close" asChild>
<Button
variant="ghost"
className="absolute top-4 right-4"
size="icon-sm"
>
<IconX
/>
<span className="sr-only">Close</span>
</Button>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button>
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-base leading-none font-medium", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}
+22
View File
@@ -0,0 +1,22 @@
import * as React from "react"
import { Label as LabelPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }
+193
View File
@@ -0,0 +1,193 @@
import * as React from "react"
import { Select as SelectPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { IconSelector, IconCheck, IconChevronUp, IconChevronDown } from "@tabler/icons-react"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return (
<SelectPrimitive.Group
data-slot="select-group"
className={cn("scroll-my-1 p-1", className)}
{...props}
/>
)
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded-4xl border border-input bg-input/30 px-3 py-2 text-sm whitespace-nowrap transition-colors outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-[3px] aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<IconSelector className="pointer-events-none size-4 text-muted-foreground" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "item-aligned",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
data-align-trigger={position === "item-aligned"}
className={cn("relative z-50 max-h-(--radix-select-content-available-height) min-w-36 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-2xl bg-popover text-popover-foreground shadow-2xl ring-1 ring-foreground/5 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", position ==="popper"&&"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", className )}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
data-position={position}
className={cn(
"data-[position=popper]:h-(--radix-select-trigger-height) data-[position=popper]:w-full data-[position=popper]:min-w-(--radix-select-trigger-width)",
position === "popper" && ""
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("px-3 py-2.5 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-2.5 rounded-xl py-2 pr-8 pl-3 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<IconCheck className="pointer-events-none" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn(
"pointer-events-none -mx-1 my-1 h-px bg-border/50",
className
)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<IconChevronUp
/>
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<IconChevronDown
/>
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}
+31
View File
@@ -0,0 +1,31 @@
import * as React from "react"
import { Switch as SwitchPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Switch({
className,
size = "default",
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root> & {
size?: "sm" | "default"
}) {
return (
<SwitchPrimitive.Root
data-slot="switch"
data-size={size}
className={cn(
"peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-[3px] aria-invalid:ring-destructive/20 data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:bg-primary data-unchecked:bg-input dark:data-unchecked:bg-input/80 data-disabled:cursor-not-allowed data-disabled:opacity-50",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className="pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] dark:data-checked:bg-primary-foreground group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 dark:data-unchecked:bg-foreground"
/>
</SwitchPrimitive.Root>
)
}
export { Switch }
+88
View File
@@ -0,0 +1,88 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Tabs as TabsPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Tabs({
className,
orientation = "horizontal",
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
className={cn(
"group/tabs flex gap-2 data-horizontal:flex-col",
className
)}
{...props}
/>
)
}
const tabsListVariants = cva(
"group/tabs-list inline-flex w-fit items-center justify-center rounded-4xl p-[3px] text-muted-foreground group-data-horizontal/tabs:h-9 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col group-data-vertical/tabs:rounded-2xl data-[variant=line]:rounded-none",
{
variants: {
variant: {
default: "bg-muted",
line: "gap-1 bg-transparent",
},
},
defaultVariants: {
variant: "default",
},
}
)
function TabsList({
className,
variant = "default",
...props
}: React.ComponentProps<typeof TabsPrimitive.List> &
VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-xl border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start group-data-vertical/tabs:px-2.5 group-data-vertical/tabs:py-1.5 hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 text-sm outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
+44
View File
@@ -0,0 +1,44 @@
import { Link } from "react-router-dom";
import { cn } from "@/lib/utils";
interface BurgerMenuProps {
isOpen: boolean;
onClose: () => void;
}
export function BurgerMenu({ isOpen, onClose }: BurgerMenuProps) {
if (!isOpen) return null;
return (
<>
{/* Затемнение фона при открытом меню */}
<div
className="fixed inset-0 z-30 bg-black/20 backdrop-blur-sm"
onClick={onClose}
/>
<div className="absolute left-0 top-16 z-40 w-64 h-[calc(100vh-4rem)] bg-card border-r p-6 shadow-xl animate-in slide-in-from-left">
<nav className="flex flex-col gap-6" onClick={onClose}>
<Link
to="/"
className="text-lg font-medium hover:text-primary transition-colors"
>
Подключение
</Link>
<Link
to="/settings"
className="text-lg font-medium hover:text-primary transition-colors"
>
Настройки
</Link>
<Link
to="/about"
className="text-lg font-medium hover:text-primary transition-colors"
>
О программе
</Link>
</nav>
</div>
</>
);
}
+66
View File
@@ -0,0 +1,66 @@
import { useState } from "react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Globe } from "lucide-react";
import { useTranslation } from "react-i18next";
import ReactCountryFlag from "react-country-flag";
import { cn } from "@/lib/utils";
const countries = [
{ code: "us", name: "United States" },
{ code: "de", name: "Germany" },
{ code: "jp", name: "Japan" },
];
export function CountrySelect() {
const { t } = useTranslation();
const [selectedCode, setSelectedCode] = useState<string>("");
const selectedCountry = countries.find((c) => c.code === selectedCode);
return (
<Select onValueChange={setSelectedCode} value={selectedCode}>
<SelectTrigger className="w-[280px] h-12 bg-white/5 backdrop-blur-lg border-white/10 hover:bg-white/10 transition-all rounded-xl shadow-lg ring-offset-0 focus:ring-0">
<div
className={cn(
"flex items-center gap-2 w-full px-3",
selectedCountry ? "justify-start" : "justify-center",
)}
>
<span className="truncate flex-1 text-left">
<SelectValue placeholder={t("select_location_placeholder")} />
</span>
</div>
</SelectTrigger>
<SelectContent
className="bg-white/10 backdrop-blur-2xl border-white/10 rounded-xl overflow-hidden"
style={{ width: "var(--radix-select-trigger-width)" }}
>
{countries.map((country) => (
<SelectItem
key={country.code}
value={country.code}
className="cursor-pointer hover:bg-white/10 focus:bg-white/20 transition-colors"
>
<div className="flex items-center gap-3">
<ReactCountryFlag
countryCode={country.code.toUpperCase()}
svg
className="size-5 rounded-sm"
/>
<span className="font-medium text-foreground">
{country.name}
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
);
}
+31
View File
@@ -0,0 +1,31 @@
import { Menu } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ThemeToggle } from "./ThemeToggle";
export function Header({ onMenuClick }: { onMenuClick: () => void }) {
return (
<header className="sticky top-0 z-50 w-full border-b border-border/40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 shadow-sm">
{/* Используем grid для идеальной центровки */}
<div className="grid h-16 w-full grid-cols-[auto_1fr_auto] items-center px-4">
{/* Слева: Бургер (занимает место по контенту) */}
<div className="flex justify-start">
<Button variant="ghost" size="icon" onClick={onMenuClick}>
<Menu className="size-6" />
</Button>
</div>
{/* По центру: Лого (занимает всё свободное пространство и центрирует контент) */}
<div className="flex justify-center">
<h1 className="text-xl font-bold tracking-tighter">
Netrunner <span className="text-primary italic">VPN</span>
</h1>
</div>
{/* Справа: Тема (занимает место по контенту) */}
<div className="flex justify-end">
<ThemeToggle />
</div>
</div>
</header>
);
}
+27
View File
@@ -0,0 +1,27 @@
import { Moon, Sun } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useDarkMode } from "@/hooks/useDarkMode";
export function ThemeToggle() {
const { isDark, toggleDark } = useDarkMode();
return (
<Button
variant="outline"
size="icon"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
toggleDark();
}}
className="rounded-full transition-all duration-300 hover:scale-105"
>
{isDark ? (
<Sun className="h-[1.2rem] w-[1.2rem]" />
) : (
<Moon className="h-[1.2rem] w-[1.2rem]" />
)}
<span className="sr-only">Переключить тему</span>
</Button>
);
}
+18
View File
@@ -0,0 +1,18 @@
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
export function LanguageToggle() {
const { i18n } = useTranslation();
const toggleLanguage = () => {
const newLang = i18n.language === "en" ? "ru" : "en";
i18n.changeLanguage(newLang);
localStorage.setItem("lang", newLang); // Сохраняем выбор
};
return (
<Button variant="ghost" onClick={toggleLanguage}>
{i18n.language.toUpperCase()}
</Button>
);
}
+124
View File
@@ -0,0 +1,124 @@
import { useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { Power } from "lucide-react";
import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next";
export function VpnControl() {
const [status, setStatus] = useState<"idle" | "connecting" | "connected">(
"idle",
);
const handleToggle = () => {
if (status === "idle") {
setStatus("connecting");
setTimeout(() => setStatus("connected"), 5000);
} else {
setStatus("idle");
}
};
const { t } = useTranslation();
const isActive = status !== "idle";
const statusMap = {
idle: t("disconnected"),
connecting: t("connecting"),
connected: t("connected"),
};
return (
<div className="flex flex-col items-center justify-center gap-6 p-10">
{/* Контейнер с эффектом жидкого стекла */}
<div className="relative flex size-64 items-center justify-center rounded-full bg-background/20 backdrop-blur-xl border border-white/10 shadow-2xl p-4">
<svg
className="absolute size-full rotate-90 pointer-events-none"
viewBox="-20 -20 300 300"
>
{/* Фоновая линия */}
<circle
cx="130"
cy="130"
r="140"
className="stroke-primary/20 fill-none"
strokeWidth="4"
/>
{/* Правая половина (по часовой от 6 к 12) */}
<motion.circle
cx="130"
cy="130"
r="140"
className="stroke-primary/80 fill-none drop-shadow-[0_0_10px_var(--color-primary)]"
strokeWidth="4"
strokeLinecap="round"
strokeDasharray="800"
initial={{ pathLength: 0 }}
animate={{
pathLength: isActive ? 0.495 : 0,
opacity: isActive ? 1 : 0,
}}
transition={{ duration: 1.5, ease: "easeInOut" }}
/>
{/* Левая половина (против часовой от 6 к 12) */}
<motion.circle
cx="130"
cy="130"
r="140"
className="stroke-primary/80 fill-none drop-shadow-[0_0_10px_var(--color-primary)]"
strokeWidth="4"
strokeLinecap="round"
strokeDasharray="800"
// Поворачиваем на 180 градусов относительно центра, чтобы рисовать в другую сторону
style={{ rotate: 180, originX: "140px", originY: "141px" }}
initial={{ pathLength: 0 }}
animate={{
pathLength: isActive ? 0.495 : 0,
opacity: isActive ? 1 : 0,
}}
transition={{ duration: 1.5, ease: "easeInOut" }}
/>
</svg>
<button
onClick={handleToggle}
className={cn(
"relative size-40 rounded-full transition-all duration-700 flex items-center justify-center",
isActive
? "bg-primary/20 shadow-[0_0_40px_var(--color-primary)]"
: "bg-muted shadow-inner",
)}
>
<Power
className={cn(
"size-16 transition-colors duration-500",
isActive ? "text-primary" : "text-muted-foreground",
)}
/>
</button>
</div>
{/* Статус */}
<div className="text-center space-y-1">
<p className="text-sm uppercase tracking-widest text-muted-foreground font-semibold">
{t("status_label")}
</p>
<AnimatePresence mode="wait">
<motion.p
key={status}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className={cn(
"text-xl font-bold",
isActive ? "text-primary" : "text-foreground",
)}
>
{statusMap[status]}
</motion.p>
</AnimatePresence>
</div>
</div>
);
}
+45
View File
@@ -0,0 +1,45 @@
import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next";
interface VpnStatsProps {
received: string;
sent: string;
className?: string;
}
export function VpnStats({ received, sent, className }: VpnStatsProps) {
const { t } = useTranslation();
return (
<div
className={cn(
"w-full max-w-70",
"relative flex items-center justify-between px-8 py-4 rounded-2xl", // Увеличил отступы внутри
"bg-background/20 backdrop-blur-md border border-white/10",
"shadow-[inset_0_1px_1px_rgba(255,255,255,0.4),0_8px_16px_rgba(0,0,0,0.1)]",
"backdrop-blur-md saturate-150 brightness-110",
className,
)}
>
{/* Теперь контент внутри растянется равномерно */}
<div className="flex flex-col items-center flex-1">
<span className="text-[10px] uppercase tracking-widest text-muted-foreground font-bold">
{t("rx")}
</span>
<span className="text-sm font-mono text-foreground font-semibold tracking-tight">
{received}
</span>
</div>
<div className="h-8 w-px bg-border/50 mx-4" />
<div className="flex flex-col items-center flex-1">
<span className="text-[10px] uppercase tracking-widest text-muted-foreground font-bold">
{t("tx")}
</span>
<span className="text-sm font-mono text-foreground font-semibold tracking-tight">
{sent}
</span>
</div>
</div>
);
}
+17
View File
@@ -0,0 +1,17 @@
import { createContext, useContext, useState, ReactNode } from "react";
const SettingsContext = createContext({
reduceMotion: false,
setReduceMotion: (val: boolean) => {},
});
export function SettingsProvider({ children }: { children: ReactNode }) {
const [reduceMotion, setReduceMotion] = useState(false);
return (
<SettingsContext.Provider value={{ reduceMotion, setReduceMotion }}>
{children}
</SettingsContext.Provider>
);
}
export const useSettings = () => useContext(SettingsContext);
+32
View File
@@ -0,0 +1,32 @@
import { useEffect, useState } from "react";
export const useDarkMode = () => {
const [isDark, setIsDark] = useState(() => {
if (typeof window === "undefined") return false;
return localStorage.getItem("theme") === "dark";
});
useEffect(() => {
// Слушатель для изменений в localStorage из других компонентов/окон
const handleStorageChange = () => {
const isDarkMode = localStorage.getItem("theme") === "dark";
setIsDark(isDarkMode);
document.documentElement.classList.toggle("dark", isDarkMode);
};
window.addEventListener("storage", handleStorageChange);
return () => window.removeEventListener("storage", handleStorageChange);
}, []);
const toggleDark = () => {
const nextDark = !isDark;
setIsDark(nextDark);
document.documentElement.classList.toggle("dark", nextDark);
localStorage.setItem("theme", nextDark ? "dark" : "light");
// ВАЖНО: вручную вызываем событие, чтобы другие компоненты в том же окне узнали об изменении
window.dispatchEvent(new Event("storage"));
};
return { isDark, toggleDark };
};
+16
View File
@@ -0,0 +1,16 @@
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import LanguageDetector from "i18next-browser-languagedetector";
import en from "./locales/en.json";
import ru from "./locales/ru.json";
i18n
.use(LanguageDetector)
.use(initReactI18next)
.init({
resources: { en: { translation: en }, ru: { translation: ru } },
fallbackLng: "en", // язык по умолчанию
interpolation: { escapeValue: false },
});
export default i18n;
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+28
View File
@@ -0,0 +1,28 @@
{
"rx": "Download",
"tx": "Upload",
"status_label": "Status",
"disconnected": "DISCONNECTED",
"connecting": "CONNECTING...",
"connected": "CONNECTED",
"about_title": "Netrunner VPN",
"about_desc": "We created Netrunner not as a standard VPN, but as a secure tunnel that ISPs perceive as regular web traffic.",
"proto_title": "Proprietary Protocol",
"proto_desc": "TLS mimicry makes your connection invisible to DPI systems.",
"zk_title": "Zero-Knowledge",
"zk_desc": "We don't keep logs. Our architecture ensures that we technically cannot see what you are transmitting.",
"version_info": "Version 1.0.0-stable | 2026",
"footer_text": "Developed with care for network freedom.",
"select_location_placeholder": "Select a VPN location",
"settings": "Settings",
"section_network": "Network",
"kill_switch": "Kill Switch",
"kill_switch_desc": "Block internet on connection loss",
"stealth_mode": "Stealth Mode",
"stealth_mode_desc": "Enhanced TLS obfuscation",
"section_interface": "Interface",
"reduce_motion": "Reduce Motion",
"reduce_motion_desc": "For resource efficiency",
"theme_toggle": "Appearance Theme",
"language_toggle": "Interface Language"
}
+28
View File
@@ -0,0 +1,28 @@
{
"rx": "Получено",
"tx": "Отправлено",
"status_label": "Статус",
"disconnected": "ОТКЛЮЧЕНО",
"connecting": "ПОДКЛЮЧЕНИЕ...",
"connected": "ПОДКЛЮЧЕНО",
"about_title": "Netrunner VPN",
"about_desc": "Мы создали Netrunner не как обычный VPN, а как защищенный туннель, который интернет-провайдеры видят просто как обычный просмотр веб-сайтов.",
"proto_title": "Собственный протокол",
"proto_desc": "Мимикрия под TLS-трафик делает ваше соединение невидимым для систем DPI.",
"zk_title": "Zero-Knowledge",
"zk_desc": "Мы не храним логи. Наша архитектура гарантирует, что мы технически не можем видеть, что вы передаете.",
"version_info": "Версия 1.0.0-stable | 2026",
"footer_text": "Разработано с заботой о свободе в сети.",
"select_location_placeholder": "Выберите локацию VPN",
"settings": "Настройки",
"section_network": "Сеть",
"kill_switch": "Kill Switch",
"kill_switch_desc": "Блокировать интернет при обрыве",
"stealth_mode": "Stealth Mode",
"stealth_mode_desc": "Усиленная TLS-маскировка",
"section_interface": "Интерфейс",
"reduce_motion": "Снизить анимации",
"reduce_motion_desc": "Для экономии ресурсов",
"theme_toggle": "Тема оформления",
"language_toggle": "Язык интерфейса"
}
+16
View File
@@ -0,0 +1,16 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "../style.css";
import "./i18n";
import { SettingsProvider } from "./hooks/SettingsContext";
const root = document.getElementById("root");
if (root) {
ReactDOM.createRoot(document.getElementById("root")!).render(
<SettingsProvider>
<App />
</SettingsProvider>,
);
}
+37
View File
@@ -0,0 +1,37 @@
import { useTranslation } from "react-i18next";
export function About() {
const { t } = useTranslation();
return (
<div className="flex flex-col items-center gap-6 p-6 max-w-[320px] text-center">
<h2 className="text-2xl font-bold tracking-tight">{t("about_title")}</h2>
<p className="text-sm text-muted-foreground leading-relaxed">
{t("about_desc")}
</p>
<div className="w-full flex flex-col gap-3">
<div className="p-4 rounded-xl bg-white/5 border border-white/10 backdrop-blur-sm">
<h4 className="text-xs font-bold uppercase tracking-widest text-primary mb-1">
{t("proto_title")}
</h4>
<p className="text-[11px] text-muted-foreground">{t("proto_desc")}</p>
</div>
<div className="p-4 rounded-xl bg-white/5 border border-white/10 backdrop-blur-sm">
<h4 className="text-xs font-bold uppercase tracking-widest text-primary mb-1">
{t("zk_title")}
</h4>
<p className="text-[11px] text-muted-foreground">{t("zk_desc")}</p>
</div>
</div>
<div className="text-[10px] text-muted-foreground/60 mt-2">
{t("version_info")}
<br />
{t("footer_text")}
</div>
</div>
);
}
+13
View File
@@ -0,0 +1,13 @@
import { VpnControl } from "../features/VpnControl";
import { VpnStats } from "../features/VpnStats";
import { CountrySelect } from "../features/CountrySelect";
export function Home() {
return (
<>
<VpnControl />
<CountrySelect />
<VpnStats received="0" sent="0" />
</>
);
}
+62
View File
@@ -0,0 +1,62 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { SettingsToggle } from "@/components/shared/SettingsToggle";
import { ThemeToggle } from "@/features/ThemeToggle";
import { LanguageToggle } from "@/features/ToggleLanguge";
import { useSettings } from "@/hooks/SettingsContext";
export function Settings() {
const { t } = useTranslation();
const [killSwitch, setKillSwitch] = useState(false);
const [stealthMode, setStealthMode] = useState(true);
const { reduceMotion, setReduceMotion } = useSettings();
return (
<div className="w-full max-w-[320px] flex flex-col gap-8 p-6 z-5">
<h2 className="text-xl font-bold">{t("settings")}</h2>
{/* Секция: Сеть
<div className="space-y-4">
<h3 className="text-[10px] font-bold text-muted-foreground uppercase tracking-wider">
{t("section_network")}
</h3>
<SettingsToggle
label={t("kill_switch")}
description={t("kill_switch_desc")}
checked={killSwitch}
onCheckedChange={setKillSwitch}
/>
<SettingsToggle
label={t("stealth_mode")}
description={t("stealth_mode_desc")}
checked={stealthMode}
onCheckedChange={setStealthMode}
/>
</div>
*/}
{/* Секция: Интерфейс */}
<div className="space-y-4">
<h3 className="text-[10px] font-bold text-muted-foreground uppercase tracking-wider">
{t("section_interface")}
</h3>
<SettingsToggle
label={t("reduce_motion")}
description={t("reduce_motion_desc")}
checked={reduceMotion}
onCheckedChange={setReduceMotion}
/>
<div className="flex items-center justify-between p-4 rounded-xl bg-white/5 border border-white/10">
<span className="text-sm font-medium">{t("theme_toggle")}</span>
<ThemeToggle />
</div>
<div className="flex items-center justify-between p-4 rounded-xl bg-white/5 border border-white/10">
<span className="text-sm font-medium">{t("language_toggle")}</span>
<LanguageToggle />
</div>
</div>
</div>
);
}
+129
View File
@@ -0,0 +1,129 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@import "@fontsource-variable/nunito-sans";
@custom-variant dark (&:is(.dark *));
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.148 0.004 228.8);
--card: oklch(1 0 0);
--card-foreground: oklch(0.148 0.004 228.8);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.148 0.004 228.8);
--primary: oklch(0.496 0.265 301.924);
--primary-foreground: oklch(0.977 0.014 308.299);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.963 0.002 197.1);
--muted-foreground: oklch(0.56 0.021 213.5);
--accent: oklch(0.963 0.002 197.1);
--accent-foreground: oklch(0.218 0.008 223.9);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.925 0.005 214.3);
--input: oklch(0.925 0.005 214.3);
--ring: oklch(0.723 0.014 214.4);
--chart-1: oklch(0.827 0.119 306.383);
--chart-2: oklch(0.627 0.265 303.9);
--chart-3: oklch(0.558 0.288 302.321);
--chart-4: oklch(0.496 0.265 301.924);
--chart-5: oklch(0.438 0.218 303.724);
--radius: 0.625rem;
--sidebar: oklch(0.987 0.002 197.1);
--sidebar-foreground: oklch(0.148 0.004 228.8);
--sidebar-primary: oklch(0.558 0.288 302.321);
--sidebar-primary-foreground: oklch(0.977 0.014 308.299);
--sidebar-accent: oklch(0.963 0.002 197.1);
--sidebar-accent-foreground: oklch(0.218 0.008 223.9);
--sidebar-border: oklch(0.925 0.005 214.3);
--sidebar-ring: oklch(0.723 0.014 214.4);
}
.dark {
--background: oklch(0.148 0.004 228.8);
--foreground: oklch(0.987 0.002 197.1);
--card: oklch(0.218 0.008 223.9);
--card-foreground: oklch(0.987 0.002 197.1);
--popover: oklch(0.218 0.008 223.9);
--popover-foreground: oklch(0.987 0.002 197.1);
--primary: oklch(0.438 0.218 303.724);
--primary-foreground: oklch(0.977 0.014 308.299);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.275 0.011 216.9);
--muted-foreground: oklch(0.723 0.014 214.4);
--accent: oklch(0.275 0.011 216.9);
--accent-foreground: oklch(0.987 0.002 197.1);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.56 0.021 213.5);
--chart-1: oklch(0.827 0.119 306.383);
--chart-2: oklch(0.627 0.265 303.9);
--chart-3: oklch(0.558 0.288 302.321);
--chart-4: oklch(0.496 0.265 301.924);
--chart-5: oklch(0.438 0.218 303.724);
--sidebar: oklch(0.218 0.008 223.9);
--sidebar-foreground: oklch(0.987 0.002 197.1);
--sidebar-primary: oklch(0.627 0.265 303.9);
--sidebar-primary-foreground: oklch(0.977 0.014 308.299);
--sidebar-accent: oklch(0.275 0.011 216.9);
--sidebar-accent-foreground: oklch(0.987 0.002 197.1);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.56 0.021 213.5);
}
@theme inline {
--font-sans: "Nunito Sans Variable", sans-serif;
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--color-foreground: var(--foreground);
--color-background: var(--background);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
html {
@apply font-sans;
}
}
+24
View File
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"]
}
+38
View File
@@ -0,0 +1,38 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
import path from "path";
const host = process.env.TAURI_DEV_HOST;
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
clearScreen: false,
server: {
port: 5173,
strictPort: true,
host: host || false,
hmr: host
? {
protocol: "ws",
host,
port: 1421,
}
: undefined,
watch: {
ignored: ["**/src-tauri/**"],
},
},
envPrefix: ["VITE_", "TAURI_ENV_*"],
build: {
target:
process.env.TAURI_ENV_PLATFORM == "windows" ? "chrome105" : "safari13",
minify: !process.env.TAURI_ENV_DEBUG ? "esbuild" : false,
sourcemap: !!process.env.TAURI_ENV_DEBUG,
},
});