mirror of
https://github.com/andrey271192/Domain_web.git
synced 2026-11-04 00:43:06 +00:00
feat: lightweight static PCA Lab site with domain groups
Replace Next.js scanner deploy with static React CDN pages: student VPN cabinet, regional setup, curated blocked-domain lists, and nginx one-line install on port 80 without touching Amnezia/Docker services. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
461
web/connect-app.jsx
Normal file
461
web/connect-app.jsx
Normal file
@@ -0,0 +1,461 @@
|
||||
// VPN connection guide — Apple-light style
|
||||
|
||||
const { useState: useStateC, useEffect: useEffectC } = React;
|
||||
|
||||
// SiteNav from nav.jsx
|
||||
|
||||
// ───────── Hero ─────────
|
||||
function Hero(){
|
||||
return (
|
||||
<section style={{maxWidth:760, margin:"0 auto", padding:"56px 28px 28px", textAlign:"center"}}>
|
||||
<div style={{display:"inline-flex", alignItems:"center", gap:6,
|
||||
padding:"6px 14px", borderRadius:999, background:"#fff",
|
||||
border:"1px solid var(--line)", boxShadow:"0 1px 1px rgba(16,24,40,.03)",
|
||||
fontSize:11, fontWeight:700, color:"var(--accent-2)", letterSpacing:".12em"}}>
|
||||
<span style={{width:6, height:6, borderRadius:999, background:"var(--accent)"}}/>
|
||||
ЧАСТНЫЙ СЕРВЕР
|
||||
</div>
|
||||
<h1 style={{margin:"18px 0 14px", fontSize:54, fontWeight:800, letterSpacing:"-0.035em", lineHeight:1.04}}>
|
||||
Подключение к VPN
|
||||
</h1>
|
||||
<p style={{margin:"0 auto", maxWidth:560, fontSize:16, lineHeight:1.55, color:"var(--ink-2)"}}>
|
||||
Используйте приложение <strong>AmneziaVPN</strong>. Конфигурацию выдаёт администратор —
|
||||
импортируйте файл или ключ, который вам передали, и включите туннель. Ниже — пошаговые
|
||||
инструкции для WireGuard и Amnezia VPN.
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ───────── Server info card ─────────
|
||||
function ServerCard(){
|
||||
const [copied,setCopied] = useStateC(false);
|
||||
function copy(){
|
||||
navigator.clipboard?.writeText("SERVER_IP");
|
||||
setCopied(true);
|
||||
setTimeout(()=>setCopied(false), 1600);
|
||||
}
|
||||
return (
|
||||
<Card padding={24} style={{maxWidth:880, margin:"0 auto 18px"}}>
|
||||
<div style={{fontSize:11, fontWeight:700, letterSpacing:".14em", color:"var(--accent-2)",
|
||||
textTransform:"uppercase"}}>Сервер</div>
|
||||
<div style={{marginTop:10, fontSize:13, fontWeight:500, color:"var(--ink-3)"}}>Адрес</div>
|
||||
<div style={{marginTop:8, display:"flex", gap:10}}>
|
||||
<div style={{flex:1, height:54, display:"flex", alignItems:"center", padding:"0 18px",
|
||||
background:"var(--bg-tint)", border:"1px solid var(--line)", borderRadius:14,
|
||||
fontFamily:"var(--mono)", fontSize:18, fontWeight:600,
|
||||
letterSpacing:"-0.005em", color:"var(--ink)"}}>
|
||||
SERVER_IP
|
||||
</div>
|
||||
<Button variant={copied?"success":"primary"} size="lg" onClick={copy}
|
||||
icon={copied?"check":"clipboard"}>{copied?"Скопировано":"Копировать"}</Button>
|
||||
</div>
|
||||
<div style={{marginTop:18, paddingTop:18, borderTop:"1px solid var(--line-2)",
|
||||
display:"flex", alignItems:"center", gap:14, flexWrap:"wrap"}}>
|
||||
<span style={{fontSize:13.5, color:"var(--ink-2)", fontWeight:500}}>Сервисы на машине:</span>
|
||||
<Badge tone="blue" dot>XRay (TCP)</Badge>
|
||||
<Badge tone="purple" dot>AmneziaWG (UDP)</Badge>
|
||||
</div>
|
||||
<p style={{margin:"12px 0 0", fontSize:12.5, color:"var(--ink-3)"}}>
|
||||
Номера портов и тип протокола уже зашиты в конфиг Amnezia — вручную их обычно не вводят.
|
||||
</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ───────── Big segmented control ─────────
|
||||
function BigSegment({ value, options, onChange }){
|
||||
return (
|
||||
<div style={{display:"grid", gridTemplateColumns:`repeat(${options.length},1fr)`,
|
||||
gap:8, padding:6, background:"#fff", border:"1px solid var(--line)", borderRadius:18,
|
||||
boxShadow:"0 1px 1px rgba(16,24,40,.03)"}}>
|
||||
{options.map(o=>{
|
||||
const on = value===o.id;
|
||||
return (
|
||||
<button key={o.id} onClick={()=>onChange(o.id)} style={{
|
||||
appearance:"none", border:0, height:52, padding:"0 16px",
|
||||
background: on ? "var(--accent-soft)" : "transparent",
|
||||
color: on ? "var(--accent-2)" : "var(--ink-2)",
|
||||
borderRadius:13, cursor:"pointer", fontWeight: on ? 600 : 500,
|
||||
fontSize:14.5, letterSpacing:"-0.005em",
|
||||
display:"inline-flex", alignItems:"center", justifyContent:"center", gap:10,
|
||||
transition:"all .15s ease",
|
||||
}}>
|
||||
{o.label}
|
||||
{o.tag && <Badge tone={o.tagTone || "neutral"} size="sm">{o.tag}</Badge>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ───────── OS tabs (smaller, pill) ─────────
|
||||
function OSTabs({ value, onChange }){
|
||||
const os = [
|
||||
{id:"ios", label:"iOS"}, {id:"android", label:"Android"},
|
||||
{id:"windows", label:"Windows"}, {id:"macos", label:"macOS"}
|
||||
];
|
||||
return (
|
||||
<div style={{display:"grid", gridTemplateColumns:"repeat(4,1fr)", gap:6,
|
||||
padding:5, background:"#fff", border:"1px solid var(--line)", borderRadius:14,
|
||||
boxShadow:"0 1px 1px rgba(16,24,40,.03)"}}>
|
||||
{os.map(o=>{
|
||||
const on = value===o.id;
|
||||
return (
|
||||
<button key={o.id} onClick={()=>onChange(o.id)} style={{
|
||||
appearance:"none", border:0, height:40,
|
||||
background: on ? "var(--ink)" : "transparent",
|
||||
color: on ? "#fff" : "var(--ink-2)",
|
||||
borderRadius:10, cursor:"pointer", fontWeight: on ? 600 : 500,
|
||||
fontSize:13.5, transition:"all .15s ease",
|
||||
boxShadow: on ? "0 2px 8px rgba(0,0,0,.18)" : "none"
|
||||
}}>{o.label}</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ───────── Numbered step ─────────
|
||||
function Step({ n, children }){
|
||||
return (
|
||||
<div style={{display:"flex", alignItems:"flex-start", gap:16, padding:"18px 22px",
|
||||
borderBottom:"1px solid var(--line-2)"}}>
|
||||
<div style={{
|
||||
flexShrink:0, width:32, height:32, borderRadius:999,
|
||||
background:"var(--accent-soft)", color:"var(--accent-2)",
|
||||
display:"grid", placeItems:"center", fontWeight:700, fontSize:14.5,
|
||||
fontVariantNumeric:"tabular-nums"
|
||||
}}>{n}</div>
|
||||
<div style={{flex:1, fontSize:15.5, lineHeight:1.55, color:"var(--ink)", paddingTop:4}}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
function SuccessStep({ children }){
|
||||
return (
|
||||
<div style={{display:"flex", alignItems:"center", gap:14, padding:"18px 22px",
|
||||
background:"var(--green-soft)", borderTop:"1px solid var(--line-2)"}}>
|
||||
<div style={{
|
||||
flexShrink:0, width:30, height:30, borderRadius:999,
|
||||
background:"var(--green)", color:"#fff",
|
||||
display:"grid", placeItems:"center",
|
||||
boxShadow:"0 4px 10px rgba(52,199,89,.35)"
|
||||
}}>
|
||||
<Icon name="check" size={16} stroke={3} color="#fff"/>
|
||||
</div>
|
||||
<div style={{fontSize:15, fontWeight:500, color:"#1f7a3a"}}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ───────── Steps card ─────────
|
||||
function StepsCard({ protocol, os }){
|
||||
// Headlines per protocol / OS
|
||||
const titles = {
|
||||
wg:{
|
||||
ios:{name:"WireGuard на iOS", sub:"iPhone и iPad · iOS 15 и новее", color:"linear-gradient(160deg,#5ac8fa,#0a84ff)", icon:"lock"},
|
||||
android:{name:"WireGuard на Android", sub:"Android 8 и новее", color:"linear-gradient(160deg,#7ce0a2,#2cb67d)", icon:"lock"},
|
||||
windows:{name:"WireGuard на Windows", sub:"Windows 10 / 11", color:"linear-gradient(160deg,#7ec2ff,#0a84ff)", icon:"lock"},
|
||||
macos:{name:"WireGuard на macOS", sub:"macOS 12 и новее", color:"linear-gradient(160deg,#bdbdc0,#6e6e73)", icon:"lock"},
|
||||
},
|
||||
awg:{
|
||||
ios:{name:"Amnezia VPN на iOS", sub:"AmneziaWG · обход DPI", color:"linear-gradient(160deg,#a385ff,#6a44f5)", icon:"shield"},
|
||||
android:{name:"Amnezia VPN на Android", sub:"AmneziaWG · обход DPI", color:"linear-gradient(160deg,#9b82ff,#6a44f5)", icon:"shield"},
|
||||
windows:{name:"Amnezia VPN на Windows", sub:"AmneziaWG · обход DPI", color:"linear-gradient(160deg,#a385ff,#6a44f5)", icon:"shield"},
|
||||
macos:{name:"Amnezia VPN на macOS", sub:"AmneziaWG · обход DPI", color:"linear-gradient(160deg,#9b82ff,#6a44f5)", icon:"shield"},
|
||||
},
|
||||
};
|
||||
const t = titles[protocol][os];
|
||||
|
||||
// Steps — protocol/os specific
|
||||
let steps;
|
||||
if (protocol==="wg"){
|
||||
if (os==="ios") steps = [
|
||||
<>Откройте <strong>App Store</strong>, найдите <strong>WireGuard</strong> и нажмите «Установить»</>,
|
||||
<>Откройте приложение, нажмите <strong>+</strong> в правом верхнем углу</>,
|
||||
<>
|
||||
Выберите <strong>«Сканировать QR-код»</strong> и наведите камеру на QR — конфиг добавится автоматически
|
||||
<FineNote>Или «Создать из файла» → выберите файл <code>.conf</code></FineNote>
|
||||
</>,
|
||||
<>Придумайте имя туннелю и нажмите <strong>«Сохранить»</strong></>,
|
||||
<>Нажмите тумблер → появится запрос, нажмите <strong>«Разрешить»</strong></>,
|
||||
];
|
||||
else if (os==="android") steps = [
|
||||
<>Установите <strong>WireGuard</strong> из Google Play или скачайте <code>.apk</code> с wireguard.com</>,
|
||||
<>Откройте приложение, тапните по <strong>«+»</strong> внизу справа</>,
|
||||
<>
|
||||
Выберите <strong>«Сканировать из QR-кода»</strong> и наведите камеру на QR
|
||||
<FineNote>Или «Импорт из файла» → найдите <code>.conf</code> в загрузках</FineNote>
|
||||
</>,
|
||||
<>Дайте имя туннелю и подтвердите создание</>,
|
||||
<>Переключите тумблер вправо — разрешите создание VPN-подключения</>,
|
||||
];
|
||||
else if (os==="windows") steps = [
|
||||
<>Скачайте <strong>WireGuard for Windows</strong> с <code>wireguard.com/install</code></>,
|
||||
<>Установите и откройте приложение</>,
|
||||
<>Нажмите <strong>«Импорт туннеля из файла»</strong> и выберите <code>.conf</code></>,
|
||||
<>Подтвердите создание туннеля</>,
|
||||
<>Нажмите <strong>«Подключить»</strong> — индикатор станет зелёным</>,
|
||||
];
|
||||
else steps = [
|
||||
<>Установите <strong>WireGuard</strong> из Mac App Store</>,
|
||||
<>Откройте приложение в строке меню</>,
|
||||
<>Выберите <strong>«Импорт туннеля из файла»</strong> → <code>.conf</code></>,
|
||||
<>Подтвердите добавление профиля в Системные настройки</>,
|
||||
<>Включите тумблер и подтвердите системный запрос</>,
|
||||
];
|
||||
} else {
|
||||
// Amnezia VPN (same flow across OS — slight wording variations)
|
||||
steps = [
|
||||
<>Скачайте приложение <strong>AmneziaVPN</strong> с <code>amnezia.org</code> или из стора своей платформы</>,
|
||||
<>Запустите приложение и согласитесь с условиями использования</>,
|
||||
<>
|
||||
Нажмите <strong>«Добавить конфигурацию»</strong> → <strong>«Сканировать QR-код»</strong>
|
||||
<FineNote>Или импорт ключа: вставьте строку вида <code>vpn://…</code></FineNote>
|
||||
</>,
|
||||
<>При запросе системы — разрешите создание VPN-профиля</>,
|
||||
<>Нажмите большую кнопку <strong>«Подключиться»</strong> — индикатор станет зелёным</>,
|
||||
];
|
||||
}
|
||||
|
||||
return (
|
||||
<Card padding={0} style={{overflow:"hidden"}}>
|
||||
<div style={{display:"flex", alignItems:"center", gap:14, padding:"22px 22px 18px"}}>
|
||||
<GlyphTile size={48} radius={14} gradient={t.color} icon={t.icon}/>
|
||||
<div>
|
||||
<h2 style={{margin:0, fontSize:20, fontWeight:700, letterSpacing:"-0.015em"}}>{t.name}</h2>
|
||||
<div style={{marginTop:3, fontSize:13.5, color:"var(--ink-3)"}}>{t.sub}</div>
|
||||
</div>
|
||||
</div>
|
||||
{steps.map((s,i)=><Step key={i} n={i+1}>{s}</Step>)}
|
||||
<SuccessStep>
|
||||
Тумблер зелёный — вы подключены. Значок VPN в строке статуса.
|
||||
</SuccessStep>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function FineNote({ children }){
|
||||
return (
|
||||
<div style={{marginTop:10, padding:"10px 14px", borderRadius:12,
|
||||
background:"var(--bg-tint)", border:"1px solid var(--line-2)",
|
||||
fontSize:13.5, color:"var(--ink-3)"}}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ───────── Region change steps ─────────
|
||||
function RegionCard({ os }){
|
||||
const cfg = {
|
||||
ios:{name:"Смена региона App Store на iPhone", sub:"iOS · Apple ID", color:"linear-gradient(160deg,#ffd166,#ff9500)"},
|
||||
android:{name:"Смена региона Google Play", sub:"Android · Google аккаунт", color:"linear-gradient(160deg,#7ce0a2,#2cb67d)"},
|
||||
windows:{name:"Смена региона Microsoft Store", sub:"Windows 10 / 11", color:"linear-gradient(160deg,#7ec2ff,#0a84ff)"},
|
||||
macos:{name:"Смена региона Mac App Store", sub:"macOS", color:"linear-gradient(160deg,#bdbdc0,#6e6e73)"},
|
||||
};
|
||||
const t = cfg[os] || cfg.ios;
|
||||
let steps;
|
||||
if (os==="ios" || os==="macos") steps = [
|
||||
<>Подключите VPN к серверу нужной страны</>,
|
||||
<>Откройте <strong>«Настройки»</strong> → ваше имя → <strong>«Медиаматериалы и покупки»</strong></>,
|
||||
<>Нажмите <strong>«Просмотреть учётную запись»</strong> → подтвердите вход</>,
|
||||
<>Тапните <strong>«Страна или регион»</strong> → <strong>«Изменить страну или регион»</strong></>,
|
||||
<>Выберите страну, примите условия, заполните адрес и способ оплаты <strong>«Нет»</strong></>,
|
||||
];
|
||||
else if (os==="android") steps = [
|
||||
<>Подключите VPN к серверу нужной страны</>,
|
||||
<>Очистите кеш и данные <strong>Google Play Store</strong> в настройках устройства</>,
|
||||
<>Откройте Play Store → меню → <strong>«Настройки» → «Общие» → «Настройки аккаунта»</strong></>,
|
||||
<>Выберите <strong>«Страна и профили»</strong>, подтвердите смену</>,
|
||||
<>Дождитесь до 24 часов и проверьте — регион обновится</>,
|
||||
];
|
||||
else steps = [
|
||||
<>Подключите VPN к серверу нужной страны</>,
|
||||
<>Откройте <strong>«Параметры» → «Время и язык» → «Язык и регион»</strong></>,
|
||||
<>В разделе <strong>«Страна или регион»</strong> выберите нужную страну</>,
|
||||
<>Перезапустите Microsoft Store</>,
|
||||
<>Войдите заново под нужным аккаунтом</>,
|
||||
];
|
||||
return (
|
||||
<Card padding={0} style={{overflow:"hidden"}}>
|
||||
<div style={{display:"flex", alignItems:"center", gap:14, padding:"22px 22px 18px"}}>
|
||||
<GlyphTile size={48} radius={14} gradient={t.color} glyph="🛍️"/>
|
||||
<div>
|
||||
<h2 style={{margin:0, fontSize:20, fontWeight:700, letterSpacing:"-0.015em"}}>{t.name}</h2>
|
||||
<div style={{marginTop:3, fontSize:13.5, color:"var(--ink-3)"}}>{t.sub}</div>
|
||||
</div>
|
||||
</div>
|
||||
{steps.map((s,i)=><Step key={i} n={i+1}>{s}</Step>)}
|
||||
<SuccessStep>
|
||||
Регион изменён — теперь доступны приложения этой страны.
|
||||
</SuccessStep>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ───────── Verify section ─────────
|
||||
function VerifySection(){
|
||||
return (
|
||||
<section style={{marginTop:48, maxWidth:1020, marginLeft:"auto", marginRight:"auto", padding:"0 28px"}}>
|
||||
<h3 style={{margin:"0 0 16px", fontSize:20, fontWeight:700, letterSpacing:"-0.015em"}}>
|
||||
Как проверить, что VPN работает?
|
||||
</h3>
|
||||
<div style={{display:"grid", gridTemplateColumns:"repeat(3,1fr)", gap:14}}>
|
||||
{[
|
||||
{n:1, text:<>Откройте <code style={cstyle()}>2ip.ru</code> или <code style={cstyle()}>whatismyip.com</code></>},
|
||||
{n:2, text:<>Страна должна измениться на страну вашего сервера</>},
|
||||
{n:3, text:<>Попробуйте открыть нужный сайт — загрузится</>},
|
||||
].map(c=>(
|
||||
<Card key={c.n} padding={18}>
|
||||
<div style={{display:"flex", alignItems:"flex-start", gap:12}}>
|
||||
<div style={{
|
||||
width:30, height:30, borderRadius:999, flexShrink:0,
|
||||
background:"var(--accent-soft)", color:"var(--accent-2)",
|
||||
display:"grid", placeItems:"center", fontWeight:700, fontSize:14
|
||||
}}>{c.n}</div>
|
||||
<div style={{fontSize:14.5, lineHeight:1.55, color:"var(--ink)"}}>{c.text}</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
function cstyle(){
|
||||
return { background:"var(--bg-tint)", border:"1px solid var(--line)",
|
||||
padding:"2px 8px", borderRadius:8, fontSize:12.5, color:"var(--ink-2)" };
|
||||
}
|
||||
|
||||
// ───────── Troubleshooting ─────────
|
||||
function Troubleshooting(){
|
||||
const items = [
|
||||
{ title:"VPN не подключается", icon:"refresh", color:"linear-gradient(160deg,#ff8a8a,#ff3b30)", points:[
|
||||
"Перезапустите приложение",
|
||||
"Выключите/включите Wi-Fi или мобильный интернет",
|
||||
"Удалите туннель и добавьте конфиг заново",
|
||||
"Попробуйте другую сеть",
|
||||
]},
|
||||
{ title:"VPN включён, нет интернета", icon:"globe", color:"linear-gradient(160deg,#ffd166,#ff9500)", points:[
|
||||
"Отключитесь и подключитесь снова",
|
||||
"Убедитесь, что конфиг актуальный",
|
||||
"Перезагрузите устройство",
|
||||
"Напишите в поддержку",
|
||||
]},
|
||||
{ title:"Сайты не открываются", icon:"search", color:"linear-gradient(160deg,#7ec2ff,#0a84ff)", points:[
|
||||
"Очистите кеш браузера",
|
||||
"Смените DNS на 1.1.1.1",
|
||||
"Откройте в режиме инкогнито",
|
||||
"Попробуйте другой браузер",
|
||||
]},
|
||||
{ title:"Медленная скорость", icon:"gauge", color:"linear-gradient(160deg,#a385ff,#6a44f5)", points:[
|
||||
"Проверьте скорость без VPN",
|
||||
"Переподключитесь",
|
||||
"Закройте лишние приложения",
|
||||
"Переключитесь Wi-Fi ↔ 4G",
|
||||
]},
|
||||
];
|
||||
return (
|
||||
<section style={{marginTop:40, maxWidth:1020, marginLeft:"auto", marginRight:"auto", padding:"0 28px"}}>
|
||||
<h3 style={{margin:"0 0 16px", fontSize:20, fontWeight:700, letterSpacing:"-0.015em"}}>
|
||||
Что делать, если что-то пошло не так
|
||||
</h3>
|
||||
<div style={{display:"grid", gridTemplateColumns:"repeat(4,1fr)", gap:14}}>
|
||||
{items.map(it=>(
|
||||
<Card key={it.title} padding={18}>
|
||||
<div style={{display:"flex", alignItems:"center", gap:10, marginBottom:12}}>
|
||||
<GlyphTile size={32} radius={10} gradient={it.color} icon={it.icon}/>
|
||||
<div style={{fontSize:14.5, fontWeight:700, letterSpacing:"-0.005em"}}>{it.title}</div>
|
||||
</div>
|
||||
<ul style={{margin:0, padding:0, listStyle:"none", display:"flex",
|
||||
flexDirection:"column", gap:8}}>
|
||||
{it.points.map((p,i)=>(
|
||||
<li key={i} style={{display:"flex", alignItems:"flex-start", gap:8,
|
||||
fontSize:13.5, color:"var(--ink-2)", lineHeight:1.45}}>
|
||||
<span style={{width:5, height:5, borderRadius:999, background:"var(--accent)",
|
||||
marginTop:7, flexShrink:0}}/>
|
||||
<span>{p}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ───────── Footer ─────────
|
||||
function Footer(){
|
||||
return (
|
||||
<footer style={{maxWidth:760, margin:"56px auto 40px", padding:"0 28px", textAlign:"center"}}>
|
||||
<p style={{margin:0, fontSize:14, color:"var(--ink-2)"}}>
|
||||
Возникли проблемы с подключением — <a href="https://t.me/PCA_Amnezia_support_bot" target="_blank" rel="noreferrer" style={{color:"var(--accent-2)", fontWeight:600, display:"inline-flex", alignItems:"center", gap:6}}><Icon name="plane" size={14}/> напишите в Telegram-бот @PCA_Amnezia_support_bot</a>.
|
||||
</p>
|
||||
<p style={{margin:"10px 0 0", fontSize:12.5, color:"var(--ink-3)"}}>
|
||||
Страница только для приглашённых пользователей. Не передавайте конфиг третьим лицам.
|
||||
</p>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
// ───────── Main App ─────────
|
||||
function ConnectApp(){
|
||||
const initialMode = (typeof URLSearchParams !== "undefined" && new URLSearchParams(location.search).get("mode") === "region")
|
||||
? "region" : "connect";
|
||||
const [mode, setMode] = useStateC(initialMode); // connect | region
|
||||
const [protocol, setProtocol] = useStateC("wg"); // wg | awg
|
||||
const [os, setOs] = useStateC("ios");
|
||||
|
||||
useEffectC(() => {
|
||||
const params = new URLSearchParams(location.search);
|
||||
if (params.get("mode") === "region") setMode("region");
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SiteNav brand="Кабинет студента" brandSub="VPN · РЕГИОН · AMNEZIA" />
|
||||
<Hero/>
|
||||
|
||||
<div style={{maxWidth:880, margin:"0 auto", padding:"0 28px"}}>
|
||||
<ServerCard/>
|
||||
|
||||
{/* Mode big segment */}
|
||||
<div style={{margin:"22px 0 14px"}}>
|
||||
<BigSegment value={mode} onChange={setMode} options={[
|
||||
{id:"connect", label:"Подключение VPN"},
|
||||
{id:"region", label:"Смена региона App Store"},
|
||||
]}/>
|
||||
</div>
|
||||
|
||||
{/* Protocol (only for connect mode) */}
|
||||
{mode==="connect" && (
|
||||
<div style={{marginBottom:14}}>
|
||||
<BigSegment value={protocol} onChange={setProtocol} options={[
|
||||
{id:"wg", label:"WireGuard", tag:"стандарт", tagTone:"blue"},
|
||||
{id:"awg", label:"Amnezia VPN", tag:"обход DPI", tagTone:"purple"},
|
||||
]}/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* OS */}
|
||||
<div style={{marginBottom:18}}>
|
||||
<OSTabs value={os} onChange={setOs}/>
|
||||
</div>
|
||||
|
||||
{/* Steps */}
|
||||
{mode==="connect"
|
||||
? <StepsCard protocol={protocol} os={os}/>
|
||||
: <RegionCard os={os}/>}
|
||||
</div>
|
||||
|
||||
<VerifySection/>
|
||||
<Troubleshooting/>
|
||||
<Footer/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")).render(<ConnectApp/>);
|
||||
71
web/connect.html
Normal file
71
web/connect.html
Normal file
@@ -0,0 +1,71 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>Подключение к VPN — Amnezia</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root{
|
||||
--bg: #f4f5f7;
|
||||
--bg-elev: #ffffff;
|
||||
--bg-tint: #f9fafb;
|
||||
--ink: #1d1d1f;
|
||||
--ink-2: #424245;
|
||||
--ink-3: #6e6e73;
|
||||
--ink-4: #8e8e93;
|
||||
--line: rgba(0,0,0,0.07);
|
||||
--line-2: rgba(0,0,0,0.04);
|
||||
--accent: #7c5cff;
|
||||
--accent-2:#5e3df1;
|
||||
--accent-soft:#efebff;
|
||||
--blue:#0a84ff;
|
||||
--blue-soft:#e6f0ff;
|
||||
--green:#34c759;
|
||||
--green-soft:#e4f8ea;
|
||||
--orange:#ff9f0a;
|
||||
--orange-soft:#fff2dc;
|
||||
--red:#ff3b30;
|
||||
--red-soft:#ffe9e7;
|
||||
--radius-card: 22px;
|
||||
--radius-inner: 14px;
|
||||
--radius-pill: 999px;
|
||||
--shadow-card: 0 1px 0 rgba(255,255,255,.7) inset, 0 1px 2px rgba(16,24,40,.04), 0 8px 24px rgba(16,24,40,.05);
|
||||
--shadow-pop: 0 1px 0 rgba(255,255,255,.7) inset, 0 4px 14px rgba(16,24,40,.08), 0 24px 56px rgba(16,24,40,.10);
|
||||
--font: "Inter", -apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue", Arial, sans-serif;
|
||||
--mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
html,body{margin:0;padding:0;background:var(--bg);color:var(--ink);font-family:var(--font);
|
||||
-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility;
|
||||
font-feature-settings:"ss01","cv11";}
|
||||
a{color:inherit;text-decoration:none}
|
||||
button{font-family:inherit}
|
||||
::selection{background:var(--accent-soft);color:var(--ink)}
|
||||
code,kbd{font-family:var(--mono); font-size:.92em}
|
||||
|
||||
body{
|
||||
background:
|
||||
radial-gradient(1100px 600px at 12% -10%, #ecebff 0%, transparent 60%),
|
||||
radial-gradient(900px 500px at 100% 0%, #e7f3ff 0%, transparent 55%),
|
||||
radial-gradient(800px 600px at 50% 100%, #fff1f4 0%, transparent 60%),
|
||||
var(--bg);
|
||||
min-height:100vh;
|
||||
}
|
||||
#root{min-height:100vh}
|
||||
</style>
|
||||
|
||||
<script src="https://unpkg.com/react@18.3.1/umd/react.production.min.js" crossorigin></script>
|
||||
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js" crossorigin></script>
|
||||
<script src="https://unpkg.com/@babel/standalone@7.26.0/babel.min.js" crossorigin></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
<script type="text/babel" src="ui.jsx"></script>
|
||||
<script type="text/babel" src="nav.jsx"></script>
|
||||
<script type="text/babel" src="connect-app.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
130
web/data/blocked-domains.json
Normal file
130
web/data/blocked-domains.json
Normal file
@@ -0,0 +1,130 @@
|
||||
{
|
||||
"updated": "2026-05-28",
|
||||
"groups": [
|
||||
{
|
||||
"id": "media",
|
||||
"title": "Медиа",
|
||||
"icon": "🎬",
|
||||
"description": "Стриминг, видео и музыкальные сервисы",
|
||||
"domains": [
|
||||
"netflix.com",
|
||||
"youtube.com",
|
||||
"youtu.be",
|
||||
"googlevideo.com",
|
||||
"ytimg.com",
|
||||
"twitch.tv",
|
||||
"spotify.com",
|
||||
"hulu.com",
|
||||
"disneyplus.com",
|
||||
"hbomax.com",
|
||||
"soundcloud.com",
|
||||
"vimeo.com"
|
||||
],
|
||||
"ips": [
|
||||
"23.246.0.0/18",
|
||||
"208.65.153.0/24",
|
||||
"142.250.0.0/15"
|
||||
],
|
||||
"v2fly_tags": ["geosite:netflix", "geosite:youtube", "geosite:twitch", "geosite:spotify"]
|
||||
},
|
||||
{
|
||||
"id": "social",
|
||||
"title": "Социальные сети",
|
||||
"icon": "🌐",
|
||||
"description": "Соцсети и платформы Meta, X и др.",
|
||||
"domains": [
|
||||
"facebook.com",
|
||||
"instagram.com",
|
||||
"cdninstagram.com",
|
||||
"x.com",
|
||||
"twitter.com",
|
||||
"twimg.com",
|
||||
"linkedin.com",
|
||||
"pinterest.com",
|
||||
"reddit.com",
|
||||
"tumblr.com",
|
||||
"threads.net"
|
||||
],
|
||||
"ips": [
|
||||
"31.13.64.0/18",
|
||||
"104.244.42.0/24"
|
||||
],
|
||||
"v2fly_tags": ["geosite:facebook", "geosite:instagram", "geosite:twitter"]
|
||||
},
|
||||
{
|
||||
"id": "ai",
|
||||
"title": "Искусственный интеллект",
|
||||
"icon": "🤖",
|
||||
"description": "Чат-боты, LLM и AI-платформы",
|
||||
"domains": [
|
||||
"openai.com",
|
||||
"chatgpt.com",
|
||||
"chat.openai.com",
|
||||
"anthropic.com",
|
||||
"claude.ai",
|
||||
"gemini.google.com",
|
||||
"bard.google.com",
|
||||
"perplexity.ai",
|
||||
"midjourney.com",
|
||||
"huggingface.co",
|
||||
"copilot.microsoft.com",
|
||||
"poe.com"
|
||||
],
|
||||
"ips": [
|
||||
"104.18.0.0/16",
|
||||
"185.199.108.0/22"
|
||||
],
|
||||
"v2fly_tags": ["geosite:openai", "geosite:anthropic", "geosite:google-gemini"]
|
||||
},
|
||||
{
|
||||
"id": "messengers",
|
||||
"title": "Мессенджеры",
|
||||
"icon": "💬",
|
||||
"description": "Мессенджеры и голосовые чаты",
|
||||
"domains": [
|
||||
"telegram.org",
|
||||
"t.me",
|
||||
"telegram.me",
|
||||
"whatsapp.com",
|
||||
"web.whatsapp.com",
|
||||
"signal.org",
|
||||
"discord.com",
|
||||
"discordapp.com",
|
||||
"viber.com",
|
||||
"skype.com",
|
||||
"zoom.us"
|
||||
],
|
||||
"ips": [
|
||||
"91.108.4.0/22",
|
||||
"149.154.160.0/20"
|
||||
],
|
||||
"v2fly_tags": ["geosite:telegram", "geosite:whatsapp", "geosite:discord"]
|
||||
},
|
||||
{
|
||||
"id": "other",
|
||||
"title": "Другое / VPN / Dev",
|
||||
"icon": "🛠️",
|
||||
"description": "Игры, разработка, облака и инструменты",
|
||||
"domains": [
|
||||
"github.com",
|
||||
"githubusercontent.com",
|
||||
"gitlab.com",
|
||||
"docker.com",
|
||||
"cloudflare.com",
|
||||
"notion.so",
|
||||
"figma.com",
|
||||
"slack.com",
|
||||
"store.steampowered.com",
|
||||
"steampowered.com",
|
||||
"epicgames.com",
|
||||
"playstation.com",
|
||||
"xbox.com"
|
||||
],
|
||||
"ips": [
|
||||
"140.82.112.0/20",
|
||||
"104.16.0.0/13"
|
||||
],
|
||||
"v2fly_tags": ["geosite:github", "geosite:steam", "geosite:cloudflare"]
|
||||
}
|
||||
]
|
||||
}
|
||||
214
web/domains-app.jsx
Normal file
214
web/domains-app.jsx
Normal file
@@ -0,0 +1,214 @@
|
||||
// Curated blocked domains — static lists (no search)
|
||||
|
||||
const { useState, useEffect, useMemo } = React;
|
||||
|
||||
function cidrToMask(bits) {
|
||||
const n = parseInt(bits || "32", 10);
|
||||
if (n === 32) return "255.255.255.255";
|
||||
return Array.from({ length: 4 }, (_, i) => (65280 >> Math.min(8, Math.max(0, n - i * 8))) & 255).join(".");
|
||||
}
|
||||
|
||||
function buildKeeneticBat(group, allGroups) {
|
||||
const lines = ["@echo off", "rem Keenetic static routes — " + (group ? group.title : "все группы"), ""];
|
||||
const src = group ? [group] : allGroups;
|
||||
const seen = new Set();
|
||||
src.forEach((g) => {
|
||||
(g.ips || []).forEach((cidr) => {
|
||||
const [ip, bits] = cidr.split("/");
|
||||
if (!ip || seen.has(cidr)) return;
|
||||
seen.add(cidr);
|
||||
const mask = cidrToMask(bits);
|
||||
lines.push(`route ADD ${ip} MASK ${mask} 0.0.0.0 & rem ${g.title}`);
|
||||
});
|
||||
});
|
||||
if (lines.length <= 3) {
|
||||
lines.push("rem IP-префиксы не заданы — используйте список доменов вручную в VPN");
|
||||
}
|
||||
return lines.join("\r\n") + "\r\n";
|
||||
}
|
||||
|
||||
function downloadText(filename, text) {
|
||||
const blob = new Blob([text], { type: "text/plain;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
async function copyText(text) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch {
|
||||
const ta = document.createElement("textarea");
|
||||
ta.value = text;
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand("copy");
|
||||
document.body.removeChild(ta);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function DomainGroupCard({ group, onCopyDomains, onCopyIps, onDownloadBat }) {
|
||||
const [open, setOpen] = useState(true);
|
||||
const [copied, setCopied] = useState(null);
|
||||
const domainText = group.domains.join("\n");
|
||||
const ipText = (group.ips || []).join("\n");
|
||||
|
||||
async function handleCopy(kind) {
|
||||
const text = kind === "domains" ? domainText : ipText;
|
||||
if (!text) return;
|
||||
await copyText(text);
|
||||
setCopied(kind);
|
||||
onCopyDomains?.(kind);
|
||||
setTimeout(() => setCopied(null), 1800);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card padding={0} style={{ overflow: "hidden" }}>
|
||||
<button type="button" onClick={() => setOpen(!open)} style={{
|
||||
width: "100%", appearance: "none", border: 0, background: "#fff",
|
||||
padding: "20px 22px", display: "flex", alignItems: "center", justifyContent: "space-between",
|
||||
cursor: "pointer", textAlign: "left",
|
||||
}}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 14 }}>
|
||||
<span style={{ fontSize: 28 }}>{group.icon}</span>
|
||||
<div>
|
||||
<h2 style={{ margin: 0, fontSize: 20, fontWeight: 700 }}>{group.title}</h2>
|
||||
<p style={{ margin: "4px 0 0", fontSize: 13.5, color: "var(--ink-3)" }}>{group.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||
<Badge tone="neutral" size="sm">{group.domains.length} доменов</Badge>
|
||||
<Icon name={open ? "chevron-down" : "chevron-right"} size={18} color="var(--ink-3)" />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div style={{ borderTop: "1px solid var(--line-2)", padding: "18px 22px 22px" }}>
|
||||
{group.v2fly_tags?.length > 0 && (
|
||||
<div style={{ marginBottom: 14, display: "flex", flexWrap: "wrap", gap: 6 }}>
|
||||
{group.v2fly_tags.map((t) => (
|
||||
<Badge key={t} tone="purple" size="sm">{t}</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div style={{
|
||||
display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(200px, 1fr))",
|
||||
gap: 8, marginBottom: 16,
|
||||
}}>
|
||||
{group.domains.map((d) => (
|
||||
<div key={d} style={{
|
||||
padding: "10px 12px", borderRadius: 10, background: "var(--bg-tint)",
|
||||
border: "1px solid var(--line-2)", fontFamily: "var(--mono)", fontSize: 12.5,
|
||||
}}>{d}</div>
|
||||
))}
|
||||
</div>
|
||||
{(group.ips || []).length > 0 && (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 700, color: "var(--ink-3)", letterSpacing: ".1em", marginBottom: 8 }}>IP / CIDR (префиксы)</div>
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
|
||||
{group.ips.map((ip) => (
|
||||
<code key={ip} style={{
|
||||
padding: "6px 10px", borderRadius: 8, background: "var(--brand-soft)",
|
||||
fontSize: 12, fontFamily: "var(--mono)",
|
||||
}}>{ip}</code>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
|
||||
<Button variant="secondary" size="sm" icon="clipboard"
|
||||
onClick={() => handleCopy("domains")}>
|
||||
{copied === "domains" ? "Скопировано" : "Копировать домены"}
|
||||
</Button>
|
||||
{(group.ips || []).length > 0 && (
|
||||
<Button variant="secondary" size="sm" icon="clipboard"
|
||||
onClick={() => handleCopy("ips")}>
|
||||
{copied === "ips" ? "Скопировано" : "Копировать IP"}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="primary" size="sm" icon="download"
|
||||
onClick={() => onDownloadBat(group)}>
|
||||
Keenetic .bat
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function DomainsApp() {
|
||||
const [groups, setGroups] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filter, setFilter] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
fetch("data/blocked-domains.json")
|
||||
.then((r) => r.json())
|
||||
.then((data) => { setGroups(data.groups || []); setLoading(false); })
|
||||
.catch(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = filter.trim().toLowerCase();
|
||||
if (!q) return groups;
|
||||
return groups.map((g) => ({
|
||||
...g,
|
||||
domains: g.domains.filter((d) => d.includes(q) || g.title.toLowerCase().includes(q)),
|
||||
})).filter((g) => g.domains.length > 0);
|
||||
}, [groups, filter]);
|
||||
|
||||
const allDomains = groups.flatMap((g) => g.domains).join("\n");
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SiteNav brandSub="ЗАБЛОКИРОВАННЫЕ СЕРВИСЫ" />
|
||||
<section style={{ maxWidth: 960, margin: "0 auto", padding: "40px 24px 24px" }}>
|
||||
<Badge tone="blue" size="sm">Статические списки · без поиска</Badge>
|
||||
<h1 style={{ margin: "14px 0 10px", fontSize: 42, fontWeight: 800, letterSpacing: "-0.03em" }}>
|
||||
Популярные заблокированные домены
|
||||
</h1>
|
||||
<p style={{ margin: "0 0 24px", fontSize: 16, lineHeight: 1.55, color: "var(--ink-2)", maxWidth: 640 }}>
|
||||
Кураторские списки для настройки VPN и маршрутизации на роутере Keenetic.
|
||||
Поиск по crt.sh на сайте отключён — только готовые пресеты по категориям.
|
||||
</p>
|
||||
<div style={{ display: "flex", gap: 10, flexWrap: "wrap", marginBottom: 28 }}>
|
||||
<input value={filter} onChange={(e) => setFilter(e.target.value)} placeholder="Фильтр по домену…"
|
||||
style={{
|
||||
flex: "1 1 220px", height: 42, padding: "0 16px", borderRadius: 12,
|
||||
border: "1px solid var(--line)", fontSize: 14,
|
||||
}} />
|
||||
<Button variant="secondary" icon="clipboard" onClick={() => copyText(allDomains)}>
|
||||
Все домены
|
||||
</Button>
|
||||
<Button variant="primary" icon="download" onClick={() => downloadText("keenetic-all-groups.bat", buildKeeneticBat(null, groups))}>
|
||||
.bat все группы
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loading && <p style={{ color: "var(--ink-3)" }}>Загрузка списков…</p>}
|
||||
{!loading && filtered.length === 0 && (
|
||||
<Card padding={24}><p style={{ margin: 0, color: "var(--ink-3)" }}>Ничего не найдено по фильтру.</p></Card>
|
||||
)}
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||||
{filtered.map((g) => (
|
||||
<DomainGroupCard key={g.id} group={g}
|
||||
onDownloadBat={(grp) => downloadText(`keenetic-${grp.id}.bat`, buildKeeneticBat(grp, groups))} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<footer style={{ maxWidth: 960, margin: "48px auto", padding: "0 24px 40px", textAlign: "center", fontSize: 13, color: "var(--ink-3)" }}>
|
||||
<a href="connect.html" style={{ color: "var(--brand)", fontWeight: 600 }}>Кабинет студента</a>
|
||||
{" · "}
|
||||
<a href="index.html">На главную</a>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")).render(<DomainsApp />);
|
||||
39
web/domains.html
Normal file
39
web/domains.html
Normal file
@@ -0,0 +1,39 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>Группы доменов — PCA Lab</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root{
|
||||
--bg: #f4f5f7; --bg-elev: #ffffff; --bg-tint: #f9fafb;
|
||||
--ink: #1d1d1f; --ink-2: #424245; --ink-3: #6e6e73;
|
||||
--line: rgba(0,0,0,0.07); --line-2: rgba(0,0,0,0.04);
|
||||
--accent: #7c5cff; --accent-2:#5e3df1; --accent-soft:#efebff;
|
||||
--brand:#0f5dff; --brand-soft:#e6f0ff;
|
||||
--green:#34c759; --radius-card: 22px;
|
||||
--shadow-card: 0 1px 0 rgba(255,255,255,.7) inset, 0 8px 24px rgba(16,24,40,.05);
|
||||
--font: "Inter", -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
--mono: "JetBrains Mono", ui-monospace, monospace;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
html,body{margin:0;padding:0;background:var(--bg);color:var(--ink);font-family:var(--font)}
|
||||
body{background:radial-gradient(900px 500px at 100% 0%, #e7f0ff 0%, transparent 55%), var(--bg);min-height:100vh}
|
||||
#root{min-height:100vh}
|
||||
a{color:inherit;text-decoration:none}
|
||||
button{font-family:inherit}
|
||||
</style>
|
||||
<script src="https://unpkg.com/react@18.3.1/umd/react.production.min.js" crossorigin></script>
|
||||
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js" crossorigin></script>
|
||||
<script src="https://unpkg.com/@babel/standalone@7.26.0/babel.min.js" crossorigin></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="text/babel" src="ui.jsx"></script>
|
||||
<script type="text/babel" src="nav.jsx"></script>
|
||||
<script type="text/babel" src="domains-app.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
468
web/home-app.jsx
Normal file
468
web/home-app.jsx
Normal file
@@ -0,0 +1,468 @@
|
||||
// PCA Lab — cover site: AI + cybersecurity research/education
|
||||
|
||||
const { useState: useH, useEffect: useEH } = React;
|
||||
|
||||
// Nav from nav.jsx (SiteNav)
|
||||
|
||||
// ───────── Hero ─────────
|
||||
function Hero(){
|
||||
return (
|
||||
<section style={{maxWidth:1200, margin:"0 auto", padding:"72px 28px 56px"}}>
|
||||
<div style={{display:"grid", gridTemplateColumns:"1.25fr 1fr", gap:48, alignItems:"center"}}>
|
||||
<div>
|
||||
<div style={{display:"inline-flex", alignItems:"center", gap:8,
|
||||
padding:"6px 14px", borderRadius:999, background:"#fff",
|
||||
border:"1px solid var(--line)", boxShadow:"0 1px 1px rgba(16,24,40,.03)",
|
||||
fontSize:11, fontWeight:700, color:"var(--ink-2)", letterSpacing:".12em"}}>
|
||||
<span style={{width:6, height:6, borderRadius:999, background:"var(--brand)"}}/>
|
||||
ОТКРЫТЫЕ ИССЛЕДОВАНИЯ · 2026
|
||||
</div>
|
||||
<h1 style={{margin:"22px 0 18px", fontSize:68, fontWeight:800, lineHeight:0.98, letterSpacing:"-0.04em"}}>
|
||||
На стыке
|
||||
<span style={{fontFamily:"var(--serif)", fontWeight:400, fontStyle:"italic", color:"var(--brand)", letterSpacing:"-0.02em"}}>искусственного интеллекта</span>
|
||||
и <span style={{fontFamily:"var(--serif)", fontWeight:400, fontStyle:"italic"}}>кибербезопасности</span>
|
||||
</h1>
|
||||
<p style={{margin:"0 0 26px", maxWidth:540, fontSize:17, lineHeight:1.55, color:"var(--ink-2)"}}>
|
||||
PCA Lab — независимая исследовательская группа. Курсы, лонгриды и open-source инструменты
|
||||
по приватности, машинному обучению и защите сетей. Без рекламы, без слежки, без шума.
|
||||
</p>
|
||||
<div style={{display:"flex", gap:10, alignItems:"center", flexWrap:"wrap"}}>
|
||||
<Button variant="primary" size="lg" iconRight="chevron-right" style={{background:"var(--ink)", boxShadow:"0 1px 0 rgba(255,255,255,.18) inset, 0 6px 16px rgba(0,0,0,.18)"}}>Начать с курса</Button>
|
||||
<Button variant="secondary" size="lg" icon="doc">Программа</Button>
|
||||
<div style={{display:"flex", alignItems:"center", gap:8, marginLeft:8, fontSize:13, color:"var(--ink-3)"}}>
|
||||
<Icon name="check" size={14} color="var(--green)" stroke={2.5}/> 4 200+ слушателей
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hero composition — research card stack */}
|
||||
<div style={{position:"relative", height:420}}>
|
||||
<Card padding={20} style={{
|
||||
position:"absolute", inset:"0 0 auto 8%", width:"92%", transform:"rotate(-2deg)",
|
||||
background:"linear-gradient(180deg,#fff 0%,#fafbff 100%)"
|
||||
}}>
|
||||
<div style={{display:"flex", justifyContent:"space-between", alignItems:"flex-start"}}>
|
||||
<Badge tone="blue" size="sm">CRYPTOGRAPHY</Badge>
|
||||
<span style={{fontSize:11.5, color:"var(--ink-3)", fontFamily:"var(--mono)"}}>2026·05</span>
|
||||
</div>
|
||||
<h4 style={{margin:"14px 0 8px", fontSize:18, fontWeight:700, letterSpacing:"-0.01em"}}>Post-quantum handshake протоколы — практический разбор</h4>
|
||||
<p style={{margin:0, fontSize:13, color:"var(--ink-3)", lineHeight:1.5}}>
|
||||
Сравниваем Kyber, Dilithium и SPHINCS+ в условиях ограниченного канала.
|
||||
</p>
|
||||
<ChartBars/>
|
||||
</Card>
|
||||
|
||||
<Card padding={18} style={{
|
||||
position:"absolute", bottom:0, left:0, width:"58%",
|
||||
background:"linear-gradient(180deg,#1d1d1f 0%, #25262b 100%)", color:"#fff",
|
||||
border:"1px solid rgba(255,255,255,.08)", boxShadow:"0 1px 0 rgba(255,255,255,.06) inset, 0 12px 32px rgba(0,0,0,.18)",
|
||||
transform:"rotate(1.5deg)"
|
||||
}}>
|
||||
<div style={{display:"flex", alignItems:"center", gap:8, fontSize:11, fontWeight:700, color:"rgba(255,255,255,.7)", letterSpacing:".12em"}}>
|
||||
<span style={{width:6, height:6, borderRadius:999, background:"#34c759"}}/>
|
||||
LIVE COURSE
|
||||
</div>
|
||||
<h4 style={{margin:"10px 0 6px", fontSize:16, fontWeight:700, letterSpacing:"-0.01em"}}>Adversarial ML — атаки на модели</h4>
|
||||
<p style={{margin:0, fontSize:12.5, color:"rgba(255,255,255,.6)"}}>Старт 1 июня · 6 недель</p>
|
||||
<div style={{marginTop:14, display:"flex", alignItems:"center", gap:-6}}>
|
||||
{["#7c5cff","#ff9f0a","#34c759","#ff3b8a"].map((c,i)=>(
|
||||
<div key={i} style={{
|
||||
width:26, height:26, borderRadius:999, background:c, border:"2px solid #1d1d1f",
|
||||
marginLeft: i===0?0:-8, fontSize:11, color:"#fff", display:"grid", placeItems:"center", fontWeight:700
|
||||
}}>{["A","B","M","P"][i]}</div>
|
||||
))}
|
||||
<span style={{marginLeft:12, fontSize:12, color:"rgba(255,255,255,.7)"}}>+ 218 студентов</span>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card padding={16} style={{
|
||||
position:"absolute", top:"42%", right:"-2%", width:"52%",
|
||||
transform:"rotate(3deg)"
|
||||
}}>
|
||||
<div style={{fontSize:11, fontWeight:700, letterSpacing:".1em", color:"var(--amber)"}}>NETWORK SECURITY</div>
|
||||
<h4 style={{margin:"8px 0 8px", fontSize:15.5, fontWeight:700, letterSpacing:"-0.01em"}}>DPI-устойчивые туннели: обзор архитектур</h4>
|
||||
<div style={{display:"flex", alignItems:"center", gap:10, marginTop:10}}>
|
||||
<Avatar name="K" color="linear-gradient(160deg,#7ec2ff,#0a84ff)" size={28}/>
|
||||
<span style={{fontSize:12, color:"var(--ink-3)"}}>К. Линеев · 12 мин</span>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ChartBars decoration
|
||||
function ChartBars(){
|
||||
const bars = [42, 68, 55, 84, 71, 90, 76, 88, 95];
|
||||
return (
|
||||
<div style={{marginTop:18, display:"flex", alignItems:"flex-end", gap:6, height:60}}>
|
||||
{bars.map((v,i)=>(
|
||||
<div key={i} style={{
|
||||
flex:1, height: v + "%", borderRadius:6,
|
||||
background: i===bars.length-1
|
||||
? "linear-gradient(180deg,#0a84ff,#0f5dff)"
|
||||
: "var(--bg-tint)",
|
||||
border: i===bars.length-1 ? "0" : "1px solid var(--line)"
|
||||
}}/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ───────── Topic grid ─────────
|
||||
function Topics(){
|
||||
const items = [
|
||||
{ tag:"AI SAFETY", title:"Безопасность языковых моделей", desc:"Prompt injection, jailbreaks, защитные слои и oversight-механизмы.",
|
||||
color:"linear-gradient(160deg,#9b82ff,#6a44f5)", icon:"sparkles", articles:14},
|
||||
{ tag:"NETWORK", title:"Современные сетевые протоколы", desc:"WireGuard, QUIC, HTTP/3, TLS 1.3 — как они устроены и где ломаются.",
|
||||
color:"linear-gradient(160deg,#7ec2ff,#0a84ff)", icon:"globe", articles:22},
|
||||
{ tag:"CRYPTOGRAPHY", title:"Прикладная криптография", desc:"От симметричных шифров до post-quantum, с практическими лабами.",
|
||||
color:"linear-gradient(160deg,#1d1d1f,#3a3a3c)", icon:"lock", articles:31},
|
||||
{ tag:"PRIVACY", title:"Приватность по умолчанию", desc:"Threat modeling, цифровая гигиена, OPSEC для исследователей.",
|
||||
color:"linear-gradient(160deg,#34c759,#1f8a5b)", icon:"shield", articles:18},
|
||||
{ tag:"MACHINE LEARNING", title:"Машинное обучение с нуля", desc:"Линейная алгебра, оптимизация и трансформеры — для практиков.",
|
||||
color:"linear-gradient(160deg,#ff9f0a,#ff6b00)", icon:"brain", articles:27},
|
||||
{ tag:"OFFENSIVE", title:"Red-team & пентест", desc:"Этичный взлом: methodology, инструменты, отчёты.",
|
||||
color:"linear-gradient(160deg,#ff3b8a,#9b51e0)", icon:"bolt", articles:12},
|
||||
];
|
||||
return (
|
||||
<section style={{maxWidth:1200, margin:"24px auto 0", padding:"0 28px"}}>
|
||||
<SectionHeader
|
||||
title="Направления"
|
||||
subtitle="Шесть треков, каждый сопровождается курсами, лонгридами и открытыми инструментами"
|
||||
right={<Button variant="secondary" iconRight="chevron-right">Все направления</Button>}/>
|
||||
<div style={{display:"grid", gridTemplateColumns:"repeat(3,1fr)", gap:18}}>
|
||||
{items.map((t,i)=>(
|
||||
<Card key={i} padding={22} hover>
|
||||
<div style={{display:"flex", alignItems:"flex-start", justifyContent:"space-between"}}>
|
||||
<GlyphTile size={44} radius={12} gradient={t.color} icon={t.icon}/>
|
||||
<span style={{
|
||||
fontSize:11, fontWeight:600, color:"var(--ink-3)", fontVariantNumeric:"tabular-nums",
|
||||
padding:"4px 10px", borderRadius:999, background:"var(--bg-tint)"
|
||||
}}>{t.articles} материалов</span>
|
||||
</div>
|
||||
<div style={{marginTop:18, fontSize:10.5, fontWeight:700, color:"var(--ink-3)", letterSpacing:".12em"}}>
|
||||
{t.tag}
|
||||
</div>
|
||||
<h3 style={{margin:"6px 0 8px", fontSize:19, fontWeight:700, letterSpacing:"-0.015em"}}>{t.title}</h3>
|
||||
<p style={{margin:0, fontSize:13.5, color:"var(--ink-3)", lineHeight:1.55}}>{t.desc}</p>
|
||||
<a href="#" style={{
|
||||
marginTop:14, display:"inline-flex", alignItems:"center", gap:5,
|
||||
fontSize:13, fontWeight:600, color:"var(--ink-2)"
|
||||
}}>Изучить трек <Icon name="chevron-right" size={13} stroke={2.2}/></a>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ───────── Featured Article + Sidebar list ─────────
|
||||
function Articles(){
|
||||
return (
|
||||
<section style={{maxWidth:1200, margin:"72px auto 0", padding:"0 28px"}}>
|
||||
<SectionHeader
|
||||
title="Последние материалы"
|
||||
subtitle="Лонгриды, лабораторные тетради, разборы инцидентов"
|
||||
right={<Button variant="secondary" iconRight="chevron-right">Архив</Button>}/>
|
||||
|
||||
<div style={{display:"grid", gridTemplateColumns:"1.4fr 1fr", gap:18}}>
|
||||
{/* Featured */}
|
||||
<Card padding={0} hover style={{overflow:"hidden"}}>
|
||||
<div style={{
|
||||
height:280, position:"relative",
|
||||
background:"linear-gradient(135deg,#1d1d1f 0%, #2d2d33 60%, #0f5dff 100%)",
|
||||
overflow:"hidden"
|
||||
}}>
|
||||
<div style={{position:"absolute", inset:0,
|
||||
backgroundImage:"radial-gradient(circle at 20% 30%, rgba(255,255,255,.18) 0%, transparent 50%), radial-gradient(circle at 80% 70%, rgba(15,93,255,.4) 0%, transparent 50%)",
|
||||
pointerEvents:"none"}}/>
|
||||
<CodeRain/>
|
||||
<div style={{position:"absolute", top:20, left:20}}>
|
||||
<Badge tone="blue" style={{background:"rgba(255,255,255,.92)", color:"#0a4ea3"}}>FEATURED · LONGREAD</Badge>
|
||||
</div>
|
||||
<div style={{position:"absolute", bottom:20, left:20, right:20, color:"#fff"}}>
|
||||
<div style={{fontSize:11.5, fontWeight:600, opacity:.7, letterSpacing:".1em"}}>15 МАЯ 2026 · 22 МИН ЧТЕНИЯ</div>
|
||||
<h3 style={{margin:"6px 0 0", fontSize:26, fontWeight:700, letterSpacing:"-0.02em", lineHeight:1.15}}>
|
||||
Почему DPI всё чаще ошибается: разбор шести популярных эвристик
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{padding:"20px 22px"}}>
|
||||
<p style={{margin:0, fontSize:14, color:"var(--ink-2)", lineHeight:1.55}}>
|
||||
Современные системы глубокой инспекции пакетов проигрывают не из-за слабого железа,
|
||||
а из-за переусложнённой эвристики. Разбираем причины и считаем false-positive rate на открытых датасетах.
|
||||
</p>
|
||||
<div style={{marginTop:14, display:"flex", alignItems:"center", gap:10}}>
|
||||
<Avatar name="K" color="linear-gradient(160deg,#7ec2ff,#0a84ff)" size={32}/>
|
||||
<div style={{lineHeight:1.2}}>
|
||||
<div style={{fontSize:13.5, fontWeight:600}}>Константин Линеев</div>
|
||||
<div style={{fontSize:12, color:"var(--ink-3)"}}>Senior researcher, Network team</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* List of small articles */}
|
||||
<div style={{display:"flex", flexDirection:"column", gap:14}}>
|
||||
{[
|
||||
{tag:"AI SAFETY", date:"12 мая", read:"9 мин", title:"Constitutional AI: как обучают модели по тексту правил", color:"linear-gradient(160deg,#9b82ff,#6a44f5)", author:"А. Морозова"},
|
||||
{tag:"PRIVACY", date:"08 мая", read:"6 мин", title:"Threat modeling для журналистов и активистов", color:"linear-gradient(160deg,#34c759,#1f8a5b)", author:"М. Петренко"},
|
||||
{tag:"CRYPTO", date:"03 мая", read:"14 мин", title:"Подписи Ed25519 — что в них особенного", color:"linear-gradient(160deg,#1d1d1f,#3a3a3c)", author:"Д. Богданов"},
|
||||
{tag:"OFFENSIVE", date:"29 апр", read:"11 мин", title:"Lateral movement в облачных средах: AWS edition", color:"linear-gradient(160deg,#ff3b8a,#9b51e0)", author:"Е. Лазарев"},
|
||||
].map((a,i)=>(
|
||||
<Card key={i} padding={16} hover>
|
||||
<div style={{display:"flex", gap:14, alignItems:"flex-start"}}>
|
||||
<div style={{
|
||||
width:48, height:48, borderRadius:12, flexShrink:0,
|
||||
background:a.color, position:"relative", overflow:"hidden"
|
||||
}}>
|
||||
<div style={{position:"absolute", inset:0, opacity:.4,
|
||||
backgroundImage:"radial-gradient(circle at 30% 30%, rgba(255,255,255,.4), transparent 60%)"}}/>
|
||||
</div>
|
||||
<div style={{flex:1, minWidth:0}}>
|
||||
<div style={{display:"flex", alignItems:"center", gap:8, fontSize:10.5, color:"var(--ink-3)"}}>
|
||||
<span style={{fontWeight:700, letterSpacing:".1em"}}>{a.tag}</span>
|
||||
<span style={{opacity:.4}}>·</span>
|
||||
<span>{a.date}</span>
|
||||
<span style={{opacity:.4}}>·</span>
|
||||
<span>{a.read}</span>
|
||||
</div>
|
||||
<h4 style={{margin:"5px 0 4px", fontSize:15, fontWeight:700, letterSpacing:"-0.01em", lineHeight:1.25}}>{a.title}</h4>
|
||||
<div style={{fontSize:12, color:"var(--ink-3)"}}>{a.author}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function CodeRain(){
|
||||
// Static "code lines" decoration
|
||||
const lines = [
|
||||
"$ openssl genpkey -algorithm Ed25519",
|
||||
"→ key.pem · 64 bytes",
|
||||
"$ sha256sum key.pem",
|
||||
"8a3f…d2c1 key.pem",
|
||||
"$ ./detect --input pcap/sample.pcap",
|
||||
"matched: 0 · false-positive: 12%",
|
||||
];
|
||||
return (
|
||||
<div style={{
|
||||
position:"absolute", right:18, bottom:80, fontFamily:"var(--mono)",
|
||||
fontSize:11, color:"rgba(255,255,255,.42)", lineHeight:1.6,
|
||||
maxWidth:"50%", textAlign:"right"
|
||||
}}>
|
||||
{lines.map((l,i)=><div key={i}>{l}</div>)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ───────── Courses CTA ─────────
|
||||
function Courses(){
|
||||
return (
|
||||
<section style={{maxWidth:1200, margin:"72px auto 0", padding:"0 28px"}}>
|
||||
<SectionHeader title="Курсы — открытый набор"
|
||||
subtitle="Записываемся бесплатно, материалы остаются у вас навсегда"/>
|
||||
<div style={{display:"grid", gridTemplateColumns:"repeat(4,1fr)", gap:18}}>
|
||||
{[
|
||||
{weeks:8, name:"Введение в нейросети", lvl:"beginner", lvlT:"green", n:1240, color:"linear-gradient(160deg,#9b82ff,#6a44f5)", icon:"brain"},
|
||||
{weeks:6, name:"Cybersec для разработчиков", lvl:"intermediate", lvlT:"blue", n:864, color:"linear-gradient(160deg,#7ec2ff,#0a84ff)", icon:"shield"},
|
||||
{weeks:12, name:"Криптография в продакшене", lvl:"advanced", lvlT:"purple", n:412, color:"linear-gradient(160deg,#1d1d1f,#3a3a3c)", icon:"lock"},
|
||||
{weeks:4, name:"OPSEC и приватность", lvl:"beginner", lvlT:"green", n:1820, color:"linear-gradient(160deg,#34c759,#1f8a5b)", icon:"shield"},
|
||||
].map((c,i)=>(
|
||||
<Card key={i} padding={20} hover>
|
||||
<GlyphTile size={42} radius={12} gradient={c.color} icon={c.icon}/>
|
||||
<h4 style={{margin:"14px 0 6px", fontSize:16, fontWeight:700, letterSpacing:"-0.01em", minHeight:42, lineHeight:1.3}}>{c.name}</h4>
|
||||
<div style={{display:"flex", alignItems:"center", gap:8, fontSize:12, color:"var(--ink-3)"}}>
|
||||
<Icon name="clock" size={13}/> {c.weeks} недель
|
||||
<span style={{opacity:.4}}>·</span>
|
||||
<Icon name="users" size={13}/> {c.n}
|
||||
</div>
|
||||
<div style={{marginTop:14, paddingTop:14, borderTop:"1px solid var(--line-2)",
|
||||
display:"flex", alignItems:"center", justifyContent:"space-between"}}>
|
||||
<Badge tone={c.lvlT} size="sm">{c.lvl}</Badge>
|
||||
<a href="#" style={{fontSize:13, fontWeight:600, color:"var(--ink-2)",
|
||||
display:"inline-flex", alignItems:"center", gap:4}}>
|
||||
Записаться <Icon name="chevron-right" size={13} stroke={2.2}/>
|
||||
</a>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ───────── Newsletter / CTA ─────────
|
||||
function Newsletter(){
|
||||
return (
|
||||
<section style={{maxWidth:1200, margin:"72px auto 0", padding:"0 28px"}}>
|
||||
<div style={{
|
||||
position:"relative", overflow:"hidden",
|
||||
borderRadius:"calc(var(--radius-card) + 6px)",
|
||||
background:"linear-gradient(135deg,#0a0a0b 0%, #1d1d1f 60%, #0f5dff 200%)",
|
||||
color:"#fff", padding:"48px 48px", border:"1px solid rgba(255,255,255,.06)",
|
||||
boxShadow:"0 1px 0 rgba(255,255,255,.06) inset, 0 18px 50px rgba(15,93,255,.18)"
|
||||
}}>
|
||||
<div style={{position:"absolute", inset:0, opacity:.6,
|
||||
background:"radial-gradient(500px 280px at 100% 100%, rgba(15,93,255,.4), transparent 60%), radial-gradient(400px 220px at 0% 0%, rgba(155,82,224,.18), transparent 60%)"}}/>
|
||||
<div style={{position:"relative", display:"grid", gridTemplateColumns:"1.2fr 1fr", gap:36, alignItems:"center"}}>
|
||||
<div>
|
||||
<div style={{fontSize:11, fontWeight:700, color:"rgba(255,255,255,.6)", letterSpacing:".14em"}}>
|
||||
· NEWSLETTER · ПО ЧЕТВЕРГАМ ·
|
||||
</div>
|
||||
<h2 style={{margin:"12px 0 10px", fontSize:34, fontWeight:700, letterSpacing:"-0.02em", lineHeight:1.1}}>
|
||||
Раз в неделю — лучшие материалы и одна короткая мысль
|
||||
</h2>
|
||||
<p style={{margin:0, fontSize:14.5, color:"rgba(255,255,255,.7)", maxWidth:480, lineHeight:1.55}}>
|
||||
Без рекламы и продаж курсов. Только то, что мы сами читаем и используем в работе.
|
||||
</p>
|
||||
</div>
|
||||
<form style={{display:"flex", gap:10}} onSubmit={(e)=>e.preventDefault()}>
|
||||
<input type="email" placeholder="your@email.com" style={{
|
||||
flex:1, height:48, padding:"0 18px",
|
||||
border:"1px solid rgba(255,255,255,.14)", background:"rgba(255,255,255,.06)",
|
||||
backdropFilter:"blur(10px)", borderRadius:14, color:"#fff",
|
||||
fontSize:14.5, outline:"none"
|
||||
}}/>
|
||||
<button style={{
|
||||
appearance:"none", border:0, height:48, padding:"0 22px",
|
||||
background:"#fff", color:"#0a0a0b", fontSize:14, fontWeight:700,
|
||||
borderRadius:14, cursor:"pointer"
|
||||
}}>Подписаться →</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ───────── Footer ─────────
|
||||
function Footer(){
|
||||
return (
|
||||
<footer style={{maxWidth:1200, margin:"72px auto 0", padding:"32px 28px 48px"}}>
|
||||
<div style={{display:"grid", gridTemplateColumns:"1.5fr 1fr 1fr 1fr 1fr", gap:32, paddingBottom:32, borderBottom:"1px solid var(--line-2)"}}>
|
||||
<div>
|
||||
<div style={{display:"flex", alignItems:"center", gap:10}}>
|
||||
<div style={{width:32, height:32, borderRadius:10, background:"linear-gradient(160deg,#1d1d1f,#2d2d33)",
|
||||
display:"grid", placeItems:"center", color:"#fff"}}>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="9" stroke="#fff" strokeWidth="1.6"/>
|
||||
<path d="M8 7l4 10 4-10" stroke="#fff" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div style={{fontSize:15, fontWeight:700, letterSpacing:"-0.01em"}}>PCA Lab</div>
|
||||
</div>
|
||||
<p style={{margin:"14px 0 14px", fontSize:13, color:"var(--ink-3)", maxWidth:280, lineHeight:1.55}}>
|
||||
Независимая исследовательская группа. Курсы, открытые статьи и инструменты
|
||||
по приватности и AI с 2022 года.
|
||||
</p>
|
||||
<div style={{display:"flex", gap:8}}>
|
||||
{[
|
||||
{icon:"github", l:"GitHub"},
|
||||
{icon:"plane", l:"Telegram"},
|
||||
{icon:"doc", l:"RSS"},
|
||||
].map(s=>(
|
||||
<a key={s.l} href="#" title={s.l} style={{
|
||||
width:34, height:34, borderRadius:10, background:"#fff",
|
||||
border:"1px solid var(--line)", display:"grid", placeItems:"center",
|
||||
color:"var(--ink-2)"
|
||||
}}><Icon name={s.icon} size={15}/></a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FootCol title="Контент" links={["Главная","Курсы","Исследования","Блог","Авторы"]}/>
|
||||
<FootCol title="Темы" links={["AI Safety","Network","Cryptography","Privacy","ML","Red-team"]}/>
|
||||
<FootCol title="Лаборатория" links={["О нас","Резиденты","Партнёры","Контакты","Открытые позиции"]}/>
|
||||
<FootCol title="Студентам" links={[
|
||||
{ l:"Программа курса" },
|
||||
{ l:"FAQ" },
|
||||
{ l:"Помощь техподдержки" },
|
||||
{ l:"Кабинет студента", href:"connect.html", highlight:true },
|
||||
{ l:"Группы доменов", href:"domains.html" },
|
||||
{ l:"VPN по региону", href:"connect.html?mode=region" },
|
||||
]}/>
|
||||
</div>
|
||||
|
||||
<div style={{paddingTop:24, display:"flex", justifyContent:"space-between", alignItems:"center", fontSize:12.5, color:"var(--ink-3)", flexWrap:"wrap", gap:14}}>
|
||||
<div>© 2022–2026 PCA Lab · Все материалы — под лицензией CC BY-SA 4.0</div>
|
||||
<div style={{display:"flex", gap:18}}>
|
||||
<a href="#" style={{color:"var(--ink-3)"}}>Конфиденциальность</a>
|
||||
<a href="#" style={{color:"var(--ink-3)"}}>Условия</a>
|
||||
<a href="#" style={{color:"var(--ink-3)"}}>RSS</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
function FootCol({ title, links }){
|
||||
return (
|
||||
<div>
|
||||
<div style={{fontSize:11, fontWeight:700, color:"var(--ink-3)", letterSpacing:".14em", textTransform:"uppercase", marginBottom:14}}>{title}</div>
|
||||
<ul style={{margin:0, padding:0, listStyle:"none", display:"flex", flexDirection:"column", gap:10}}>
|
||||
{links.map((l,i)=>{
|
||||
const item = typeof l === "string" ? { l } : l;
|
||||
return (
|
||||
<li key={i}>
|
||||
<a href={item.href || "#"} style={{
|
||||
fontSize:13.5, color: item.highlight ? "var(--ink)" : "var(--ink-2)",
|
||||
fontWeight: item.highlight ? 600 : 500,
|
||||
display:"inline-flex", alignItems:"center", gap:6,
|
||||
}}>
|
||||
{item.l}
|
||||
{item.highlight && <Icon name="chevron-right" size={12} stroke={2.2} color="var(--ink-3)"/>}
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ───────── Stats strip ─────────
|
||||
function StatsStrip(){
|
||||
return (
|
||||
<section style={{maxWidth:1200, margin:"40px auto 0", padding:"0 28px"}}>
|
||||
<div style={{display:"grid", gridTemplateColumns:"repeat(4, 1fr)", gap:0,
|
||||
background:"#fff", border:"1px solid var(--line)", borderRadius:18,
|
||||
boxShadow:"var(--shadow-card)", overflow:"hidden"}}>
|
||||
{[
|
||||
{v:"124", l:"открытых статей"},
|
||||
{v:"12", l:"живых курсов"},
|
||||
{v:"4 200+", l:"слушателей"},
|
||||
{v:"32", l:"open-source проекта"},
|
||||
].map((s,i)=>(
|
||||
<div key={i} style={{padding:"22px 24px", borderLeft: i===0 ? "0" : "1px solid var(--line-2)"}}>
|
||||
<div style={{fontSize:30, fontWeight:700, letterSpacing:"-0.02em", fontVariantNumeric:"tabular-nums"}}>{s.v}</div>
|
||||
<div style={{marginTop:2, fontSize:13, color:"var(--ink-3)"}}>{s.l}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ───────── App ─────────
|
||||
function HomeApp(){
|
||||
return (
|
||||
<div>
|
||||
<SiteNav/>
|
||||
<Hero/>
|
||||
<StatsStrip/>
|
||||
<Topics/>
|
||||
<Articles/>
|
||||
<Courses/>
|
||||
<Newsletter/>
|
||||
<Footer/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")).render(<HomeApp/>);
|
||||
67
web/index.html
Normal file
67
web/index.html
Normal file
@@ -0,0 +1,67 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>PCA Lab — Исследования в области AI и кибербезопасности</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500&family=Instrument+Serif&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root{
|
||||
--bg: #f4f5f7;
|
||||
--bg-elev: #ffffff;
|
||||
--bg-tint: #f9fafb;
|
||||
--ink: #1d1d1f;
|
||||
--ink-2: #424245;
|
||||
--ink-3: #6e6e73;
|
||||
--ink-4: #8e8e93;
|
||||
--line: rgba(0,0,0,0.07);
|
||||
--line-2: rgba(0,0,0,0.04);
|
||||
--accent: #1d1d1f; /* serious editorial black */
|
||||
--accent-2:#0a0a0b;
|
||||
--accent-soft:#f1f2f6;
|
||||
--brand:#0f5dff; /* tech blue */
|
||||
--brand-soft:#e6f0ff;
|
||||
--green:#1f7a3a;
|
||||
--green-soft:#e4f8ea;
|
||||
--amber:#a36a00;
|
||||
--amber-soft:#fff2dc;
|
||||
--radius-card: 22px;
|
||||
--radius-inner: 14px;
|
||||
--shadow-card: 0 1px 0 rgba(255,255,255,.7) inset, 0 1px 2px rgba(16,24,40,.04), 0 8px 24px rgba(16,24,40,.05);
|
||||
--shadow-pop: 0 1px 0 rgba(255,255,255,.7) inset, 0 4px 14px rgba(16,24,40,.08), 0 24px 56px rgba(16,24,40,.10);
|
||||
--font: "Inter", -apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue", Arial, sans-serif;
|
||||
--serif: "Instrument Serif", "Times New Roman", Georgia, serif;
|
||||
--mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
html,body{margin:0;padding:0;background:var(--bg);color:var(--ink);font-family:var(--font);
|
||||
-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility;
|
||||
font-feature-settings:"ss01","cv11";}
|
||||
a{color:inherit;text-decoration:none}
|
||||
button{font-family:inherit}
|
||||
::selection{background:var(--accent-soft);color:var(--ink)}
|
||||
|
||||
body{
|
||||
background:
|
||||
radial-gradient(900px 500px at 110% -10%, #e7f0ff 0%, transparent 55%),
|
||||
radial-gradient(900px 500px at -10% 30%, #f1eaff 0%, transparent 55%),
|
||||
var(--bg);
|
||||
min-height:100vh;
|
||||
}
|
||||
#root{min-height:100vh}
|
||||
</style>
|
||||
|
||||
<script src="https://unpkg.com/react@18.3.1/umd/react.production.min.js" crossorigin></script>
|
||||
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js" crossorigin></script>
|
||||
<script src="https://unpkg.com/@babel/standalone@7.26.0/babel.min.js" crossorigin></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
<script type="text/babel" src="ui.jsx"></script>
|
||||
<script type="text/babel" src="nav.jsx"></script>
|
||||
<script type="text/babel" src="home-app.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
57
web/nav.jsx
Normal file
57
web/nav.jsx
Normal file
@@ -0,0 +1,57 @@
|
||||
// Shared site navigation
|
||||
|
||||
const SITE_NAV = [
|
||||
{ href: "index.html", label: "Главная", match: (p) => /index\.html$|^\/$/.test(p) },
|
||||
{ href: "connect.html", label: "Кабинет студента", match: (p) => /connect\.html$/.test(p) && !/mode=region/.test(location.search) },
|
||||
{ href: "connect.html?mode=region", label: "VPN по региону", match: (p) => /connect\.html/.test(p) && /mode=region/.test(location.search) },
|
||||
{ href: "domains.html", label: "Группы доменов", match: (p) => /domains\.html$/.test(p) },
|
||||
];
|
||||
|
||||
function SiteNav({ brand = "PCA Lab", brandSub = "RESEARCH · EDUCATION" }) {
|
||||
const path = typeof location !== "undefined" ? (location.pathname + (location.search || "")) : "";
|
||||
return (
|
||||
<header style={{
|
||||
position: "sticky", top: 0, zIndex: 50,
|
||||
backdropFilter: "saturate(180%) blur(20px)",
|
||||
WebkitBackdropFilter: "saturate(180%) blur(20px)",
|
||||
background: "rgba(244,245,247,0.78)",
|
||||
borderBottom: "1px solid var(--line)",
|
||||
}}>
|
||||
<div style={{
|
||||
maxWidth: 1200, margin: "0 auto", padding: "12px 20px",
|
||||
display: "flex", alignItems: "center", justifyContent: "space-between", gap: 16, flexWrap: "wrap",
|
||||
}}>
|
||||
<a href="index.html" style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||
<div style={{
|
||||
width: 36, height: 36, borderRadius: 11,
|
||||
background: "linear-gradient(160deg,#1d1d1f 0%, #2d2d33 100%)",
|
||||
display: "grid", placeItems: "center", color: "#fff",
|
||||
}}>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="9" stroke="#fff" strokeWidth="1.6" />
|
||||
<path d="M8 7l4 10 4-10" stroke="#fff" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
<div style={{ lineHeight: 1.05 }}>
|
||||
<div style={{ fontSize: 16, fontWeight: 700 }}>{brand}</div>
|
||||
<div style={{ fontSize: 10.5, color: "var(--ink-3)", letterSpacing: ".14em", fontWeight: 600 }}>{brandSub}</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<nav style={{ display: "flex", alignItems: "center", gap: 2, flexWrap: "wrap" }}>
|
||||
{SITE_NAV.map((item, i) => {
|
||||
const active = item.match(path + (location.search || ""));
|
||||
return (
|
||||
<a key={i} href={item.href} style={{
|
||||
padding: "8px 12px", fontSize: 13, fontWeight: active ? 600 : 500,
|
||||
color: active ? "var(--ink)" : "var(--ink-2)",
|
||||
borderRadius: 999, background: active ? "#fff" : "transparent",
|
||||
border: active ? "1px solid var(--line)" : "1px solid transparent",
|
||||
}}>{item.label}</a>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
281
web/ui.jsx
Normal file
281
web/ui.jsx
Normal file
@@ -0,0 +1,281 @@
|
||||
// Shared UI primitives — Apple-inspired light system
|
||||
|
||||
const { useState, useEffect, useRef, useMemo } = React;
|
||||
|
||||
// ───────── Icons (line, 1.6 stroke, rounded) ─────────
|
||||
function Icon({ name, size = 18, stroke = 1.6, color = "currentColor" }) {
|
||||
const props = { width: size, height: size, viewBox: "0 0 24 24", fill: "none",
|
||||
stroke: color, strokeWidth: stroke, strokeLinecap: "round", strokeLinejoin: "round" };
|
||||
switch (name) {
|
||||
case "server": return (<svg {...props}><rect x="3" y="4" width="18" height="7" rx="2"/><rect x="3" y="13" width="18" height="7" rx="2"/><circle cx="7" cy="7.5" r=".6" fill={color}/><circle cx="7" cy="16.5" r=".6" fill={color}/></svg>);
|
||||
case "users": return (<svg {...props}><circle cx="9" cy="8" r="3.5"/><path d="M3 20c0-3.3 2.7-6 6-6s6 2.7 6 6"/><path d="M16 11.5a3 3 0 0 0 0-6"/><path d="M21 20c0-2.5-1.8-4.6-4.2-5"/></svg>);
|
||||
case "settings": return (<svg {...props}><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"/></svg>);
|
||||
case "link": return (<svg {...props}><path d="M10 14a5 5 0 0 0 7.07 0l3-3a5 5 0 1 0-7.07-7.07L11.5 5.5"/><path d="M14 10a5 5 0 0 0-7.07 0l-3 3a5 5 0 1 0 7.07 7.07L12.5 18.5"/></svg>);
|
||||
case "lock": return (<svg {...props}><rect x="4" y="11" width="16" height="10" rx="2.5"/><path d="M8 11V7a4 4 0 1 1 8 0v4"/></svg>);
|
||||
case "tools": return (<svg {...props}><path d="M14.7 6.3a4 4 0 0 0 5 5L21 12.7a8 8 0 0 1-9.4 1.4l-6.3 6.3a2.1 2.1 0 1 1-3-3l6.3-6.3A8 8 0 0 1 10 1.7L11.3 3a4 4 0 0 0 3.4 3.3z"/></svg>);
|
||||
case "gauge": return (<svg {...props}><path d="M12 14l4-4"/><circle cx="12" cy="14" r="9"/><path d="M3 14a9 9 0 0 1 18 0"/></svg>);
|
||||
case "check": return (<svg {...props}><polyline points="4 12 10 18 20 6"/></svg>);
|
||||
case "plus": return (<svg {...props}><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>);
|
||||
case "chevron-down": return (<svg {...props}><polyline points="6 9 12 15 18 9"/></svg>);
|
||||
case "chevron-right": return (<svg {...props}><polyline points="9 6 15 12 9 18"/></svg>);
|
||||
case "back": return (<svg {...props}><line x1="20" y1="12" x2="4" y2="12"/><polyline points="10 18 4 12 10 6"/></svg>);
|
||||
case "trash": return (<svg {...props}><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6M14 11v6"/><path d="M9 6V4a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v2"/></svg>);
|
||||
case "config": return (<svg {...props}><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="8" y1="13" x2="16" y2="13"/><line x1="8" y1="17" x2="13" y2="17"/></svg>);
|
||||
case "stop": return (<svg {...props}><rect x="6" y="6" width="12" height="12" rx="2"/></svg>);
|
||||
case "play": return (<svg {...props}><polygon points="6 4 20 12 6 20 6 4"/></svg>);
|
||||
case "qr": return (<svg {...props}><rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><path d="M14 14h3v3h-3zM20 14v3M14 20h3M20 20v1"/></svg>);
|
||||
case "calendar": return (<svg {...props}><rect x="3" y="5" width="18" height="16" rx="2.5"/><line x1="3" y1="10" x2="21" y2="10"/><line x1="8" y1="3" x2="8" y2="7"/><line x1="16" y1="3" x2="16" y2="7"/></svg>);
|
||||
case "clock": return (<svg {...props}><circle cx="12" cy="12" r="9"/><polyline points="12 7 12 12 15.5 14"/></svg>);
|
||||
case "download": return (<svg {...props}><path d="M12 4v12"/><polyline points="7 11 12 16 17 11"/><path d="M5 20h14"/></svg>);
|
||||
case "upload": return (<svg {...props}><path d="M12 20V8"/><polyline points="7 13 12 8 17 13"/><path d="M5 4h14"/></svg>);
|
||||
case "cpu": return (<svg {...props}><rect x="6" y="6" width="12" height="12" rx="2"/><rect x="9" y="9" width="6" height="6" rx="1"/><path d="M10 2v3M14 2v3M10 19v3M14 19v3M2 10h3M2 14h3M19 10h3M19 14h3"/></svg>);
|
||||
case "ram": return (<svg {...props}><rect x="3" y="7" width="18" height="10" rx="2"/><path d="M7 7v10M11 7v10M15 7v10M19 7v10"/></svg>);
|
||||
case "disk": return (<svg {...props}><rect x="3" y="4" width="18" height="16" rx="2.5"/><circle cx="12" cy="12" r="4"/><circle cx="12" cy="12" r=".8" fill={color}/></svg>);
|
||||
case "globe": return (<svg {...props}><circle cx="12" cy="12" r="9"/><path d="M3 12h18"/><path d="M12 3a14 14 0 0 1 0 18"/><path d="M12 3a14 14 0 0 0 0 18"/></svg>);
|
||||
case "refresh": return (<svg {...props}><polyline points="20 4 20 10 14 10"/><polyline points="4 20 4 14 10 14"/><path d="M20 10a8 8 0 0 0-14.9-2"/><path d="M4 14a8 8 0 0 0 14.9 2"/></svg>);
|
||||
case "logout": return (<svg {...props}><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>);
|
||||
case "sun": return (<svg {...props}><circle cx="12" cy="12" r="4"/><path d="M12 3v2M12 19v2M3 12h2M19 12h2M5.6 5.6l1.4 1.4M17 17l1.4 1.4M5.6 18.4l1.4-1.4M17 7l1.4-1.4"/></svg>);
|
||||
case "moon": return (<svg {...props}><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>);
|
||||
case "key": return (<svg {...props}><circle cx="8" cy="15" r="4"/><path d="M11 12l9-9M16 7l3 3"/></svg>);
|
||||
case "sparkles": return (<svg {...props}><path d="M12 3l1.6 4.4L18 9l-4.4 1.6L12 15l-1.6-4.4L6 9l4.4-1.6z"/><path d="M19 14l.7 1.8L21.5 16.5l-1.8.7L19 19l-.7-1.8L16.5 16.5l1.8-.7z"/></svg>);
|
||||
case "satellite":return (<svg {...props}><path d="M5 19l5-5"/><path d="M14 4l6 6-5 5-6-6z"/><path d="M9 9l-3 3 3 3 3-3"/><path d="M17 17a4 4 0 0 0-4-4"/><path d="M20 17a7 7 0 0 0-7-7"/></svg>);
|
||||
case "bolt": return (<svg {...props}><polygon points="13 2 4 14 11 14 10 22 20 9 13 9 13 2"/></svg>);
|
||||
case "plane": return (<svg {...props}><path d="M21 14l-7-1-3 8-2-1 1-7-7-3 8-3-1-6 2-1 3 7 7-1 1 2-6 3 4 5z"/></svg>);
|
||||
case "shield": return (<svg {...props}><path d="M12 3l8 3v6c0 4.5-3.3 8.7-8 9-4.7-.3-8-4.5-8-9V6z"/></svg>);
|
||||
case "wand": return (<svg {...props}><path d="M3 21l14-14"/><path d="M17 3l1 2 2 1-2 1-1 2-1-2-2-1 2-1z"/><path d="M5 7l.6 1.4L7 9l-1.4.6L5 11l-.6-1.4L3 9l1.4-.6z"/></svg>);
|
||||
case "brain": return (<svg {...props}><path d="M9 4a3 3 0 0 0-3 3 3 3 0 0 0-1 5.7 3 3 0 0 0 1 4.6A3 3 0 0 0 9 20a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2z"/><path d="M15 4a3 3 0 0 1 3 3 3 3 0 0 1 1 5.7 3 3 0 0 1-1 4.6 3 3 0 0 1-3 2.7 2 2 0 0 1-2-2V6a2 2 0 0 1 2-2z"/></svg>);
|
||||
case "star": return (<svg {...props}><polygon points="12 3 14.5 9 21 9.5 16 14 17.5 21 12 17.5 6.5 21 8 14 3 9.5 9.5 9"/></svg>);
|
||||
case "github": return (<svg {...props}><path d="M9 19c-4 1.5-4-2-6-2.5M15 22v-3.9a3.4 3.4 0 0 0-.9-2.6c3 0 6-2 6-5.5a4.3 4.3 0 0 0-1.2-3 4 4 0 0 0-.1-3.1S17.2 3 15 4.4a13 13 0 0 0-7 0C5.8 3 4.7 3.4 4.7 3.4a4 4 0 0 0-.1 3.1A4.3 4.3 0 0 0 3.5 9.5C3.5 13 6.5 15 9.5 15a3.4 3.4 0 0 0-.9 2.6V22"/></svg>);
|
||||
case "doc": return (<svg {...props}><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>);
|
||||
case "edit": return (<svg {...props}><path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z"/></svg>);
|
||||
case "more": return (<svg {...props}><circle cx="5" cy="12" r="1.3" fill={color}/><circle cx="12" cy="12" r="1.3" fill={color}/><circle cx="19" cy="12" r="1.3" fill={color}/></svg>);
|
||||
case "search": return (<svg {...props}><circle cx="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.5" y2="16.5"/></svg>);
|
||||
case "heart": return (<svg {...props}><path d="M20.8 4.6a5.5 5.5 0 0 0-7.8 0L12 5.7l-1-1.1a5.5 5.5 0 1 0-7.8 7.8l1 1L12 21l7.8-7.6 1-1a5.5 5.5 0 0 0 0-7.8z"/></svg>);
|
||||
case "qr-scan": return (<svg {...props}><path d="M4 8V5a1 1 0 0 1 1-1h3"/><path d="M16 4h3a1 1 0 0 1 1 1v3"/><path d="M20 16v3a1 1 0 0 1-1 1h-3"/><path d="M8 20H5a1 1 0 0 1-1-1v-3"/><rect x="8" y="8" width="8" height="8" rx="1"/></svg>);
|
||||
case "clipboard":return (<svg {...props}><rect x="6" y="4" width="12" height="18" rx="2"/><rect x="9" y="2" width="6" height="4" rx="1"/></svg>);
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ───────── Buttons ─────────
|
||||
function Button({ children, variant = "primary", size = "md", icon, iconRight, onClick, type, disabled, style, full }) {
|
||||
const sizes = {
|
||||
sm: { h: 30, px: 12, fs: 13, gap: 6, r: 10 },
|
||||
md: { h: 38, px: 16, fs: 14, gap: 8, r: 12 },
|
||||
lg: { h: 46, px: 22, fs: 15, gap: 10, r: 14 },
|
||||
}[size];
|
||||
const variants = {
|
||||
primary: { bg: "var(--accent)", color: "#fff", border: "transparent",
|
||||
shadow:"0 1px 0 rgba(255,255,255,.18) inset, 0 6px 16px rgba(124,92,255,.35)" },
|
||||
primaryGrad: { bg: "linear-gradient(180deg, #8a6cff, #6a44f5)", color:"#fff", border:"transparent",
|
||||
shadow:"0 1px 0 rgba(255,255,255,.22) inset, 0 8px 22px rgba(106,68,245,.35)" },
|
||||
secondary: { bg: "#fff", color:"var(--ink)", border: "var(--line)",
|
||||
shadow:"0 1px 0 rgba(255,255,255,.6) inset, 0 1px 1px rgba(16,24,40,.04), 0 3px 8px rgba(16,24,40,.05)" },
|
||||
ghost: { bg: "transparent", color:"var(--ink-2)", border:"transparent", shadow:"none" },
|
||||
danger: { bg: "#fff", color:"var(--red)", border:"rgba(255,59,48,.22)",
|
||||
shadow:"0 1px 1px rgba(16,24,40,.03)" },
|
||||
dangerSolid:{ bg:"var(--red)", color:"#fff", border:"transparent",
|
||||
shadow:"0 1px 0 rgba(255,255,255,.18) inset, 0 6px 14px rgba(255,59,48,.3)" },
|
||||
success: { bg:"#fff", color:"var(--green)", border:"rgba(52,199,89,.25)" , shadow:"0 1px 1px rgba(16,24,40,.03)"},
|
||||
};
|
||||
const v = variants[variant] || variants.primary;
|
||||
return (
|
||||
<button onClick={onClick} type={type || "button"} disabled={disabled}
|
||||
style={{
|
||||
appearance:"none", height: sizes.h, padding:`0 ${sizes.px}px`, gap: sizes.gap,
|
||||
display:"inline-flex", alignItems:"center", justifyContent:"center",
|
||||
fontSize: sizes.fs, fontWeight:600, letterSpacing:"-0.005em",
|
||||
borderRadius: sizes.r, background: v.bg, color: v.color,
|
||||
border:`1px solid ${v.border}`, boxShadow:v.shadow,
|
||||
cursor: disabled ? "not-allowed" : "pointer", opacity: disabled ? .55 : 1,
|
||||
transition:"transform .12s ease, box-shadow .18s ease, background .18s ease",
|
||||
width: full ? "100%" : "auto", ...style,
|
||||
}}
|
||||
onMouseDown={(e)=>e.currentTarget.style.transform="scale(0.985)"}
|
||||
onMouseUp={(e)=>e.currentTarget.style.transform="scale(1)"}
|
||||
onMouseLeave={(e)=>e.currentTarget.style.transform="scale(1)"}
|
||||
>
|
||||
{icon && <Icon name={icon} size={sizes.fs + 2}/>}
|
||||
{children}
|
||||
{iconRight && <Icon name={iconRight} size={sizes.fs + 2}/>}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ───────── Card ─────────
|
||||
function Card({ children, padding = 22, radius, hover, style, onClick }) {
|
||||
const [h, setH] = useState(false);
|
||||
return (
|
||||
<div onClick={onClick}
|
||||
onMouseEnter={()=>setH(true)} onMouseLeave={()=>setH(false)}
|
||||
style={{
|
||||
background:"var(--bg-elev)", borderRadius: radius || "var(--radius-card)",
|
||||
border:"1px solid var(--line)", boxShadow:"var(--shadow-card)",
|
||||
padding, transition:"transform .2s ease, box-shadow .25s ease",
|
||||
transform: hover && h ? "translateY(-2px)" : "translateY(0)",
|
||||
boxShadow: hover && h ? "var(--shadow-pop)" : "var(--shadow-card)",
|
||||
cursor: onClick ? "pointer" : "default",
|
||||
...style,
|
||||
}}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ───────── Badge ─────────
|
||||
function Badge({ children, tone = "neutral", dot, size="md", style }) {
|
||||
const tones = {
|
||||
neutral:{ bg:"#f1f2f6", color:"#3a3a3c", dot:"#8e8e93"},
|
||||
green: { bg:"var(--green-soft)", color:"#1f7a3a", dot:"var(--green)" },
|
||||
blue: { bg:"var(--blue-soft)", color:"#0a4ea3", dot:"var(--blue)" },
|
||||
purple: { bg:"var(--accent-soft)", color:"#4a2fcc", dot:"var(--accent)" },
|
||||
orange: { bg:"var(--orange-soft)", color:"#a05a00", dot:"var(--orange)" },
|
||||
red: { bg:"var(--red-soft)", color:"#a8261d", dot:"var(--red)" },
|
||||
};
|
||||
const t = tones[tone] || tones.neutral;
|
||||
const sizes = { sm:{h:20, fs:10.5, px:7}, md:{h:24, fs:11.5, px:9}, lg:{h:28, fs:12.5, px:11}};
|
||||
const s = sizes[size] || sizes.md;
|
||||
return (
|
||||
<span style={{
|
||||
display:"inline-flex", alignItems:"center", gap:6,
|
||||
height:s.h, padding:`0 ${s.px}px`, fontSize:s.fs, fontWeight:600,
|
||||
letterSpacing:".02em", textTransform:"uppercase",
|
||||
borderRadius:999, background:t.bg, color:t.color, ...style,
|
||||
}}>
|
||||
{dot && <span style={{width:6, height:6, borderRadius:999, background:t.dot}}/>}
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ───────── Glyph tile (app-icon style with gradient) ─────────
|
||||
function GlyphTile({ icon, size = 44, gradient, radius = 12, glyph }) {
|
||||
return (
|
||||
<div style={{
|
||||
width:size, height:size, borderRadius:radius, flexShrink:0,
|
||||
display:"grid", placeItems:"center", color:"#fff",
|
||||
background: gradient, fontSize: size*0.5,
|
||||
boxShadow:"0 1px 0 rgba(255,255,255,.35) inset, 0 4px 10px rgba(16,24,40,.10)",
|
||||
letterSpacing:0,
|
||||
}}>
|
||||
{glyph ? <span style={{fontSize:size*0.52, lineHeight:1, filter:"drop-shadow(0 1px 1px rgba(0,0,0,.15))"}}>{glyph}</span> : <Icon name={icon} size={size*0.5} stroke={1.8}/>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ───────── Stat chip (used in server header) ─────────
|
||||
function Stat({ icon, label, value, tone="neutral" }) {
|
||||
const tones = {
|
||||
neutral:{c:"#3a3a3c", bg:"#f4f5f7"},
|
||||
green:{c:"#1f7a3a", bg:"var(--green-soft)"},
|
||||
orange:{c:"#a05a00", bg:"var(--orange-soft)"},
|
||||
blue:{c:"#0a4ea3", bg:"var(--blue-soft)"},
|
||||
};
|
||||
const t = tones[tone] || tones.neutral;
|
||||
return (
|
||||
<div style={{display:"flex",alignItems:"center", gap:8, padding:"8px 12px",
|
||||
borderRadius:14, background:t.bg, color:t.c}}>
|
||||
<Icon name={icon} size={14} stroke={1.8}/>
|
||||
<div style={{display:"flex", flexDirection:"column", lineHeight:1.1}}>
|
||||
<span style={{fontSize:10.5, opacity:.7, fontWeight:600, letterSpacing:".04em", textTransform:"uppercase"}}>{label}</span>
|
||||
<span style={{fontSize:13, fontWeight:600, fontVariantNumeric:"tabular-nums"}}>{value}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ───────── Avatar (initial) ─────────
|
||||
function Avatar({ name, color = "var(--accent)", size = 36 }) {
|
||||
const letter = (name || "?").trim().charAt(0).toUpperCase();
|
||||
return (
|
||||
<div style={{
|
||||
width:size, height:size, borderRadius: size*0.32,
|
||||
background: color, color:"#fff",
|
||||
display:"grid", placeItems:"center", fontWeight:700, fontSize:size*0.42,
|
||||
boxShadow:"0 1px 0 rgba(255,255,255,.3) inset, 0 2px 6px rgba(16,24,40,.10)",
|
||||
flexShrink:0,
|
||||
}}>{letter}</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ───────── Toggle ─────────
|
||||
function Toggle({ on, onChange }) {
|
||||
return (
|
||||
<button onClick={()=>onChange && onChange(!on)}
|
||||
style={{ appearance:"none", border:0, width:42, height:25, padding:2,
|
||||
borderRadius:999, background: on ? "var(--green)" : "#d1d1d6",
|
||||
position:"relative", cursor:"pointer", transition:"background .2s ease"}}>
|
||||
<span style={{
|
||||
position:"absolute", top:2, left: on ? 19 : 2,
|
||||
width:21, height:21, borderRadius:999, background:"#fff",
|
||||
boxShadow:"0 1px 2px rgba(0,0,0,.2), 0 2px 4px rgba(0,0,0,.1)",
|
||||
transition:"left .2s ease"
|
||||
}}/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ───────── Input ─────────
|
||||
function Input({ label, value, onChange, placeholder, mono, type="text", suffix, hint }) {
|
||||
return (
|
||||
<label style={{display:"flex", flexDirection:"column", gap:6}}>
|
||||
{label && <span style={{fontSize:10.5, fontWeight:600, letterSpacing:".06em",
|
||||
textTransform:"uppercase", color:"var(--ink-3)"}}>{label}</span>}
|
||||
<div style={{position:"relative"}}>
|
||||
<input type={type} value={value} placeholder={placeholder}
|
||||
onChange={(e)=>onChange && onChange(e.target.value)}
|
||||
style={{
|
||||
width:"100%", height:42, padding:`0 ${suffix?44:14}px 0 14px`,
|
||||
border:"1px solid var(--line)", borderRadius:12, background:"#fff",
|
||||
fontFamily: mono ? "var(--mono)" : "var(--font)", fontSize:14,
|
||||
color:"var(--ink)", outline:"none",
|
||||
boxShadow:"0 1px 1px rgba(16,24,40,.03)",
|
||||
transition:"border-color .15s ease, box-shadow .15s ease",
|
||||
}}
|
||||
onFocus={(e)=>{e.target.style.borderColor="var(--accent)"; e.target.style.boxShadow="0 0 0 3px rgba(124,92,255,.18)"}}
|
||||
onBlur={(e)=>{e.target.style.borderColor="var(--line)"; e.target.style.boxShadow="0 1px 1px rgba(16,24,40,.03)"}}
|
||||
/>
|
||||
{suffix && <div style={{position:"absolute", right:12, top:"50%", transform:"translateY(-50%)",
|
||||
color:"var(--ink-3)", fontSize:12, fontWeight:500}}>{suffix}</div>}
|
||||
</div>
|
||||
{hint && <span style={{fontSize:12, color:"var(--ink-3)"}}>{hint}</span>}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
// ───────── Checkbox ─────────
|
||||
function Checkbox({ checked, onChange, label }) {
|
||||
return (
|
||||
<label style={{display:"inline-flex", alignItems:"center", gap:10, cursor:"pointer", userSelect:"none"}}>
|
||||
<span style={{
|
||||
width:20, height:20, borderRadius:6,
|
||||
background: checked ? "var(--accent)" : "#fff",
|
||||
border: checked ? "1px solid var(--accent)" : "1px solid var(--line)",
|
||||
display:"grid", placeItems:"center", transition:"all .15s ease",
|
||||
boxShadow: checked ? "0 2px 6px rgba(124,92,255,.3)" : "0 1px 1px rgba(16,24,40,.03)"
|
||||
}}>
|
||||
{checked && <Icon name="check" size={13} stroke={3} color="#fff"/>}
|
||||
</span>
|
||||
<input type="checkbox" checked={!!checked} onChange={(e)=>onChange && onChange(e.target.checked)} style={{display:"none"}}/>
|
||||
<span style={{fontSize:14, color:"var(--ink)"}}>{label}</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
// ───────── Section header ─────────
|
||||
function SectionHeader({ title, subtitle, right }) {
|
||||
return (
|
||||
<div style={{display:"flex", alignItems:"end", justifyContent:"space-between", gap:16, marginBottom:14}}>
|
||||
<div>
|
||||
<h2 style={{margin:0, fontSize:24, fontWeight:700, letterSpacing:"-0.02em", color:"var(--ink)"}}>{title}</h2>
|
||||
{subtitle && <p style={{margin:"4px 0 0", fontSize:14, color:"var(--ink-3)"}}>{subtitle}</p>}
|
||||
</div>
|
||||
{right}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// expose globally
|
||||
Object.assign(window, {
|
||||
Icon, Button, Card, Badge, GlyphTile, Stat, Avatar, Toggle, Input, Checkbox, SectionHeader,
|
||||
});
|
||||
BIN
web/uploads/pasted-1779726646439-0.png
Normal file
BIN
web/uploads/pasted-1779726646439-0.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 182 KiB |
BIN
web/uploads/pasted-1779726706299-0.png
Normal file
BIN
web/uploads/pasted-1779726706299-0.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
BIN
web/uploads/pasted-1779726828382-0.png
Normal file
BIN
web/uploads/pasted-1779726828382-0.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 729 KiB |
BIN
web/uploads/pasted-1779726864755-0.png
Normal file
BIN
web/uploads/pasted-1779726864755-0.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 480 KiB |
BIN
web/uploads/pasted-1779727187856-0.png
Normal file
BIN
web/uploads/pasted-1779727187856-0.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 165 KiB |
BIN
web/uploads/pasted-1779729711186-0.png
Normal file
BIN
web/uploads/pasted-1779729711186-0.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 330 KiB |
Reference in New Issue
Block a user