mobile app web view
This commit is contained in:
@@ -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
|
||||||
Vendored
+3
-1
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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": {}
|
||||||
|
}
|
||||||
@@ -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
@@ -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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+4754
-110
File diff suppressed because it is too large
Load Diff
@@ -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 |
@@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
tauri_build::build();
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
|
"identifier": "default",
|
||||||
|
"description": "Capability for the main window",
|
||||||
|
"windows": ["main"],
|
||||||
|
"permissions": ["core:default"]
|
||||||
|
}
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
fn main() {}
|
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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 }
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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 }
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -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 };
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from "clsx"
|
||||||
|
import { twMerge } from "tailwind-merge"
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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": "Язык интерфейса"
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user